Skip to content

feat(factors): reproduce valley weighted-price-quantile factor (PR-L, strongest of the loop) - #72

Merged
StackOverFlow11 merged 2 commits into
mainfrom
feat/pr-l-valley-price-quantile
Jul 20, 2026
Merged

feat(factors): reproduce valley weighted-price-quantile factor (PR-L, strongest of the loop)#72
StackOverFlow11 merged 2 commits into
mainfrom
feat/pr-l-valley-price-quantile

Conversation

@StackOverFlow11

Copy link
Copy Markdown
Owner

Summary

Reproduce the valley weighted-price-quantile factor, reversal-neutralized — the sixth factor of Kaiyuan Securities microstructure #27 (loop reproduction 9/10). This is the price-POSITION family, testing whether PR-I/PR-J succeeded because of the ratio FORM or because of price-level INFORMATION generally.

Result: the strongest factor of the loop. Watch/Watch, both assessed axes PASS — IC +0.0265, ICIR 0.414 (CI low 0.342 > 0.30), NW-t 13.30, win rate 0.693, monotonicity 1.0000 (perfect), quantile ladder Q1→Q5 final NAV 0.915 / 1.379 / 1.575 / 1.741 / 2.230; incremental ICIR +0.302 (CI low +0.227). Turnover 0.679, half-life 12.81, cross-section 929/947 (healthy — no scarcity gate). Prototype (persisted pre-run, same seed-20260720 names as PR-I/J/K): IC before neutralization +0.0188 → after +0.0243, i.e. removing the reversal exposure IMPROVES the signal, matching the report's stated rationale for the step.

What distinguishes it is not any single number but the combination: a positive net long-short at base cost (+4.8e-05) together with near-perfect monotonicity and a clean Incremental PASS. (Correcting an earlier draft claim: PR-L is not the only factor with a positive base-cost spread — PR-F's is +7.1e-05, larger — but PR-F pairs it with 0.9 monotonicity and an overall weak profile, and PR-K's positive figure sits on a broken 0.20 monotonicity.) The positive spread does not survive a 2× fee (2× −0.000631, 4× −0.001989), so this is a thin, cost-sensitive edge, not a tradability claim.

Lookahead handling — the highest-risk part of this factor. The report neutralizes on a 20-day reversal; the naive form uses close_d, which is 15:00 information at a 14:50 decision. Pinned and implemented as rev20 = −(close_{d−1}/close_{d−21} − 1) on qfq closes from the runner's existing panel. Independent review monkeypatched reversal_20 to the naive form at both the function level and end-to-end and confirmed no leak in the shipped code.

Review found the flagship guard test was toothless — now fixed (commit 6416a23). test_reversal_uses_t_minus_1_close_not_day_d originally bumped every cross-section member's close_d uniformly, which is an affine transform of the OLS regressor — and intercept-OLS residuals are invariant to affine reparametrization of x, so the test passed even under the naive implementation. Fixed to a single-symbol perturbation, with monkeypatch evidence recorded: new test FAILS under naive / PASSES under real; the old uniform shape passes under naive (reproducing the gap). A third test (test_uniform_close_d_bump_cannot_distinguish_the_two_reversal_bases) now records the affine-invariance reasoning in the suite so the guard cannot be silently re-weakened.

Ex-date disclosure corrected (same commit). Unlike PR-I/J/K (whose same-day ratios cancel the adjustment factor), this factor's range uses prev_close across the overnight boundary. Review quantified it from real cache: 15.6% of valid days show prev_close widening (overwhelmingly ordinary overnight gaps — the intended behaviour, since the report's range includes the previous close precisely so gaps count), only 0.73% are true ex-dates, and only ~0.21% are both a true ex-date and actually range-distorted, before the 20-day mean dilutes it. The direction was also documented backwards: A-share ex-dates shift raw prices DOWN, so hi is pulled UP (42 top-widening vs 4 bottom-widening) and q_day is compressed toward the low end, not toward the middle. Fixed in the module docstring, factor spec and config; an unsupported claim about coverage diagnostics was removed too.

