Files
momentum-system/alpaca_paper/client.py
momentum devandClaude Opus 4.8 e74539d449 Resiliency + risk + monitoring: SIP backfill, freshness watchdog, gross cap, naked alarm
From the multi-agent audit (ship-now, low-overfit-risk cluster):

INGESTION RESILIENCY
- client._request: 429/5xx now retry with Retry-After + exponential backoff
  (previously hard-failed, feeding silent-stale ingest).
- SIP backfill feed (settings.backfill_feed='sip'): live realtime stays IEX, but
  backtest/backfill ingestion uses the full consolidated tape — IEX sees only
  ~2-5% of true volume (verified: AAPL 22,797 vs 452,704), which was silently
  corrupting every rvol/$-volume decision and backtest. eod_replay wired to it.
- step_ingest freshness watchdog: emits a STALE event + board note when the
  newest bar is older than INGEST_STALE_SECONDS during RTH (45% of passes
  historically returned 0 rows with no error). Optional hard arm-block
  (INGEST_STALE_BLOCK, off by default).

RISK
- Portfolio gross-notional cap (TRADING_MAX_GROSS_NOTIONAL_PCT=0.60): both sizing
  paths now cap each entry to the remaining gross budget, so 6 x 16% can't pile
  the book into ~96% gross of correlated small-caps. Blocks/shrinks late entries.
- Fixed the duplicate TRADING_MAX_CONCURRENT_POSITIONS in .env.example.

MONITORING (UI)
- Naked/unprotected-position alarm: _unprotected_positions joins the latest
  position + order snapshots (order sync now flattens legs) and the TUI board
  shows a red UNPROTECTED banner the instant a held long lacks stop coverage —
  the exact blind spot when APWC/NOWL went naked. Verified it flags today's
  3 overnight positions.

Tests: gross-notional cap blocks when budget full; 50 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:18:36 -04:00

368 lines
15 KiB
Python

