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:
1 parent
5ed038feb4
commit
297cb905d5
3 files changed
+162
-1
No files matched your search
+53
-1
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user