Skip to content

Commit 68b9aa5

Browse files
authored
Handle Schwab quote rate limits
1 parent 0c6f7d6 commit 68b9aa5

2 files changed

Lines changed: 126 additions & 11 deletions

File tree

application/runtime_broker_adapters.py

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import time
56
from dataclasses import dataclass
67
from datetime import date, datetime, timedelta, timezone
78
from typing import Any
@@ -22,13 +23,25 @@ def _utcnow() -> datetime:
2223

2324

2425
_NEW_YORK_TZ = ZoneInfo("America/New_York")
26+
_QUOTE_RATE_LIMIT_MAX_ATTEMPTS = 3
27+
_QUOTE_RATE_LIMIT_BACKOFF_SECONDS = (0.5, 1.5)
2528

2629

2730
def _market_date(value: datetime) -> date:
2831
normalized = value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
2932
return normalized.astimezone(_NEW_YORK_TZ).date()
3033

3134

35+
def _is_quote_rate_limit_error(exc: Exception) -> bool:
36+
status_code = getattr(exc, "status_code", None)
37+
if status_code == 429:
38+
return True
39+
response = getattr(exc, "response", None)
40+
if getattr(response, "status_code", None) == 429:
41+
return True
42+
return "429" in str(exc)
43+
44+
3245
@dataclass(frozen=True)
3346
class SchwabRuntimeBrokerAdapters:
3447
managed_symbols: tuple[str, ...]
@@ -45,15 +58,12 @@ def build_market_data_port(self, client):
4558
quote_cache: dict[str, QuoteSnapshot] = {}
4659
price_series_cache: dict[str, PriceSeries] = {}
4760

