11from __future__ import annotations
22
3- from datetime import datetime
4- from typing import Any
3+ import os
4+ import time
5+ from datetime import datetime , timezone
6+ from email .utils import parsedate_to_datetime
7+ from typing import Any , Callable , Optional
58
69from quant_platform_kit .common .models import QuoteSnapshot
710
11+ RETRYABLE_STATUS_CODES = {429 , 500 , 502 , 503 , 504 }
12+ DEFAULT_HTTP_MAX_ATTEMPTS = 4
13+ DEFAULT_HTTP_BACKOFF_SECONDS = 1.0
14+ DEFAULT_HTTP_MAX_BACKOFF_SECONDS = 8.0
15+
16+
17+ def _env_int (name : str , default : int , * , minimum : int , maximum : int ) -> int :
18+ raw_value = os .environ .get (name )
19+ if not raw_value :
20+ return default
21+ try :
22+ value = int (raw_value )
23+ except ValueError :
24+ return default
25+ return min (max (value , minimum ), maximum )
26+
27+
28+ def _env_float (name : str , default : float , * , minimum : float , maximum : float ) -> float :
29+ raw_value = os .environ .get (name )
30+ if not raw_value :
31+ return default
32+ try :
33+ value = float (raw_value )
34+ except ValueError :
35+ return default
36+ return min (max (value , minimum ), maximum )
37+
38+
39+ def _header_value (headers : Any , name : str ) -> Optional [str ]:
40+ if not headers :
41+ return None
42+ if hasattr (headers , "get" ):
43+ value = headers .get (name )
44+ if value is None :
45+ value = headers .get (name .lower ())
46+ if value is None :
47+ value = headers .get (name .upper ())
48+ return str (value ).strip () if value is not None else None
49+ return None
50+
51+
52+ def _retry_after_seconds (response : Any , fallback_seconds : float , max_seconds : float ) -> float :
53+ raw_value = _header_value (getattr (response , "headers" , None ), "Retry-After" )
54+ if not raw_value :
55+ return min (fallback_seconds , max_seconds )
56+ try :
57+ return min (max (float (raw_value ), 0.0 ), max_seconds )
58+ except ValueError :
59+ pass
60+
61+ try :
62+ retry_at = parsedate_to_datetime (raw_value )
63+ except (TypeError , ValueError ):
64+ return min (fallback_seconds , max_seconds )
65+ if retry_at .tzinfo is None :
66+ retry_at = retry_at .replace (tzinfo = timezone .utc )
67+ wait_seconds = (retry_at - datetime .now (timezone .utc )).total_seconds ()
68+ return min (max (wait_seconds , 0.0 ), max_seconds )
69+
70+
71+ def _request_with_retries (request_fn : Callable [[], Any ]) -> Any :
72+ max_attempts = _env_int ("QPK_SCHWAB_HTTP_MAX_ATTEMPTS" , DEFAULT_HTTP_MAX_ATTEMPTS , minimum = 1 , maximum = 8 )
73+ backoff_seconds = _env_float (
74+ "QPK_SCHWAB_HTTP_BACKOFF_SECONDS" ,
75+ DEFAULT_HTTP_BACKOFF_SECONDS ,
76+ minimum = 0.0 ,
77+ maximum = 30.0 ,
78+ )
79+ max_backoff_seconds = _env_float (
80+ "QPK_SCHWAB_HTTP_MAX_BACKOFF_SECONDS" ,
81+ DEFAULT_HTTP_MAX_BACKOFF_SECONDS ,
82+ minimum = 0.0 ,
83+ maximum = 60.0 ,
84+ )
85+
86+ response = None
87+ for attempt in range (1 , max_attempts + 1 ):
88+ response = request_fn ()
89+ status_code = getattr (response , "status_code" , None )
90+ if status_code not in RETRYABLE_STATUS_CODES or attempt >= max_attempts :
91+ return response
92+
93+ fallback_seconds = backoff_seconds * (2 ** (attempt - 1 ))
94+ wait_seconds = _retry_after_seconds (response , fallback_seconds , max_backoff_seconds )
95+ if wait_seconds > 0 :
96+ time .sleep (wait_seconds )
97+
98+ return response
99+
8100
9101def decode_response_json (response : Any , context : str ) -> Any :
10102 if response .status_code not in (200 , 201 ):
@@ -18,12 +110,14 @@ def decode_response_json(response: Any, context: str) -> Any:
18110def fetch_default_daily_price_history_candles (api_client : Any , symbol : str ) -> list [dict [str , Any ]]:
19111 from schwab import client
20112
21- response = api_client .get_price_history (
22- symbol ,
23- period_type = client .Client .PriceHistory .PeriodType .YEAR ,
24- period = client .Client .PriceHistory .Period .TWO_YEARS ,
25- frequency_type = client .Client .PriceHistory .FrequencyType .DAILY ,
26- frequency = client .Client .PriceHistory .Frequency .DAILY ,
113+ response = _request_with_retries (
114+ lambda : api_client .get_price_history (
115+ symbol ,
116+ period_type = client .Client .PriceHistory .PeriodType .YEAR ,
117+ period = client .Client .PriceHistory .Period .TWO_YEARS ,
118+ frequency_type = client .Client .PriceHistory .FrequencyType .DAILY ,
119+ frequency = client .Client .PriceHistory .Frequency .DAILY ,
120+ )
27121 )
28122 payload = decode_response_json (response , f"{ symbol } history" )
29123 candles = payload .get ("candles" )
@@ -33,7 +127,7 @@ def fetch_default_daily_price_history_candles(api_client: Any, symbol: str) -> l
33127
34128
35129def fetch_quotes (api_client : Any , symbols : list [str ] | tuple [str , ...]) -> dict [str , QuoteSnapshot ]:
36- payload = decode_response_json (api_client .get_quotes (symbols ), "Quotes" )
130+ payload = decode_response_json (_request_with_retries ( lambda : api_client .get_quotes (symbols ) ), "Quotes" )
37131 as_of = datetime .utcnow ()
38132 snapshots : dict [str , QuoteSnapshot ] = {}
39133 for symbol in symbols :
0 commit comments