Files
momentum-system/tests/unit/test_triggers.py
T
momentum devandClaude Fable 5 3fdf268227 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>
2026-07-21 08:50:41 -05:00

462 lines
18 KiB
Python

"""Armed-trigger fast path: opening range, the trigger book, and live-fire.
Locks in the "don't miss the move" mechanics: the opening range is computed
once it's complete; a qualified trigger arms and fires only when live price
crosses it; fired names are pinned across re-ranking; and submit_breakout_now
honours the same risk gates as the slow path while placing a marketable order.
"""
import pandas as pd
from alpaca_paper.execution import AlpacaPaperExecutor
from runtime.triggers import (
ARMED,
FILLED,
FIRED,
WAITING,
WEAK,
ArmedTriggerBook,
)
from storage.event_store import EventStore
from strategy.evaluation.structure import opening_range
from trading_execution import ExecutionSettings, TradingExecutionService
def _bars(highs_lows):
rows = []
base = pd.Timestamp("2026-06-17 13:30:00") # 09:30 ET in UTC-naive
for i, (h, l) in enumerate(highs_lows):
rows.append({
"timestamp": base + pd.Timedelta(minutes=i),
"open": l, "high": h, "low": l, "close": h, "volume": 1000,
"is_regular_hours": True,
})
return pd.DataFrame(rows)
# -- opening range --------------------------------------------------------
def test_opening_range_incomplete_until_n_bars():
hi, lo, complete = opening_range(_bars([(10, 9.5), (10.2, 9.8)]), orb_bars=5)
assert complete is False and hi is None and lo is None
def test_opening_range_high_low():
bars = _bars([(10, 9.5), (10.5, 9.8), (10.3, 9.9), (10.1, 9.7), (10.6, 9.6),
(11.0, 10.0)])
hi, lo, complete = opening_range(bars, orb_bars=5)
assert complete is True
assert hi == 10.6 # max high of first 5
assert lo == 9.5 # min low of first 5
# -- trigger book ---------------------------------------------------------
def _cand(sym, gap=5.0, rvol=3.0, trig=10.0, stop=9.5, rng=0.05, complete=True):
return {"symbol": sym, "gap_pct": gap, "rvol": rvol, "trigger": trig,
"stop": stop, "range_pct": rng, "complete": complete}
def test_book_arms_when_complete_and_qualified():
b = ArmedTriggerBook(gap_min=3, rvol_min=2)
b.arm([_cand("AAA")])
assert b.triggers["AAA"].state == ARMED
def test_book_waiting_until_range_complete():
b = ArmedTriggerBook()
b.arm([_cand("AAA", complete=False, trig=None, stop=None)])
assert b.triggers["AAA"].state == WAITING
def test_book_weak_below_thresholds():
b = ArmedTriggerBook(gap_min=5, rvol_min=3)
b.arm([_cand("AAA", gap=2, rvol=1)])
assert b.triggers["AAA"].state == WEAK
def test_book_weak_below_min_dollar_vol():
"""A thin name (low recent $-volume) is too illiquid to fire even if gap/rvol
qualify — it would fill at the top of a one-bar spike and reverse."""
b = ArmedTriggerBook(gap_min=3, rvol_min=2, min_dollar_vol=10_000)
thin = _cand("AAA"); thin["dollar_vol"] = 4_000
b.arm([thin])
assert b.triggers["AAA"].state == WEAK
liquid = _cand("BBB"); liquid["dollar_vol"] = 80_000
b.arm([liquid])
assert b.triggers["BBB"].state == ARMED
def test_book_caps_at_max_armed():
b = ArmedTriggerBook(max_armed=2)
b.arm([_cand(f"S{i}") for i in range(5)])
assert len([t for t in b.triggers.values() if t.state != FIRED]) == 2
def test_fires_only_when_price_crosses():
b = ArmedTriggerBook(gap_min=3, rvol_min=2)
b.arm([_cand("AAA", trig=10.0)])
b.update_price("AAA", 9.9)
assert b.fires() == []
b.update_price("AAA", 10.01)
assert [t.symbol for t in b.fires()] == ["AAA"]
def test_fire_blocked_when_price_is_stale():
"""A stalled feed must not fire on a frozen quote — fires() requires the
price to be fresher than max_price_age_s."""
b = ArmedTriggerBook(gap_min=3, rvol_min=2, max_price_age_s=5)
b.arm([_cand("AAA", trig=10.0)])
b.update_price("AAA", 9.9) # below the level (fresh-cross arm)
b.update_price("AAA", 10.5) # fresh cross -> fires
assert [t.symbol for t in b.fires()] == ["AAA"]
b.triggers["AAA"].price_ts -= 10 # age the price past the window
assert b.fires() == [] # stale -> no fire
b.update_price("AAA", 10.5) # fresh again -> fires
assert [t.symbol for t in b.fires()] == ["AAA"]
b.update_price("AAA", None) # failed fetch clears the price
assert b.fires() == [] and b.triggers["AAA"].price is None
def test_fired_state_pinned_across_rearm():
b = ArmedTriggerBook(max_armed=2, gap_min=3, rvol_min=2)
b.arm([_cand("AAA"), _cand("BBB")])
b.mark_fired("AAA")
b.arm([_cand("CCC"), _cand("DDD")]) # new ranking without AAA
assert "AAA" in b.triggers and b.triggers["AAA"].state == FIRED
def test_mark_filled_promotes_held():
b = ArmedTriggerBook(gap_min=3, rvol_min=2)
b.arm([_cand("AAA")])
b.mark_filled({"AAA"})
assert b.triggers["AAA"].state == FILLED
# -- live fire (submit_breakout_now) --------------------------------------
class _FakeClient:
def __init__(self, resp=None, account=None, positions=None):
self.resp = resp or {"id": "o1", "status": "new", "filled_qty": "0"}
self.account = account or {"equity": "100000", "last_equity": "100000"}
self._positions = positions or []
self.last = None
self.closed = []
self.canceled = []
def submit_order(self, **kw):
self.last = kw
return self.resp
def cancel_order(self, order_id):
self.canceled.append(order_id)
def get_account(self):
return self.account
def get_positions(self, fresh=False):
return self._positions
def close_position(self, symbol):
self.closed.append(symbol)
return {"id": "c"}
def _svc(client):
store = EventStore(":memory:")
svc = TradingExecutionService(
store,
executor=AlpacaPaperExecutor(store, client=client),
settings=ExecutionSettings(auto_approve=True, max_daily_loss_pct=0.5),
session_id="t",
)
return store, svc
def test_breakout_now_submits_marketable_limit():
client = _FakeClient()
store, svc = _svc(client)
res = svc.submit_breakout_now("AAA", trigger=10.0, stop=9.5, last_price=10.0)
assert res["ok"] is True
assert client.last["order_type"] == "limit"
assert client.last["limit_price"] >= 10.0 # capped ABOVE the trigger
assert store.query_events(event_type="order_approved", limit=None)
def test_breakout_now_blocks_when_halted():
# equity -60% vs prior close, breaker at 50% -> halt
client = _FakeClient(account={"equity": "40000", "last_equity": "100000"})
store, svc = _svc(client)
res = svc.submit_breakout_now("AAA", 10.0, 9.5, last_price=10.0)
assert res["ok"] is False and res["skipped"] == "halted"
def test_breakout_now_dedups_second_attempt():
client = _FakeClient()
store, svc = _svc(client)
svc.submit_breakout_now("AAA", 10.0, 9.5, last_price=10.0)
res2 = svc.submit_breakout_now("AAA", 10.0, 9.5, last_price=10.0)
assert res2["ok"] is False and res2["skipped"] == "already_active"
def test_breakout_now_blocks_when_gross_notional_full():
"""Portfolio gross-notional cap: when open positions already fill the budget,
a new entry is blocked instead of piling the book toward ~100% gross."""
# equity 100k, cap 0.60 -> 60k budget; an open position worth 60k exhausts it
client = _FakeClient(positions=[{"symbol": "HELD", "qty": "1",
"market_value": "60000"}])
store, svc = _svc(client)
res = svc.submit_breakout_now("AAA", trigger=10.0, stop=9.5, last_price=10.0)
assert res["ok"] is False and res["skipped"] == "gross_notional_cap"
def test_breakout_now_blocks_when_over_extended():
"""Anti-chase ceiling: a price already +27% above the trigger gapped THROUGH
the ORB high (a parabolic spike or a halt re-open, like the real NEOV/ICCM
chases) — block it before it reaches the broker. Default ceiling is 15%."""
client = _FakeClient()
store, svc = _svc(client)
res = svc.submit_breakout_now("AAA", trigger=10.0, stop=9.5, last_price=12.7)
assert res["ok"] is False and res["skipped"] == "over_extended"
assert client.last is None # never submitted
def test_breakout_now_allows_extended_under_default_ceiling():
"""A +9% break (real entries cluster <=9.8%) is under the 15% default and
still fires — the ceiling targets the 26-64% disaster tail, not normal moves."""
client = _FakeClient()
store, svc = _svc(client)
res = svc.submit_breakout_now("AAA", trigger=10.0, stop=9.5, last_price=10.9)
assert res["ok"] is True
def test_breakout_now_rejects_nonpositive_price():
"""A 0.0/garbage last price must be rejected, not coerced to the trigger
(which would skip the extension guard and size on a phantom price)."""
client = _FakeClient()
store, svc = _svc(client)
res = svc.submit_breakout_now("AAA", trigger=10.0, stop=9.5, last_price=0.0)
assert res["ok"] is False and res["skipped"] == "bad_price"
assert client.last is None
def test_breakout_now_blocks_over_extended_on_day():
"""Day ceiling: a CLEAN trigger break (+3.8% above trigger, passes the trigger
ceiling) of a name already +35% above the session open is a parabolic chase —
the real ATPC case (clean break, +32% on the day, lost). Blocked by the day
ceiling that the trigger ceiling can't see."""
client = _FakeClient()
store, svc = _svc(client)
res = svc.submit_breakout_now("AAA", trigger=13.0, stop=12.5,
last_price=13.5, day_open=10.0)
assert res["ok"] is False and res["skipped"] == "over_extended_day"
assert client.last is None
def test_breakout_now_allows_modest_day_extension():
"""+10% above the session open (a normal gapper) is under the 30% day ceiling."""
client = _FakeClient()
store, svc = _svc(client)
res = svc.submit_breakout_now("AAA", trigger=10.0, stop=9.5,
last_price=11.0, day_open=10.0)
assert res["ok"] is True
def test_day_extension_skipped_without_day_open():
"""No day_open (e.g. pre-open / missing bars) -> the day ceiling can't apply,
entry proceeds under the other gates."""
client = _FakeClient()
store, svc = _svc(client)
res = svc.submit_breakout_now("AAA", trigger=10.0, stop=9.5,
last_price=11.0, day_open=None)
assert res["ok"] is True
def test_breakout_now_allows_clean_break_within_ceiling():
"""A clean break crosses the trigger smoothly (+3%, well under the 15%
ceiling) and still fires — the guard must not kill normal breakouts."""
client = _FakeClient()
store, svc = _svc(client)
res = svc.submit_breakout_now("AAA", trigger=10.0, stop=9.5, last_price=10.3)
assert res["ok"] is True
def test_breakout_now_blocks_halted_symbol():
"""A name flagged halted is skipped — you can't exit during a halt and it
gaps through the stop on resume."""
client = _FakeClient()
store, svc = _svc(client)
res = svc.submit_breakout_now("AAA", 10.0, 9.5, last_price=10.0, halted=True)
assert res["ok"] is False and res["skipped"] == "halted_symbol"
assert client.last is None
def test_halt_guard_can_be_disabled():
"""With the halt guard off, the halted flag is ignored and the entry fires."""
client = _FakeClient()
store = EventStore(":memory:")
svc = TradingExecutionService(
store,
executor=AlpacaPaperExecutor(store, client=client),
settings=ExecutionSettings(auto_approve=True, max_daily_loss_pct=0.5,
halt_guard_enabled=False),
session_id="t",
)
res = svc.submit_breakout_now("AAA", 10.0, 9.5, last_price=10.0, halted=True)
assert res["ok"] is True
def test_extension_ceiling_off_when_zero():
"""extension_max_pct=0 disables the ceiling (an over-extended price fires)."""
client = _FakeClient()
store = EventStore(":memory:")
svc = TradingExecutionService(
store,
executor=AlpacaPaperExecutor(store, client=client),
settings=ExecutionSettings(auto_approve=True, max_daily_loss_pct=0.5,
extension_max_pct=0.0),
session_id="t",
)
res = svc.submit_breakout_now("AAA", 10.0, 9.5, last_price=11.2)
assert res["ok"] is True
def test_breakout_now_rejects_bad_geometry():
store, svc = _svc(_FakeClient())
res = svc.submit_breakout_now("AAA", 10.0, 10.5, last_price=10.0) # stop > entry
assert res["ok"] is False and res["skipped"] == "bad_geometry"
def test_guard_never_cancels_a_filled_position():
"""The naked-stop fix: a stale-looking armed entry whose position is actually
OPEN at the broker must NOT be cancelled (that would strip its stop/TP)."""
from datetime import datetime, timedelta
store = EventStore(":memory:")
client = _FakeClient(positions=[{"symbol": "AAA", "qty": "100"}]) # AAA filled
svc = TradingExecutionService(
store, executor=AlpacaPaperExecutor(store, client=client),
settings=ExecutionSettings(entry_timeout_bars=1, entry_invalidate_pct=0.0,
max_daily_loss_pct=0.5),
session_id="t", price_provider=lambda s: 0.5, # below trigger -> would invalidate
)
# an entry that LOOKS stale (armed 5 min ago, price below trigger)
svc._armed["o1"] = {"symbol": "AAA", "entry_price": 1.0, "broker_order_id": "b1",
"armed_at": datetime.now() - timedelta(minutes=5), "checks": 0}
backed = svc.expire_stale_entries()
assert backed == [] # not backed out
assert client.canceled == [] # stop/TP legs NOT stripped
assert "o1" not in svc._armed # but we stop tracking the filled entry
def test_guard_grace_window_protects_just_armed_entry():
"""A marketable entry that just armed (still filling) must NOT be cancelled
even if price dipped below the trigger — cancelling strips its bracket."""
from datetime import datetime
store = EventStore(":memory:")
client = _FakeClient() # no positions reported yet (fill not reflected)
svc = TradingExecutionService(
store, executor=AlpacaPaperExecutor(store, client=client),
settings=ExecutionSettings(entry_invalidate_pct=0.0, entry_grace_seconds=5,
max_daily_loss_pct=0.5),
session_id="t", price_provider=lambda s: 0.5, # well below trigger
)
svc._armed["o1"] = {"symbol": "AAA", "entry_price": 1.0, "broker_order_id": "b1",
"armed_at": datetime.now(), "checks": 0} # armed just now
assert svc.expire_stale_entries() == [] # grace -> not cancelled
assert "o1" in svc._armed and client.canceled == []
def test_gap_cap_blocks_mid_band_but_squeeze_exception_readmits_real_monsters():
"""2026-07-06 (LHSW +278%, the trader's only real profit, capped out at 4:49am):
gap_max still blocks the dead 35-150% band, but a REAL mega-gapper — huge gap
AND a genuine volume surge — re-arms via the squeeze exception. A reverse-split
repricing (huge "gap", no volume) must stay excluded."""
b = ArmedTriggerBook(gap_min=10, gap_max=35, gap_squeeze_min=150,
gap_squeeze_rvol=5.0, rvol_min=2)
b.arm([
_cand("MID", gap=80.0, rvol=20.0), # dead band: capped
_cand("LHSW", gap=278.0, rvol=40.0), # real monster: admitted
_cand("SPLIT", gap=278.0, rvol=0.4), # reverse-split fake: excluded
])
assert b.triggers["MID"].state == WEAK
assert b.triggers["LHSW"].state == ARMED
assert b.triggers["SPLIT"].state == WEAK
def test_squeeze_exception_off_by_default():
b = ArmedTriggerBook(gap_min=10, gap_max=35, rvol_min=2)
b.arm([_cand("LHSW", gap=278.0, rvol=40.0)])
assert b.triggers["LHSW"].state == WEAK
def test_trigger_eligibility_uses_static_open_gap_not_dynamic():
"""2026-07-08 audit: TVRD's true overnight gap was 6% but its dynamic gap crossed
10% on an intraday rally and it traded as a 'gapper'. Once the session open is
known, eligibility must use the STATIC gap."""
b = ArmedTriggerBook(gap_min=10, gap_max=35, rvol_min=2)
c = _cand("TVRD", gap=13.2, rvol=6.0) # dynamic says 13.2%...
c["gap_open_pct"] = 6.0 # ...but the true overnight gap is 6%
b.arm([c])
assert b.triggers["TVRD"].state == WEAK
c2 = _cand("BABU", gap=25.0, rvol=6.0)
c2["gap_open_pct"] = 19.0 # true gapper stays armed
b.arm([c2])
assert b.triggers["BABU"].state == ARMED
def test_book_arms_on_rvol_tod_when_legacy_rvol_low():
"""The 07-20 miss: IREX legacy rvol 0.76 minutes after the open (cum-so-far
vs full-day mean can't reach 2x yet) while the time-of-day rvol showed 16.8.
tod must drive eligibility when present."""
b = ArmedTriggerBook(gap_min=3, rvol_min=2)
c = _cand("IREX", rvol=0.76); c["rvol_tod"] = 16.8
b.arm([c])
assert b.triggers["IREX"].state == ARMED
def test_book_weak_when_rvol_tod_low_even_if_present():
b = ArmedTriggerBook(gap_min=3, rvol_min=2)
c = _cand("DEAD", rvol=3.0); c["rvol_tod"] = 0.2 # tod wins when provided
b.arm([c])
assert b.triggers["DEAD"].state == WEAK
def test_book_falls_back_to_legacy_rvol_without_tod():
b = ArmedTriggerBook(gap_min=3, rvol_min=2)
b.arm([_cand("LEGACY", rvol=3.0)]) # no rvol_tod key at all
assert b.triggers["LEGACY"].state == ARMED
def _prime(b, sym):
b.arm([_cand(sym)])
return b.triggers[sym]
def test_fire_requires_fresh_cross_not_stale_break():
"""SNXX 2026-07-21: after a blind window the price already stood +1.6% above
the trigger — that is a stale break, not an entry. No fire until price pulls
back below the trigger and re-crosses."""
b = ArmedTriggerBook(gap_min=3, rvol_min=2, fresh_cross_tol=0.015)
_prime(b, "SNXX")
b.update_price("SNXX", 10.17) # first tick: +1.7% above trigger 10.0
assert b.fires() == []
b.update_price("SNXX", 9.95) # pullback below the level
b.update_price("SNXX", 10.02) # fresh re-cross
assert [t.symbol for t in b.fires()] == ["SNXX"]
def test_fire_allowed_at_the_break_within_tolerance():
b = ArmedTriggerBook(gap_min=3, rvol_min=2, fresh_cross_tol=0.015)
_prime(b, "GAP")
b.update_price("GAP", 10.01) # +0.1%: AT the break, no cross seen
assert [t.symbol for t in b.fires()] == ["GAP"]
def test_fire_normal_cross_still_works():
b = ArmedTriggerBook(gap_min=3, rvol_min=2)
_prime(b, "NORM")
b.update_price("NORM", 9.90)
assert b.fires() == []
b.update_price("NORM", 10.05)
assert [t.symbol for t in b.fires()] == ["NORM"]