Pins (all disclosed on the spec): prev_close = last visible (≤14:50) raw close of d−1, keeping a single visibility definition throughout; no clip on q (measured escape rate 0.0521%, independently reproduced by review as pure floating-point — valley bars trading exactly at the day's high, max excess <2.1e-07); day valid = ≥100 classifiable AND ≥20 tradable valley AND hi>lo AND prev_close available; 20-day mean; NaN below 10 valid days; per-date OLS residual on rev20 with ≥10 paired names (11 of 1200 early dates non-residualized → NaN, never raw values passed off as neutralized).

Test plan

  • Full pytest: 1583 passed (baseline 1527 + 56) — rerun independently by orchestrator
  • ruff clean; phase0 ic 0.9600 / annual 0.8408 unchanged; git diff main -- analytics/eval/ data/clean/intraday_volume_prv.py empty (both frozen-layer defects left untouched)
  • Five prior factors' modules and test files: 0 diff
  • Independent review: APPROVE-WITH-NOTES → both notes fixed before merge, with monkeypatch evidence
  • Secret scan: 0 token values across diff, artifacts, logs, tmp/ records

Reproduces the SIXTH factor of the Kaiyuan market-microstructure series #27
(reportId 4957417, §5) — 量谷加权价格分位点 — as a first-class factor evaluated by
the frozen StandardFactorEvaluator on the PR-C..PR-K eval cell (CSI500, PIT,
2021-07..2026-06, daily, OOS split 2024-01-01, cache-only).

Sixth reproduction from this report and a new statistic family: prior runs covered a
count (PR-F, weak), a timing moment (PR-H, null), two price RATIOS (PR-I/PR-J, both
passing) and a return (PR-K, sign transferred but Reject). This is a price POSITION,
so it separates the ratio FORM from price-level INFORMATION as the reason PR-I/PR-J
worked.

The minute classification is REUSED verbatim from data/clean/intraday_volume_prv.py
(zero diff), so a different verdict cannot be a taxonomy artefact.

Two subtleties drive the design, both pinned and disclosed on the factor spec:

  * The price range is [min(visible low, prev_close), max(visible high, prev_close)]
    with prev_close = the last VISIBLE (<=14:50) raw close of the previous trading
    day. A disclosed DEVIATION from the report (true previous daily close), chosen so
    every price in the factor comes from one source under one visibility rule rather
    than mixing the minute cache with a daily-close feed.
  * The reversal neutralization is taken at T-1:
    rev20 = -(close_{d-1}/close_{d-21} - 1) on front-adjusted panel closes. The
    report's naive close_d form is 15:00 information and would be a lookahead at our
    14:50 decision. A dedicated test perturbs day d's close and asserts the factor is
    unchanged; its companion perturbs close_{d-1} and asserts it moves, so the first
    cannot pass by the closes being ignored.

Also disclosed, not silently corrected: prev_close crosses the overnight boundary, so
on an ex-date the previous RAW close sits on the old price scale and widens the range
— an imperfection PR-I/PR-J/PR-K did not have (both of their legs sat inside one day).

The daily quantile is deliberately NOT clipped: an out-of-range value means the range
is wrong and must stay visible. Measured escape rate on the 30-name prototype panel is
0.05% (11 of 21,102 symbol-days, all marginally above 1.0).

Prototype sanity (same 30 CSI500 names as PR-I/J/K, seed 20260720, 2022-2024): mean
daily RankIC +0.0243 after neutralization vs +0.0188 before, matching the
pre-registered sign +1.

Real run: covered=995/996, stk_mins_live_calls=0, 1,135,926 factor rows, 470s.
No-book Watch (predictive PASS), with-book Watch (incremental PASS); rank IC 0.0265,
ICIR 0.414 (CI low 0.342, N_eff 792), NW-t 13.3, monotonicity 1.0.
…date bias direction

Review follow-up. Neither change touches a factor value: the first is test-only, the
second is prose. No re-evaluation was run.

1. test_reversal_uses_t_minus_1_close_not_day_d did not guard what it claimed. It
   bumped EVERY cross-section member's close_d by the same x1.5, which sends the naive
   regressor 1 - close_d/close_{d-20} to k*rev - (k-1) -- an affine reparametrization of
   x. Intercept-OLS residuals are invariant under exactly that (span{1, x} ==
   span{1, a*x + b}), the same property test_residualization_is_invariant_to_the_
   reversal_sign_convention deliberately relies on, so the test passed even with
   reversal_20 monkeypatched to the report's naive shift(0)/shift(days) form.

   The perturbation is now SINGLE-SYMBOL, which moves one symbol's regressor relative to
   the rest of the cross-section, changes the fitted line, and therefore moves every
   residual on the date -- under the naive form only. Verified by monkeypatching
   reversal_20 to the naive form: the test now FAILS there and passes on the real
   implementation, while the old uniform-bump shape still passes under the naive form.

   Added test_uniform_close_d_bump_cannot_distinguish_the_two_reversal_bases so the
   reasoning cannot be quietly lost by someone "simplifying" the perturbation back.
   test_reversal_20_ignores_day_d_close_entirely is unchanged -- it already had teeth,
   since it inspects rev20 directly rather than the residual.

2. The ex-date disclosure was directionally wrong. A-share ex-dates shift day-d raw
   prices DOWN relative to d-1's raw close, so prev_close (old, higher scale) pulls hi UP
   -- 42 top-widening vs 4 bottom-widening among true ex-dates -- inflating hi - lo while
   the valley VWAP and lo stay on the new scale. That compresses q_day toward the LOW END
   of an artificially wide range, not "toward the middle" as previously written in the
   module docstring, the factor spec and the config comment. All three are corrected and
   now carry the measured magnitudes: 15.6% of valid days show any prev_close widening
   (overwhelmingly ordinary overnight gapping, which is the INTENDED behaviour), only
   0.73% are true ex-dates, and only ~0.21% are both a true ex-date and actually
   range-distorted, before the 20-day mean dilutes it.

   Also dropped the module docstring's claim that callers "can read the ex-date share off
   the coverage diagnostics" -- NeutralizationCoverage reports no such field, so the
   sentence was an overclaim.
@StackOverFlow11
StackOverFlow11 merged commit bd82a99 into main Jul 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant