fix: trigger fires require a fresh cross — stale breaks after a blind window are chases

SNXX 2026-07-21: 18s after a mid-morning restart the fast path bought a
15-minute-old break +1.6% above the trigger — fires() only checked
price >= trigger, so any blind window (boot, halt, feed outage) fired
instantly on already-extended moves. A trigger now fires only if the live
price was seen BELOW the trigger since arming (a real cross), with a
small at-the-break tolerance (TRIGGER_FRESH_CROSS_TOL, default 1.5%)
so a fast break between ticks still fires; beyond it the name must pull
back below the level and re-cross. Gap-through runners stay covered by
the slow marketable path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Fable 5 committed 2026-07-21 08:50:41 -05:00
1 parent 256bdc31dc
commit 3fdf268227
3 files changed
+51

No files matched your search

+15
View File
@@ -73,6 +73,12 @@ class ArmedTrigger:
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.
@@ -126,6 +132,11 @@ class ArmedTriggerBook:
# 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)
@@ -209,6 +220,8 @@ class ArmedTriggerBook:
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]:
@@ -223,6 +236,8 @@ class ArmedTriggerBook:
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)
]