Entry guards: anti-chase extension ceiling + halt skip

Addresses the failure mode from the source trader's recap ("+100% move then 3
halts down on bad news") and our own data: parsing 22 real live-ORB fires, the
median entry was already +8% above the trigger, with a disaster tail at 26-64%
(NEOV x4 @27%, ICCM @64%/26%, BIRD @30%) — i.e. the system was chasing spike-tops
that reverse, and there was no guard against entering a halted name.

Both guards sit in submit_breakout_now (the single choke point every long entry
funnels through), as silent skips matching the existing gates:
- extension ceiling: block when last_price > trigger * (1 + extension_max_pct).
  A clean break crosses the ORB high smoothly (~0%); a violent gap-THROUGH (a
  parabolic spike, or a halt reopening past the level) lands far above it.
  Default 0.15 is calibrated to the data's dead zone: real entries cluster
  <=9.8%, nothing fires in 10-26%, then the disaster tail — so 15% blocks every
  chase and passes every normal break. Tunable via TRADING_EXTENSION_MAX_PCT;
  0 disables.
- halt guard: skip a name flagged halted (can't exit during a halt; it gaps
  through the stop on resume). Detection is a bar-gap heuristic in the loop
  (_recently_halted): during RTH the symbol's freshest bar is older than
  TRADING_HALT_MAX_BAR_GAP_MIN (default 3) WHILE ingestion is globally healthy,
  so a feed hiccup can't block the whole book. LULD halts run >=5 min so 3 min
  catches a real halt without tripping on normal IEX lag.

The fast-trigger loop passes halted=_recently_halted(sym) and logs TRIGGER
BLOCKED once per symbol. tests/unit/test_triggers.py: +6 (24 pass; 146 total).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Opus 4.8 committed 2026-06-19 07:24:51 -04:00
1 parent 5ed038feb4
commit 297cb905d5
3 files changed
+162 -1

No files matched your search

