Skip to content

Commit 714dd47

Browse files
committed
feat: add real-time price fetching and fix data delays
- Add get_ticker() method for real-time quotes across all markets - Add get_realtime_price() service with ticker/kline fallback chain - Fix yfinance end date issue for US stocks and futures - Fix forex timezone parsing for Tiingo UTC timestamps - Add retry mechanism with exponential backoff for Tiingo API - Add API rate limiting for portfolio (3 concurrent, 0.3s interval) - Add force refresh option to bypass price cache on manual refresh
1 parent ac932ae commit 714dd47

10 files changed

Lines changed: 644 additions & 79 deletions

File tree

backend_api_python/app/data_sources/cn_stock.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,94 @@ def _fetch_akshare(
386386
logger.error(traceback.format_exc())
387387

388388
return klines
389+
390+
def get_ticker(self, symbol: str) -> Dict[str, Any]:
391+
"""
392+
获取A股实时报价
393+
394+
使用东方财富实时行情API获取实时报价
395+
396+
Returns:
397+
dict: {
398+
'last': 当前价格,
399+
'change': 涨跌额,
400+
'changePercent': 涨跌幅,
401+
'high': 最高价,
402+
'low': 最低价,
403+
'open': 开盘价,
404+
'previousClose': 昨收价
405+
}
406+
"""
407+
symbol = (symbol or '').strip()
408+
409+
# 优先使用东方财富实时行情 API
410+
try:
411+
# 判断市场
412+
if symbol.startswith('6'):
413+
secid = f"1.{symbol}" # 上海
414+
elif symbol.startswith('0') or symbol.startswith('3'):
415+
secid = f"0.{symbol}" # 深圳
416+
elif symbol.startswith('4') or symbol.startswith('8'):
417+
secid = f"0.{symbol}" # 北交所
418+
else:
419+
secid = f"1.{symbol}"
420+
421+
# 东方财富实时行情接口
422+
url = "https://push2.eastmoney.com/api/qt/stock/get"
423+
params = {
424+
'secid': secid,
425+
'fields': 'f43,f44,f45,f46,f47,f48,f57,f58,f60,f169,f170',
426+
# f43=最新价, f44=最高价, f45=最低价, f46=开盘价
427+
# f60=昨收价, f169=涨跌额, f170=涨跌幅
428+
}
429+
430+
session = get_retry_session()
431+
response = session.get(url, params=params, timeout=10)
432+
if response.status_code == 200:
433+
data = response.json()
434+
if data and data.get('data'):
435+
d = data['data']
436+
last_price = d.get('f43', 0)
437+
# 东方财富返回的价格是整数(分),需要除以100
438+
if last_price and last_price > 0:
439+
divisor = 100 if last_price > 1000 else 1 # 价格超过10元时用分表示
440+
return {
441+
'last': last_price / divisor,
442+
'high': d.get('f44', 0) / divisor,
443+
'low': d.get('f45', 0) / divisor,
444+
'open': d.get('f46', 0) / divisor,
445+
'previousClose': d.get('f60', 0) / divisor,
446+
'change': d.get('f169', 0) / divisor,
447+
'changePercent': d.get('f170', 0) / 100 # 涨跌幅是整数(%*100)
448+
}
449+
except Exception as e:
450+
logger.debug(f"Eastmoney ticker failed for {symbol}: {e}")
451+
452+
# 降级使用腾讯实时报价
453+
try:
454+
tencent_symbol = self._to_tencent_symbol(symbol)
455+
if tencent_symbol:
456+
url = f"http://qt.gtimg.cn/q={tencent_symbol}"
457+
response = requests.get(url, timeout=10)
458+
content = response.content.decode('gbk', errors='ignore')
459+
if '="' in content:
460+
data_str = content.split('="')[1].strip('";\n')
461+
if data_str:
462+
parts = data_str.split('~')
463+
if len(parts) > 32:
464+
return {
465+
'last': float(parts[3]) if parts[3] else 0,
466+
'change': float(parts[31]) if parts[31] else 0,
467+
'changePercent': float(parts[32]) if parts[32] else 0,
468+
'high': float(parts[33]) if len(parts) > 33 and parts[33] else 0,
469+
'low': float(parts[34]) if len(parts) > 34 and parts[34] else 0,
470+
'open': float(parts[5]) if len(parts) > 5 and parts[5] else 0,
471+
'previousClose': float(parts[4]) if parts[4] else 0
472+
}
473+
except Exception as e:
474+
logger.debug(f"Tencent ticker failed for {symbol}: {e}")
475+
476+
return {'last': 0, 'symbol': symbol}
389477

390478

391479
class HShareDataSource(BaseDataSource, TencentDataMixin):
@@ -613,3 +701,79 @@ def _fetch_akshare(
613701
logger.error(traceback.format_exc())
614702

615703
return klines
704+
705+
def get_ticker(self, symbol: str) -> Dict[str, Any]:
706+
"""
707+
获取港股实时报价
708+
709+
使用腾讯财经实时行情API获取实时报价
710+
711+
Returns:
712+
dict: {
713+
'last': 当前价格,
714+
'change': 涨跌额,
715+
'changePercent': 涨跌幅,
716+
'high': 最高价,
717+
'low': 最低价,
718+
'open': 开盘价,
719+
'previousClose': 昨收价
720+
}
721+
"""
722+
symbol = (symbol or '').strip()
723+
724+
# 使用腾讯财经实时报价
725+
try:
726+
tencent_symbol = self._to_tencent_symbol(symbol)
727+
url = f"http://qt.gtimg.cn/q={tencent_symbol}"
728+
response = requests.get(url, timeout=10)
729+
content = response.content.decode('gbk', errors='ignore')
730+
if '="' in content:
731+
data_str = content.split('="')[1].strip('";\n')
732+
if data_str:
733+
parts = data_str.split('~')
734+
if len(parts) > 32:
735+
return {
736+
'last': float(parts[3]) if parts[3] else 0,
737+
'change': float(parts[31]) if parts[31] else 0,
738+
'changePercent': float(parts[32]) if parts[32] else 0,
739+
'high': float(parts[33]) if len(parts) > 33 and parts[33] else 0,
740+
'low': float(parts[34]) if len(parts) > 34 and parts[34] else 0,
741+
'open': float(parts[5]) if len(parts) > 5 and parts[5] else 0,
742+
'previousClose': float(parts[4]) if parts[4] else 0
743+
}
744+
except Exception as e:
745+
logger.debug(f"Tencent ticker failed for {symbol}: {e}")
746+
747+
# 降级使用东方财富
748+
try:
749+
hk_symbol = symbol.zfill(5)
750+
secid = f"116.{hk_symbol}"
751+
752+
url = "https://push2.eastmoney.com/api/qt/stock/get"
753+
params = {
754+
'secid': secid,
755+
'fields': 'f43,f44,f45,f46,f47,f48,f57,f58,f60,f169,f170',
756+
}
757+
758+
session = get_retry_session()
759+
response = session.get(url, params=params, timeout=10)
760+
if response.status_code == 200:
761+
data = response.json()
762+
if data and data.get('data'):
763+
d = data['data']
764+
last_price = d.get('f43', 0)
765+
if last_price and last_price > 0:
766+
divisor = 1000 if last_price > 10000 else 100 if last_price > 1000 else 1
767+
return {
768+
'last': last_price / divisor,
769+
'high': d.get('f44', 0) / divisor,
770+
'low': d.get('f45', 0) / divisor,
771+
'open': d.get('f46', 0) / divisor,
772+
'previousClose': d.get('f60', 0) / divisor,
773+
'change': d.get('f169', 0) / divisor,
774+
'changePercent': d.get('f170', 0) / 100
775+
}
776+
except Exception as e:
777+
logger.debug(f"Eastmoney ticker failed for {symbol}: {e}")
778+
779+
return {'last': 0, 'symbol': symbol}

backend_api_python/app/data_sources/factory.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,31 @@ def get_kline(
103103
except Exception as e:
104104
logger.error(f"Failed to fetch K-lines {market}:{symbol} - {str(e)}")
105105
return []
106+
107+
@classmethod
108+
def get_ticker(cls, market: str, symbol: str) -> Dict[str, Any]:
109+
"""
110+
获取实时报价的便捷方法
111+
112+
Args:
113+
market: 市场类型
114+
symbol: 交易对/股票代码
115+
116+
Returns:
117+
实时报价数据: {
118+
'last': 最新价,
119+
'change': 涨跌额,
120+
'changePercent': 涨跌幅,
121+
...
122+
}
123+
"""
124+
try:
125+
source = cls.get_source(market)
126+
return source.get_ticker(symbol)
127+
except NotImplementedError:
128+
logger.warning(f"get_ticker not implemented for market: {market}")
129+
return {'last': 0, 'symbol': symbol}
130+
except Exception as e:
131+
logger.error(f"Failed to fetch ticker {market}:{symbol} - {str(e)}")
132+
return {'last': 0, 'symbol': symbol}
106133

backend_api_python/app/data_sources/forex.py

Lines changed: 143 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,109 @@ def __init__(self):
5555
if not APIKeys.TIINGO_API_KEY:
5656
logger.warning("Tiingo API key is not configured; FX data will be unavailable")
5757

58+
def get_ticker(self, symbol: str) -> Dict[str, Any]:
59+
"""
60+
获取外汇实时报价
61+
62+
使用 Tiingo FX Top-of-Book API 获取实时报价
63+
64+
Returns:
65+
dict: {
66+
'last': 当前价格 (mid price),
67+
'bid': 买价,
68+
'ask': 卖价,
69+
'change': 涨跌额,
70+
'changePercent': 涨跌幅
71+
}
72+
"""
73+
api_key = APIKeys.TIINGO_API_KEY
74+
if not api_key:
75+
logger.warning("Tiingo API key not configured")
76+
return {'last': 0, 'symbol': symbol}
77+
78+
try:
79+
# 解析 symbol
80+
tiingo_symbol = self.SYMBOL_MAP.get(symbol)
81+
if not tiingo_symbol:
82+
tiingo_symbol = symbol.lower()
83+
84+
# Tiingo FX Top-of-Book API
85+
# https://api.tiingo.com/tiingo/fx/top?tickers=eurusd&token=...
86+
url = f"{self.base_url}/fx/top"
87+
params = {
88+
'tickers': tiingo_symbol,
89+
'token': api_key
90+
}
91+
92+
# 重试逻辑:处理 429 速率限制
93+
for attempt in range(3):
94+
response = requests.get(url, params=params, timeout=TiingoConfig.TIMEOUT)
95+
if response.status_code == 429:
96+
time.sleep(2 * (attempt + 1))
97+
continue
98+
break
99+
100+
if response.status_code == 429:
101+
logger.warning("Tiingo rate limit exceeded for ticker request")
102+
return {'last': 0, 'symbol': symbol}
103+
104+
response.raise_for_status()
105+
data = response.json()
106+
107+
if data and isinstance(data, list) and len(data) > 0:
108+
item = data[0]
109+
# Tiingo FX top returns: ticker, quoteTimestamp, bidPrice, bidSize, askPrice, askSize, midPrice
110+
bid = float(item.get('bidPrice', 0) or 0)
111+
ask = float(item.get('askPrice', 0) or 0)
112+
mid = float(item.get('midPrice', 0) or 0)
113+
114+
# 如果没有 midPrice,计算中间价
115+
if not mid and bid and ask:
116+
mid = (bid + ask) / 2
117+
118+
last_price = mid or bid or ask
119+
120+
# 获取前一天收盘价来计算涨跌(需要额外请求日线数据)
121+
prev_close = 0
122+
change = 0
123+
change_pct = 0
124+
125+
try:
126+
# 获取昨日收盘价
127+
yesterday = (datetime.now() - timedelta(days=2)).strftime('%Y-%m-%d')
128+
today = datetime.now().strftime('%Y-%m-%d')
129+
price_url = f"{self.base_url}/fx/{tiingo_symbol}/prices"
130+
price_params = {
131+
'startDate': yesterday,
132+
'endDate': today,
133+
'resampleFreq': '1day',
134+
'token': api_key
135+
}
136+
price_resp = requests.get(price_url, params=price_params, timeout=TiingoConfig.TIMEOUT)
137+
if price_resp.status_code == 200:
138+
price_data = price_resp.json()
139+
if price_data and len(price_data) > 0:
140+
prev_close = float(price_data[-1].get('close', 0) or 0)
141+
if prev_close and last_price:
142+
change = last_price - prev_close
143+
change_pct = (change / prev_close) * 100
144+
except Exception:
145+
pass # 涨跌计算失败不影响主要功能
146+
147+
return {
148+
'last': round(last_price, 5),
149+
'bid': round(bid, 5),
150+
'ask': round(ask, 5),
151+
'change': round(change, 5),
152+
'changePercent': round(change_pct, 2),
153+
'previousClose': round(prev_close, 5) if prev_close else 0
154+
}
155+
156+
except Exception as e:
157+
logger.error(f"Failed to get forex ticker for {symbol}: {e}")
158+
159+
return {'last': 0, 'symbol': symbol}
160+
58161
def _get_timeframe_seconds(self, timeframe: str) -> int:
59162
"""获取时间周期对应的秒数"""
60163
return TIMEFRAME_SECONDS.get(timeframe, 86400)
@@ -140,7 +243,7 @@ def get_kline(
140243
start_date_str = start_dt.strftime('%Y-%m-%d')
141244
end_date_str = end_dt.strftime('%Y-%m-%d')
142245

143-
# 4. API 请求
246+
# 4. API 请求(带重试逻辑)
144247
# URL: https://api.tiingo.com/tiingo/fx/{ticker}/prices
145248
url = f"{self.base_url}/fx/{tiingo_symbol}/prices"
146249

@@ -154,11 +257,42 @@ def get_kline(
154257

155258
# logger.info(f"Tiingo Request: {url} params={params}")
156259

157-
response = requests.get(url, params=params, timeout=TiingoConfig.TIMEOUT)
260+
# 重试逻辑:处理 429 速率限制
261+
max_retries = 3
262+
retry_delay = 2 # 秒
263+
response = None
158264

159-
if response.status_code == 403: # 具体的权限错误
160-
logger.error("Tiingo API permission error (403): check whether your API key is valid and has access to this dataset.")
161-
return []
265+
for attempt in range(max_retries):
266+
try:
267+
response = requests.get(url, params=params, timeout=TiingoConfig.TIMEOUT)
268+
269+
if response.status_code == 429:
270+
# 速率限制,等待后重试
271+
wait_time = retry_delay * (attempt + 1)
272+
logger.warning(f"Tiingo rate limit (429), waiting {wait_time}s before retry ({attempt + 1}/{max_retries})")
273+
time.sleep(wait_time)
274+
continue
275+
276+
break # 成功或其他错误,退出重试循环
277+
278+
except requests.exceptions.Timeout:
279+
if attempt < max_retries - 1:
280+
logger.warning(f"Tiingo request timeout, retrying ({attempt + 1}/{max_retries})")
281+
time.sleep(retry_delay)
282+
continue
283+
raise
284+
285+
if response is None:
286+
logger.error("Tiingo API request failed after all retries")
287+
return []
288+
289+
if response.status_code == 429:
290+
logger.error("Tiingo API rate limit exceeded. Please wait a moment before retrying.")
291+
return []
292+
293+
if response.status_code == 403:
294+
logger.error("Tiingo API permission error (403): check whether your API key is valid and has access to this dataset.")
295+
return []
162296

163297
response.raise_for_status()
164298
data = response.json()
@@ -186,14 +320,13 @@ def get_kline(
186320
for item in data:
187321
# 解析时间: "2023-01-01T00:00:00.000Z"
188322
dt_str = item.get('date')
189-
# 简化处理,Tiingo 返回的是 UTC 时间 ISO 格式
190-
# datetime.fromisoformat 在 Py3.7+ 支持,但要注意 Z 的处理
191-
# 这里简单处理一下 Z
323+
# Tiingo 返回的是 UTC 时间 ISO 格式,需要正确处理时区
324+
# 将 UTC 时间转换为本地时间戳
192325
if dt_str.endswith('Z'):
193-
dt_str = dt_str[:-1]
326+
dt_str = dt_str[:-1] + '+00:00' # 替换 Z 为 +00:00 表示 UTC
194327

195328
dt = datetime.fromisoformat(dt_str)
196-
ts = int(dt.timestamp())
329+
ts = int(dt.timestamp()) # 现在会正确处理 UTC 时区
197330

198331
klines.append({
199332
'time': ts,

0 commit comments

Comments
 (0)