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 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Opus 4.8 committed 2026-06-29 15:48:35 -04:00
1 parent 2c2c457b0e
commit 31c35f01c1
4 files changed
+138 -5

No files matched your search

+92 -2
View File
@@ -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__":
+16 -2
View File
@@ -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,
+11 -1
View File
@@ -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
+19
View File
@@ -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