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",
|
||||
)
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user