22美股数据源
33使用 yfinance 和 finnhub 获取数据
44"""
5+ import os
6+ import threading
7+ import time
58from typing import Dict , List , Any , Optional
69from datetime import datetime , timedelta
710
811import yfinance as yf
12+ import pandas as pd
913import requests
1014
1115from app .data_sources .base import BaseDataSource
@@ -19,6 +23,15 @@ class USStockDataSource(BaseDataSource):
1923 """美股数据源"""
2024
2125 name = "USStock/yfinance"
26+ _quote_batch_lock = threading .RLock ()
27+ _quote_condition = threading .Condition (_quote_batch_lock )
28+ _quote_cache : Dict [str , tuple [float , Dict [str , Any ]]] = {}
29+ _quote_pending : set [str ] = set ()
30+ _quote_batch_inflight = False
31+ _finnhub_lock = threading .Lock ()
32+ _finnhub_next_request_at = 0.0
33+ _finnhub_blocked_until = 0.0
34+ _finnhub_failures = 0
2235
2336 INTERVAL_MAP = {
2437 '1m' : '1m' ,
@@ -96,7 +109,257 @@ def _yahoo_symbol(symbol: str) -> str:
96109 def _nasdaq_symbol (symbol : str ) -> str :
97110 return (symbol or "" ).strip ().upper ().replace ("$" , "^" )
98111
112+ @staticmethod
113+ def _quote_cache_ttl () -> float :
114+ try :
115+ return max (1.0 , float (os .getenv ("US_STOCK_QUOTE_CACHE_TTL_SEC" , "15" )))
116+ except (TypeError , ValueError ):
117+ return 15.0
118+
119+ @staticmethod
120+ def _finnhub_min_interval () -> float :
121+ try :
122+ return max (0.0 , float (os .getenv ("FINNHUB_QUOTE_MIN_INTERVAL_SEC" , "1.05" )))
123+ except (TypeError , ValueError ):
124+ return 1.05
125+
126+ @staticmethod
127+ def _finnhub_rate_limit_backoff () -> float :
128+ try :
129+ return max (5.0 , float (os .getenv ("FINNHUB_429_BACKOFF_SEC" , "60" )))
130+ except (TypeError , ValueError ):
131+ return 60.0
132+
133+ @classmethod
134+ def clear_quote_cache (cls ) -> None :
135+ with cls ._quote_condition :
136+ cls ._quote_cache .clear ()
137+ cls ._quote_pending .clear ()
138+ cls ._quote_batch_inflight = False
139+ cls ._quote_condition .notify_all ()
140+ with cls ._finnhub_lock :
141+ cls ._finnhub_next_request_at = 0.0
142+ cls ._finnhub_blocked_until = 0.0
143+ cls ._finnhub_failures = 0
144+
145+ def get_tickers (self , symbols : List [str ]) -> Dict [str , Dict [str , Any ]]:
146+ """Coalesce concurrent requests into one cache/rate-limited quote batch."""
147+ normalized = list (dict .fromkeys (
148+ str (symbol or "" ).strip ().upper ()
149+ for symbol in symbols
150+ if str (symbol or "" ).strip ()
151+ ))
152+ if not normalized :
153+ return {}
154+
155+ is_leader = False
156+ deadline = time .monotonic () + 45.0
157+ while True :
158+ with self ._quote_condition :
159+ now = time .monotonic ()
160+ output = {
161+ symbol : dict (cached [1 ])
162+ for symbol in normalized
163+ if (cached := self ._quote_cache .get (symbol )) and cached [0 ] > now
164+ }
165+ missing = [symbol for symbol in normalized if symbol not in output ]
166+ if not missing :
167+ return output
168+ self ._quote_pending .update (missing )
169+ if not self ._quote_batch_inflight :
170+ self .__class__ ._quote_batch_inflight = True
171+ is_leader = True
172+ break
173+ remaining = deadline - now
174+ if remaining <= 0 :
175+ return {
176+ ** output ,
177+ ** {
178+ symbol : {"last" : 0 , "symbol" : symbol }
179+ for symbol in missing
180+ },
181+ }
182+ self ._quote_condition .wait (timeout = min (remaining , 5.0 ))
183+
184+ if is_leader :
185+ # Allow requests from sibling strategy threads to join this batch.
186+ try :
187+ batch_window = max (
188+ 0.0 ,
189+ min (
190+ 0.25 ,
191+ float (os .getenv ("US_STOCK_QUOTE_BATCH_WINDOW_MS" , "50" )) / 1000.0 ,
192+ ),
193+ )
194+ except (TypeError , ValueError ):
195+ batch_window = 0.05
196+ if batch_window :
197+ time .sleep (batch_window )
198+ with self ._quote_condition :
199+ batch_symbols = sorted (self ._quote_pending )
200+ self ._quote_pending .clear ()
201+ try :
202+ fresh_quotes = (
203+ self ._fetch_yfinance_batch_quotes (batch_symbols )
204+ if len (batch_symbols ) > 1
205+ else {}
206+ )
207+ for symbol in batch_symbols :
208+ if symbol not in fresh_quotes :
209+ fresh_quotes [symbol ] = self ._fetch_ticker (symbol )
210+ except Exception as exc :
211+ logger .warning ("US stock quote batch failed: %s" , exc )
212+ fresh_quotes = {
213+ symbol : {"last" : 0 , "symbol" : symbol }
214+ for symbol in batch_symbols
215+ }
216+ finally :
217+ with self ._quote_condition :
218+ success_expiry = time .monotonic () + self ._quote_cache_ttl ()
219+ failure_expiry = time .monotonic () + 2.0
220+ for symbol in batch_symbols :
221+ quote = dict (fresh_quotes .get (symbol ) or {})
222+ quote .setdefault ("symbol" , symbol )
223+ expires_at = (
224+ success_expiry
225+ if float (quote .get ("last" ) or 0.0 ) > 0
226+ else failure_expiry
227+ )
228+ self ._quote_cache [symbol ] = (expires_at , quote )
229+ self .__class__ ._quote_batch_inflight = False
230+ self ._quote_condition .notify_all ()
231+
232+ with self ._quote_condition :
233+ now = time .monotonic ()
234+ return {
235+ symbol : dict (cached [1 ])
236+ for symbol in normalized
237+ if (cached := self ._quote_cache .get (symbol )) and cached [0 ] > now
238+ }
239+
240+ def _fetch_yfinance_batch_quotes (self , symbols : List [str ]) -> Dict [str , Dict [str , Any ]]:
241+ """Fetch the latest minute for several US symbols through one batch API."""
242+ if not symbols :
243+ return {}
244+ yahoo_to_source = {
245+ self ._yahoo_symbol (symbol ): symbol
246+ for symbol in symbols
247+ }
248+ try :
249+ frame = yf .download (
250+ list (yahoo_to_source ),
251+ period = "2d" ,
252+ interval = "1m" ,
253+ group_by = "ticker" ,
254+ auto_adjust = False ,
255+ prepost = True ,
256+ progress = False ,
257+ threads = True ,
258+ timeout = 8 ,
259+ )
260+ except Exception as exc :
261+ logger .debug ("yfinance batch quote failed; using provider fallbacks: %s" , exc )
262+ return {}
263+ if frame is None or frame .empty :
264+ return {}
265+ output : Dict [str , Dict [str , Any ]] = {}
266+ for yahoo_symbol , source_symbol in yahoo_to_source .items ():
267+ try :
268+ quote_frame = frame [yahoo_symbol ]
269+ closes = quote_frame ["Close" ].dropna ()
270+ except (KeyError , TypeError ):
271+ continue
272+ if closes .empty :
273+ continue
274+ session_dates = pd .Index (closes .index .date )
275+ latest_session = session_dates [- 1 ]
276+ latest_mask = session_dates == latest_session
277+ latest_closes = closes [latest_mask ]
278+ latest_frame = quote_frame .loc [latest_closes .index ]
279+ prior_closes = closes [~ latest_mask ]
280+ last = float (latest_closes .iloc [- 1 ])
281+ previous_close = (
282+ float (prior_closes .iloc [- 1 ])
283+ if not prior_closes .empty
284+ else 0.0
285+ )
286+ opens = latest_frame ["Open" ].dropna ()
287+ highs = latest_frame ["High" ].dropna ()
288+ lows = latest_frame ["Low" ].dropna ()
289+ open_price = float (opens .iloc [0 ]) if not opens .empty else last
290+ change = last - previous_close if previous_close else 0.0
291+ output [source_symbol ] = {
292+ "last" : last ,
293+ "change" : change ,
294+ "changePercent" : (
295+ change / previous_close * 100.0
296+ if previous_close
297+ else 0.0
298+ ),
299+ "high" : float (highs .max ()) if not highs .empty else last ,
300+ "low" : float (lows .min ()) if not lows .empty else last ,
301+ "open" : open_price ,
302+ "previousClose" : previous_close ,
303+ }
304+ return output
305+
99306 def get_ticker (self , symbol : str ) -> Dict [str , Any ]:
307+ normalized = str (symbol or "" ).strip ().upper ()
308+ return self .get_tickers ([normalized ]).get (
309+ normalized ,
310+ {"last" : 0 , "symbol" : normalized },
311+ )
312+
313+ def _fetch_finnhub_quote (self , symbol : str ) -> Dict [str , Any ]:
314+ if not self .finnhub_client :
315+ return {}
316+ with self ._finnhub_lock :
317+ now = time .monotonic ()
318+ if now < self ._finnhub_blocked_until :
319+ return {}
320+ wait_seconds = self ._finnhub_next_request_at - now
321+ if wait_seconds > 0 :
322+ time .sleep (wait_seconds )
323+ self .__class__ ._finnhub_next_request_at = (
324+ time .monotonic () + self ._finnhub_min_interval ()
325+ )
326+ try :
327+ quote = self .finnhub_client .quote (symbol )
328+ if quote and quote .get ("c" ):
329+ self .__class__ ._finnhub_failures = 0
330+ return quote
331+ except Exception as exc :
332+ detail = str (exc ).lower ()
333+ is_rate_limited = "429" in detail or "rate limit" in detail
334+ no_access = (
335+ "403" in detail
336+ or "don't have access" in detail
337+ or "no access" in detail
338+ )
339+ self .__class__ ._finnhub_failures += 1
340+ if is_rate_limited :
341+ delay = self ._finnhub_rate_limit_backoff ()
342+ elif no_access :
343+ delay = 300.0
344+ else :
345+ delay = min (60.0 , float (2 ** min (self ._finnhub_failures , 6 )))
346+ self .__class__ ._finnhub_blocked_until = time .monotonic () + delay
347+ if is_rate_limited :
348+ logger .warning (
349+ "Finnhub quote rate limited; pausing all quote requests for %.0fs" ,
350+ delay ,
351+ )
352+ elif no_access :
353+ logger .debug ("Finnhub quote skipped (no access): %s: %s" , symbol , exc )
354+ else :
355+ logger .warning (
356+ "Finnhub quote failed; shared retry backoff %.0fs: %s" ,
357+ delay ,
358+ exc ,
359+ )
360+ return {}
361+
362+ def _fetch_ticker (self , symbol : str ) -> Dict [str , Any ]:
100363 """
101364 获取美股实时报价
102365
@@ -115,25 +378,17 @@ def get_ticker(self, symbol: str) -> Dict[str, Any]:
115378 """
116379 symbol = (symbol or '' ).strip ().upper ()
117380
118- if self .finnhub_client :
119- try :
120- quote = self .finnhub_client .quote (symbol )
121- if quote and quote .get ('c' ):
122- return {
123- 'last' : quote .get ('c' , 0 ), # 当前价格
124- 'change' : quote .get ('d' , 0 ), # 涨跌额
125- 'changePercent' : quote .get ('dp' , 0 ), # 涨跌幅
126- 'high' : quote .get ('h' , 0 ), # 日内最高
127- 'low' : quote .get ('l' , 0 ), # 日内最低
128- 'open' : quote .get ('o' , 0 ), # 开盘价
129- 'previousClose' : quote .get ('pc' , 0 ) # 昨收价
130- }
131- except Exception as e :
132- msg = str (e ).lower ()
133- if "403" in str (e ) or "don't have access" in msg or "no access" in msg :
134- logger .debug (f"Finnhub quote skipped (no access): { symbol } : { e } " )
135- else :
136- logger .warning (f"Finnhub quote failed for { symbol } : { e } " )
381+ quote = self ._fetch_finnhub_quote (symbol )
382+ if quote :
383+ return {
384+ 'last' : quote .get ('c' , 0 ),
385+ 'change' : quote .get ('d' , 0 ),
386+ 'changePercent' : quote .get ('dp' , 0 ),
387+ 'high' : quote .get ('h' , 0 ),
388+ 'low' : quote .get ('l' , 0 ),
389+ 'open' : quote .get ('o' , 0 ),
390+ 'previousClose' : quote .get ('pc' , 0 ),
391+ }
137392
138393 nasdaq_quote = self ._fetch_nasdaq_quote (symbol )
139394 if nasdaq_quote :
@@ -642,4 +897,3 @@ def _convert_dataframe(self, df, limit: int) -> List[Dict[str, Any]]:
642897 continue
643898
644899 return klines
645-
0 commit comments