"""Minimal Alpaca REST client (stdlib only — no SDK dependency).
Covers what the system needs: account, positions, orders, order submission
and cancellation, minute bars, latest trades, and the most-actives screener.
"""
from __future__ import annotations
import json
import logging
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from alpaca_paper.settings import AlpacaPaperSettings
logger = logging.getLogger(__name__)
def _install_dns_cache() -> None:
"""Cache DNS resolution process-wide (the real fix for the flaky-DNS host).
The WSL host resolves the Alpaca hostnames slowly/intermittently (~5s on ~1
in 5 lookups). urllib opens a fresh connection — and re-resolves — on EVERY
request, so across ~20 calls/pass this stretched loop passes to 60-120s.
Caching successful lookups for a few minutes pays the slow resolve at most
once per host. Opt out with ALPACA_DNS_CACHE=0.
"""
import os
import socket
import time as _t
if os.environ.get("ALPACA_DNS_CACHE", "1").strip().lower() in {"0", "false", "no", "off"}:
return
if getattr(socket.getaddrinfo, "_is_cached", False):
return
orig = socket.getaddrinfo
ttl = float(os.environ.get("ALPACA_DNS_TTL", "300"))
cache: dict = {}
def cached_getaddrinfo(host, *args, **kwargs):
key = (host, args, tuple(sorted(kwargs.items())))
now = _t.monotonic()
hit = cache.get(key)
if hit and now - hit[1] < ttl:
return hit[0]
res = orig(host, *args, **kwargs)
cache[key] = (res, now)
return res
cached_getaddrinfo._is_cached = True # type: ignore[attr-defined]
socket.getaddrinfo = cached_getaddrinfo
_install_dns_cache()
class AlpacaApiError(RuntimeError):
def __init__(self, status: int, body: str):
super().__init__(f"Alpaca API error {status}: {body[:300]}")
self.status = status
self.body = body
class AlpacaPaperClient:
def __init__(self, settings: AlpacaPaperSettings | None = None, timeout: int = 15,
max_retries: int = 2):
self.settings = settings or AlpacaPaperSettings.from_env()
self.timeout = timeout
# 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 -------------------------------------------------------
def _headers(self) -> dict[str, str]:
return {
"APCA-API-KEY-ID": self.settings.api_key,
"APCA-API-SECRET-KEY": self.settings.secret_key,
"Content-Type": "application/json",
}
def _request(
self, method: str, url: str, params: dict | None = None, body: dict | None = None
):
if params:
qs = urllib.parse.urlencode(
{k: v for k, v in params.items() if v is not None}
)
url = f"{url}?{qs}"
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
url, data=data, headers=self._headers(), method=method
)
import time as _time
for attempt in range(self.max_retries + 1):
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
raw = resp.read().decode()
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as exc:
# 429 (rate limit) and 5xx (server) are TRANSIENT — retry with
# backoff, honoring Retry-After. Previously these hard-failed,
# which fed silent-stale ingest (a rate-limited bar fetch looked
# like a clean "0 rows" pass). Other 4xx are real — surface them.
if exc.code in (429, 500, 502, 503, 504) and attempt < self.max_retries:
retry_after = exc.headers.get("Retry-After") if exc.headers else None
try:
delay = float(retry_after) if retry_after else 0.0
except (TypeError, ValueError):
delay = 0.0
_time.sleep(max(delay, 0.5 * (2 ** attempt))) # exp backoff floor
continue
raise AlpacaApiError(exc.code, exc.read().decode(errors="replace")) from exc
except urllib.error.URLError:
# transient network/DNS (e.g. gaierror) — retry a couple times
if attempt >= self.max_retries:
raise
_time.sleep(0.4 * (attempt + 1))
raise RuntimeError("request retries exhausted") # unreachable
def _trading(self, method: str, path: str, **kw):
return self._request(
method, f"{self.settings.trading_base_url}/v2{path}", **kw
)
def _data(self, method: str, path: str, **kw):
return self._request(method, f"{self.settings.data_base_url}{path}", **kw)
@staticmethod
def _chunked(items: list[str], size: int = 100):
"""Yield symbol batches; Alpaca caps multi-symbol requests (~100)."""
for i in range(0, len(items), size):
yield items[i : i + size]
# -- trading API -------------------------------------------------------
def get_account(self) -> dict:
return self._cached("account", lambda: self._trading("GET", "/account"))
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.
if fresh:
val = self._trading("GET", "/positions")
with self._cache_lock:
self._cache["positions"] = (val, time.monotonic())
return val
return self._cached("positions", lambda: self._trading("GET", "/positions"))
def get_orders(
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.
#
# 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(), "direction": "desc"}
if symbols:
# scoped to a few names — well under the cap, one page is complete
params["symbols"] = ",".join(symbols)
return self._trading("GET", "/orders", params=params)
# unscoped: page backwards with `until` so the whole history is captured,
# not just the most-recent page (the journal needs every fill, incl. early
# ones, or realized P&L silently drops the morning's round-trips). Dedup by
# id across page boundaries; cap pages as a runaway guard.
out: list[dict] = []
seen: set = set()
until: str | None = None
for _ in range(20):
page_params = dict(params)
if until:
page_params["until"] = until
page = self._trading("GET", "/orders", params=page_params) or []
fresh = [o for o in page if o.get("id") not in seen]
for o in fresh:
seen.add(o.get("id"))
out.extend(fresh)
if len(page) < params["limit"]:
break # last page
subs = [o.get("submitted_at") for o in page if o.get("submitted_at")]
if not subs:
break
until = min(subs) # next page: orders older than this one
return out
def submit_order(
self,
symbol: str,
qty: int,
side: str = "buy",
order_type: str = "market",
time_in_force: str = "day",
limit_price: float | None = None,
stop_loss_price: float | None = None,
take_profit_price: float | None = None,
client_order_id: str | None = None,
) -> dict:
self._invalidate_cache()
body: dict = {
"symbol": symbol,
"qty": str(qty),
"side": side,
"type": order_type,
"time_in_force": time_in_force,
}
if limit_price is not None:
body["limit_price"] = str(round(limit_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:
body["order_class"] = "bracket" if (
stop_loss_price and take_profit_price
) else "oto"
if stop_loss_price is not None:
body["stop_loss"] = {"stop_price": str(round(stop_loss_price, 2))}
if take_profit_price is not None:
body["take_profit"] = {"limit_price": str(round(take_profit_price, 2))}
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(
self, order_id: str, stop_price: float | None = None,
limit_price: float | None = None, qty: int | None = None,
) -> dict:
"""PATCH an open order — e.g. move a bracket STOP leg up to breakeven or
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))
if limit_price is not None:
body["limit_price"] = str(round(limit_price, 2))
if qty is not None:
body["qty"] = str(int(qty))
if not body:
return {}
return self._trading("PATCH", f"/orders/{order_id}", body=body)
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))
elif percentage is not None:
params["percentage"] = str(round(percentage, 4))
return self._trading("DELETE", f"/positions/{symbol}",
params=params or None)
# -- market data API ----------------------------------------------------
def get_minute_bars(
self,
symbols: list[str],
start_iso: str,
end_iso: str | None = None,
limit: int = 10_000,
feed: str | None = None,
) -> dict[str, list[dict]]:
"""Fetch 1-minute bars, batching symbols (cap-safe) + following pagination."""
all_bars: dict[str, list[dict]] = {s: [] for s in symbols}
for chunk in self._chunked(symbols, 100):
page_token = None
while True:
params = {
"symbols": ",".join(chunk),
"timeframe": "1Min",
"start": start_iso,
"end": end_iso,
"limit": limit,
"feed": feed or self.settings.feed,
"adjustment": "raw",
"page_token": page_token,
}
payload = self._data("GET", "/v2/stocks/bars", params=params)
for symbol, bars in (payload.get("bars") or {}).items():
all_bars.setdefault(symbol, []).extend(bars or [])
page_token = payload.get("next_page_token")
if not page_token:
break
return all_bars
def get_latest_trades(self, symbols: list[str]) -> dict[str, dict]:
trades: dict[str, dict] = {}
for chunk in self._chunked(symbols, 100):
payload = self._data(
"GET",
"/v2/stocks/trades/latest",
params={"symbols": ",".join(chunk), "feed": self.settings.feed},
)
trades.update(payload.get("trades") or {})
return trades
def get_most_actives(self, top: int = 20, by: str = "volume") -> list[dict]:
payload = self._data(
"GET",
"/v1beta1/screener/stocks/most-actives",
params={"top": top, "by": by},
)
return payload.get("most_actives") or []
def get_daily_bars(
self, symbols: list[str], start_iso: str, end_iso: str | None = None
) -> dict[str, list[dict]]:
"""Daily bars, batching symbols (cap-safe) + following pagination."""
all_bars: dict[str, list[dict]] = {}
for chunk in self._chunked(symbols, 100):
page_token = None
while True:
payload = self._data(
"GET",
"/v2/stocks/bars",
params={
"symbols": ",".join(chunk),
"timeframe": "1Day",
"start": start_iso,
"end": end_iso,
"limit": 10_000,
"feed": self.settings.feed,
"adjustment": "raw",
"page_token": page_token,
},
)
for symbol, bars in (payload.get("bars") or {}).items():
all_bars.setdefault(symbol, []).extend(bars or [])
page_token = payload.get("next_page_token")
if not page_token:
break
return all_bars