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():