diff --git a/run_live_paper.py b/run_live_paper.py index b353ab7..2ae21c7 100644 --- a/run_live_paper.py +++ b/run_live_paper.py @@ -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): diff --git a/tests/unit/test_triggers.py b/tests/unit/test_triggers.py index 85300d3..7e5ff56 100644 --- a/tests/unit/test_triggers.py +++ b/tests/unit/test_triggers.py @@ -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 diff --git a/trading_execution.py b/trading_execution.py index 2a86a7b..484d239 100644 --- a/trading_execution.py +++ b/trading_execution.py @@ -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