diff --git a/research/ingestion/signals.py b/research/ingestion/signals.py index 3215c1a..b845aa4 100644 --- a/research/ingestion/signals.py +++ b/research/ingestion/signals.py @@ -32,6 +32,12 @@ class GapperCandidate: # 29.4M). None when no baseline. Legacy relative_volume is kept untouched — # validated boundaries (gap-squeeze rvol>=5) are calibrated in its units. relative_volume_tod: float | None = None + # Squeeze-verification rvol: cum volume vs MEDIAN(20d), NOT prorated — the + # legacy units the gap-squeeze rvol>=5 boundary was calibrated in, but on a + # baseline one or two prior spike days can't poison. KITT 2026-09-25 (+557%, + # 9.3M shares) read legacy 1.54 because two 11-14M days put the 20d mean at + # 1.66M vs a 333k median, and was benched as "squeeze unverified". + relative_volume_med: float | None = None def _session_elapsed_fraction(now: datetime | None = None) -> float: @@ -111,6 +117,8 @@ def scan_gappers( rvol = (float(cum_volume) / float(adv)) if adv and adv > 0 else 0.0 rvol_tod = (float(cum_volume) / (float(med_vol) * frac) if med_vol and float(med_vol) > 0 else None) + rvol_med = (float(cum_volume) / float(med_vol) + if med_vol and float(med_vol) > 0 else None) # either rvol flavour clears the bar: legacy is ~0.1-0.3 early in the # session even for a heavy name (cum-so-far vs full-day mean), so the # tod variant keeps real early movers from being silently dropped @@ -127,6 +135,7 @@ def scan_gappers( relative_volume=rvol, vwap=float(vwap) if vwap is not None else None, relative_volume_tod=rvol_tod, + relative_volume_med=rvol_med, ) ) candidates.sort(key=lambda c: (c.gap_pct * max(c.relative_volume, 0.1)), reverse=True) diff --git a/research/ingestion/watcher_task.py b/research/ingestion/watcher_task.py index cff6931..09f9db0 100644 --- a/research/ingestion/watcher_task.py +++ b/research/ingestion/watcher_task.py @@ -71,6 +71,9 @@ class ResearchWatchlistProvider: avg_daily_volume=research_query.query_avg_daily_volume( self.con, symbol, session_date ), + median_daily_volume=research_query.query_median_daily_volume( + self.con, symbol, session_date + ), source="manual", ) ) @@ -89,6 +92,9 @@ class ResearchWatchlistProvider: avg_daily_volume=research_query.query_avg_daily_volume( self.con, symbol, session_date ), + median_daily_volume=research_query.query_median_daily_volume( + self.con, symbol, session_date + ), source="research", ) ) @@ -124,7 +130,7 @@ class LiveWatchlistProvider: for symbol in self.symbols: daily = self._daily_bars(symbol) prev_close = None - adv = None + adv = med = None if daily: # last bar strictly before the session date prior = [ @@ -135,11 +141,15 @@ class LiveWatchlistProvider: prev_close = float(prior[-1]["c"]) recent = prior[-20:] adv = sum(float(b["v"]) for b in recent) / len(recent) + vols = sorted(float(b["v"]) for b in recent) + mid = len(vols) // 2 + med = vols[mid] if len(vols) % 2 else (vols[mid - 1] + vols[mid]) / 2 candidates.append( WatchCandidate( symbol=symbol, previous_close=prev_close, avg_daily_volume=adv, + median_daily_volume=med, source="live", ) ) diff --git a/research/query.py b/research/query.py index f625455..b694d63 100644 --- a/research/query.py +++ b/research/query.py @@ -69,6 +69,22 @@ def query_previous_close(con, symbol: str, session_date: date) -> float | None: return float(row[0]) if row else None +def query_median_daily_volume(con, symbol: str, session_date: date, days: int = 20) -> float | None: + """Median of the last ``days`` daily volumes — the spike-robust baseline for + squeeze verification (one or two prior squeeze days can multiply the mean).""" + row = con.execute( + """ + SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY volume) FROM ( + SELECT volume FROM daily_bars + WHERE symbol = ? AND trade_date < ? + ORDER BY trade_date DESC LIMIT ? + ) recent + """, + [symbol, session_date, days], + ).fetchone() + return float(row[0]) if row and row[0] is not None else None + + def query_avg_daily_volume(con, symbol: str, session_date: date, days: int = 20) -> float | None: row = con.execute( """ diff --git a/run_live_paper.py b/run_live_paper.py index 2dbf5a2..8ede6b3 100644 --- a/run_live_paper.py +++ b/run_live_paper.py @@ -625,20 +625,26 @@ def main(argv: list[str] | None = None) -> int: _ingest_stale_block = os.environ.get("INGEST_STALE_BLOCK", "0").strip() in {"1", "true", "yes"} _data_stale = {"v": False} + def _minutes_since_premarket() -> float: + try: + from zoneinfo import ZoneInfo + now_et = datetime.now(ZoneInfo("America/New_York")) + pre_et = now_et.replace(hour=4, minute=0, second=0, microsecond=0) + return max(0.0, (now_et - pre_et).total_seconds() / 60.0) + except Exception: # noqa: BLE001 + return 0.0 + def _first_pass_lookback() -> int: # The first ingest must cover the whole session-so-far so the # opening-range bars land in the DB (even on a mid-session restart); # after that we only pull the last few minutes — closed bars never # change, and re-fetching a 4-hour window for 80 symbols every minute # was the ~46s step that froze the loop. - try: - from zoneinfo import ZoneInfo - now_et = datetime.now(ZoneInfo("America/New_York")) - open_et = now_et.replace(hour=9, minute=30, second=0, microsecond=0) - since_open = max(0.0, (now_et - open_et).total_seconds() / 60.0) - except Exception: # noqa: BLE001 - since_open = 0.0 - return int(max(lookback, since_open + 15)) + # "Session" starts at the 04:00 ET premarket, not 09:30: KITT was found at + # 09:35 on 2026-09-25 and got bars from 09:13 only, losing the 09:00 + # squeeze and ~6M premarket shares — its volume, gap context and rvol + # were all computed from the tail of the move. + return int(max(lookback, _minutes_since_premarket() + 15)) def _freshest_bar_age(): """Seconds since the newest stored minute bar (UTC), or None. The bar @@ -1015,6 +1021,7 @@ def main(argv: list[str] | None = None) -> int: "gap_open_pct": gap_open_pct, "rvol": g.relative_volume, "rvol_tod": g.relative_volume_tod, + "rvol_med": g.relative_volume_med, "trigger": hi, "stop": stop, "range_pct": ((hi - lo) / hi) if (hi and lo and hi > 0) else 0.0, @@ -1140,7 +1147,7 @@ def main(argv: list[str] | None = None) -> int: day_open=t.day_open, rank=t.rank, # STATIC overnight gap only — never the dynamic book gap gap_pct=(t.gap_open_pct / 100.0) if t.gap_open_pct is not None else None, - rvol=t.rvol, price=t.price, + rvol=t.squeeze_rvol, price=t.price, ) if res.get("ok"): book.mark_fired(t.symbol) diff --git a/runtime/triggers.py b/runtime/triggers.py index fb3e854..f1fdb34 100644 --- a/runtime/triggers.py +++ b/runtime/triggers.py @@ -62,6 +62,9 @@ class ArmedTrigger: # nothing early (07-20: IREX 0.76/IRE 0.58/BIYA 1.27 all stuck "weak" while # breaking out; tod = 16.8/11.9/481). None -> fall back to legacy rvol. rvol_tod: float | None = None + # squeeze-verification rvol on the median(20d) baseline (legacy units) — see + # GapperCandidate.relative_volume_med. None -> legacy rvol only. + rvol_med: float | None = None trigger: float | None = None # opening-range high (entry breakout level) stop: float | None = None # opening-range low, cushioned (protective stop) range_pct: float = 0.0 # (high-low)/high — how wide the opening range is @@ -90,6 +93,13 @@ class ArmedTrigger: return (self.price - self.trigger) / self.trigger return None + @property + def squeeze_rvol(self) -> float: + """The rvol that verifies a >=150% squeeze: the stronger of the legacy + full-day-mean reading and the median-baseline one (same units), so a + prior spike day in the 20d window can't bench a real squeeze.""" + return max(self.rvol, self.rvol_med or 0.0) + def as_dict(self) -> dict: d = self.distance_pct() return { @@ -99,6 +109,7 @@ class ArmedTrigger: "gap": round(self.gap_pct, 2), "rvol": round(self.rvol, 2), "rvol_tod": round(self.rvol_tod, 2) if self.rvol_tod is not None else None, + "rvol_med": round(self.rvol_med, 2) if self.rvol_med is not None else None, "trigger": round(self.trigger, 4) if self.trigger else None, "stop": round(self.stop, 4) if self.stop else None, "price": round(self.price, 4) if self.price else None, @@ -150,7 +161,7 @@ class ArmedTriggerBook: return True return (self.gap_squeeze_min > 0 and g >= self.gap_squeeze_min - and t.rvol >= self.gap_squeeze_rvol) + and t.squeeze_rvol >= self.gap_squeeze_rvol) def _eligible(self, t: ArmedTrigger) -> bool: return ( @@ -193,6 +204,8 @@ class ArmedTriggerBook: t.rvol = float(c.get("rvol") or 0.0) _rt = c.get("rvol_tod") t.rvol_tod = float(_rt) if _rt is not None else None + _rm = c.get("rvol_med") + t.rvol_med = float(_rm) if _rm is not None else None t.trigger = c.get("trigger") t.stop = c.get("stop") t.range_pct = float(c.get("range_pct") or 0.0) diff --git a/runtime/watcher.py b/runtime/watcher.py index d608e46..6fc7b6a 100644 --- a/runtime/watcher.py +++ b/runtime/watcher.py @@ -43,6 +43,8 @@ class WatchCandidate: previous_close: float | None = None avg_daily_volume: float | None = None source: str = "research" + # spike-robust baseline for squeeze verification (see _population_reject) + median_daily_volume: float | None = None class WatchlistProvider(Protocol): @@ -169,9 +171,13 @@ class Watcher: volume = rvol = None try: volume = float(bars["volume"].sum()) - adv = candidate.avg_daily_volume - if adv and float(adv) > 0: - rvol = volume / float(adv) + # squeeze verification takes the stronger of the mean- and + # median-baseline readings (same units): a prior spike day in the + # 20d window must not bench a real squeeze (KITT 2026-09-25). + readings = [volume / float(b) for b in (candidate.avg_daily_volume, + candidate.median_daily_volume) + if b and float(b) > 0] + rvol = max(readings) if readings else None except Exception: # noqa: BLE001 volume = rvol = None return population_reject( diff --git a/tests/unit/runtime/test_watcher_population_gate.py b/tests/unit/runtime/test_watcher_population_gate.py index 2177aa4..1551b4c 100644 --- a/tests/unit/runtime/test_watcher_population_gate.py +++ b/tests/unit/runtime/test_watcher_population_gate.py @@ -214,3 +214,21 @@ def test_a_genuinely_flat_name_is_still_skipped_with_premarket_present(): w = _watcher(store, [WatchCandidate("NOK", previous_close=4.0)], {"NOK": _bars(4.0, premarket_open=3.6)}, population=GAPPER) assert w.tick(SESSION).skipped == 1 + + +def test_squeeze_verified_against_median_when_a_spike_day_inflates_the_mean(): + """KITT 2026-09-25: the 20d mean (1.66M) carried two 11-14M squeeze days, so + 6.7M shares read as rvol ~4 and the +557% squeeze was benched. Against the + 333k median it is ~20x — verified.""" + cfg = PopulationConfig(price_min=2.0, price_max=20.0, min_entry_gap_pct=0.10, + gap_dead_band_lo=0.35, gap_dead_band_hi=1.50, + squeeze_rvol_min=5.0) + bars = {"KITT": _bars(3.23, volume=223_000)} # 30 bars ~= 6.7M shares + mean_only = WatchCandidate("KITT", previous_close=0.4915, avg_daily_volume=1_663_373) + store = FakeStore() + assert _watcher(store, [mean_only], bars, population=cfg).tick(SESSION).skipped == 1 + + both = WatchCandidate("KITT", previous_close=0.4915, avg_daily_volume=1_663_373, + median_daily_volume=333_146) + store = FakeStore() + assert _watcher(store, [both], bars, population=cfg).tick(SESSION).evaluated == 1 diff --git a/tests/unit/test_triggers.py b/tests/unit/test_triggers.py index 7258ba1..4710b67 100644 --- a/tests/unit/test_triggers.py +++ b/tests/unit/test_triggers.py @@ -459,3 +459,29 @@ def test_fire_normal_cross_still_works(): assert b.fires() == [] b.update_price("NORM", 10.05) assert [t.symbol for t in b.fires()] == ["NORM"] + + +def test_squeeze_verified_on_median_baseline_when_spike_days_poison_the_mean(): + """KITT 2026-09-25: +557%, 9.3M shares, but two 11-14M days in the 20d window + put the mean at 1.66M (legacy rvol 1.54) vs a 333k median. The median-baseline + reading (same units) verifies the squeeze; a reverse-split fake stays out.""" + b = ArmedTriggerBook(gap_min=10, gap_max=35, gap_squeeze_min=150, + gap_squeeze_rvol=5.0, rvol_min=2) + kitt = _cand("KITT", gap=557.0, rvol=1.54) + kitt["rvol_med"] = 20.0 + kitt["rvol_tod"] = 150.0 # time-of-day arm signal, as the scanner would report + fake = _cand("SPLIT", gap=557.0, rvol=0.01) + fake["rvol_med"] = 0.02 + b.arm([kitt, fake]) + assert b.triggers["KITT"].squeeze_rvol == 20.0 + assert b.triggers["KITT"].state == ARMED + assert b.triggers["SPLIT"].state == WEAK + + +def test_squeeze_rvol_falls_back_to_legacy_without_a_median(): + b = ArmedTriggerBook(gap_min=10, gap_max=35, gap_squeeze_min=150, + gap_squeeze_rvol=5.0, rvol_min=2) + b.arm([_cand("LHSW", gap=278.0, rvol=40.0)]) + assert b.triggers["LHSW"].rvol_med is None + assert b.triggers["LHSW"].squeeze_rvol == 40.0 + assert b.triggers["LHSW"].state == ARMED