From 31c35f01c1e6a4447abd145d73fd91356e952cac Mon Sep 17 00:00:00 2001 From: momentum dev Date: Mon, 29 Jun 2026 15:48:35 -0400 Subject: [PATCH] VWAP-reclaim P3: placeability gate + pre-registered promotion validator (which FAILS in-sample) Acts on the P2 edge-audit's two findings: (a) ~half of curl fires have a stop tighter than the spread (median R 1.7% of price, $0.05 on a $2.62 name) so the backtested +1R/-1R is unfillable; (b) the only subset that survived refutation was price>=$5 AND mid-tercile impulse, scored as a +2R/runner filter. - detect_vwap_reclaim: PLACEABILITY GATE (min_r_frac=0.015, min_r_abs=0.02, default ON for live safety) -- rejects a fire whose R=(entry-pullback_low) sits inside the spread. signal_values gains r_frac. Tests: a shallow-pullback curl now rejects "stop unplaceable" and fires only with the gate disabled. - labeler: validate_vwap_reclaim + `validate-vr` CLI scores the PRE-REGISTERED candidate (placeable AND price>=$5 AND impulse in [0.15,0.26]) against a LOCKED bar: +2R Wilson-95 lower bound > base 0.349 AND absolute max-upside-60m beats same-symbol-day ORB at +5%/+10%. Constants + _wilson_lower are committed so the bar can't drift. The shadow BASE track (vr1) deliberately keeps the gate OFF so report-vr base rates stay complete; placeability is a filter in validate-vr. IN-SAMPLE RESULT (decisive): enforcing placeability collapses the audit's n=48 subset to n=16 -- i.e. most of its apparent +2R lift rode on UNPLACEABLE stops. The remainder FAILS the locked bar on both criteria: +2R 44% (Wilson LB 23.1% < 34.9% base) and, most damningly on the artifact-proof axis, it produces a +5%/60m move only 31% of the time vs ORB's 73% on the same days. Pre-registration did its job: it stopped us chasing an n=48 mirage of sub-spread stops. VERDICT: do not promote; the curl does not beat the bot's existing ORB even in its best placeable subset. 15/15 unit tests green. Co-Authored-By: Claude Opus 4.8 --- research/labeler.py | 94 ++++++++++++++++++++++++++++- strategy/evaluation/vwap_reclaim.py | 18 +++++- tests/unit/research/test_labeler.py | 12 +++- tests/unit/test_vwap_reclaim.py | 19 ++++++ 4 files changed, 138 insertions(+), 5 deletions(-) diff --git a/research/labeler.py b/research/labeler.py index 66e171d..84964ec 100644 --- a/research/labeler.py +++ b/research/labeler.py @@ -48,6 +48,30 @@ VR_SETUP_VERSION = "vr1" VR_NAME = "vwap_reclaim" VR_MIN_BARS = 21 +# P3 PRE-REGISTERED promotion hypothesis (locked 2026-06-29 from the edit-audit, BEFORE +# any forward data). The raw setup is negative after cost (stop-unit artifact); the only +# subset that survived adversarial refutation is price>=$5 AND impulse in the MID tercile, +# scored as a +2R/RUNNER filter (NOT +1R). These bands are POST-HOC on the 24-day window +# -- a HYPOTHESIS to validate forward, never to be hand-tuned to fit. PASS BAR: on +# out-of-sample fires the conditioned subset's +2R Wilson-95 lower bound must exceed the +# base +2R rate AND its absolute max-upside-60m must beat same-symbol-day ORB at +5%/+10%. +VR_PROMO_MIN_PRICE = 5.0 +VR_PROMO_IMPULSE_LO = 0.15 +VR_PROMO_IMPULSE_HI = 0.26 +VR_PROMO_BASE_2R = 0.349 # the unconditioned +2R base rate the subset must clear + + +def _wilson_lower(k: int, n: int, z: float = 1.96) -> float: + """Lower bound of the Wilson score interval for k/n at confidence z (default 95%). + Honest small-sample lower bound -- the bar a rate must clear, not the point estimate.""" + if n == 0: + return 0.0 + p = k / n + d = 1 + z * z / n + centre = p + z * z / (2 * n) + half = z * ((p * (1 - p) / n + z * z / (4 * n * n)) ** 0.5) + return max(0.0, (centre - half) / d) + _MB_COLS = ["timestamp", "session_date", "is_premarket", "is_regular_hours", "is_afterhours", "open", "high", "low", "close", "volume", "vwap"] @@ -275,7 +299,13 @@ def compute_vwap_reclaim_setups(symbol, session_date, df, prior_close, avg_vol, over the RTH bars and emits on each RISING EDGE (not-valid -> valid) with a bar cooldown so one curl episode is one setup. Each emit carries the detector's OWN entry/stop/R; labels come from _forward_labels (no skew). STRICT time split: the - detector at bar i sees only bars[:i+1]; labels use only RTH bars after i. PURE.""" + detector at bar i sees only bars[:i+1]; labels use only RTH bars after i. PURE. + + The shadow base track keeps EVERY curl fire (placeability gate OFF: min_r_*=0) so + report-vr's base rates stay complete and the placeability filter's effect is + measurable; validate-vr applies placeability + the promotion conditioning. The live + detector defaults the gate ON -- that is a live-tradeability default, not a base-rate + one.""" df = df.copy() df["is_regular_hours"] = df["is_regular_hours"].astype(bool) rth = df[df["is_regular_hours"]].reset_index(drop=True) @@ -286,7 +316,7 @@ def compute_vwap_reclaim_setups(symbol, session_date, df, prior_close, avg_vol, return [] out, prev_fire, last_emit = [], False, -10 ** 9 for i in range(VR_MIN_BARS - 1, len(rth)): - sig = detect_vwap_reclaim(rth.iloc[:i + 1]) + sig = detect_vwap_reclaim(rth.iloc[:i + 1], min_r_frac=0.0, min_r_abs=0.0) firing = sig.is_valid rising = firing and not prev_fire and (i - last_emit) >= cooldown prev_fire = firing @@ -391,6 +421,63 @@ def report_vwap_reclaim(con): f"{(cmp[0] or 0)*100:.0f}% over {cmp[1]} ORB setups (vs the vwap_reclaim {row[0]*100:.0f}% above)") +def validate_vwap_reclaim(con, min_r_frac=0.015, min_r_abs=0.02): + """Score the PRE-REGISTERED promotion candidate against the locked pass bar (P3). + Candidate = vr1 fires that are PLACEABLE (R >= max(min_r_abs, min_r_frac*entry)) + AND price >= VR_PROMO_MIN_PRICE AND impulse in [VR_PROMO_IMPULSE_LO, _HI]. The bar: + (1) +2R Wilson-95 lower bound > VR_PROMO_BASE_2R (a runner filter, not a +1R one), and + (2) absolute max-upside-60m beats same-symbol-day ORB at BOTH +5% and +10%. + Runs identically on in-sample and forward data; in-sample is a HYPOTHESIS, not a pass.""" + sub = con.execute( + "SELECT se.symbol, se.session_date, " + "CASE WHEN l.reached_2r_before_minus_1r THEN 1 ELSE 0 END, l.max_upside_next_60m " + "FROM setup_events se JOIN outcome_labels l ON l.setup_id = se.setup_id " + "WHERE se.setup_version=? AND se.entry_reference_price >= ? " + "AND se.impulse_pct >= ? AND se.impulse_pct <= ? " + "AND (se.entry_reference_price - se.invalidation_price) >= " + " GREATEST(?, ? * se.entry_reference_price)", + [VR_SETUP_VERSION, VR_PROMO_MIN_PRICE, VR_PROMO_IMPULSE_LO, VR_PROMO_IMPULSE_HI, + min_r_abs, min_r_frac]).fetchall() + n = len(sub) + print("=== vwap_reclaim PROMOTION-CANDIDATE validation (P3, pre-registered bar) ===") + print(f" rule: placeable (R>=max(${min_r_abs:.2f},{min_r_frac*100:.1f}%)) AND price>=${VR_PROMO_MIN_PRICE:.0f} " + f"AND impulse in [{VR_PROMO_IMPULSE_LO},{VR_PROMO_IMPULSE_HI}]") + if not n: + print(" no candidate fires yet (run build-vr first / awaiting forward data).") + return + k2r = sum(r[2] for r in sub) + lb = _wilson_lower(k2r, n) + up5 = sum(1 for r in sub if (r[3] or 0) >= 0.05) / n + up10 = sum(1 for r in sub if (r[3] or 0) >= 0.10) / n + days = len({(r[0], r[1]) for r in sub}) + # ORB on the SAME symbol-days, absolute 60m up-move thresholds + orb = con.execute( + "WITH sd AS (SELECT DISTINCT se.symbol, se.session_date " + " FROM setup_events se JOIN outcome_labels l ON l.setup_id=se.setup_id " + " WHERE se.setup_version=? AND se.entry_reference_price >= ? " + " AND se.impulse_pct >= ? AND se.impulse_pct <= ? " + " AND (se.entry_reference_price - se.invalidation_price) >= GREATEST(?, ? * se.entry_reference_price)) " + "SELECT avg(CASE WHEN l.max_upside_next_60m>=0.05 THEN 1.0 ELSE 0 END), " + " avg(CASE WHEN l.max_upside_next_60m>=0.10 THEN 1.0 ELSE 0 END), count(*) " + "FROM setup_events se JOIN outcome_labels l ON l.setup_id=se.setup_id " + "JOIN sd ON sd.symbol=se.symbol AND sd.session_date=se.session_date " + "WHERE se.setup_version=?", + [VR_SETUP_VERSION, VR_PROMO_MIN_PRICE, VR_PROMO_IMPULSE_LO, VR_PROMO_IMPULSE_HI, + min_r_abs, min_r_frac, SETUP_VERSION]).fetchone() + orb_up5, orb_up10, orb_n = (orb[0] or 0), (orb[1] or 0), int(orb[2] or 0) + pass_2r = lb > VR_PROMO_BASE_2R + pass_abs = (up5 >= orb_up5) and (up10 >= orb_up10) + mark = lambda ok: "PASS" if ok else "FAIL" + print(f" candidate: {n} fires, {days} session-days") + print(f" +2R rate {k2r}/{n} = {k2r/n*100:.0f}% | Wilson-95 LB {lb*100:.1f}% vs base " + f"{VR_PROMO_BASE_2R*100:.1f}% -> [{mark(pass_2r)}]") + print(f" abs +5%/60m {up5*100:.0f}% vs ORB {orb_up5*100:.0f}% | " + f"+10%/60m {up10*100:.0f}% vs ORB {orb_up10*100:.0f}% (ORB n={orb_n}) -> [{mark(pass_abs)}]") + print(f" OVERALL: [{mark(pass_2r and pass_abs)}]") + print(" NOTE: in-sample (post-hoc tercile selection) is a HYPOTHESIS, not a pass. " + "The bar is locked; only OUT-OF-SAMPLE forward fires can clear it.") + + def report(con): n = con.execute("SELECT count(*) FROM setup_events WHERE setup_version=?", [SETUP_VERSION]).fetchone()[0] if not n: @@ -513,6 +600,7 @@ def main(): bv.add_argument("--limit", type=int); bv.add_argument("--rebuild", action="store_true") bv.add_argument("--cooldown", type=int, default=10) sub.add_parser("report-vr", help="forward outcomes of the vwap_reclaim shadow track") + sub.add_parser("validate-vr", help="score the pre-registered promotion candidate vs the locked bar") args = ap.parse_args() con = open_research_db("market") if args.cmd == "build": @@ -525,6 +613,8 @@ def main(): build_vwap_reclaim(con, limit=args.limit, rebuild=args.rebuild, cooldown=args.cooldown) elif args.cmd == "report-vr": report_vwap_reclaim(con) + elif args.cmd == "validate-vr": + validate_vwap_reclaim(con) if __name__ == "__main__": diff --git a/strategy/evaluation/vwap_reclaim.py b/strategy/evaluation/vwap_reclaim.py index 89d8beb..4ae896d 100644 --- a/strategy/evaluation/vwap_reclaim.py +++ b/strategy/evaluation/vwap_reclaim.py @@ -100,6 +100,8 @@ def detect_vwap_reclaim( hold_frac: float = 0.5, hod_margin: float = 0.02, lookback: int | None = 40, + min_r_frac: float = 0.015, + min_r_abs: float = 0.02, min_bars: int = 21, ) -> VwapReclaimSignal: """Detect a VWAP-reclaim continuation (first-pullback curl). Pure. @@ -107,7 +109,14 @@ def detect_vwap_reclaim( The impulse leg is anchored to a ROLLING LOCAL-SWING window of `lookback` bars (P1), not the day's single HOD -- so it re-arms on each intraday leg. Pass lookback=None for the legacy whole-session anchor (used in tests to - contrast the two).""" + contrast the two). + + PLACEABILITY GATE (P3): the stop is the pullback low, so a shallow pullback + yields a sub-tick R that sits INSIDE the spread -- the P2 edge audit found + 46.5% of fires had R < 1.5% of price (a median $0.05 stop on a $2.62 name), + making the backtested +1R/-1R physically unfillable and the setup negative + after cost. Reject any fire with R = (entry - pullback_low) < max(min_r_abs, + min_r_frac * entry). Set min_r_frac=0, min_r_abs=0 to disable (analysis only).""" if bars is None or len(bars) < min_bars: return VwapReclaimSignal(False, reason=f"need >= {min_bars} bars") bars = bars.reset_index(drop=True) @@ -153,7 +162,9 @@ def detect_vwap_reclaim( green = float(ema9.iloc[last]) >= float(ema20.iloc[last]) entry = float(close.iloc[last]) - rr = ((hod - entry) / (entry - pullback_low)) if entry > pullback_low else 0.0 + r_abs = entry - pullback_low # the stop distance (= 1R) + placeable = r_abs >= max(min_r_abs, min_r_frac * entry) + rr = ((hod - entry) / r_abs) if r_abs > 0 else 0.0 vals = { "run_pct": round(run / run_low, 4) if run_low > 0 else None, "pullback_low": round(pullback_low, 4), @@ -165,6 +176,7 @@ def detect_vwap_reclaim( "macd_hist": round(_macd_hist(close), 5), "crossed_vwap_up": bool(crossed_vwap_up), "target_rr": round(rr, 2), + "r_frac": round(r_abs / entry, 4) if entry > 0 else None, } if not ran: @@ -177,6 +189,8 @@ def detect_vwap_reclaim( return VwapReclaimSignal(False, reason="at/too near HOD (not the curl)", signal_values=vals) if not green: return VwapReclaimSignal(False, reason="momentum not green (ema9 < ema20)", signal_values=vals) + if not placeable: + return VwapReclaimSignal(False, reason="stop unplaceable (R inside the spread)", signal_values=vals) return VwapReclaimSignal( True, diff --git a/tests/unit/research/test_labeler.py b/tests/unit/research/test_labeler.py index 9628fde..db6224f 100644 --- a/tests/unit/research/test_labeler.py +++ b/tests/unit/research/test_labeler.py @@ -4,7 +4,7 @@ from datetime import date, datetime import pandas as pd -from research.labeler import compute_setup, compute_vwap_reclaim_setups +from research.labeler import compute_setup, compute_vwap_reclaim_setups, _wilson_lower COLS = ["timestamp", "session_date", "is_premarket", "is_regular_hours", "is_afterhours", "open", "high", "low", "close", "volume", "vwap"] @@ -106,3 +106,13 @@ def test_vwap_reclaim_silent_on_one_way_fade(): fade = _bars(_leg(0, 2.00, 1.20, 26)) assert compute_vwap_reclaim_setups("FADE", D, fade, prior_close=2.10, avg_vol=50_000, cats={}) == [] + + +def test_wilson_lower_bound(): + # the honest small-sample lower bound: below the point estimate, 0 at n=0, + # monotonic in successes, and tighter (higher) as n grows at a fixed rate. + assert _wilson_lower(0, 0) == 0.0 + assert _wilson_lower(50, 100) < 0.5 # LB sits below the 50% point est + assert 0.39 < _wilson_lower(50, 100) < 0.41 # ~0.404 for 50/100 + assert _wilson_lower(28, 48) > _wilson_lower(20, 48) # monotonic in k + assert _wilson_lower(500, 1000) > _wilson_lower(50, 100) # tighter at larger n, same rate diff --git a/tests/unit/test_vwap_reclaim.py b/tests/unit/test_vwap_reclaim.py index 0f8b885..502c4a8 100644 --- a/tests/unit/test_vwap_reclaim.py +++ b/tests/unit/test_vwap_reclaim.py @@ -71,6 +71,25 @@ def test_target_rr_and_macd_tag_logged(): assert r.signal_values["target_rr"] > 0 # HOD is above the entry +def test_unplaceable_tight_stop_rejected(): + """P3: a very shallow pullback yields a sub-1.5% R (stop inside the spread). The + curl is otherwise valid, so the placeability gate must reject it -- and disabling + the gate must let it fire (proving it's the gate, not another criterion).""" + # impulse 4.0->6.0, SHALLOW pullback to ~5.80 (held ~90%), curl to ~5.84: + # entry ~5.84, stop ~5.79 -> R ~0.9% of price -> unplaceable. + tight = _df(_seg(4.0, 6.0, 18, 12000) + _seg(6.0, 5.80, 8, 8000) + _seg(5.80, 5.84, 14, 11000)) + r = detect_vwap_reclaim(tight) + assert not r.is_valid and "unplaceable" in (r.reason or "") + assert r.signal_values["r_frac"] < 0.015 # the diagnosed cause + off = detect_vwap_reclaim(tight, min_r_frac=0.0, min_r_abs=0.0) + assert off.is_valid and off.reason == "vwap_reclaim" # gate was the only blocker + + +def test_clean_fire_is_placeable(): + r = detect_vwap_reclaim(CLEAN) + assert r.is_valid and r.signal_values["r_frac"] >= 0.015 # a real, placeable stop + + def test_local_anchor_rearms_after_higher_leg_gave_back(): """P1: the whole-session anchor goes permanently blind once the day's single HOD deeply gives back, but a name often sets a SECOND leg from a higher base and curls