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):
|
||||
|
||||
Reference in new issue
Block a user