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:
1 parent
256bdc31dc
commit
3fdf268227
3 files changed
+51
No files matched your search
@@ -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)
|
||||
]
|
||||
|
||||
|
||||
Reference in new issue
Block a user