Skip to content

Commit 6fbc0e8

Browse files
HenryHenry
authored andcommitted
perf(runtime): reduce market data and state IO
1 parent f1bfd22 commit 6fbc0e8

10 files changed

Lines changed: 992 additions & 77 deletions

File tree

backend_api_python/app/data_sources/factory.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,7 @@ def get_ticker(cls, market: str, symbol: str, exchange_id: Optional[str] = None,
381381
str(e),
382382
)
383383
return {'last': 0, 'symbol': symbol}
384+
384385
except NotImplementedError:
385386
cls._log_limited(
386387
"warning",
@@ -401,3 +402,47 @@ def get_ticker(cls, market: str, symbol: str, exchange_id: Optional[str] = None,
401402
str(e),
402403
)
403404
return {'last': 0, 'symbol': symbol}
405+
406+
@classmethod
407+
def get_tickers(
408+
cls,
409+
market: str,
410+
symbols: List[str],
411+
exchange_id: Optional[str] = None,
412+
market_type: Optional[str] = None,
413+
) -> Dict[str, Dict[str, Any]]:
414+
"""Fetch a quote batch through the market's shared coordinator."""
415+
normalized_symbols = list(dict.fromkeys(
416+
str(symbol or "").strip()
417+
for symbol in symbols
418+
if str(symbol or "").strip()
419+
))
420+
if not normalized_symbols:
421+
return {}
422+
m = cls.normalize_market(market or "")
423+
try:
424+
source = cls._resolve_source(
425+
m,
426+
exchange_id=exchange_id,
427+
market_type=market_type,
428+
)
429+
batch_fetch = getattr(source, "get_tickers", None)
430+
if callable(batch_fetch):
431+
return dict(batch_fetch(normalized_symbols) or {})
432+
except Exception as exc:
433+
cls._log_limited(
434+
"warning",
435+
f"ticker-batch:{m}:{type(exc).__name__}:{str(exc)[:160]}",
436+
"Batch ticker fetch failed for %s: %s",
437+
m,
438+
exc,
439+
)
440+
return {
441+
symbol: cls.get_ticker(
442+
m,
443+
symbol,
444+
exchange_id=exchange_id,
445+
market_type=market_type,
446+
)
447+
for symbol in normalized_symbols
448+
}

backend_api_python/app/data_sources/us_stock.py

Lines changed: 274 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@
22
美股数据源
33
使用 yfinance 和 finnhub 获取数据
44
"""
5+
import os
6+
import threading
7+
import time
58
from typing import Dict, List, Any, Optional
69
from datetime import datetime, timedelta
710

811
import yfinance as yf
12+
import pandas as pd
913
import requests
1014

1115
from 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

Comments
 (0)