48-
def load_quote(symbol: str) -> QuoteSnapshot:
49-
normalized_symbol = str(symbol).strip().upper()
50-
cached = quote_cache.get(normalized_symbol)
51-
if cached is not None:
52-
return cached
53-
raw_quotes = self.fetch_quotes_fn(client, [normalized_symbol])
54-
raw_snapshot = raw_quotes[normalized_symbol]
55-
snapshot = QuoteSnapshot(
56-
symbol=normalized_symbol,
61+
def normalize_quote_symbol(symbol: str) -> str:
62+
return str(symbol).strip().upper()
63+
64+
def build_quote_snapshot(symbol: str, raw_snapshot) -> QuoteSnapshot:
65+
return QuoteSnapshot(
66+
symbol=symbol,
5767
as_of=self.clock(),
5868
last_price=float(raw_snapshot.last_price),
5969
ask_price=(
@@ -67,8 +77,47 @@ def load_quote(symbol: str) -> QuoteSnapshot:
6777
else None
6878
),
6979
)
70-
quote_cache[normalized_symbol] = snapshot
71-
return snapshot
80+
81+
def quote_batch_symbols(requested_symbol: str) -> tuple[str, ...]:
82+
symbols = [normalize_quote_symbol(symbol) for symbol in self.managed_symbols]
83+
symbols.append(requested_symbol)
84+
return tuple(dict.fromkeys(symbol for symbol in symbols if symbol))
85+
86+
def fetch_and_cache_quotes(symbols: tuple[str, ...]) -> None:
87+
missing = tuple(symbol for symbol in symbols if symbol not in quote_cache)
88+
if not missing:
89+
return
90+
last_error: Exception | None = None
91+
for attempt in range(_QUOTE_RATE_LIMIT_MAX_ATTEMPTS):
92+
try:
93+
raw_quotes = self.fetch_quotes_fn(client, list(missing))
94+
break
95+
except Exception as exc:
96+
last_error = exc
97+
if (
98+
attempt >= _QUOTE_RATE_LIMIT_MAX_ATTEMPTS - 1
99+
or not _is_quote_rate_limit_error(exc)
100+
):
101+
raise
102+
time.sleep(
103+
_QUOTE_RATE_LIMIT_BACKOFF_SECONDS[
104+
min(attempt, len(_QUOTE_RATE_LIMIT_BACKOFF_SECONDS) - 1)
105+
]
106+
)
107+
else: # pragma: no cover - loop always exits through break or raise
108+
raise last_error or RuntimeError("Schwab quote fetch failed")
109+
for symbol in missing:
110+
raw_snapshot = raw_quotes.get(symbol)
111+
if raw_snapshot is not None:
112+
quote_cache[symbol] = build_quote_snapshot(symbol, raw_snapshot)
113+
114+
def load_quote(symbol: str) -> QuoteSnapshot:
115+
normalized_symbol = str(symbol).strip().upper()
116+
cached = quote_cache.get(normalized_symbol)
117+
if cached is not None:
118+
return cached
119+
fetch_and_cache_quotes(quote_batch_symbols(normalized_symbol))
120+
return quote_cache[normalized_symbol]
72121

73122
def load_price_series(symbol: str) -> PriceSeries:
74123
normalized_symbol = str(symbol).strip().upper()

tests/test_runtime_broker_adapters.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from datetime import datetime, timezone
44
from types import SimpleNamespace
55

6+
from application import runtime_broker_adapters as broker_adapters_module
67
from application.runtime_broker_adapters import build_runtime_broker_adapters
78

89

@@ -70,3 +71,68 @@ def fail_quotes(_client, _symbols):
7071
history = adapters.build_price_history(adapters.build_market_data_port(object()), "SOXX")
7172

7273
assert [point["close"] for point in history] == [105.0]
74+
75+
76+
def test_market_data_port_batches_managed_quotes_and_caches_results():
77+
observed_calls = []
78+
79+
adapters = build_runtime_broker_adapters(
80+
managed_symbols=("SOXL", "SOXX", "BOXX"),
81+
fetch_account_snapshot_fn=None,
82+
fetch_quotes_fn=lambda _client, symbols: (
83+
observed_calls.append(tuple(symbols)),
84+
{
85+
symbol: SimpleNamespace(
86+
last_price=float(index + 1),
87+
bid_price=None,
88+
ask_price=None,
89+
)
90+
for index, symbol in enumerate(symbols)
91+
},
92+
)[-1],
93+
fetch_daily_price_history_fn=lambda _client, _symbol: [],
94+
submit_equity_order_fn=None,
95+
clock=lambda: datetime(2026, 5, 27, 19, 45, tzinfo=timezone.utc),
96+
)
97+
98+
market_data_port = adapters.build_market_data_port(object())
99+
quote_a = market_data_port.get_quote("SOXL")
100+
quote_b = market_data_port.get_quote("BOXX")
101+
102+
assert quote_a.symbol == "SOXL"
103+
assert quote_b.symbol == "BOXX"
104+
assert observed_calls == [("SOXL", "SOXX", "BOXX")]
105+
106+
107+
def test_market_data_port_retries_rate_limited_quote_batch(monkeypatch):
108+
observed_calls = []
109+
sleeps = []
110+
111+
def fetch_quotes(_client, symbols):
112+
observed_calls.append(tuple(symbols))
113+
if len(observed_calls) == 1:
114+
raise RuntimeError("Quotes failed: 429")
115+
return {
116+
symbol: SimpleNamespace(last_price=10.0, bid_price=None, ask_price=None)
117+
for symbol in symbols
118+
}
119+
120+
monkeypatch.setattr(
121+
broker_adapters_module.time,
122+
"sleep",
123+
lambda seconds: sleeps.append(seconds),
124+
)
125+
adapters = build_runtime_broker_adapters(
126+
managed_symbols=("SOXL", "SOXX"),
127+
fetch_account_snapshot_fn=None,
128+
fetch_quotes_fn=fetch_quotes,
129+
fetch_daily_price_history_fn=lambda _client, _symbol: [],
130+
submit_equity_order_fn=None,
131+
clock=lambda: datetime(2026, 5, 27, 19, 45, tzinfo=timezone.utc),
132+
)
133+
134+
quote = adapters.build_market_data_port(object()).get_quote("SOXX")
135+
136+
assert quote.last_price == 10.0
137+
assert observed_calls == [("SOXL", "SOXX"), ("SOXL", "SOXX")]
138+
assert sleeps == [0.5]

0 commit comments

Comments
 (0)