22
33from __future__ import annotations
44
5+ import time
56from dataclasses import dataclass
67from datetime import date , datetime , timedelta , timezone
78from 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
2730def _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 )
3346class 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 ()
0 commit comments