V2.2.1: Membership & Billing, USDT TRC20 payment, VIP free indicators, AI Trading Radar, simplified strategy creation, system settings simplification, bug fixes and UI improvements
This commit is contained in:
@@ -6,12 +6,10 @@
|
||||
- 熔断器保护 (circuit_breaker)
|
||||
- 数据缓存 (cache_manager)
|
||||
- 防封禁策略 (rate_limiter)
|
||||
- 多数据源自动切换 (data_manager)
|
||||
"""
|
||||
from app.data_sources.factory import DataSourceFactory
|
||||
from app.data_sources.circuit_breaker import (
|
||||
CircuitBreaker,
|
||||
get_ashare_circuit_breaker,
|
||||
get_realtime_circuit_breaker
|
||||
)
|
||||
from app.data_sources.cache_manager import (
|
||||
@@ -22,24 +20,16 @@ from app.data_sources.cache_manager import (
|
||||
)
|
||||
from app.data_sources.rate_limiter import (
|
||||
RateLimiter,
|
||||
get_eastmoney_limiter,
|
||||
get_tencent_limiter,
|
||||
get_akshare_limiter,
|
||||
get_random_user_agent,
|
||||
random_sleep,
|
||||
retry_with_backoff
|
||||
)
|
||||
from app.data_sources.data_manager import (
|
||||
AShareDataManager,
|
||||
get_ashare_data_manager
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# 工厂
|
||||
'DataSourceFactory',
|
||||
# 熔断器
|
||||
'CircuitBreaker',
|
||||
'get_ashare_circuit_breaker',
|
||||
'get_realtime_circuit_breaker',
|
||||
# 缓存
|
||||
'DataCache',
|
||||
@@ -48,14 +38,7 @@ __all__ = [
|
||||
'get_stock_info_cache',
|
||||
# 限流器
|
||||
'RateLimiter',
|
||||
'get_eastmoney_limiter',
|
||||
'get_tencent_limiter',
|
||||
'get_akshare_limiter',
|
||||
'get_random_user_agent',
|
||||
'random_sleep',
|
||||
'retry_with_backoff',
|
||||
# 数据管理器
|
||||
'AShareDataManager',
|
||||
'get_ashare_data_manager',
|
||||
]
|
||||
|
||||
|
||||
@@ -178,11 +178,11 @@ class DataCache:
|
||||
# 全局缓存实例
|
||||
# ============================================
|
||||
|
||||
# A股实时行情缓存(20分钟TTL,全市场数据量大)
|
||||
_ashare_realtime_cache = DataCache(
|
||||
name="ashare_realtime",
|
||||
# 实时行情缓存(20分钟TTL)
|
||||
_realtime_cache = DataCache(
|
||||
name="realtime",
|
||||
default_ttl=1200.0, # 20分钟
|
||||
max_size=6000 # 约5000+股票
|
||||
max_size=6000
|
||||
)
|
||||
|
||||
# K线数据缓存(5分钟TTL,按需缓存)
|
||||
@@ -202,7 +202,7 @@ _stock_info_cache = DataCache(
|
||||
|
||||
def get_realtime_cache() -> DataCache:
|
||||
"""获取实时行情缓存"""
|
||||
return _ashare_realtime_cache
|
||||
return _realtime_cache
|
||||
|
||||
|
||||
def get_kline_cache() -> DataCache:
|
||||
|
||||
@@ -161,13 +161,6 @@ class CircuitBreaker:
|
||||
# 全局熔断器实例
|
||||
# ============================================
|
||||
|
||||
# A股数据源熔断器(标准策略)
|
||||
_ashare_circuit_breaker = CircuitBreaker(
|
||||
failure_threshold=3, # 连续失败3次熔断
|
||||
cooldown_seconds=300.0, # 冷却5分钟
|
||||
half_open_max_calls=1
|
||||
)
|
||||
|
||||
# 实时行情熔断器(更严格的策略)
|
||||
_realtime_circuit_breaker = CircuitBreaker(
|
||||
failure_threshold=2, # 连续失败2次熔断
|
||||
@@ -176,11 +169,6 @@ _realtime_circuit_breaker = CircuitBreaker(
|
||||
)
|
||||
|
||||
|
||||
def get_ashare_circuit_breaker() -> CircuitBreaker:
|
||||
"""获取A股数据源熔断器"""
|
||||
return _ashare_circuit_breaker
|
||||
|
||||
|
||||
def get_realtime_circuit_breaker() -> CircuitBreaker:
|
||||
"""获取实时行情熔断器"""
|
||||
return _realtime_circuit_breaker
|
||||
|
||||
@@ -1,877 +0,0 @@
|
||||
"""
|
||||
CN/HK stock data source.
|
||||
Supports A-Share and H-Share with multiple public sources.
|
||||
|
||||
改进版本(参考 daily_stock_analysis 项目):
|
||||
- 多数据源自动切换(按优先级)
|
||||
- 熔断器保护
|
||||
- 数据缓存
|
||||
- 防封禁策略(随机休眠+UA轮换)
|
||||
|
||||
Priority (AShare): Eastmoney > Tencent > Sina > Akshare > yfinance
|
||||
Priority (HShare): Tencent > Eastmoney > yfinance > akshare
|
||||
"""
|
||||
import json
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
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.data_sources.data_manager import get_ashare_data_manager, AShareDataManager
|
||||
from app.data_sources.circuit_breaker import get_ashare_circuit_breaker, get_realtime_circuit_breaker
|
||||
from app.data_sources.rate_limiter import get_request_headers, get_tencent_limiter, get_eastmoney_limiter
|
||||
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.
|
||||
|
||||
改进版本:使用 AShareDataManager 实现多数据源自动切换
|
||||
- 熔断器保护
|
||||
- 数据缓存
|
||||
- 防封禁策略
|
||||
"""
|
||||
|
||||
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()
|
||||
# 使用新的数据管理器
|
||||
self._data_manager = get_ashare_data_manager()
|
||||
# 熔断器和限流器
|
||||
self._circuit_breaker = get_ashare_circuit_breaker()
|
||||
self._em_limiter = get_eastmoney_limiter()
|
||||
|
||||
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, source = self._data_manager.get_kline(
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
limit=limit,
|
||||
before_time=before_time
|
||||
)
|
||||
|
||||
if klines:
|
||||
self.log_result(symbol, klines, timeframe)
|
||||
return klines
|
||||
|
||||
# 如果数据管理器失败,使用传统方式作为最后备选
|
||||
logger.warning(f"[AShare] 数据管理器获取 {symbol} 失败,尝试传统方式")
|
||||
|
||||
# 传统方式:直接调用东方财富
|
||||
klines = self._fetch_eastmoney_ashare_legacy(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:
|
||||
klines = self.us_stock_source.get_kline(yahoo_symbol, timeframe, limit, before_time)
|
||||
if klines:
|
||||
return klines
|
||||
|
||||
logger.warning(f"AShare {symbol} data fetch failed")
|
||||
return []
|
||||
|
||||
def _fetch_eastmoney_ashare_legacy(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
传统方式获取东方财富数据(兜底用)
|
||||
不使用新的熔断器和限流器,保持原有逻辑
|
||||
"""
|
||||
return self._fetch_eastmoney_ashare(symbol, timeframe, limit)
|
||||
|
||||
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股实时报价
|
||||
|
||||
改进版本:使用数据管理器自动切换数据源
|
||||
- 熔断器保护
|
||||
- 数据缓存(60秒TTL)
|
||||
- 多数据源自动切换
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'last': 当前价格,
|
||||
'change': 涨跌额,
|
||||
'changePercent': 涨跌幅,
|
||||
'high': 最高价,
|
||||
'low': 最低价,
|
||||
'open': 开盘价,
|
||||
'previousClose': 昨收价
|
||||
}
|
||||
"""
|
||||
symbol = (symbol or '').strip()
|
||||
|
||||
# 使用数据管理器获取实时报价(自动切换数据源)
|
||||
quote, source = self._data_manager.get_realtime_quote(symbol)
|
||||
if quote and quote.get('last', 0) > 0:
|
||||
return quote
|
||||
|
||||
# 如果数据管理器失败,使用传统方式作为兜底
|
||||
logger.debug(f"[AShare] 数据管理器获取 {symbol} 实时报价失败,尝试传统方式")
|
||||
return self._get_ticker_legacy(symbol)
|
||||
|
||||
def _get_ticker_legacy(self, symbol: str) -> Dict[str, Any]:
|
||||
"""
|
||||
传统方式获取实时报价(兜底用)
|
||||
|
||||
保持原有逻辑,不使用熔断器和限流器
|
||||
"""
|
||||
# 优先使用东方财富实时行情 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',
|
||||
}
|
||||
|
||||
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 = 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}")
|
||||
|
||||
# 降级使用腾讯实时报价
|
||||
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}")
|
||||
|
||||
# 第三备选: yfinance
|
||||
try:
|
||||
import yfinance as yf
|
||||
# 港股在 yfinance 中的格式是 XXXX.HK
|
||||
yf_symbol = f"{symbol.zfill(4)}.HK"
|
||||
ticker = yf.Ticker(yf_symbol)
|
||||
|
||||
# Try fast_info first (faster)
|
||||
try:
|
||||
info = ticker.fast_info
|
||||
if hasattr(info, 'last_price') and info.last_price and info.last_price > 0:
|
||||
return {
|
||||
'last': float(info.last_price),
|
||||
'change': float(info.last_price - info.previous_close) if hasattr(info, 'previous_close') and info.previous_close else 0,
|
||||
'changePercent': float((info.last_price - info.previous_close) / info.previous_close * 100) if hasattr(info, 'previous_close') and info.previous_close else 0,
|
||||
'high': float(info.day_high) if hasattr(info, 'day_high') and info.day_high else float(info.last_price),
|
||||
'low': float(info.day_low) if hasattr(info, 'day_low') and info.day_low else float(info.last_price),
|
||||
'open': float(info.open) if hasattr(info, 'open') and info.open else float(info.last_price),
|
||||
'previousClose': float(info.previous_close) if hasattr(info, 'previous_close') and info.previous_close else 0
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to history
|
||||
hist = ticker.history(period="2d")
|
||||
if hist is not None and not hist.empty:
|
||||
current = float(hist['Close'].iloc[-1])
|
||||
prev_close = float(hist['Close'].iloc[-2]) if len(hist) > 1 else current
|
||||
return {
|
||||
'last': current,
|
||||
'change': current - prev_close,
|
||||
'changePercent': (current - prev_close) / prev_close * 100 if prev_close else 0,
|
||||
'high': float(hist['High'].iloc[-1]),
|
||||
'low': float(hist['Low'].iloc[-1]),
|
||||
'open': float(hist['Open'].iloc[-1]),
|
||||
'previousClose': prev_close
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"yfinance ticker failed for {symbol}: {e}")
|
||||
|
||||
return {'last': 0, 'symbol': symbol}
|
||||
@@ -1,629 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
A股数据源管理器 (Data Manager)
|
||||
===================================
|
||||
|
||||
参考 daily_stock_analysis 项目的 DataFetcherManager 实现
|
||||
统一管理多个A股数据源,实现自动故障切换
|
||||
|
||||
数据源优先级:
|
||||
1. 东方财富 (Eastmoney) - 数据最全,首选
|
||||
2. 腾讯财经 (Tencent) - 稳定可靠
|
||||
3. 新浪财经 (Sina) - 轻量级
|
||||
4. Akshare - 功能丰富,但容易被封
|
||||
5. yfinance - 国际数据源,兜底
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from datetime import datetime
|
||||
import requests
|
||||
|
||||
from app.data_sources.circuit_breaker import (
|
||||
CircuitBreaker,
|
||||
get_ashare_circuit_breaker,
|
||||
get_realtime_circuit_breaker
|
||||
)
|
||||
from app.data_sources.cache_manager import (
|
||||
DataCache,
|
||||
get_realtime_cache,
|
||||
get_kline_cache,
|
||||
generate_kline_cache_key
|
||||
)
|
||||
from app.data_sources.rate_limiter import (
|
||||
RateLimiter,
|
||||
get_eastmoney_limiter,
|
||||
get_tencent_limiter,
|
||||
get_akshare_limiter,
|
||||
get_request_headers,
|
||||
retry_with_backoff
|
||||
)
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.http import get_retry_session
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# ============================================
|
||||
# 数据源常量
|
||||
# ============================================
|
||||
|
||||
class DataSource:
|
||||
"""数据源标识"""
|
||||
EASTMONEY = "eastmoney"
|
||||
TENCENT = "tencent"
|
||||
SINA = "sina"
|
||||
AKSHARE = "akshare"
|
||||
YFINANCE = "yfinance"
|
||||
|
||||
|
||||
# 数据源优先级(数字越小优先级越高)
|
||||
DATA_SOURCE_PRIORITY = {
|
||||
DataSource.EASTMONEY: 0,
|
||||
DataSource.TENCENT: 1,
|
||||
DataSource.SINA: 2,
|
||||
DataSource.AKSHARE: 3,
|
||||
DataSource.YFINANCE: 4,
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# A股数据管理器
|
||||
# ============================================
|
||||
|
||||
class AShareDataManager:
|
||||
"""
|
||||
A股数据源管理器
|
||||
|
||||
功能:
|
||||
1. 多数据源自动切换(按优先级)
|
||||
2. 熔断器保护
|
||||
3. 数据缓存
|
||||
4. 防封禁策略
|
||||
"""
|
||||
|
||||
# 东方财富 K 线周期映射
|
||||
EM_PERIOD_MAP = {
|
||||
'1m': '1',
|
||||
'5m': '5',
|
||||
'15m': '15',
|
||||
'30m': '30',
|
||||
'1H': '60',
|
||||
'4H': '240',
|
||||
'1D': '101',
|
||||
'1W': '102',
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
# 熔断器
|
||||
self._circuit_breaker = get_ashare_circuit_breaker()
|
||||
self._realtime_cb = get_realtime_circuit_breaker()
|
||||
|
||||
# 缓存
|
||||
self._realtime_cache = get_realtime_cache()
|
||||
self._kline_cache = get_kline_cache()
|
||||
|
||||
# 限流器
|
||||
self._em_limiter = get_eastmoney_limiter()
|
||||
self._tencent_limiter = get_tencent_limiter()
|
||||
self._akshare_limiter = get_akshare_limiter()
|
||||
|
||||
# Akshare 可用性检查
|
||||
self._has_akshare = self._check_akshare()
|
||||
|
||||
def _check_akshare(self) -> bool:
|
||||
"""检查 akshare 是否可用"""
|
||||
try:
|
||||
import akshare
|
||||
return True
|
||||
except ImportError:
|
||||
logger.debug("akshare 未安装,相关功能已禁用")
|
||||
return False
|
||||
|
||||
def get_kline(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
before_time: Optional[int] = None,
|
||||
use_cache: bool = True
|
||||
) -> Tuple[List[Dict[str, Any]], str]:
|
||||
"""
|
||||
获取K线数据(自动切换数据源)
|
||||
|
||||
Args:
|
||||
symbol: 股票代码
|
||||
timeframe: 时间周期
|
||||
limit: 数据条数
|
||||
before_time: 获取此时间之前的数据
|
||||
use_cache: 是否使用缓存
|
||||
|
||||
Returns:
|
||||
(K线数据列表, 成功的数据源名称)
|
||||
"""
|
||||
# 检查缓存
|
||||
if use_cache:
|
||||
cache_key = generate_kline_cache_key(symbol, timeframe, limit, before_time)
|
||||
cached = self._kline_cache.get(cache_key)
|
||||
if cached:
|
||||
logger.debug(f"[缓存命中] K线数据 {symbol}:{timeframe}")
|
||||
return cached, "cache"
|
||||
|
||||
errors = []
|
||||
|
||||
# 按优先级尝试各个数据源
|
||||
sources = [
|
||||
(DataSource.EASTMONEY, self._fetch_eastmoney_kline),
|
||||
(DataSource.TENCENT, self._fetch_tencent_kline),
|
||||
(DataSource.AKSHARE, self._fetch_akshare_kline),
|
||||
(DataSource.YFINANCE, self._fetch_yfinance_kline),
|
||||
]
|
||||
|
||||
for source_name, fetch_func in sources:
|
||||
# 检查熔断器
|
||||
if not self._circuit_breaker.is_available(source_name):
|
||||
logger.debug(f"[熔断] {source_name} 处于熔断状态,跳过")
|
||||
continue
|
||||
|
||||
# 跳过不可用的 akshare
|
||||
if source_name == DataSource.AKSHARE and not self._has_akshare:
|
||||
continue
|
||||
|
||||
try:
|
||||
logger.debug(f"[数据源] 尝试 {source_name} 获取 {symbol}")
|
||||
klines = fetch_func(symbol, timeframe, limit, before_time)
|
||||
|
||||
if klines:
|
||||
self._circuit_breaker.record_success(source_name)
|
||||
|
||||
# 更新缓存
|
||||
if use_cache:
|
||||
cache_key = generate_kline_cache_key(symbol, timeframe, limit, before_time)
|
||||
self._kline_cache.set(cache_key, klines)
|
||||
|
||||
logger.info(f"[数据源] {source_name} 成功获取 {symbol} {len(klines)} 条数据")
|
||||
return klines, source_name
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"{source_name}: {str(e)}"
|
||||
errors.append(error_msg)
|
||||
self._circuit_breaker.record_failure(source_name, str(e))
|
||||
logger.warning(f"[数据源] {error_msg}")
|
||||
|
||||
# 所有数据源都失败
|
||||
logger.error(f"[数据源] 所有数据源获取 {symbol} 失败: {errors}")
|
||||
return [], ""
|
||||
|
||||
def _fetch_eastmoney_kline(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
before_time: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""使用东方财富获取K线数据"""
|
||||
klines = []
|
||||
|
||||
period = self.EM_PERIOD_MAP.get(timeframe)
|
||||
if not period:
|
||||
raise ValueError(f"Eastmoney 不支持时间周期: {timeframe}")
|
||||
|
||||
# 限流
|
||||
self._em_limiter.wait()
|
||||
|
||||
# 确定市场代码
|
||||
if symbol.startswith('6'):
|
||||
secid = f"1.{symbol}" # 上海
|
||||
else:
|
||||
secid = f"0.{symbol}" # 深圳
|
||||
|
||||
url = "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,
|
||||
}
|
||||
|
||||
headers = get_request_headers(referer='https://quote.eastmoney.com/')
|
||||
|
||||
session = get_retry_session()
|
||||
response = session.get(url, params=params, headers=headers, timeout=15)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise ConnectionError(f"HTTP {response.status_code}")
|
||||
|
||||
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({
|
||||
'time': int(dt.timestamp()),
|
||||
'open': round(float(parts[1]), 4),
|
||||
'high': round(float(parts[3]), 4),
|
||||
'low': round(float(parts[4]), 4),
|
||||
'close': round(float(parts[2]), 4),
|
||||
'volume': round(float(parts[5]), 2)
|
||||
})
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
# 过滤和排序
|
||||
klines.sort(key=lambda x: x['time'])
|
||||
if before_time:
|
||||
klines = [k for k in klines if k['time'] < before_time]
|
||||
if len(klines) > limit:
|
||||
klines = klines[-limit:]
|
||||
|
||||
return klines
|
||||
|
||||
def _fetch_tencent_kline(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
before_time: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""使用腾讯财经获取K线数据"""
|
||||
# 腾讯周期映射
|
||||
period_map = {
|
||||
'1m': 1, '5m': 5, '15m': 15, '30m': 30,
|
||||
'1H': 60, '1D': 'day', '1W': 'week'
|
||||
}
|
||||
|
||||
period = period_map.get(timeframe)
|
||||
if period is None:
|
||||
raise ValueError(f"腾讯财经不支持时间周期: {timeframe}")
|
||||
|
||||
# 转换代码格式
|
||||
if symbol.startswith('6'):
|
||||
tencent_symbol = f"sh{symbol}"
|
||||
elif symbol.startswith('0') or symbol.startswith('3'):
|
||||
tencent_symbol = f"sz{symbol}"
|
||||
else:
|
||||
tencent_symbol = f"bj{symbol}"
|
||||
|
||||
# 限流
|
||||
self._tencent_limiter.wait()
|
||||
|
||||
# 构建URL
|
||||
if isinstance(period, int):
|
||||
url = f"http://ifzq.gtimg.cn/appstock/app/kline/mkline?param={tencent_symbol},m{period},,{limit}"
|
||||
else:
|
||||
url = f"http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param={tencent_symbol},{period},,,{limit},qfq"
|
||||
|
||||
response = requests.get(url, timeout=10)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise ConnectionError(f"HTTP {response.status_code}")
|
||||
|
||||
data = response.json()
|
||||
klines = []
|
||||
|
||||
if data.get('code') == 0 and 'data' in data:
|
||||
stock_data = data['data'].get(tencent_symbol)
|
||||
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:
|
||||
try:
|
||||
time_str = str(candle[0])
|
||||
if len(time_str) == 12:
|
||||
dt = datetime.strptime(time_str, '%Y%m%d%H%M')
|
||||
elif len(time_str) == 10:
|
||||
dt = datetime.strptime(time_str, '%Y-%m-%d')
|
||||
else:
|
||||
continue
|
||||
|
||||
klines.append({
|
||||
'time': int(dt.timestamp()),
|
||||
'open': round(float(candle[1]), 4),
|
||||
'high': round(float(candle[3]), 4),
|
||||
'low': round(float(candle[4]), 4),
|
||||
'close': round(float(candle[2]), 4),
|
||||
'volume': round(float(candle[5]), 2) if len(candle) > 5 else 0
|
||||
})
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
# 过滤和排序
|
||||
klines.sort(key=lambda x: x['time'])
|
||||
if before_time:
|
||||
klines = [k for k in klines if k['time'] < before_time]
|
||||
if len(klines) > limit:
|
||||
klines = klines[-limit:]
|
||||
|
||||
return klines
|
||||
|
||||
def _fetch_akshare_kline(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
before_time: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""使用 Akshare 获取K线数据"""
|
||||
if not self._has_akshare:
|
||||
raise RuntimeError("akshare 未安装")
|
||||
|
||||
import akshare as ak
|
||||
from datetime import timedelta
|
||||
|
||||
# Akshare 只支持日线/周线
|
||||
period_map = {'1D': 'daily', '1W': 'weekly'}
|
||||
period = period_map.get(timeframe)
|
||||
if not period:
|
||||
raise ValueError(f"Akshare 不支持时间周期: {timeframe}")
|
||||
|
||||
# 限流
|
||||
self._akshare_limiter.wait()
|
||||
|
||||
# 计算日期范围
|
||||
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')
|
||||
|
||||
df = ak.stock_zh_a_hist(
|
||||
symbol=symbol,
|
||||
period=period,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
adjust="qfq"
|
||||
)
|
||||
|
||||
klines = []
|
||||
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({
|
||||
'time': ts,
|
||||
'open': round(float(row['开盘']), 4),
|
||||
'high': round(float(row['最高']), 4),
|
||||
'low': round(float(row['最低']), 4),
|
||||
'close': round(float(row['收盘']), 4),
|
||||
'volume': round(float(row['成交量']), 2)
|
||||
})
|
||||
|
||||
return klines
|
||||
|
||||
def _fetch_yfinance_kline(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
before_time: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""使用 yfinance 获取K线数据(兜底)"""
|
||||
import yfinance as yf
|
||||
|
||||
# 转换为 Yahoo 格式
|
||||
if symbol.startswith('6'):
|
||||
yahoo_symbol = f"{symbol}.SS"
|
||||
elif symbol.startswith('0') or symbol.startswith('3'):
|
||||
yahoo_symbol = f"{symbol}.SZ"
|
||||
else:
|
||||
yahoo_symbol = f"{symbol}.SS"
|
||||
|
||||
# 周期映射
|
||||
period_map = {
|
||||
'1D': ('1d', f'{limit}d'),
|
||||
'1W': ('1wk', f'{limit * 7}d'),
|
||||
'1H': ('1h', f'{limit}d'),
|
||||
}
|
||||
|
||||
interval, period = period_map.get(timeframe, ('1d', f'{limit}d'))
|
||||
|
||||
ticker = yf.Ticker(yahoo_symbol)
|
||||
df = ticker.history(period=period, interval=interval)
|
||||
|
||||
klines = []
|
||||
if df is not None and not df.empty:
|
||||
df = df.tail(limit)
|
||||
for idx, row in df.iterrows():
|
||||
ts = int(idx.timestamp())
|
||||
klines.append({
|
||||
'time': ts,
|
||||
'open': round(float(row['Open']), 4),
|
||||
'high': round(float(row['High']), 4),
|
||||
'low': round(float(row['Low']), 4),
|
||||
'close': round(float(row['Close']), 4),
|
||||
'volume': round(float(row['Volume']), 2)
|
||||
})
|
||||
|
||||
return klines
|
||||
|
||||
def get_realtime_quote(
|
||||
self,
|
||||
symbol: str,
|
||||
use_cache: bool = True
|
||||
) -> Tuple[Dict[str, Any], str]:
|
||||
"""
|
||||
获取实时报价(自动切换数据源)
|
||||
|
||||
Args:
|
||||
symbol: 股票代码
|
||||
use_cache: 是否使用缓存
|
||||
|
||||
Returns:
|
||||
(报价数据字典, 成功的数据源名称)
|
||||
"""
|
||||
# 检查缓存
|
||||
if use_cache:
|
||||
cached = self._realtime_cache.get(f"quote:{symbol}")
|
||||
if cached:
|
||||
return cached, "cache"
|
||||
|
||||
errors = []
|
||||
|
||||
# 按优先级尝试各个数据源
|
||||
sources = [
|
||||
(DataSource.EASTMONEY, self._fetch_eastmoney_quote),
|
||||
(DataSource.TENCENT, self._fetch_tencent_quote),
|
||||
(DataSource.AKSHARE, self._fetch_akshare_quote),
|
||||
]
|
||||
|
||||
for source_name, fetch_func in sources:
|
||||
if not self._realtime_cb.is_available(source_name):
|
||||
continue
|
||||
|
||||
if source_name == DataSource.AKSHARE and not self._has_akshare:
|
||||
continue
|
||||
|
||||
try:
|
||||
quote = fetch_func(symbol)
|
||||
if quote and quote.get('last', 0) > 0:
|
||||
self._realtime_cb.record_success(source_name)
|
||||
|
||||
# 更新缓存
|
||||
if use_cache:
|
||||
self._realtime_cache.set(f"quote:{symbol}", quote, ttl=60.0)
|
||||
|
||||
return quote, source_name
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"{source_name}: {str(e)}")
|
||||
self._realtime_cb.record_failure(source_name, str(e))
|
||||
|
||||
logger.warning(f"[实时报价] 所有数据源获取 {symbol} 失败")
|
||||
return {'last': 0, 'symbol': symbol}, ""
|
||||
|
||||
def _fetch_eastmoney_quote(self, symbol: str) -> Dict[str, Any]:
|
||||
"""使用东方财富获取实时报价"""
|
||||
if symbol.startswith('6'):
|
||||
secid = f"1.{symbol}"
|
||||
else:
|
||||
secid = f"0.{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',
|
||||
}
|
||||
|
||||
self._em_limiter.wait()
|
||||
|
||||
session = get_retry_session()
|
||||
response = session.get(url, params=params, headers=get_request_headers(), 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 = 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
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
def _fetch_tencent_quote(self, symbol: str) -> Dict[str, Any]:
|
||||
"""使用腾讯财经获取实时报价"""
|
||||
if symbol.startswith('6'):
|
||||
tencent_symbol = f"sh{symbol}"
|
||||
else:
|
||||
tencent_symbol = f"sz{symbol}"
|
||||
|
||||
url = f"http://qt.gtimg.cn/q={tencent_symbol}"
|
||||
|
||||
self._tencent_limiter.wait()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
def _fetch_akshare_quote(self, symbol: str) -> Dict[str, Any]:
|
||||
"""使用 Akshare 获取实时报价"""
|
||||
if not self._has_akshare:
|
||||
return {}
|
||||
|
||||
import akshare as ak
|
||||
|
||||
self._akshare_limiter.wait()
|
||||
|
||||
df = ak.stock_zh_a_spot_em()
|
||||
if df is not None and not df.empty:
|
||||
row = df[df['代码'] == symbol]
|
||||
if not row.empty:
|
||||
row = row.iloc[0]
|
||||
return {
|
||||
'last': float(row.get('最新价', 0) or 0),
|
||||
'change': float(row.get('涨跌额', 0) or 0),
|
||||
'changePercent': float(row.get('涨跌幅', 0) or 0),
|
||||
'high': float(row.get('最高', 0) or 0),
|
||||
'low': float(row.get('最低', 0) or 0),
|
||||
'open': float(row.get('今开', 0) or 0),
|
||||
'previousClose': float(row.get('昨收', 0) or 0)
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""获取数据管理器状态"""
|
||||
return {
|
||||
'circuit_breaker': self._circuit_breaker.get_status(),
|
||||
'realtime_circuit_breaker': self._realtime_cb.get_status(),
|
||||
'realtime_cache_stats': self._realtime_cache.stats(),
|
||||
'kline_cache_stats': self._kline_cache.stats(),
|
||||
'has_akshare': self._has_akshare,
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# 全局实例
|
||||
# ============================================
|
||||
|
||||
_ashare_data_manager: Optional[AShareDataManager] = None
|
||||
|
||||
|
||||
def get_ashare_data_manager() -> AShareDataManager:
|
||||
"""获取A股数据管理器单例"""
|
||||
global _ashare_data_manager
|
||||
if _ashare_data_manager is None:
|
||||
_ashare_data_manager = AShareDataManager()
|
||||
return _ashare_data_manager
|
||||
@@ -21,7 +21,7 @@ class DataSourceFactory:
|
||||
获取指定市场的数据源
|
||||
|
||||
Args:
|
||||
market: 市场类型 (Crypto, USStock, AShare, HShare)
|
||||
market: 市场类型 (Crypto, USStock, Forex, Futures)
|
||||
|
||||
Returns:
|
||||
数据源实例
|
||||
@@ -55,12 +55,6 @@ class DataSourceFactory:
|
||||
elif market == 'USStock':
|
||||
from app.data_sources.us_stock import USStockDataSource
|
||||
return USStockDataSource()
|
||||
elif market == 'AShare':
|
||||
from app.data_sources.cn_stock import AShareDataSource
|
||||
return AShareDataSource()
|
||||
elif market == 'HShare':
|
||||
from app.data_sources.cn_stock import HShareDataSource
|
||||
return HShareDataSource()
|
||||
elif market == 'Forex':
|
||||
from app.data_sources.forex import ForexDataSource
|
||||
return ForexDataSource()
|
||||
|
||||
Reference in New Issue
Block a user