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:
1 parent
7f1ebe9cab
commit
81e505b845
8 files changed
+119
-14
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user