diff --git a/alpaca_paper/client.py b/alpaca_paper/client.py index eb2f5c0..05254b3 100644 --- a/alpaca_paper/client.py +++ b/alpaca_paper/client.py @@ -8,6 +8,8 @@ from __future__ import annotations import json import logging +import threading +import time import urllib.error import urllib.parse import urllib.request @@ -32,6 +34,29 @@ class AlpacaPaperClient: # retry transient network/DNS errors (not HTTP 4xx/5xx) a couple times; # DNS failures fail fast, so this smooths brief blips without much latency self.max_retries = max_retries + # tiny TTL cache for hot read-only endpoints (account/positions). The + # breaker, sync, guard and exit manager each read these every pass; on a + # flaky-DNS host every call risks a multi-second resolve, so collapsing + # the redundant reads within a pass is the difference between ~2s and + # ~60s loop passes. Short TTL keeps risk checks effectively current. + self._cache: dict = {} + self._cache_lock = threading.Lock() + self.cache_ttl = 2.5 + + def _cached(self, key: str, fn): + now = time.monotonic() + with self._cache_lock: + hit = self._cache.get(key) + if hit and (now - hit[1]) < self.cache_ttl: + return hit[0] + val = fn() # network call OUTSIDE the lock + with self._cache_lock: + self._cache[key] = (val, time.monotonic()) + return val + + def _invalidate_cache(self): + with self._cache_lock: + self._cache.clear() # -- low level ------------------------------------------------------- @@ -87,10 +112,10 @@ class AlpacaPaperClient: # -- trading API ------------------------------------------------------- def get_account(self) -> dict: - return self._trading("GET", "/account") + return self._cached("account", lambda: self._trading("GET", "/account")) def get_positions(self) -> list[dict]: - return self._trading("GET", "/positions") + return self._cached("positions", lambda: self._trading("GET", "/positions")) def get_orders( self, status: str = "all", limit: int = 100, nested: bool = True @@ -114,6 +139,7 @@ class AlpacaPaperClient: take_profit_price: float | None = None, client_order_id: str | None = None, ) -> dict: + self._invalidate_cache() body: dict = { "symbol": symbol, "qty": str(qty), @@ -136,6 +162,7 @@ class AlpacaPaperClient: return self._trading("POST", "/orders", body=body) def cancel_order(self, order_id: str) -> None: + self._invalidate_cache() self._trading("DELETE", f"/orders/{order_id}") def replace_order( @@ -146,6 +173,7 @@ class AlpacaPaperClient: trail it — WITHOUT cancelling it. Cancelling a bracket leg tears down the OCO and leaves the position naked (the bug we already fixed once); a replace keeps the protection intact and just changes the price.""" + self._invalidate_cache() body: dict = {} if stop_price is not None: body["stop_price"] = str(round(stop_price, 2)) @@ -159,6 +187,7 @@ class AlpacaPaperClient: def close_position(self, symbol: str, qty: int | None = None, percentage: float | None = None) -> dict: + self._invalidate_cache() params = {} if qty is not None: params["qty"] = str(int(qty)) diff --git a/runtime/exit_manager.py b/runtime/exit_manager.py index ffa6a2c..d66ea90 100644 --- a/runtime/exit_manager.py +++ b/runtime/exit_manager.py @@ -56,16 +56,19 @@ class LiveExitManager: _ACTIVE = {"held", "new", "accepted", "pending_new", "accepted_for_bidding", "partially_filled"} - def _stop_leg(self, symbol: str): + def _all_orders(self): + try: + return self.client.get_orders(status="all", limit=100, nested=True) + except Exception: # noqa: BLE001 + return None + + def _stop_leg(self, symbol: str, orders): """(order_id, stop_price) of the live sell-stop protecting symbol. - Queries status="all" because the protective leg is a 'held' child of the - filled entry order — status="open" wouldn't return the filled parent and - so wouldn't surface the leg. + Uses pre-fetched ``orders`` (status="all" — the protective leg is a + 'held' child of the FILLED entry, which status="open" wouldn't surface). """ - try: - orders = self.client.get_orders(status="all", limit=100, nested=True) - except Exception: # noqa: BLE001 + if orders is None: return (None, None) for o in orders: for c in [o, *(o.get("legs") or [])]: @@ -77,11 +80,9 @@ class LiveExitManager: return (c.get("id"), float(sp) if sp else None) return (None, None) - def _entry_time(self, symbol: str): + def _entry_time(self, symbol: str, orders): """UTC-naive fill time of the most recent FILLED buy entry for symbol.""" - try: - orders = self.client.get_orders(status="all", limit=100, nested=True) - except Exception: # noqa: BLE001 + if orders is None: return None best = None for o in orders: @@ -121,6 +122,11 @@ class LiveExitManager: for sym in list(self._tracked): # stop tracking closed names if sym not in held: self._tracked.pop(sym, None) + 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() actions: list[str] = [] for sym, p in held.items(): @@ -134,12 +140,12 @@ class LiveExitManager: if entry <= 0: continue if sym not in self._tracked: - _leg, cur_stop = self._stop_leg(sym) + _leg, cur_stop = self._stop_leg(sym, orders) if cur_stop is None or cur_stop >= entry: continue # no usable protective stop yet (or above entry) # trail from the ACTUAL entry fill time (so high-water/R reflect # the move since entry, not since we first noticed the position) - entry_ts = self._entry_time(sym) or _last_ts(bars) + entry_ts = self._entry_time(sym, orders) or _last_ts(bars) self._tracked[sym] = _Tracked(entry, cur_stop, entry_ts, cur_stop) tr = self._tracked[sym] @@ -175,7 +181,7 @@ class LiveExitManager: logger.warning("scale-out failed %s: %s", sym, exc) # (3) ratchet the protective stop UP (breakeven / trail) via replace - leg_id, cur_stop = self._stop_leg(sym) + leg_id, cur_stop = self._stop_leg(sym, orders) if cur_stop is not None and d.desired_stop > cur_stop + 0.01: last_px = float(p.get("current_price") or 0.0) if last_px > 0 and d.desired_stop >= last_px: