From 9bfd458956f7f040dbb9a9512d2a859494ccea70 Mon Sep 17 00:00:00 2001 From: momentum dev Date: Tue, 21 Jul 2026 11:59:51 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20tod1=20shadow=20track=20=E2=80=94=20zer?= =?UTF-8?q?o-gap=20intraday=20volume-explosion=20detector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 07-21 misses (CPHI +417%, DFNS +94%, GREE/WGRX +40%) were all static-gap <10% names the validated gapper population excludes by design, but they share a signature no refuted detector (liq1/run1) measured: rvol_tod (cum volume vs median-20d prorated by elapsed session) at 50-1000x on a flat open. New labeler setup tod1 fires on the rising edge of rvol_tod>=50 AND price>= prev_close*1.20, restricted to static gap <10%, stop = 5-bar swing low, scored via the shared _forward_labels. Shadow-only; pre-registered promotion bar locked before forward data (median fwdmax60 >=+10%, median dd60 >-6%, +1R>50%, top stratum rvol_tod>=200 clears alone). Co-Authored-By: Claude Fable 5 --- research/labeler.py | 177 ++++++++++++++++++++++++++++ tests/unit/research/test_labeler.py | 42 +++++++ 2 files changed, 219 insertions(+) diff --git a/research/labeler.py b/research/labeler.py index 5d73ab3..7193c47 100644 --- a/research/labeler.py +++ b/research/labeler.py @@ -122,6 +122,27 @@ PREMARKET_PROMO_MEDIAN_ADVERSE = -0.06 # (2) median max-drawdown > -6% PREMARKET_PROMO_MIN_1R = 0.50 # (3) +1R-before-(-1R) > 50% # (4) top gap-stratum (>=50% gap) must clear (3) — the trader's real premarket runners. +# tod1: zero-gap intraday volume-explosion shadow track (2026-07-21). The 07-21 misses +# (CPHI +417%, DFNS +94%, GREE/WGRX +40% — all static gap <10%, all excluded by the +# validated gapper population) share ONE signature the refuted liq1/run1 detectors never +# had: rvol_tod = cum volume / (MEDIAN(20d prior daily volume) * elapsed-session fraction) +# at 50-1000x while the static gap is ~0. Tests whether THAT population converts. Shadow-only. +TOD1_SETUP_VERSION = "tod1" +TOD1_NAME = "intraday_volume_explosion" +TOD1_MIN_RVOL_TOD = 50.0 # detection floor (CPHI 1039, GREE 137, WGRX 66 on 07-21) +TOD1_MIN_DYN_GAIN = 0.20 # price >= prev_close * 1.20 (a real intraday runner) +TOD1_MAX_STATIC_GAP = 0.10 # the EXCLUDED class only (static gap under the live 10% floor) +TOD1_MIN_RTH_BARS = 24 # forward room so outcome labels aren't truncated +TOD1_STOP_LOOKBACK = 5 # invalidation = low of the prior N RTH bars (swing low) +# PRE-REGISTERED promotion bar (locked 2026-07-21, BEFORE forward data — same null as the +# 7 refuted levers: separates a population but doesn't convert). Promote off shadow ONLY if +# out-of-sample: (1) median max-upside 60m >= +10%; (2) median max-drawdown 60m > -6%; +# (3) +1R-before-(-1R) > 50%; (4) the top stratum (rvol_tod >= 200) clears (3) on its own. +TOD1_PROMO_MEDIAN_FWDMAX_60M = 0.10 +TOD1_PROMO_MEDIAN_ADVERSE_60M = -0.06 +TOD1_PROMO_MIN_1R = 0.50 +TOD1_PROMO_TOP_STRATUM = 200.0 + # No-chase entry shadow-validation (the final mechanic lever). Realistic per-name round-trip # cost proxy (minute_bars has no NBBO, so a price-tiered spread/slippage stand-in). PRE- # REGISTERED bar locked BEFORE forward data: promote the no-chase VWAP-pullback entry only if @@ -1156,6 +1177,154 @@ def compute_premarket_setups(symbol, session_date, df, prior_close, avg_vol, return out +def _preload_med_vol(con) -> dict: + """{(symbol, session_date): median of the <=20 prior daily volumes} — the SAME + baseline the live scanner's rvol_tod uses (median kills reverse-split and + prior-squeeze poisoning that inflates the 20d mean; BIYA: mean 29.4M vs median 1.5M).""" + rows = con.execute( + "SELECT symbol, trade_date, volume FROM daily_bars " + "WHERE symbol IN (SELECT DISTINCT symbol FROM minute_bars) " + "ORDER BY symbol, trade_date").fetchall() + out: dict = {} + prior: dict = {} + for sym, d, vol in rows: + pv = prior.get(sym) + if pv: + out[(sym, d)] = _median(pv[-20:]) + if vol is not None: + prior.setdefault(sym, []).append(float(vol)) + return out + + +def compute_tod1_setups(symbol, session_date, df, prior_close, med_vol, + cats: dict, *, cooldown=10, spy_session=None) -> list: + """0..N intraday_volume_explosion shadow fires for one session. Fires on the + rising edge of (rvol_tod >= floor AND price >= prev_close*(1+gain)) restricted + to the population the live gates EXCLUDE (static gap < 10%). Cum volume includes + premarket (matches the live scanner); the elapsed fraction is RTH-based with the + same 0.05 floor. Labels via the shared _forward_labels. PURE.""" + if not prior_close or prior_close <= 0 or not med_vol or med_vol <= 0: + return [] + df = df.copy() + df["is_regular_hours"] = df["is_regular_hours"].astype(bool) + rth = df[df["is_regular_hours"]].reset_index(drop=True) + if len(rth) < TOD1_MIN_RTH_BARS: + return [] + session_open = float(rth.iloc[0]["open"]) + if session_open <= 0: + return [] + static_gap = (session_open - float(prior_close)) / float(prior_close) + if static_gap >= TOD1_MAX_STATIC_GAP: + return [] # gapper class — the LIVE population owns it + pm_vol = float(df[~df["is_regular_hours"]] + [df["timestamp"] < rth.iloc[0]["timestamp"]]["volume"].sum()) + cum = pm_vol + dyn_floor = float(prior_close) * (1.0 + TOD1_MIN_DYN_GAIN) + out, prev_fire, last_emit = [], False, -10 ** 9 + for i in range(len(rth)): + bar = rth.iloc[i] + cum += float(bar["volume"]) + frac = min(1.0, max(0.05, (i + 1) / 390.0)) + rvol_tod = cum / (float(med_vol) * frac) + firing = rvol_tod >= TOD1_MIN_RVOL_TOD and float(bar["close"]) >= dyn_floor + rising = firing and not prev_fire and (i - last_emit) >= cooldown + prev_fire = firing + if not rising or i < TOD1_STOP_LOOKBACK: + continue + entry_ref = float(bar["close"]) + invalidation = float(rth.iloc[i - TOD1_STOP_LOOKBACK:i]["low"].min()) + if entry_ref <= invalidation: + continue + last_emit = i + setup_ts = bar["timestamp"] + _, cat_fresh = _catalyst_at(cats, symbol, setup_ts) + spy_ret, tape = _spy_context(spy_session, setup_ts) + sid = f"{symbol}:{session_date}:b{i}:{TOD1_SETUP_VERSION}" + setup = dict(setup_id=sid, symbol=symbol, setup_time=setup_ts, session_date=session_date, + setup_name=TOD1_NAME, setup_version=TOD1_SETUP_VERSION, + entry_reference_price=entry_ref, invalidation_price=invalidation, + target_r_multiple=0.0, gap_pct=round(static_gap * 100.0, 3), + relative_volume=round(rvol_tod, 2), relative_volume_source="tod1", + pullback_volume_ratio=cum, # cumulative volume (liquidity strat) + catalyst_freshness_at_trigger=cat_fresh, session_minute_number=i + 1, + tape_regime_at_trigger=tape, spy_intraday_return_pct_at_trigger=spy_ret) + lab = _forward_labels(rth, setup_ts, entry_ref, invalidation, session_open) + out.append((setup, dict(setup_id=sid, label_version=LABEL_VERSION, **lab))) + return out + + +def build_tod1(con, limit=None, rebuild=False, cooldown=10): + if rebuild: + con.execute("DELETE FROM outcome_labels WHERE setup_id IN " + "(SELECT setup_id FROM setup_events WHERE setup_version=?)", [TOD1_SETUP_VERSION]) + con.execute("DELETE FROM setup_events WHERE setup_version=?", [TOD1_SETUP_VERSION]) + daily = _preload_daily(con) + med = _preload_med_vol(con) + cats = _preload_catalysts(con) + spy = _preload_spy(con) + sessions = con.execute( + "SELECT DISTINCT symbol, session_date FROM minute_bars ORDER BY session_date, symbol" + + (f" LIMIT {int(limit)}" if limit else "")).fetchall() + built = sess_with = 0 + for sym, d in sessions: + rows = con.execute( + f"SELECT {', '.join(_MB_COLS)} FROM minute_bars WHERE symbol=? AND session_date=? " + "ORDER BY timestamp", [sym, d]).fetchall() + if not rows: + continue + dfb = pd.DataFrame(rows, columns=_MB_COLS) + pc, _av = daily.get((sym, d), (None, None)) + try: + setups = compute_tod1_setups(sym, d, dfb, pc, med.get((sym, d)), cats, + cooldown=cooldown, spy_session=spy.get(d)) + except Exception as exc: # noqa: BLE001 — one bad session can't stop the build + print(f" ! {sym} {d}: {exc}") + continue + if setups: + sess_with += 1 + for setup, label in setups: + _insert(con, "setup_events", setup) + _insert(con, "outcome_labels", label) + built += 1 + print(f"built {built} {TOD1_NAME} shadow fires across {sess_with} sessions " + f"(of {len(sessions)} symbol-sessions scanned)") + return built + + +def report_tod1(con): + n = con.execute("SELECT count(*) FROM setup_events WHERE setup_version=?", + [TOD1_SETUP_VERSION]).fetchone()[0] + if not n: + print("No intraday_volume_explosion setups — run `build-tod1` first.") + return + days, syms = con.execute( + "SELECT count(DISTINCT session_date), count(DISTINCT symbol) FROM setup_events " + "WHERE setup_version=?", [TOD1_SETUP_VERSION]).fetchone() + print(f"=== {TOD1_NAME} shadow report ({n} fires, {syms} symbols, {days} session-days) ===") + print(f"pre-registered bar: median fwdmax60 >= {TOD1_PROMO_MEDIAN_FWDMAX_60M:+.0%}, " + f"median dd60 > {TOD1_PROMO_MEDIAN_ADVERSE_60M:+.0%}, +1R>{TOD1_PROMO_MIN_1R:.0%}, " + f"top stratum rvol_tod >= {TOD1_PROMO_TOP_STRATUM:g} clears on its own") + for name, cond in [("ALL", "1=1"), + (f"rvol_tod >= {TOD1_PROMO_TOP_STRATUM:g}", + f"se.relative_volume >= {TOD1_PROMO_TOP_STRATUM}"), + ("rvol_tod < 200", "se.relative_volume < 200")]: + row = con.execute( + "SELECT count(*), " + "avg(CASE WHEN l.reached_1r_before_minus_1r THEN 1.0 ELSE 0 END), " + "percentile_cont(0.5) WITHIN GROUP (ORDER BY l.max_upside_next_60m), " + "percentile_cont(0.5) WITHIN GROUP (ORDER BY l.max_drawdown_next_60m), " + "avg(CASE WHEN l.failed_breakout_flag THEN 1.0 ELSE 0 END) " + "FROM outcome_labels l JOIN setup_events se ON se.setup_id = l.setup_id " + f"WHERE se.setup_version=? AND {cond}", [TOD1_SETUP_VERSION]).fetchone() + if not row or not row[0]: + print(f" [{name}] n=0") + continue + print(f" [{name}] n={row[0]} +1R-first {row[1]*100:.0f}% " + f"median fwdmax60 {row[2]*100 if row[2] is not None else 0:+.1f}% " + f"median dd60 {row[3]*100 if row[3] is not None else 0:+.1f}% " + f"failed-breakout {row[4]*100:.0f}%") + + def build_premarket(con, limit=None, rebuild=False, cooldown=10): if rebuild: con.execute("DELETE FROM outcome_labels WHERE setup_id IN " @@ -1531,6 +1700,10 @@ def main(): bp = sub.add_parser("build-premarket", help="build the premarket_setup shadow track (premarket bars)") bp.add_argument("--limit", type=int); bp.add_argument("--rebuild", action="store_true") bp.add_argument("--cooldown", type=int, default=10) + bt = sub.add_parser("build-tod1", help="build the intraday_volume_explosion shadow track") + bt.add_argument("--limit", type=int); bt.add_argument("--rebuild", action="store_true") + bt.add_argument("--cooldown", type=int, default=10) + sub.add_parser("report-tod1") sub.add_parser("report-premarket", help="forward outcomes + gap stratification of the premarket track") sub.add_parser("validate-premarket", help="score the premarket track vs its locked pre-registered bar") args = ap.parse_args() @@ -1563,6 +1736,10 @@ def main(): report_liquid(con) elif args.cmd == "validate-liquid": validate_liquid(con) + elif args.cmd == "build-tod1": + build_tod1(con, limit=args.limit, rebuild=args.rebuild, cooldown=args.cooldown) + elif args.cmd == "report-tod1": + report_tod1(con) elif args.cmd == "build-premarket": build_premarket(con, limit=args.limit, rebuild=args.rebuild, cooldown=args.cooldown) elif args.cmd == "report-premarket": diff --git a/tests/unit/research/test_labeler.py b/tests/unit/research/test_labeler.py index f20d70b..42755e7 100644 --- a/tests/unit/research/test_labeler.py +++ b/tests/unit/research/test_labeler.py @@ -195,3 +195,45 @@ def test_wilson_lower_bound(): 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 + + +def _tod1_bars(base_vol=200_000, gain_to=2.60): + """Zero-gap open at 2.00, explodes intraday to `gain_to` on huge volume.""" + flat = [(m, 2.00, 2.02, 1.98, 2.00) for m in range(0, 10)] + step = (gain_to - 2.00) / 20 + run = [(10 + i, 2.00 + step * i, 2.00 + step * (i + 1), 1.99 + step * i, + 2.00 + step * (i + 1)) for i in range(20)] + after = [(30 + m, gain_to, gain_to + 0.1, gain_to - 0.05, gain_to + 0.05) + for m in range(20)] + df = _bars(flat + run + after) + df["volume"] = base_vol + return df + + +def test_tod1_fires_on_zero_gap_volume_explosion(): + from research.labeler import compute_tod1_setups + # prev_close 2.00 (zero gap), med20 volume 100k; 200k/bar -> rvol_tod huge fast + setups = compute_tod1_setups("CPHI", D, _tod1_bars(), prior_close=2.00, + med_vol=100_000, cats={}) + assert setups, "should fire on the zero-gap explosion" + setup, label = setups[0] + assert setup["setup_version"] == "tod1" + assert setup["relative_volume"] >= 50 # rvol_tod at detection + assert setup["gap_pct"] < 10 # the excluded class + assert setup["entry_reference_price"] >= 2.00 * 1.20 # dynamic gain floor + assert setup["invalidation_price"] < setup["entry_reference_price"] + + +def test_tod1_excludes_gappers(): + from research.labeler import compute_tod1_setups + # same tape but prev_close 1.50 -> static gap +33% -> live population owns it + assert compute_tod1_setups("GAPR", D, _tod1_bars(), prior_close=1.50, + med_vol=100_000, cats={}) == [] + + +def test_tod1_silent_without_volume_explosion(): + from research.labeler import compute_tod1_setups + # price runs +30% but volume is ~baseline pace -> rvol_tod small -> no fire + df = _tod1_bars(base_vol=300) # 300/min vs med 100k -> tod << 50 + assert compute_tod1_setups("QUIET", D, df, prior_close=2.00, + med_vol=100_000, cats={}) == []