Files
momentum-system/runtime/triggers.py
T
momentum devandClaude Opus 4.8 44ff36a669 Population gate audit: static overnight gap everywhere + full band
The 2026-07-08 audit (owner: "figure out if there is some kind of logic
or audit issue") found BOTH entry paths feeding the population gate the
WRONG gap. The fast path used the trigger book's dynamic gap (latest
price vs prev close), so intraday rallies masqueraded as gappers: TVRD
(6.0% true gap), SKIN (2.2%, filled at $0.98), JLHL (4.3%) all traded
outside the validated population. The slow path used max(gap, intraday)
with "open" taken from the first lookback-window bar, and had no upper
band at all — dead-band names SEER (38.6%) and IOTR (40.7%) entered.

Fixes: candidates carry gap_open_pct (session open vs prev close);
trigger eligibility + submit_breakout_now use it exclusively once the
session is open; _population_reject now implements the full validated
band (10-35% OR volume-verified >=150% squeeze, $1-20 price band) for
both paths; evaluator gap uses the session open; signals carry
relative_volume for squeeze verification. Plus momentum-testpg.service
so the test-isolation Postgres survives reboots.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 10:34:46 -04:00

244 lines
10 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
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
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
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),
"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
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.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)
and 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)
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()
@_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 (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),
)
]