From 11ab6c7b56f55bdea1628abacf378117a4c5c445 Mon Sep 17 00:00:00 2001 From: momentum dev Date: Thu, 18 Jun 2026 12:34:43 -0400 Subject: [PATCH] Exit manager: scope get_orders to held symbols (stop-leg truncation) Alpaca's orders `limit` keeps only the most-RECENT orders and counts child legs toward the cap. On a busy day the order list runs to hundreds (every backed-out entry is an order), so the exit manager's limit=100 snapshot silently dropped the stop legs of older OPEN positions. A stop-leg lookup then read "no stop" on a fully-protected position -> broken trailing/profit-lock for that name (and false-naked alarms in monitoring). Fix: get_orders gains a `symbols` filter; the exit manager scopes its snapshot to the handful of held names (no truncation possible). Verified live: an unscoped limit=100 query reported PLUG stop=None while the scoped query correctly surfaced PLUG's stop @2.65. Co-Authored-By: Claude Opus 4.8 --- alpaca_paper/client.py | 20 +++++++++++++++----- runtime/exit_manager.py | 13 +++++++++---- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/alpaca_paper/client.py b/alpaca_paper/client.py index da10d21..0e5f995 100644 --- a/alpaca_paper/client.py +++ b/alpaca_paper/client.py @@ -162,14 +162,24 @@ class AlpacaPaperClient: return self._cached("positions", lambda: self._trading("GET", "/positions")) def get_orders( - self, status: str = "all", limit: int = 100, nested: bool = True + self, status: str = "all", limit: int = 500, nested: bool = True, + symbols: list[str] | None = None, ) -> list[dict]: # nested=true includes bracket child legs (stop/target) so working risk # isn't under-reported in the orders snapshot. - return self._trading( - "GET", "/orders", - params={"status": status, "limit": limit, "nested": str(nested).lower()}, - ) + # + # IMPORTANT: Alpaca's `limit` truncates to the most-RECENT orders (and + # counts child legs toward the cap). On a busy day the order list runs to + # hundreds (every backed-out entry is an order), so a small limit silently + # drops the stop legs of older OPEN positions — a stop-leg lookup then + # reads "no stop" on a fully-protected position. Pass `symbols` to scope + # the query to the handful of held names (no truncation risk); that's what + # the exit manager / safety checks must do. + params: dict = {"status": status, "limit": min(limit, 500), + "nested": str(nested).lower()} + if symbols: + params["symbols"] = ",".join(symbols) + return self._trading("GET", "/orders", params=params) def submit_order( self, diff --git a/runtime/exit_manager.py b/runtime/exit_manager.py index 09a08f1..f475cf3 100644 --- a/runtime/exit_manager.py +++ b/runtime/exit_manager.py @@ -57,9 +57,13 @@ class LiveExitManager: _ACTIVE = {"held", "new", "accepted", "pending_new", "accepted_for_bidding", "partially_filled"} - def _all_orders(self): + def _all_orders(self, symbols=None): + # scope to the held symbols — Alpaca's `limit` keeps only the most-recent + # orders, so on a busy day an unscoped query drops older positions' stop + # legs and we'd misread a protected position as naked. try: - return self.client.get_orders(status="all", limit=100, nested=True) + return self.client.get_orders(status="all", limit=500, nested=True, + symbols=list(symbols) if symbols else None) except Exception: # noqa: BLE001 return None @@ -126,8 +130,9 @@ class LiveExitManager: if not held: return [] # fetch the orders snapshot ONCE per pass (status="all" is the heavy call; - # reuse it for every position's stop-leg + entry-time lookups) - orders = self._all_orders() + # reuse it for every position's stop-leg + entry-time lookups), scoped to + # the held symbols so a busy day's order volume can't truncate the legs. + orders = self._all_orders(held.keys()) actions: list[str] = [] for sym, p in held.items():