feat(factors): reproduce valley weighted-price-quantile factor (PR-L, strongest of the loop) - #72
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 monkeypatchedreversal_20to 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_doriginally 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
hiis 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
git diff main -- analytics/eval/ data/clean/intraday_volume_prv.pyempty (both frozen-layer defects left untouched)