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:
1 parent
2c2c457b0e
commit
31c35f01c1
4 files changed
+138
-5
No files matched your search
+92
-2
@@ -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__":
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user