Files
DinQuant/backend_api_python/app/data_sources/cn_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

780 lines
30 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.
"""
CN/HK stock data source.
Supports A-Share and H-Share with multiple public sources.
Priority (AShare): Eastmoney (intraday/daily) > yfinance (daily) > akshare (daily, optional).
Priority (HShare): Tencent (intraday) > Eastmoney/Tencent (daily) > yfinance (daily) > akshare (daily, optional).
"""
import json
from typing import Dict, List, Any, Optional
from datetime import datetime, timedelta
import requests
import yfinance as yf
from app.data_sources.base import BaseDataSource
from app.data_sources.us_stock import USStockDataSource
from app.utils.logger import get_logger
from app.utils.http import get_retry_session
logger = get_logger(__name__)
# Optional dependency: akshare
try:
import akshare as ak # type: ignore
HAS_AKSHARE = True
logger.debug("akshare is available")
except ImportError:
HAS_AKSHARE = False
# Keep it quiet to avoid noisy startup logs on Windows.
logger.debug("akshare is not installed; akshare-based features are disabled")
class TencentDataMixin:
"""Tencent quote API mixin (mostly for H-Share and legacy fallback)."""
# 腾讯 K 线周期映射(注意:腾讯分钟级接口不支持240分钟,4H需要特殊处理)
TENCENT_PERIOD_MAP = {
'1m': 1,
'5m': 5,
'15m': 15,
'30m': 30,
'1H': 60,
'1D': 'day',
'1W': 'week'
}
def _fetch_tencent_kline(
self,
symbol_code: str,
timeframe: str,
limit: int
) -> List[Dict[str, Any]]:
"""
使用腾讯财经接口获取K线数据
Args:
symbol_code: 腾讯格式的代码 (sh600000, sz000001, hk00700)
timeframe: 时间周期
limit: 数据条数
"""
klines = []
# 4H 需要特殊处理:获取1H数据然后聚合
if timeframe == '4H':
return self._fetch_and_aggregate_4h(symbol_code, limit)
try:
period = self.TENCENT_PERIOD_MAP.get(timeframe)
if period is None:
logger.warning(f"Unsupported timeframe: {timeframe}")
return []
# 构建请求URL
if isinstance(period, int):
# 分钟级数据
url = f"http://ifzq.gtimg.cn/appstock/app/kline/mkline?param={symbol_code},m{period},,{limit}"
else:
# 日线/周线数据
url = f"http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param={symbol_code},{period},,,{limit},qfq"
# logger.info(f"腾讯财经请求: {symbol_code}, 周期: {timeframe}, URL: {url[:80]}...")
session = get_retry_session()
response = session.get(url, timeout=10)
if response.status_code != 200:
logger.warning(f"Tencent quote returned status: {response.status_code}")
return []
data = response.json()
# 解析响应数据
if data.get('code') == 0 and 'data' in data:
stock_data = data['data'].get(symbol_code)
if stock_data:
# 分钟级数据格式
if isinstance(period, int):
candles = stock_data.get(f'm{period}', [])
else:
# 日线/周线数据格式
candles = stock_data.get('qfqday', stock_data.get('day', []))
for candle in candles:
if len(candle) >= 5:
# 解析时间
time_str = str(candle[0])
try:
if len(time_str) == 12: # 分钟级: 202411301430
dt = datetime.strptime(time_str, '%Y%m%d%H%M')
elif len(time_str) == 10: # 日线: 2024-11-30
dt = datetime.strptime(time_str, '%Y-%m-%d')
else:
continue
klines.append(self.format_kline(
timestamp=int(dt.timestamp()),
open_price=float(candle[1]),
high=float(candle[3]),
low=float(candle[4]),
close=float(candle[2]),
volume=float(candle[5]) if len(candle) > 5 else 0
))
except (ValueError, IndexError) as e:
logger.debug(f"Failed to parse kline candle: {candle}, error: {e}")
continue
# logger.info(f"腾讯财经返回 {len(klines)} 条数据")
else:
logger.warning(f"Tencent quote returned unexpected data: code={data.get('code')}")
except Exception as e:
logger.error(f"Tencent quote fetch failed: {e}")
import traceback
logger.error(traceback.format_exc())
return klines
def _fetch_and_aggregate_4h(self, symbol_code: str, limit: int) -> List[Dict[str, Any]]:
"""获取1H数据并聚合为4H"""
# 获取足够多的1H数据
hour_klines = self._fetch_tencent_kline(symbol_code, '1H', limit * 4 + 10)
if not hour_klines:
return []
# 按4小时聚合
aggregated = []
i = 0
while i < len(hour_klines):
# 取4根K线
batch = hour_klines[i:i+4]
if len(batch) < 4:
break
aggregated.append(self.format_kline(
timestamp=batch[0]['time'],
open_price=batch[0]['open'],
high=max(k['high'] for k in batch),
low=min(k['low'] for k in batch),
close=batch[-1]['close'],
volume=sum(k['volume'] for k in batch)
))
i += 4
# logger.info(f"聚合生成 {len(aggregated)} 条 4H 数据")
return aggregated[-limit:] if len(aggregated) > limit else aggregated
class AShareDataSource(BaseDataSource, TencentDataMixin):
"""A-Share data source."""
name = "AShare"
# akshare 时间周期映射
AKSHARE_PERIOD_MAP = {
'1D': 'daily',
'1W': 'weekly'
}
# 东方财富 K 线周期映射
EM_PERIOD_MAP = {
'1m': '1',
'5m': '5',
'15m': '15',
'30m': '30',
'1H': '60',
'4H': '240',
'1D': '101',
'1W': '102',
}
def __init__(self):
self.us_stock_source = USStockDataSource()
def get_kline(
self,
symbol: str,
timeframe: str,
limit: int,
before_time: Optional[int] = None
) -> List[Dict[str, Any]]:
"""Fetch A-Share Kline data."""
klines = []
# Prefer Eastmoney (supports most intraday timeframes)
klines = self._fetch_eastmoney_ashare(symbol, timeframe, limit)
if klines:
klines = self.filter_and_limit(klines, limit, before_time)
self.log_result(symbol, klines, timeframe)
return klines
# Fallback: yfinance (daily/weekly)
if timeframe in ('1D', '1W'):
yahoo_symbol = self._to_yahoo_symbol(symbol)
if yahoo_symbol:
# logger.info(f"尝试使用 yfinance 获取A股: {yahoo_symbol}")
klines = self.us_stock_source.get_kline(yahoo_symbol, timeframe, limit, before_time)
if klines:
# logger.info(f"yfinance 成功获取 {len(klines)} 条A股数据")
return klines
# Fallback: akshare (daily/weekly)
if HAS_AKSHARE and timeframe in self.AKSHARE_PERIOD_MAP:
klines = self._fetch_akshare(symbol, timeframe, limit, before_time)
if klines:
return klines
logger.warning(f"AShare {symbol} data fetch failed")
return klines
def _to_tencent_symbol(self, symbol: str) -> Optional[str]:
"""转换为腾讯财经格式"""
if symbol.startswith('6'):
return f"sh{symbol}"
elif symbol.startswith('0') or symbol.startswith('3'):
return f"sz{symbol}"
elif symbol.startswith('4') or symbol.startswith('8'):
return f"bj{symbol}" # 北交所
return None
def _to_yahoo_symbol(self, symbol: str) -> Optional[str]:
"""转换为 Yahoo Finance 格式"""
if symbol.startswith('6'):
return f"{symbol}.SS"
elif symbol.startswith('0') or symbol.startswith('3'):
return f"{symbol}.SZ"
elif symbol.startswith('4') or symbol.startswith('8'):
return f"{symbol}.BJ"
return None
def _fetch_eastmoney_ashare(
self,
symbol: str,
timeframe: str,
limit: int
) -> List[Dict[str, Any]]:
"""使用东方财富获取A股数据"""
klines = []
period = self.EM_PERIOD_MAP.get(timeframe)
if not period:
logger.warning(f"Eastmoney unsupported timeframe: {timeframe}")
return []
try:
# 确定市场代码: 上海=1, 深圳=0, 北交所=0
if symbol.startswith('6'):
secid = f"1.{symbol}"
else:
secid = f"0.{symbol}"
# 东方财富K线接口
url = f"https://push2his.eastmoney.com/api/qt/stock/kline/get"
params = {
'secid': secid,
'fields1': 'f1,f2,f3,f4,f5,f6',
'fields2': 'f51,f52,f53,f54,f55,f56,f57',
'klt': period,
'fqt': '1', # 前复权
'end': '20500101',
'lmt': limit,
}
# logger.info(f"东方财富A股请求: {symbol}, 周期: {timeframe}")
# 添加浏览器请求头
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://quote.eastmoney.com/',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
}
session = get_retry_session()
response = session.get(url, params=params, headers=headers, timeout=15)
if response.status_code != 200:
logger.warning(f"Eastmoney HTTP status: {response.status_code}")
return []
data = response.json()
# 解析响应
if data.get('data') and data['data'].get('klines'):
for line in data['data']['klines']:
try:
parts = line.split(',')
if len(parts) >= 6:
time_str = parts[0]
if ' ' in time_str:
dt = datetime.strptime(time_str, '%Y-%m-%d %H:%M')
else:
dt = datetime.strptime(time_str, '%Y-%m-%d')
klines.append(self.format_kline(
timestamp=int(dt.timestamp()),
open_price=float(parts[1]),
high=float(parts[3]),
low=float(parts[4]),
close=float(parts[2]),
volume=float(parts[5])
))
except (ValueError, IndexError) as e:
logger.debug(f"Failed to parse Eastmoney data line: {line}, error: {e}")
continue
# logger.info(f"东方财富返回 {len(klines)} 条A股数据")
else:
logger.warning("Eastmoney returned no data")
except Exception as e:
logger.error(f"Eastmoney A-share fetch failed: {e}")
import traceback
logger.error(traceback.format_exc())
return klines
def _fetch_akshare(
self,
symbol: str,
timeframe: str,
limit: int,
before_time: Optional[int]
) -> List[Dict[str, Any]]:
"""使用 akshare 获取数据"""
klines = []
try:
period = self.AKSHARE_PERIOD_MAP.get(timeframe, 'daily')
# 计算日期范围
if before_time:
end_date = datetime.fromtimestamp(before_time).strftime('%Y%m%d')
else:
end_date = datetime.now().strftime('%Y%m%d')
days = limit * 2 if timeframe == '1D' else limit * 10
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d')
# logger.info(f"使用 akshare 获取A股: {symbol}, 周期: {period}")
df = ak.stock_zh_a_hist(
symbol=symbol,
period=period,
start_date=start_date,
end_date=end_date,
adjust="qfq" # 前复权
)
if df is not None and not df.empty:
df = df.tail(limit)
for _, row in df.iterrows():
ts = int(datetime.strptime(str(row['日期']), '%Y-%m-%d').timestamp())
klines.append(self.format_kline(
timestamp=ts,
open_price=row['开盘'],
high=row['最高'],
low=row['最低'],
close=row['收盘'],
volume=row['成交量']
))
# logger.info(f"akshare 返回 {len(klines)} 条A股数据")
except Exception as e:
logger.error(f"Akshare A-share fetch failed: {e}")
import traceback
logger.error(traceback.format_exc())
return klines
def get_ticker(self, symbol: str) -> Dict[str, Any]:
"""
获取A股实时报价
使用东方财富实时行情API获取实时报价
Returns:
dict: {
'last': 当前价格,
'change': 涨跌额,
'changePercent': 涨跌幅,
'high': 最高价,
'low': 最低价,
'open': 开盘价,
'previousClose': 昨收价
}
"""
symbol = (symbol or '').strip()
# 优先使用东方财富实时行情 API
try:
# 判断市场
if symbol.startswith('6'):
secid = f"1.{symbol}" # 上海
elif symbol.startswith('0') or symbol.startswith('3'):
secid = f"0.{symbol}" # 深圳
elif symbol.startswith('4') or symbol.startswith('8'):
secid = f"0.{symbol}" # 北交所
else:
secid = f"1.{symbol}"
# 东方财富实时行情接口
url = "https://push2.eastmoney.com/api/qt/stock/get"
params = {
'secid': secid,
'fields': 'f43,f44,f45,f46,f47,f48,f57,f58,f60,f169,f170',
# f43=最新价, f44=最高价, f45=最低价, f46=开盘价
# f60=昨收价, f169=涨跌额, f170=涨跌幅
}
session = get_retry_session()
response = session.get(url, params=params, timeout=10)
if response.status_code == 200:
data = response.json()
if data and data.get('data'):
d = data['data']
last_price = d.get('f43', 0)
# 东方财富返回的价格是整数(分),需要除以100
if last_price and last_price > 0:
divisor = 100 if last_price > 1000 else 1 # 价格超过10元时用分表示
return {
'last': last_price / divisor,
'high': d.get('f44', 0) / divisor,
'low': d.get('f45', 0) / divisor,
'open': d.get('f46', 0) / divisor,
'previousClose': d.get('f60', 0) / divisor,
'change': d.get('f169', 0) / divisor,
'changePercent': d.get('f170', 0) / 100 # 涨跌幅是整数(%*100
}
except Exception as e:
logger.debug(f"Eastmoney ticker failed for {symbol}: {e}")
# 降级使用腾讯实时报价
try:
tencent_symbol = self._to_tencent_symbol(symbol)
if tencent_symbol:
url = f"http://qt.gtimg.cn/q={tencent_symbol}"
response = requests.get(url, timeout=10)
content = response.content.decode('gbk', errors='ignore')
if '="' in content:
data_str = content.split('="')[1].strip('";\n')
if data_str:
parts = data_str.split('~')
if len(parts) > 32:
return {
'last': float(parts[3]) if parts[3] else 0,
'change': float(parts[31]) if parts[31] else 0,
'changePercent': float(parts[32]) if parts[32] else 0,
'high': float(parts[33]) if len(parts) > 33 and parts[33] else 0,
'low': float(parts[34]) if len(parts) > 34 and parts[34] else 0,
'open': float(parts[5]) if len(parts) > 5 and parts[5] else 0,
'previousClose': float(parts[4]) if parts[4] else 0
}
except Exception as e:
logger.debug(f"Tencent ticker failed for {symbol}: {e}")
return {'last': 0, 'symbol': symbol}
class HShareDataSource(BaseDataSource, TencentDataMixin):
"""港股数据源"""
name = "HShare"
def __init__(self):
self.us_stock_source = USStockDataSource()
def get_kline(
self,
symbol: str,
timeframe: str,
limit: int,
before_time: Optional[int] = None
) -> List[Dict[str, Any]]:
"""获取港股K线数据"""
klines = []
# 方案1: 腾讯财经 (港股日线/周线首选,稳定可靠)
if timeframe in ('1D', '1W'):
tencent_symbol = self._to_tencent_symbol(symbol)
if tencent_symbol:
# logger.info(f"尝试使用腾讯财经获取港股: {tencent_symbol}")
klines = self._fetch_tencent_kline(tencent_symbol, timeframe, limit)
if klines:
klines = self.filter_and_limit(klines, limit, before_time)
self.log_result(symbol, klines, timeframe)
return klines
# 方案2: 东方财富 (支持所有周期,但可能有地域限制)
klines = self._fetch_eastmoney_kline(symbol, timeframe, limit)
if klines:
klines = self.filter_and_limit(klines, limit, before_time)
self.log_result(symbol, klines, timeframe)
return klines
# 方案3: 尝试 yfinance (日线级别备选)
if timeframe in ('1D', '1W'):
yahoo_symbol = self._to_yahoo_symbol(symbol)
if yahoo_symbol:
# logger.info(f"尝试使用 yfinance 获取港股: {yahoo_symbol}")
klines = self.us_stock_source.get_kline(yahoo_symbol, timeframe, limit, before_time)
if klines:
# logger.info(f"yfinance 成功获取 {len(klines)} 条港股数据")
return klines
# 方案4: 尝试 akshare (日线级别)
if HAS_AKSHARE and timeframe in ('1D', '1W'):
klines = self._fetch_akshare(symbol, timeframe, limit, before_time)
if klines:
return klines
# 分钟级数据获取失败提示
if timeframe not in ('1D', '1W'):
logger.warning(f"HK stock {symbol}: minute-level data is not supported (data source limitations)")
else:
logger.warning(f"HK stock {symbol}: data fetch failed (timeframe: {timeframe})")
return klines
def _to_tencent_symbol(self, symbol: str) -> str:
"""转换为腾讯财经格式"""
# 港股代码补齐到5位
padded = symbol.zfill(5)
return f"hk{padded}"
def _to_yahoo_symbol(self, symbol: str) -> str:
"""转换为 Yahoo Finance 格式"""
# 港股代码补齐到4位
padded = symbol.zfill(4)
return f"{padded}.HK"
def _fetch_eastmoney_kline(
self,
symbol: str,
timeframe: str,
limit: int
) -> List[Dict[str, Any]]:
"""使用东方财富获取港股分钟级数据"""
klines = []
# 东方财富 K 线周期映射
em_period_map = {
'1m': '1',
'5m': '5',
'15m': '15',
'30m': '30',
'1H': '60',
'4H': '240',
'1D': '101',
'1W': '102',
}
period = em_period_map.get(timeframe)
if not period:
logger.warning(f"Eastmoney unsupported timeframe: {timeframe}")
return []
try:
# 港股代码补齐到5位
hk_symbol = symbol.zfill(5)
# 东方财富港股代码格式: 116.00700 (116是港股市场代码)
secid = f"116.{hk_symbol}"
# 东方财富K线接口
url = f"https://push2his.eastmoney.com/api/qt/stock/kline/get"
params = {
'secid': secid,
'fields1': 'f1,f2,f3,f4,f5,f6',
'fields2': 'f51,f52,f53,f54,f55,f56,f57',
'klt': period, # K线类型
'fqt': '1', # 前复权
'end': '20500101',
'lmt': limit,
}
# logger.info(f"东方财富港股请求: {hk_symbol}, 周期: {timeframe}")
# 添加浏览器请求头,避免被拒绝
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://quote.eastmoney.com/',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
}
session = get_retry_session()
response = session.get(url, params=params, headers=headers, timeout=15)
if response.status_code != 200:
logger.warning(f"Eastmoney HTTP status: {response.status_code}")
return []
data = response.json()
# 解析响应
if data.get('data') and data['data'].get('klines'):
for line in data['data']['klines']:
try:
# 格式: "2025-11-28 15:00,400.0,401.0,399.0,400.5,1000,100000"
# 日期,开盘,收盘,最高,最低,成交量,成交额
parts = line.split(',')
if len(parts) >= 6:
time_str = parts[0]
# 解析时间
if ' ' in time_str:
dt = datetime.strptime(time_str, '%Y-%m-%d %H:%M')
else:
dt = datetime.strptime(time_str, '%Y-%m-%d')
klines.append(self.format_kline(
timestamp=int(dt.timestamp()),
open_price=float(parts[1]),
high=float(parts[3]),
low=float(parts[4]),
close=float(parts[2]),
volume=float(parts[5])
))
except (ValueError, IndexError) as e:
logger.debug(f"Failed to parse Eastmoney data line: {line}, error: {e}")
continue
# logger.info(f"东方财富返回 {len(klines)} 条港股数据")
else:
logger.warning("Eastmoney returned no data")
except Exception as e:
logger.error(f"Eastmoney HK stock fetch failed: {e}")
import traceback
logger.error(traceback.format_exc())
return klines
def _fetch_akshare(
self,
symbol: str,
timeframe: str,
limit: int,
before_time: Optional[int]
) -> List[Dict[str, Any]]:
"""使用 akshare 获取港股数据"""
klines = []
try:
# 计算日期范围
if before_time:
end_date = datetime.fromtimestamp(before_time).strftime('%Y%m%d')
else:
end_date = datetime.now().strftime('%Y%m%d')
days = limit * 2 if timeframe == '1D' else limit * 10
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d')
# 港股代码补齐到5位
hk_symbol = symbol.zfill(5)
# logger.info(f"使用 akshare 获取港股: {hk_symbol}")
df = ak.stock_hk_hist(
symbol=hk_symbol,
period="daily",
start_date=start_date,
end_date=end_date,
adjust="qfq"
)
if df is not None and not df.empty:
df = df.tail(limit)
for _, row in df.iterrows():
ts = int(datetime.strptime(str(row['日期']), '%Y-%m-%d').timestamp())
klines.append(self.format_kline(
timestamp=ts,
open_price=row['开盘'],
high=row['最高'],
low=row['最低'],
close=row['收盘'],
volume=row['成交量']
))
# logger.info(f"akshare 返回 {len(klines)} 条港股数据")
except Exception as e:
logger.error(f"Akshare HK stock fetch failed: {e}")
import traceback
logger.error(traceback.format_exc())
return klines
def get_ticker(self, symbol: str) -> Dict[str, Any]:
"""
获取港股实时报价
使用腾讯财经实时行情API获取实时报价
Returns:
dict: {
'last': 当前价格,
'change': 涨跌额,
'changePercent': 涨跌幅,
'high': 最高价,
'low': 最低价,
'open': 开盘价,
'previousClose': 昨收价
}
"""
symbol = (symbol or '').strip()
# 使用腾讯财经实时报价
try:
tencent_symbol = self._to_tencent_symbol(symbol)
url = f"http://qt.gtimg.cn/q={tencent_symbol}"
response = requests.get(url, timeout=10)
content = response.content.decode('gbk', errors='ignore')
if '="' in content:
data_str = content.split('="')[1].strip('";\n')
if data_str:
parts = data_str.split('~')
if len(parts) > 32:
return {
'last': float(parts[3]) if parts[3] else 0,
'change': float(parts[31]) if parts[31] else 0,
'changePercent': float(parts[32]) if parts[32] else 0,
'high': float(parts[33]) if len(parts) > 33 and parts[33] else 0,
'low': float(parts[34]) if len(parts) > 34 and parts[34] else 0,
'open': float(parts[5]) if len(parts) > 5 and parts[5] else 0,
'previousClose': float(parts[4]) if parts[4] else 0
}
except Exception as e:
logger.debug(f"Tencent ticker failed for {symbol}: {e}")
# 降级使用东方财富
try:
hk_symbol = symbol.zfill(5)
secid = f"116.{hk_symbol}"
url = "https://push2.eastmoney.com/api/qt/stock/get"
params = {
'secid': secid,
'fields': 'f43,f44,f45,f46,f47,f48,f57,f58,f60,f169,f170',
}
session = get_retry_session()
response = session.get(url, params=params, timeout=10)
if response.status_code == 200:
data = response.json()
if data and data.get('data'):
d = data['data']
last_price = d.get('f43', 0)
if last_price and last_price > 0:
divisor = 1000 if last_price > 10000 else 100 if last_price > 1000 else 1
return {
'last': last_price / divisor,
'high': d.get('f44', 0) / divisor,
'low': d.get('f45', 0) / divisor,
'open': d.get('f46', 0) / divisor,
'previousClose': d.get('f60', 0) / divisor,
'change': d.get('f169', 0) / divisor,
'changePercent': d.get('f170', 0) / 100
}
except Exception as e:
logger.debug(f"Eastmoney ticker failed for {symbol}: {e}")
return {'last': 0, 'symbol': symbol}