Files
momentum-system/tests/unit/test_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

404 lines
16 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", 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