Files
DinQuant/backend_api_python/app/data_sources/us_stock.py
T
TIANHE 714dd47c86 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
2026-01-13 00:21:33 +08:00

308 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
美股数据源
使用 yfinance 和 finnhub 获取数据
"""
from typing import Dict, List, Any, Optional
from datetime import datetime, timedelta
import yfinance as yf
from app.data_sources.base import BaseDataSource
from app.utils.logger import get_logger
from app.config import APIKeys, YFinanceConfig
logger = get_logger(__name__)
class USStockDataSource(BaseDataSource):
"""美股数据源"""
name = "USStock/yfinance"
# yfinance 时间周期映射
INTERVAL_MAP = {
'1m': '1m',
'5m': '5m',
'15m': '15m',
'30m': '30m',
'1H': '1h',
'4H': '4h',
'1D': '1d',
'1W': '1wk'
}
# 不同周期获取数据的天数范围
DAYS_MAP = {
'1m': lambda limit: min(7, max(1, (limit // 390) + 2)),
'5m': lambda limit: min(60, max(1, (limit // 78) + 2)),
'15m': lambda limit: min(60, max(1, (limit // 26) + 2)),
'30m': lambda limit: min(60, max(1, (limit // 13) + 2)),
'1H': lambda limit: min(730, max(1, (limit // 24) + 2)),
'4H': lambda limit: min(730, max(1, (limit // 6) + 2)),
'1D': lambda limit: min(3650, limit + 1),
'1W': lambda limit: min(3650, (limit * 7) + 7)
}
def __init__(self):
# 初始化 finnhub 作为备选
self.finnhub_client = None
try:
import finnhub
if APIKeys.is_configured('FINNHUB_API_KEY'):
self.finnhub_client = finnhub.Client(api_key=APIKeys.FINNHUB_API_KEY)
logger.info("Finnhub client initialized")
except Exception as e:
logger.warning(f"Finnhub init failed: {e}")
def get_ticker(self, symbol: str) -> Dict[str, Any]:
"""
获取美股实时报价
优先使用 Finnhub(更实时),降级使用 yfinance fast_info
Returns:
dict: {
'last': 当前价格,
'change': 涨跌额,
'changePercent': 涨跌幅,
'high': 最高价,
'low': 最低价,
'open': 开盘价,
'previousClose': 昨收价
}
"""
symbol = (symbol or '').strip().upper()
# 优先使用 Finnhub(实时数据)
if self.finnhub_client:
try:
quote = self.finnhub_client.quote(symbol)
if quote and quote.get('c'):
return {
'last': quote.get('c', 0), # 当前价格
'change': quote.get('d', 0), # 涨跌额
'changePercent': quote.get('dp', 0), # 涨跌幅
'high': quote.get('h', 0), # 日内最高
'low': quote.get('l', 0), # 日内最低
'open': quote.get('o', 0), # 开盘价
'previousClose': quote.get('pc', 0) # 昨收价
}
except Exception as e:
logger.warning(f"Finnhub quote failed for {symbol}: {e}")
# 降级使用 yfinance
try:
ticker = yf.Ticker(symbol)
# 尝试 fast_info(更快)
try:
fast_info = ticker.fast_info
last_price = fast_info.get('lastPrice') or fast_info.get('last_price')
prev_close = fast_info.get('previousClose') or fast_info.get('previous_close') or fast_info.get('regularMarketPreviousClose')
if last_price:
change = (last_price - prev_close) if prev_close else 0
change_pct = (change / prev_close * 100) if prev_close else 0
return {
'last': float(last_price),
'change': round(change, 4),
'changePercent': round(change_pct, 2),
'high': float(fast_info.get('dayHigh') or fast_info.get('day_high') or last_price),
'low': float(fast_info.get('dayLow') or fast_info.get('day_low') or last_price),
'open': float(fast_info.get('open') or fast_info.get('regularMarketOpen') or last_price),
'previousClose': float(prev_close) if prev_close else 0
}
except Exception as e:
logger.debug(f"yfinance fast_info failed for {symbol}: {e}")
# 降级使用 info(较慢但数据更全)
try:
info = ticker.info
last_price = info.get('regularMarketPrice') or info.get('currentPrice')
prev_close = info.get('regularMarketPreviousClose') or info.get('previousClose')
if last_price:
change = (last_price - prev_close) if prev_close else 0
change_pct = (change / prev_close * 100) if prev_close else 0
return {
'last': float(last_price),
'change': round(change, 4),
'changePercent': round(change_pct, 2),
'high': float(info.get('regularMarketDayHigh') or info.get('dayHigh') or last_price),
'low': float(info.get('regularMarketDayLow') or info.get('dayLow') or last_price),
'open': float(info.get('regularMarketOpen') or info.get('open') or last_price),
'previousClose': float(prev_close) if prev_close else 0
}
except Exception as e:
logger.debug(f"yfinance info failed for {symbol}: {e}")
# 最后降级:使用最近的 1 分钟 K 线
try:
hist = ticker.history(period='1d', interval='1m')
if hist is not None and not hist.empty:
last_row = hist.iloc[-1]
first_row = hist.iloc[0]
last_price = float(last_row['Close'])
open_price = float(first_row['Open'])
return {
'last': last_price,
'change': round(last_price - open_price, 4),
'changePercent': round((last_price - open_price) / open_price * 100, 2) if open_price else 0,
'high': float(hist['High'].max()),
'low': float(hist['Low'].min()),
'open': open_price,
'previousClose': open_price # 近似
}
except Exception as e:
logger.debug(f"yfinance history fallback failed for {symbol}: {e}")
except Exception as e:
logger.error(f"Failed to get ticker for {symbol}: {e}")
return {'last': 0, 'symbol': symbol}
def get_kline(
self,
symbol: str,
timeframe: str,
limit: int,
before_time: Optional[int] = None
) -> List[Dict[str, Any]]:
"""获取美股K线数据"""
klines = []
try:
interval = self.INTERVAL_MAP.get(timeframe, '1d')
days_func = self.DAYS_MAP.get(timeframe, lambda x: x + 1)
days = days_func(limit)
# 计算日期范围
if before_time:
end_date = datetime.fromtimestamp(before_time)
start_date = end_date - timedelta(days=days)
else:
end_date = datetime.now()
start_date = end_date - timedelta(days=days)
# logger.info(f"使用 yfinance 获取 {symbol}, 周期: {interval}, 日期: {start_date.date()} ~ {end_date.date()}")
# 尝试 yfinance
df = self._fetch_yfinance(symbol, interval, start_date, end_date)
if df is None or df.empty:
# 尝试 finnhub
if self.finnhub_client and timeframe == '1D':
klines = self._fetch_finnhub(symbol, start_date, end_date, limit)
if klines:
return klines
else:
klines = self._convert_dataframe(df, limit)
# 过滤和限制
klines = self.filter_and_limit(klines, limit, before_time)
# 记录结果
self.log_result(symbol, klines, timeframe)
except Exception as e:
logger.error(f"Failed to fetch US stock K-lines {symbol}: {str(e)}")
import traceback
logger.error(traceback.format_exc())
return klines
def _fetch_yfinance(self, symbol: str, interval: str, start_date: datetime, end_date: datetime):
"""使用 yfinance 获取数据"""
try:
ticker = yf.Ticker(symbol)
# yfinance 的 end 参数是不包含的(exclusive),所以需要加一天才能包含 end_date 当天的数据
# 例如:end="2026-01-12" 实际只返回到 2026-01-11 的数据
end_date_inclusive = end_date + timedelta(days=1)
df = ticker.history(
start=start_date.strftime('%Y-%m-%d'),
end=end_date_inclusive.strftime('%Y-%m-%d'),
interval=interval
)
# logger.info(f"yfinance 返回 {len(df) if df is not None and not df.empty else 0} 条数据")
return df
except Exception as e:
logger.warning(f"yfinance fetch failed: {e}")
return None
def _fetch_finnhub(
self,
symbol: str,
start_date: datetime,
end_date: datetime,
limit: int
) -> List[Dict[str, Any]]:
"""使用 finnhub 获取日线数据"""
klines = []
try:
start_ts = int(start_date.timestamp())
end_ts = int(end_date.timestamp())
# logger.info(f"使用 Finnhub 获取 {symbol} 日线数据")
candles = self.finnhub_client.stock_candles(symbol, 'D', start_ts, end_ts)
if candles and candles.get('s') == 'ok':
for i in range(len(candles['t'])):
klines.append(self.format_kline(
timestamp=candles['t'][i],
open_price=candles['o'][i],
high=candles['h'][i],
low=candles['l'][i],
close=candles['c'][i],
volume=candles['v'][i]
))
# logger.info(f"Finnhub 返回 {len(klines)} 条数据")
except Exception as e:
logger.error(f"Finnhub fetch failed: {e}")
return klines
def _convert_dataframe(self, df, limit: int) -> List[Dict[str, Any]]:
"""转换 DataFrame 为K线列表"""
klines = []
df = df.tail(limit).reset_index()
# 确定时间列名(日线是 Date,分钟级是 Datetime
time_col = None
if 'Datetime' in df.columns:
time_col = 'Datetime'
elif 'Date' in df.columns:
time_col = 'Date'
elif 'index' in df.columns:
time_col = 'index'
if time_col is None:
logger.warning(f"Unable to determine time column; available columns: {df.columns.tolist()}")
return klines
for _, row in df.iterrows():
try:
# 处理时间戳
time_value = row[time_col]
if hasattr(time_value, 'timestamp'):
ts = int(time_value.timestamp())
else:
continue
klines.append(self.format_kline(
timestamp=ts,
open_price=row['Open'],
high=row['High'],
low=row['Low'],
close=row['Close'],
volume=row['Volume']
))
except Exception as e:
logger.debug(f"Failed to parse row data: {e}")
continue
return klines