714dd47c86
- 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
308 lines
12 KiB
Python
308 lines
12 KiB
Python
"""
|
||
美股数据源
|
||
使用 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
|
||
|