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>
282 lines
12 KiB
Python
282 lines
12 KiB
Python
"""Armed opening-range-breakout triggers — the fast, top-N watchlist.
|
|
|
|
The watcher path reacts only after a 1-minute candle *closes* above the
|
|
breakout level and then walks the ingest->watch->execute cadence (up to a
|
|
couple of minutes), and it rests a limit *at* the trigger that a fast runner
|
|
never comes back to fill. Both make us late and make us miss the move.
|
|
|
|
This book is the fix. It keeps the handful of most-promising gappers "armed":
|
|
the trigger (opening-range high) and stop (opening-range low) are pre-computed
|
|
the moment the opening range finishes forming, so the live loop can fire the
|
|
instant the *live* price crosses the trigger — the systematic version of Ross
|
|
Cameron's "enter when the candle is breaking, on faith that the volume is
|
|
there." Pure state + decision logic; the loop owns I/O (prices, orders).
|
|
|
|
State machine per symbol:
|
|
|
|
waiting - opening range not complete yet (before ~09:35 ET)
|
|
armed - range complete and gap/rvol/range qualify -> may fire on a cross
|
|
weak - range complete but the setup is too soft to fire (shown, greyed)
|
|
fired - a breakout order has been submitted
|
|
filled - the position is open at the broker
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
|
|
WAITING = "waiting"
|
|
ARMED = "armed"
|
|
WEAK = "weak"
|
|
FIRED = "fired"
|
|
FILLED = "filled"
|
|
|
|
# states that represent a committed trade — pinned across re-ranking so a name
|
|
# that fired doesn't get bumped off the board when the gapper ranking rotates
|
|
_COMMITTED = (FIRED, FILLED)
|
|
|
|
|
|
def _synced(method):
|
|
"""Serialize a book method behind self._lock so the main loop's arm() and
|
|
the trigger thread's poll/fire never iterate ``triggers`` while it's being
|
|
rebuilt."""
|
|
@functools.wraps(method)
|
|
def wrapper(self, *args, **kwargs):
|
|
with self._lock:
|
|
return method(self, *args, **kwargs)
|
|
return wrapper
|
|
|
|
|
|
@dataclass
|
|
class ArmedTrigger:
|
|
symbol: str
|
|
gap_pct: float = 0.0 # DYNAMIC gap (latest vs prev close) — display/rank only
|
|
gap_open_pct: float | None = None # STATIC overnight gap (session open vs prev close)
|
|
rvol: float = 0.0
|
|
# time-of-day rvol (cum vs median(20d) prorated by elapsed session) — the arm
|
|
# eligibility signal. Legacy rvol is full-day-mean based: ~0.1-0.3 minutes
|
|
# after the open for a NORMAL heavy name, so rvol_min=2 vs legacy armed almost
|
|
# 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
|
|
price: float | None = None # latest live trade price
|
|
price_ts: float = 0.0 # monotonic time of that price (staleness guard)
|
|
cum_volume: float = 0.0 # session cumulative volume (for liquidity sizing)
|
|
dollar_vol: float = 0.0 # recent $-volume (liquidity gate: skip thin spikes)
|
|
catalyst: str = "" # fresh news headline (if any) — why it's hot
|
|
day_open: float | None = None # session-open price (for the day-extension gate)
|
|
state: str = WAITING
|
|
rank: int = 0
|
|
# live price has been observed BELOW the trigger since arming — a fire is a
|
|
# fresh CROSS, not "price happens to stand above the level". Without this,
|
|
# any blind window (boot after a blocked spell, halt, feed outage) fires
|
|
# instantly on stale, already-extended breaks (SNXX 2026-07-21: bought a
|
|
# 15-min-old break +1.6% above trigger 18s after restart).
|
|
seen_below: bool = False
|
|
|
|
def distance_pct(self) -> float | None:
|
|
"""How far live price is from the trigger, as a fraction.
|
|
|
|
Negative = still below the trigger (waiting for the break); >= 0 = at or
|
|
through it. None until we have both a price and a trigger.
|
|
"""
|
|
if self.price and self.trigger:
|
|
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 {
|
|
"symbol": self.symbol,
|
|
"state": self.state,
|
|
"rank": self.rank,
|
|
"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,
|
|
"dist": round(d, 5) if d is not None else None,
|
|
"range_pct": round(self.range_pct, 4),
|
|
"catalyst": self.catalyst,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class ArmedTriggerBook:
|
|
"""Tracks the top ``max_armed`` gappers and decides when each may fire."""
|
|
|
|
max_armed: int = 6
|
|
gap_min: float = 3.0 # min overnight gap % to be eligible to fire
|
|
gap_max: float = 1e9 # max gap % — skip blow-off gappers (they fade)
|
|
# mega-gapper exception: re-admit gaps >= this % DESPITE gap_max, but only on a
|
|
# real volume surge (rvol floor below) so reverse-split repricings stay out.
|
|
# Year-scale replay 2026-07-06 (n=2,879, split artifacts removed): the 35-150%
|
|
# band is dead (+0.02/+0.00R — gap_max earns its keep there), but REAL >150%
|
|
# squeezes (n=82, the LHSW/NXTS class) ran +0.63R with 8/14 months >= 0. 0 = off.
|
|
gap_squeeze_min: float = 0.0
|
|
gap_squeeze_rvol: float = 5.0 # rvol floor for the exception (split fakes ~0.01)
|
|
rvol_min: float = 2.0 # min relative volume to be eligible to fire
|
|
min_range_pct: float = 0.004 # opening range must be at least this wide
|
|
# min recent $-volume to fire. rvol is RELATIVE, so 2x of near-nothing still
|
|
# qualifies — thin names then fill at the top of a one-bar spike and reverse
|
|
# (observed live: WKSP/LNKS/UWMC entered on 100-400 share bars). 0 = off.
|
|
min_dollar_vol: float = 0.0
|
|
# max age (s) of the last price before a trigger may fire. A stalled feed
|
|
# would otherwise let a name fire on a frozen quote into a reverted market.
|
|
# 0 = off.
|
|
max_price_age_s: float = 0.0
|
|
# fire without a seen-below cross only within this fraction ABOVE the trigger
|
|
# ("at the break" tolerance). Beyond it, require a pullback below the trigger
|
|
# and a fresh re-cross — stale breaks discovered after a blind window are not
|
|
# entries, they're chases. 0 = always require the cross.
|
|
fresh_cross_tol: float = 0.015
|
|
triggers: dict[str, ArmedTrigger] = field(default_factory=dict)
|
|
_lock: object = field(default_factory=threading.RLock, repr=False, compare=False)
|
|
|
|
def _gap_ok(self, t: ArmedTrigger) -> bool:
|
|
# STATIC overnight gap (open vs prev close) defines the validated population.
|
|
# The dynamic gap let intraday rallies masquerade as gappers (TVRD 6% true
|
|
# gap traded 2026-07-07 as a "13%+ gapper"; audit 2026-07-08). Before the
|
|
# open (no session open yet) the dynamic gap is the only available proxy.
|
|
g = t.gap_open_pct if t.gap_open_pct is not None else t.gap_pct
|
|
if self.gap_min <= g <= self.gap_max:
|
|
return True
|
|
return (self.gap_squeeze_min > 0
|
|
and g >= self.gap_squeeze_min
|
|
and t.squeeze_rvol >= self.gap_squeeze_rvol)
|
|
|
|
def _eligible(self, t: ArmedTrigger) -> bool:
|
|
return (
|
|
t.trigger is not None
|
|
and t.stop is not None
|
|
and t.stop < t.trigger
|
|
and self._gap_ok(t)
|
|
# tod rvol when the scanner provides it; legacy full-day-mean otherwise
|
|
and (t.rvol_tod if t.rvol_tod is not None else t.rvol) >= self.rvol_min
|
|
and t.range_pct >= self.min_range_pct
|
|
and t.dollar_vol >= self.min_dollar_vol
|
|
)
|
|
|
|
@_synced
|
|
def arm(self, candidates: list[dict]) -> None:
|
|
"""Refresh the book from a ranked candidate list.
|
|
|
|
``candidates`` are dicts (best first):
|
|
{symbol, gap_pct, rvol, trigger, stop, range_pct, complete}
|
|
|
|
Symbols already ``fired``/``filled`` are pinned (kept regardless of the
|
|
new ranking); the remaining ``max_armed`` slots are filled from the top
|
|
of ``candidates`` and (re)classified waiting/armed/weak.
|
|
"""
|
|
pinned = {s: t for s, t in self.triggers.items() if t.state in _COMMITTED}
|
|
new: dict[str, ArmedTrigger] = dict(pinned)
|
|
rank = 0
|
|
for c in candidates:
|
|
sym = c["symbol"]
|
|
if sym in new: # already pinned/committed
|
|
continue
|
|
active = sum(1 for t in new.values() if t.state not in _COMMITTED)
|
|
if active >= self.max_armed:
|
|
break
|
|
rank += 1
|
|
t = self.triggers.get(sym) or ArmedTrigger(symbol=sym)
|
|
t.gap_pct = float(c.get("gap_pct") or 0.0)
|
|
_go = c.get("gap_open_pct")
|
|
t.gap_open_pct = float(_go) if _go is not None else None
|
|
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)
|
|
t.cum_volume = float(c.get("cum_volume") or 0.0)
|
|
t.dollar_vol = float(c.get("dollar_vol") or 0.0)
|
|
t.catalyst = c.get("catalyst") or ""
|
|
t.day_open = c.get("day_open")
|
|
t.rank = rank
|
|
if not c.get("complete") or t.trigger is None:
|
|
t.state = WAITING
|
|
elif self._eligible(t):
|
|
t.state = ARMED
|
|
else:
|
|
t.state = WEAK
|
|
new[sym] = t
|
|
self.triggers = new
|
|
|
|
@_synced
|
|
def update_price(self, symbol: str, price: float | None) -> None:
|
|
t = self.triggers.get(symbol)
|
|
if t is None:
|
|
return
|
|
if price is None:
|
|
t.price = None # failed fetch -> clear, so we can't fire on stale
|
|
else:
|
|
t.price = float(price)
|
|
t.price_ts = time.monotonic()
|
|
if t.trigger is not None and t.price < t.trigger:
|
|
t.seen_below = True
|
|
|
|
@_synced
|
|
def fires(self) -> list[ArmedTrigger]:
|
|
"""Armed triggers whose live price has reached or crossed the trigger —
|
|
and whose price is fresh (a stalled feed must not fire on a frozen quote)."""
|
|
now = time.monotonic()
|
|
max_age = self.max_price_age_s
|
|
return [
|
|
t
|
|
for t in self.triggers.values()
|
|
if t.state == ARMED
|
|
and t.price is not None
|
|
and t.trigger is not None
|
|
and t.price >= t.trigger
|
|
and (t.seen_below
|
|
or t.price <= t.trigger * (1.0 + self.fresh_cross_tol))
|
|
and (max_age <= 0 or (now - t.price_ts) <= max_age)
|
|
]
|
|
|
|
@_synced
|
|
def mark_fired(self, symbol: str) -> None:
|
|
t = self.triggers.get(symbol)
|
|
if t is not None:
|
|
t.state = FIRED
|
|
|
|
@_synced
|
|
def mark_filled(self, held_symbols) -> None:
|
|
"""Promote tracked symbols that are now open broker positions to filled."""
|
|
held = set(held_symbols or ())
|
|
for sym, t in self.triggers.items():
|
|
if sym in held:
|
|
t.state = FILLED
|
|
|
|
@_synced
|
|
def snapshot(self) -> list[dict]:
|
|
"""Board-ready rows, ordered armed -> fired -> filled -> waiting -> weak."""
|
|
order = {ARMED: 0, FIRED: 1, FILLED: 2, WAITING: 3, WEAK: 4}
|
|
return [
|
|
t.as_dict()
|
|
for t in sorted(
|
|
self.triggers.values(),
|
|
key=lambda t: (order.get(t.state, 9), t.rank),
|
|
)
|
|
]
|