From b5909bc79319c3d48e6c1a98b6af80cfb9a8f078 Mon Sep 17 00:00:00 2001 From: momentum dev Date: Mon, 29 Jun 2026 20:16:24 -0400 Subject: [PATCH] Naked-stop & catastrophe enforcement: rest a protective stop when the market is closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KEEL was carried into after-hours as a naked long (its bracket stop leg never attached — 0 stop orders in its whole history). The enforcement tried to FLATTEN it with a market order every pass, but a market sell can't fill when the market is closed, so it churned submit->cancel ~every 30s (76 market-sells / 79 cancels, the "naked-stop enforced exit KEEL xN" spam) and left the position genuinely unprotected. Fix (runtime/exit_manager.py): both the naked-stop enforcement AND the catastrophe backstop now branch on the broker clock. Market OPEN -> flatten (a market exit fills, unchanged). Market CLOSED -> REST a protective sell-stop instead (GTC) — at breakeven when in profit, a catastrophe-distance stop below market when underwater — so the position survives to the open PROTECTED and the next pass sees the stop and stops enforcing. Catastrophe with an existing (loose) stop while closed just defers to it (no market churn, no double stop). New client bits: AlpacaPaperClient.get_clock() and a standalone stop_price on submit_order. 3 exit-manager tests added (open->flatten, closed->breakeven-rest, closed-underwater->catastrophe-rest); 7/7 green. Operationally: KEEL was protected at breakeven ($5.56) immediately and the loop now leaves that stop in place instead of churning. Co-Authored-By: Claude Opus 4.8 --- alpaca_paper/client.py | 8 ++++ runtime/exit_manager.py | 82 ++++++++++++++++++++++++++++----- tests/unit/test_exit_manager.py | 71 ++++++++++++++++++++++++---- 3 files changed, 140 insertions(+), 21 deletions(-) diff --git a/alpaca_paper/client.py b/alpaca_paper/client.py index d0e2b63..8054ea6 100644 --- a/alpaca_paper/client.py +++ b/alpaca_paper/client.py @@ -162,6 +162,11 @@ class AlpacaPaperClient: def get_account(self) -> dict: return self._cached("account", lambda: self._trading("GET", "/account")) + def get_clock(self) -> dict: + """Broker market clock: {is_open, next_open, next_close, timestamp}. The + authoritative open/closed check (handles weekends, holidays, half-days).""" + return self._trading("GET", "/clock") + def get_positions(self, fresh: bool = False) -> list[dict]: # fresh=True bypasses the TTL cache — REQUIRED for the naked-stop guard, # which must never decide an order is unfilled off a stale snapshot. @@ -224,6 +229,7 @@ class AlpacaPaperClient: order_type: str = "market", time_in_force: str = "day", limit_price: float | None = None, + stop_price: float | None = None, stop_loss_price: float | None = None, take_profit_price: float | None = None, client_order_id: str | None = None, @@ -238,6 +244,8 @@ class AlpacaPaperClient: } if limit_price is not None: body["limit_price"] = str(round(limit_price, 2)) + if stop_price is not None: # standalone stop / stop-limit trigger + body["stop_price"] = str(round(stop_price, 2)) if client_order_id: body["client_order_id"] = client_order_id if stop_loss_price is not None or take_profit_price is not None: diff --git a/runtime/exit_manager.py b/runtime/exit_manager.py index f54a6e1..2b1173e 100644 --- a/runtime/exit_manager.py +++ b/runtime/exit_manager.py @@ -97,6 +97,38 @@ class LiveExitManager: nested snapshot, reused to avoid a redundant fetch.""" cancel_protective_and_close(self.client, symbol, orders=orders) + def _market_open(self) -> bool: + """Broker clock — is the regular session open? A market FLATTEN can't fill + when closed (it just churns submit/cancel every pass), so the naked-stop + enforcement rests a protective stop instead while closed. Falls back to an + ET wall-clock check if the clock call fails; assumes OPEN if even that fails + (flatten is the safe intraday default).""" + try: + return bool(self.client.get_clock().get("is_open")) + except Exception: # noqa: BLE001 + try: + from datetime import datetime + from zoneinfo import ZoneInfo + now = datetime.now(ZoneInfo("America/New_York")) + return now.weekday() < 5 and (9, 30) <= (now.hour, now.minute) and now.hour < 16 + except Exception: # noqa: BLE001 + return True + + def _rest_protective_stop(self, symbol: str, p: dict, entry: float, cur: float) -> float: + """Place a resting sell-stop on a NAKED long so it survives after-hours and + guards the open — at BREAKEVEN when in profit; a catastrophe-distance stop just + below market when underwater (a sell-stop can't sit above the last price). + Returns the stop price placed.""" + qty = int(abs(float(p.get("qty") or 0))) + if cur and cur <= entry: + pct = self.cfg.catastrophe_pct or 0.10 + stop_px = round(cur * (1 - pct), 2) + else: + stop_px = round(entry, 2) # breakeven + self.client.submit_order(symbol=symbol, qty=qty, side="sell", + order_type="stop", stop_price=stop_px, time_in_force="gtc") + return stop_px + def _entry_time(self, symbol: str, orders): """UTC-naive fill time of the most recent FILLED buy entry for symbol.""" if orders is None: @@ -163,15 +195,28 @@ class LiveExitManager: self.cfg.catastrophe_risk_mult)): loss_pct = (entry - cur) / entry * 100 try: - self._flatten(sym, orders) + if self._market_open(): + self._flatten(sym, orders) + self._emit(sym, f"CATASTROPHE exit {sym} @~{cur:.4f} " + f"({loss_pct:.1f}% below entry)", "exit_catastrophe", + {"price": cur, "entry": entry, "stop": cur_stop}) + actions.append(f"{sym} CATASTROPHE-exit @{cur:.2f} (-{loss_pct:.0f}%)") + elif cur_stop is None: + # closed + naked: a market flatten can't fill — rest a protective + # stop below market so it fires at the open instead of churning. + stop_px = self._rest_protective_stop(sym, p, entry, cur) + self._emit(sym, f"CATASTROPHE protect {sym} @{stop_px:.2f} (market " + f"closed — rested a stop, {loss_pct:.1f}% below entry)", + "exit_catastrophe", {"price": cur, "entry": entry, "stop": stop_px}) + actions.append(f"{sym} CATASTROPHE rest-stop @{stop_px:.2f} (closed)") + else: + # closed but already has a stop — it fires at the open; don't + # market-churn and don't double up the protection. + continue self._tracked.pop(sym, None) self._naked_passes.pop(sym, None) - self._emit(sym, f"CATASTROPHE exit {sym} @~{cur:.4f} " - f"({loss_pct:.1f}% below entry)", "exit_catastrophe", - {"price": cur, "entry": entry, "stop": cur_stop}) - actions.append(f"{sym} CATASTROPHE-exit @{cur:.2f} (-{loss_pct:.0f}%)") except Exception as exc: # noqa: BLE001 - logger.warning("catastrophe exit failed %s: %s", sym, exc) + logger.warning("catastrophe enforcement failed %s: %s", sym, exc) continue # STOP ENFORCEMENT — a held position MUST have a live protective stop. @@ -185,14 +230,27 @@ class LiveExitManager: self._naked_passes[sym] = n if n >= grace: try: - self._flatten(sym, orders) + if self._market_open(): + # session open: a market flatten fills — the safe exit. + self._flatten(sym, orders) + self._emit(sym, f"naked-stop enforced exit {sym} " + f"(no live stop, {n} passes)", "exit_naked_stop", + {"passes": n, "price": cur, "entry": entry}) + actions.append(f"{sym} naked-stop exit (no live stop)") + else: + # closed: a market sell can't fill (it just churns every + # pass) — REST a protective stop so the position survives + # to the open protected, and the next pass sees the stop + # and stops enforcing. + stop_px = self._rest_protective_stop(sym, p, entry, cur) + self._emit(sym, f"naked-stop PROTECT {sym} @{stop_px:.2f} " + f"(market closed — rested a stop, {n} passes)", + "exit_naked_stop", {"passes": n, "stop": stop_px, + "entry": entry}) + actions.append(f"{sym} rested protective stop @{stop_px:.2f} (closed)") self._tracked.pop(sym, None) - self._emit(sym, f"naked-stop enforced exit {sym} " - f"(no live stop, {n} passes)", "exit_naked_stop", - {"passes": n, "price": cur, "entry": entry}) - actions.append(f"{sym} naked-stop exit (no live stop)") except Exception as exc: # noqa: BLE001 - logger.warning("naked-stop exit failed %s: %s", sym, exc) + logger.warning("naked-stop enforcement failed %s: %s", sym, exc) self._naked_passes.pop(sym, None) continue # don't run trail logic on a naked position self._naked_passes.pop(sym, None) # has a live stop diff --git a/tests/unit/test_exit_manager.py b/tests/unit/test_exit_manager.py index da8af47..df98276 100644 --- a/tests/unit/test_exit_manager.py +++ b/tests/unit/test_exit_manager.py @@ -8,12 +8,14 @@ from strategy.exits import ExitConfig, TRAIL_PRIOR_LOW class _FakeBroker: - def __init__(self, positions, stop_leg): + def __init__(self, positions, stop_leg, market_open=True): self._positions = positions self._stop_leg = stop_leg + self.market_open = market_open self.replaced = [] self.closed = [] self.canceled = [] + self.submitted = [] def get_positions(self): return self._positions @@ -21,6 +23,15 @@ class _FakeBroker: def get_orders(self, status="open", limit=200, nested=True, symbols=None): return [self._stop_leg] + def get_clock(self): + return {"is_open": self.market_open} + + def submit_order(self, symbol, qty, side="buy", order_type="market", + stop_price=None, time_in_force="day", **kw): + self.submitted.append({"symbol": symbol, "qty": qty, "side": side, + "type": order_type, "stop_price": stop_price}) + return {"id": "s"} + def replace_order(self, order_id, stop_price=None, **kw): self.replaced.append((order_id, stop_price)) return {"id": order_id} @@ -93,17 +104,22 @@ def test_flatten_cancels_protective_orders_then_closes(): assert broker.closed == ["AAA"] # then liquidated -def test_naked_position_flattened_after_grace(): +class _NakedBroker(_FakeBroker): + def get_orders(self, status="open", limit=200, nested=True, symbols=None): + # naked until a protective stop is rested, then reflect it (like the real + # broker) so the NEXT pass sees the stop and stops re-resting/enforcing. + return [{"id": "rested", "symbol": o["symbol"], "type": o["type"], + "side": o["side"], "stop_price": str(o["stop_price"]), "status": "accepted"} + for o in self.submitted] + + +def test_naked_position_flattened_after_grace_when_market_open(): """A held position with NO live protective stop (bracket leg never attached - or got stripped) is the NIVF case — flatten it after the grace passes.""" + or got stripped) is the NIVF case — flatten it after grace WHEN THE MARKET IS OPEN + (a market exit can fill).""" store = EventStore(":memory:") pos = [{"symbol": "AAA", "avg_entry_price": "10.0", "current_price": "10.1", "qty": "100"}] - - class _NakedBroker(_FakeBroker): - def get_orders(self, status="open", limit=200, nested=True, symbols=None): - return [] # no protective stop leg at all -> naked - - broker = _NakedBroker(pos, None) + broker = _NakedBroker(pos, None, market_open=True) mgr = LiveExitManager( broker, store, lambda s: _bars([(10.2, 10.0, 10.1)]), cfg=ExitConfig(enforce_stop_grace_passes=2, catastrophe_pct=0.10), @@ -113,6 +129,43 @@ def test_naked_position_flattened_after_grace(): assert broker.closed == [] mgr.manage() # pass 2: grace reached -> flatten assert broker.closed == [("AAA", None)] + assert broker.submitted == [] # flattened, not rest-a-stop + + +def test_naked_position_rests_stop_when_market_closed(): + """When the market is CLOSED a market flatten can't fill (it just churns every + pass), so enforcement RESTS a protective stop instead — at breakeven when in + profit. The next pass would see the stop and stop enforcing.""" + store = EventStore(":memory:") + pos = [{"symbol": "AAA", "avg_entry_price": "10.0", "current_price": "10.4", "qty": "100"}] + broker = _NakedBroker(pos, None, market_open=False) + mgr = LiveExitManager( + broker, store, lambda s: _bars([(10.5, 10.3, 10.4)]), + cfg=ExitConfig(enforce_stop_grace_passes=2, catastrophe_pct=0.10), + session_id="t", + ) + mgr.manage(); mgr.manage() # grace reached while CLOSED + assert broker.closed == [] # NOT flattened (would never fill) + assert len(broker.submitted) == 1 # rested a protective stop instead + o = broker.submitted[0] + assert o["side"] == "sell" and o["type"] == "stop" and o["qty"] == 100 + assert o["stop_price"] == 10.0 # breakeven (position is in profit) + + +def test_naked_underwater_rests_catastrophe_stop_when_closed(): + """Closed + naked + UNDERWATER: a sell-stop can't sit above the last price, so + enforcement rests it a catastrophe distance BELOW current, not at breakeven.""" + store = EventStore(":memory:") + pos = [{"symbol": "AAA", "avg_entry_price": "10.0", "current_price": "9.0", "qty": "100"}] + broker = _NakedBroker(pos, None, market_open=False) + mgr = LiveExitManager( + broker, store, lambda s: _bars([(9.1, 8.9, 9.0)]), + cfg=ExitConfig(enforce_stop_grace_passes=2, catastrophe_pct=0.10), + session_id="t", + ) + mgr.manage(); mgr.manage() + assert broker.closed == [] and len(broker.submitted) == 1 + assert broker.submitted[0]["stop_price"] == 8.1 # 9.0 * (1 - 0.10), below market def test_position_with_stop_not_flattened_as_naked():