fix: verify squeezes on a median volume baseline and ingest from the premarket

KITT on 2026-09-25 (+557%, 9.3M shares, the day's biggest mover) was benched
as "gap 557% squeeze unverified (rvol 1.54)". Two separate faults:

1. The rvol baseline was the 20-day MEAN, and two 11-14M-share days on
   09-01/09-02 put it at 1.66M against a 333k median. Squeeze verification
   (gap >= 150% needs rvol >= 5) now takes the stronger of the mean-baseline
   reading and the median-baseline one. Both are cumulative volume over a
   full day's baseline, not prorated, so they share the units the rvol >= 5
   boundary was calibrated in. A reverse-split fake still reads ~0.01 on
   both. This applies in the watcher pre-filter, the trigger book's squeeze
   exception and the fast-path population gate. The ranking and the tod-rvol
   arm signal are unchanged.

2. A name's first ingest reached back only to ~09:15 (09:30 + 15 min, or
   the 20-minute refresh floor). KITT was found at 09:35, so its stored day
   began at 09:13 and missed the 09:00 squeeze and ~6M premarket shares.
   The first pass now covers the session from 04:00 ET.

With both fixed, KITT's volume at 09:35 is ~20x its median day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Opus 5.5 committed 2026-09-25 12:08:13 -05:00
1 parent 7f1ebe9cab
commit 81e505b845
8 files changed
+119 -14

No files matched your search

+9
View File
@@ -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)
+11 -1
View File
@@ -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",
)
)
+16
View File
@@ -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(
"""
+16 -9
View File
@@ -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)
+14 -1
View File
@@ -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)
+9 -3
View File
@@ -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(
@@ -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
+26
View File
@@ -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