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 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Opus 4.8 committed 2026-06-18 12:34:43 -04:00
1 parent 1cd82de6a8
commit 11ab6c7b56
2 files changed
+24 -9

No files matched your search

+15 -5
View File
@@ -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,
+9 -4
View File
@@ -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():