# -*- 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