f43312a858
Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
616 lines
23 KiB
Python
616 lines
23 KiB
Python
"""
|
|
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
|
|
|
|
|
|
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
|