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>
368 lines
15 KiB
Python
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
|