From 6c339121f084b2a998c439f2f585ada3edf5ccd7 Mon Sep 17 00:00:00 2001 From: momentum dev Date: Tue, 16 Jun 2026 08:20:57 -0400 Subject: [PATCH] Circuit breaker: auto-flatten on breach When the daily-loss breaker trips it now also flattens the book: cancels unfilled (armed) entries and market-closes every open position via client.close_position (which cancels resting bracket legs). Configurable via TRADING_FLATTEN_ON_BREACH (default on). Best-effort per symbol; emits a 'halted_and_flattened' risk_rule_triggered event with the flatten summary. Verified: trip at -4% closes 2 seeded positions + cancels 1 armed entry. 67/67 green. Co-Authored-By: Claude Opus 4.8 --- GO_LIVE.md | 6 ++--- trading_execution.py | 55 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/GO_LIVE.md b/GO_LIVE.md index c1c0392..748008e 100644 --- a/GO_LIVE.md +++ b/GO_LIVE.md @@ -58,8 +58,9 @@ TRADING_MAX_CONCURRENT_POSITIONS=3 - **Manual approval instead of auto:** `TRADING_AUTO_APPROVE=0`, drop `--no-dashboard`, approve each signal at `http://127.0.0.1:8765`. - **Observe only (no orders):** `TRADING_EXECUTION_ENABLED=0`. -- The breaker halts **new** entries at -3%; it does **not** auto-flatten open - positions — exit those from the dashboard. +- The breaker at -3% halts **new** entries **and flattens the book** — cancels + unfilled entries + market-closes open positions. Set + `TRADING_FLATTEN_ON_BREACH=0` for halt-only (leave positions open). ## 5. Tuning (env vars) | var | default | meaning | @@ -84,7 +85,6 @@ PY ## Known limitations (honest) - `quality_score` is a constant 1.0, `float_rotation` is 0.0, `spread_pct` is null — these fields are not real metrics yet. -- The circuit breaker halts new entries but does not auto-flatten open positions. - News-catalyst discovery (RSS) exists but isn't wired into the watchlist. - Free Alpaca **IEX** data is thinner than SIP, especially premarket — some sub-$20 names show few/no bars until volume builds. diff --git a/trading_execution.py b/trading_execution.py index 1210ab7..b942e54 100644 --- a/trading_execution.py +++ b/trading_execution.py @@ -55,6 +55,8 @@ class ExecutionSettings: risk_per_trade_pct: float = 0.01 default_equity: float = 100_000.0 max_daily_loss_pct: float = 0.03 + # on a daily-loss breach, also flatten open positions + cancel unfilled entries + flatten_on_breach: bool = True # --- entry mechanism (Ross-Cameron-style; all tunable) ----------------- # reward target as a multiple of risk (entry + reward_multiple * (entry-stop)) reward_multiple: float = 2.0 @@ -93,6 +95,7 @@ class ExecutionSettings: risk_per_trade_pct=float(values.get("TRADING_RISK_PER_TRADE_PCT", "0.01")), default_equity=float(values.get("TRADING_DEFAULT_EQUITY", "100000")), max_daily_loss_pct=float(values.get("TRADING_MAX_DAILY_LOSS_PCT", "0.03")), + flatten_on_breach=flag("TRADING_FLATTEN_ON_BREACH", "1"), reward_multiple=float(values.get("TRADING_REWARD_MULTIPLE", "2.0")), entry_order_type=order_type, entry_timeout_bars=int(values.get("TRADING_ENTRY_TIMEOUT_BARS", "2")), @@ -162,6 +165,38 @@ class TradingExecutionService: if p.get("symbol") } + def _flatten_all(self, reason: str) -> dict: + """Cancel unfilled entries and market-close every open position. + + Used when the daily-loss circuit breaker trips: stop the bleeding by + cancelling resting entries and flattening the book. Best-effort — errors + are collected, not raised, so one bad symbol can't block the rest. + ``close_position`` also cancels the position's resting bracket legs. + """ + result: dict = {"cancelled_entries": 0, "closed_positions": [], "errors": []} + # 1) cancel unfilled (armed) entries we are tracking + for order_id in list(self._armed): + armed = self._armed.pop(order_id) + symbol = armed.get("symbol", "") + try: + self.executor.cancel_entry( + order_id, armed.get("broker_order_id"), symbol, reason + ) + result["cancelled_entries"] += 1 + except Exception as exc: # noqa: BLE001 + result["errors"].append(f"cancel {symbol}: {exc}") + self._requested_symbols.discard(symbol) + # 2) market-close open positions + client = getattr(self.executor, "client", None) + for symbol in sorted(self._held_symbols()): + try: + if client is not None and hasattr(client, "close_position"): + client.close_position(symbol) + result["closed_positions"].append(symbol) + except Exception as exc: # noqa: BLE001 + result["errors"].append(f"close {symbol}: {exc}") + return result + def _daily_loss_breach(self) -> bool: """True once the day's loss hits the circuit-breaker threshold. @@ -189,6 +224,19 @@ class TradingExecutionService: pnl_pct = (equity - baseline) / baseline if pnl_pct <= -max_loss: self._halted = True + flat = ( + self._flatten_all("daily_loss_circuit_breaker") + if self.settings.flatten_on_breach + else {"closed_positions": [], "cancelled_entries": 0} + ) + extra = "" + if self.settings.flatten_on_breach: + n_closed = len(flat["closed_positions"]) + n_cancel = flat["cancelled_entries"] + extra = ( + f" — flattened {n_closed} position(s), " + f"cancelled {n_cancel} unfilled entr{'y' if n_cancel == 1 else 'ies'}" + ) self.store.emit( RiskRuleTriggeredEvent( timestamp=datetime.now(), @@ -196,7 +244,7 @@ class TradingExecutionService: correlation_id=self.session_id, message=( f"Daily-loss circuit breaker tripped: {pnl_pct:+.2%} " - f"(limit -{max_loss:.0%}) — halting new entries" + f"(limit -{max_loss:.0%}) — halting new entries{extra}" ), rule_type="daily_loss", rule_value=max_loss, @@ -204,8 +252,11 @@ class TradingExecutionService: "equity": equity, "baseline_equity": baseline, "pnl_pct": round(pnl_pct, 4), + "flatten": flat, }, - action_taken="halted_new_entries", + action_taken="halted_and_flattened" + if self.settings.flatten_on_breach + else "halted_new_entries", ) ) return self._halted