+53 -1
View File
@@ -396,6 +396,46 @@ def main(argv: list[str] | None = None) -> int:
# holiday/early-close aware (was weekday<5 only, which traded holidays)
return is_regular_hours()
def _symbol_bar_age(symbol):
"""Seconds since this symbol's newest minute bar today (UTC), or None."""
from datetime import timezone
try:
row = rt["research_con"].execute(
"SELECT max(timestamp) FROM minute_bars "
"WHERE symbol = ? AND session_date = ?",
[symbol, _now_session_date()]).fetchone()
if not row or not row[0]:
return None
ts = row[0]
if isinstance(ts, str):
ts = datetime.fromisoformat(ts.replace("Z", "+00:00"))
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - ts).total_seconds()
except Exception: # noqa: BLE001
return None
def _recently_halted(symbol):
"""Halt heuristic for the entry guard: during RTH this symbol has gone
silent (freshest bar older than the configured gap) WHILE ingestion is
otherwise healthy (some name printed recently). For the liquid gappers we
trade, a multi-minute silence is a trading halt, not thin coverage — and
entering a halted name is the "+100% then halts down" trap. Conservative
on purpose: a false positive just skips one entry (it retries next tick)."""
ex = rt.get("execution")
if ex is None or not ex.settings.halt_guard_enabled or not _is_rth():
return False
gap_s = ex.settings.halt_max_bar_gap_min * 60.0
if gap_s <= 0:
return False
sym_age = _symbol_bar_age(symbol)
if sym_age is None or sym_age <= gap_s:
return False
# only blame the symbol if ingestion is healthy globally — otherwise it's
# an ingest lag, not a halt, and we must not block every name on a hiccup
global_age = _freshest_bar_age()
return global_age is not None and global_age <= gap_s
def step_ingest():
if not client:
return "skipped (no keys)"
@@ -613,6 +653,8 @@ def main(argv: list[str] | None = None) -> int:
pass
return out
_block_logged: set[str] = set() # dedup TRIGGER BLOCKED spam (re-checked every pass)
def _trigger_pass():
snap = book.snapshot()
syms = [r["symbol"] for r in snap]
@@ -621,6 +663,7 @@ def main(argv: list[str] | None = None) -> int:
for s, px in _batch_prices(syms).items():
book.update_price(s, px)
fired: list[str] = []
blocked: list[str] = []
if rt["execution"] is not None:
try:
book.mark_filled(rt["execution"]._held_symbols())
@@ -629,11 +672,18 @@ def main(argv: list[str] | None = None) -> int:
for t in book.fires():
res = rt["execution"].submit_breakout_now(
t.symbol, t.trigger, t.stop, last_price=t.price,
cum_volume=t.cum_volume,
cum_volume=t.cum_volume, halted=_recently_halted(t.symbol),
)
if res.get("ok"):
book.mark_fired(t.symbol)
fired.append(t.symbol)
elif res.get("skipped") in ("over_extended", "halted_symbol"):
# surface the anti-chase / halt skips once per symbol (they
# re-check every 4s; routine dedup skips stay quiet entirely)
key = f"{t.symbol}:{res['skipped']}"
if key not in _block_logged:
_block_logged.add(key)
blocked.append(key)
snap = book.snapshot()
armed_n = sum(1 for r in snap if r["state"] == "armed")
rt["store"].emit(
@@ -648,6 +698,8 @@ def main(argv: list[str] | None = None) -> int:
)
if fired:
print(f"[{datetime.now():%H:%M:%S}] TRIGGER FIRED {', '.join(fired)}", flush=True)
if blocked:
print(f"[{datetime.now():%H:%M:%S}] TRIGGER BLOCKED {', '.join(blocked)}", flush=True)
def trigger_loop():
while not stop_event.wait(trigger_interval):
+69
View File
@@ -209,6 +209,75 @@ def test_breakout_now_blocks_when_gross_notional_full():
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_allows_clean_break_within_ceiling():
"""A clean break crosses the trigger smoothly (+3%, under the 6% 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
+40
View File
@@ -132,6 +132,26 @@ class ExecutionSettings:
# only bench a closed name if it exited DOWN more than this fraction — a real
# stop-out, not a scratch/win. 0 = bench on any close (the blunt original).
reentry_min_loss_pct: float = 0.01
# --- anti-chase / halt protection (the "+100% then 3 halts down" failure) ---
# Block a live entry whose price has already extended more than this fraction
# ABOVE the trigger (opening-range high). A clean break crosses the level
# smoothly (~0% extension and passes); a violent gap-THROUGH — a parabolic
# spike, or a halt reopening past the level — lands far above it, and chasing
# that buys the top tick that then reverses/halts down. 0 = off.
# Default 0.15 calibrated on real fires: historical entries cluster <=9.8%
# extension, with a clean gap to a disaster tail at 26-64% (NEOV x4 @27%,
# ICCM @64%/26%, BIRD @30%). 15% sits in that dead zone — it blocks the
# spike-top chases and passes every normal breakout.
extension_max_pct: float = 0.15
# Skip entries on a name that looks HALTED: in a trading halt you can't exit
# and it gaps through your stop on resume. The caller passes a per-symbol
# halt flag (a bar-gap heuristic over the liquid gappers we trade). This flag
# only gates whether that signal is honoured.
halt_guard_enabled: bool = True
# treat a symbol as halted when, during RTH, its freshest minute bar is older
# than this many minutes WHILE other names are still printing (LULD halts run
# >= 5 min, so 3 catches a real halt without tripping on normal IEX lag).
halt_max_bar_gap_min: float = 3.0
@classmethod
def from_env(cls, env: dict[str, str] | None = None) -> "ExecutionSettings":
@@ -184,6 +204,13 @@ class ExecutionSettings:
reentry_min_loss_pct=float(
values.get("TRADING_REENTRY_MIN_LOSS_PCT", "0.01")
),
extension_max_pct=float(
values.get("TRADING_EXTENSION_MAX_PCT", "0.15")
),
halt_guard_enabled=flag("TRADING_HALT_GUARD_ENABLED", "1"),
halt_max_bar_gap_min=float(
values.get("TRADING_HALT_MAX_BAR_GAP_MIN", "3")
),
)
@@ -642,6 +669,7 @@ class TradingExecutionService:
last_price: float | None = None,
reason: str = "orb_live_break",
cum_volume: float = 0.0,
halted: bool = False,
) -> dict:
"""Fire a breakout entry immediately on a LIVE price cross.
@@ -677,6 +705,18 @@ class TradingExecutionService:
if stop_f >= entry_ref or trigger_f <= 0:
return {"ok": False, "skipped": "bad_geometry"}
# Anti-chase ceiling: a clean break crosses the trigger smoothly (~0%
# extension), but a violent gap-THROUGH — a parabolic spike, or a halt
# reopening past the level — lands far above it. Chasing that buys the top
# tick that then reverses/halts down (the "+100% then halts down" trap).
ext_max = self.settings.extension_max_pct
if ext_max > 0 and entry_ref > trigger_f * (1.0 + ext_max):
return {"ok": False, "skipped": "over_extended"}
# Halt guard: you can't exit during a trading halt and it gaps through the
# stop on resume — don't enter a name the caller flagged as halted.
if halted and self.settings.halt_guard_enabled:
return {"ok": False, "skipped": "halted_symbol"}
eq = self._current_equity()
liq = self.settings.liquidity_max_volume_pct
max_shares = int(liq * cum_volume) if (liq > 0 and cum_volume > 0) else None