# -*- coding: utf-8 -*- """ =================================== EfinanceFetcher - 优先数据源 (Priority 0) =================================== 数据来源:东方财富爬虫(通过 efinance 库) 特点:免费、无需 Token、数据全面、API 简洁 仓库:https://github.com/Micro-sheep/efinance 与 AkshareFetcher 类似,但 efinance 库: 1. API 更简洁易用 2. 支持批量获取数据 3. 更稳定的接口封装 防封禁策略: 1. 每次请求前随机休眠 1.5-3.0 秒 2. 随机轮换 User-Agent 3. 使用 tenacity 实现指数退避重试 4. 熔断器机制:连续失败后自动冷却 """ import logging import os import random import re import time from dataclasses import dataclass, field from datetime import datetime from typing import Optional, Dict, Any, List, Tuple import pandas as pd import requests # 引入 requests 以捕获异常 from tenacity import ( retry, stop_after_attempt, wait_exponential, retry_if_exception_type, before_sleep_log, ) from .base import BaseFetcher, DataFetchError, RateLimitError, STANDARD_COLUMNS from .realtime_types import ( UnifiedRealtimeQuote, RealtimeSource, get_realtime_circuit_breaker, safe_float, safe_int # 使用统一的类型转换函数 ) # 保留旧的类型别名,用于向后兼容 @dataclass class EfinanceRealtimeQuote: """ 实时行情数据(来自 efinance)- 向后兼容别名 新代码建议使用 UnifiedRealtimeQuote """ code: str name: str = "" price: float = 0.0 # 最新价 change_pct: float = 0.0 # 涨跌幅(%) change_amount: float = 0.0 # 涨跌额 # 量价指标 volume: int = 0 # 成交量 amount: float = 0.0 # 成交额 turnover_rate: float = 0.0 # 换手率(%) amplitude: float = 0.0 # 振幅(%) # 价格区间 high: float = 0.0 # 最高价 low: float = 0.0 # 最低价 open_price: float = 0.0 # 开盘价 def to_dict(self) -> Dict[str, Any]: """转换为字典""" return { 'code': self.code, 'name': self.name, 'price': self.price, 'change_pct': self.change_pct, 'change_amount': self.change_amount, 'volume': self.volume, 'amount': self.amount, 'turnover_rate': self.turnover_rate, 'amplitude': self.amplitude, 'high': self.high, 'low': self.low, 'open': self.open_price, } logger = logging.getLogger(__name__) # User-Agent 池,用于随机轮换 USER_AGENTS = [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', ] # 缓存实时行情数据(避免重复请求) # TTL 设为 10 分钟 (600秒):批量分析场景下避免重复拉取 _realtime_cache: Dict[str, Any] = { 'data': None, 'timestamp': 0, 'ttl': 600 # 10分钟缓存有效期 } # ETF 实时行情缓存(与股票分开缓存) _etf_realtime_cache: Dict[str, Any] = { 'data': None, 'timestamp': 0, 'ttl': 600 # 10分钟缓存有效期 } def _is_etf_code(stock_code: str) -> bool: """ 判断代码是否为 ETF 基金 ETF 代码规则: - 上交所 ETF: 51xxxx, 52xxxx, 56xxxx, 58xxxx - 深交所 ETF: 15xxxx, 16xxxx, 18xxxx Args: stock_code: 股票/基金代码 Returns: True 表示是 ETF 代码,False 表示是普通股票代码 """ etf_prefixes = ('51', '52', '56', '58', '15', '16', '18') return stock_code.startswith(etf_prefixes) and len(stock_code) == 6 def _is_us_code(stock_code: str) -> bool: """ 判断代码是否为美股 美股代码规则: - 1-5个大写字母,如 'AAPL', 'TSLA' - 可能包含 '.',如 'BRK.B' """ code = stock_code.strip().upper() return bool(re.match(r'^[A-Z]{1,5}(\.[A-Z])?$', code)) class EfinanceFetcher(BaseFetcher): """ Efinance 数据源实现 优先级:0(最高,优先于 AkshareFetcher) 数据来源:东方财富网(通过 efinance 库封装) 仓库:https://github.com/Micro-sheep/efinance 主要 API: - ef.stock.get_quote_history(): 获取历史 K 线数据 - ef.stock.get_base_info(): 获取股票基本信息 - ef.stock.get_realtime_quotes(): 获取实时行情 关键策略: - 每次请求前随机休眠 1.5-3.0 秒 - 随机 User-Agent 轮换 - 失败后指数退避重试(最多3次) """ name = "EfinanceFetcher" priority = int(os.getenv("EFINANCE_PRIORITY", "0")) # 最高优先级,排在 AkshareFetcher 之前 def __init__(self, sleep_min: float = 1.5, sleep_max: float = 3.0): """ 初始化 EfinanceFetcher Args: sleep_min: 最小休眠时间(秒) sleep_max: 最大休眠时间(秒) """ self.sleep_min = sleep_min self.sleep_max = sleep_max self._last_request_time: Optional[float] = None def _set_random_user_agent(self) -> None: """ 设置随机 User-Agent 通过修改 requests Session 的 headers 实现 这是关键的反爬策略之一 """ try: random_ua = random.choice(USER_AGENTS) logger.debug(f"设置 User-Agent: {random_ua[:50]}...") except Exception as e: logger.debug(f"设置 User-Agent 失败: {e}") def _enforce_rate_limit(self) -> None: """ 强制执行速率限制 策略: 1. 检查距离上次请求的时间间隔 2. 如果间隔不足,补充休眠时间 3. 然后再执行随机 jitter 休眠 """ if self._last_request_time is not None: elapsed = time.time() - self._last_request_time min_interval = self.sleep_min if elapsed < min_interval: additional_sleep = min_interval - elapsed logger.debug(f"补充休眠 {additional_sleep:.2f} 秒") time.sleep(additional_sleep) # 执行随机 jitter 休眠 self.random_sleep(self.sleep_min, self.sleep_max) self._last_request_time = time.time() @retry( stop=stop_after_attempt(1), # 减少到1次,避免触发限流 wait=wait_exponential(multiplier=1, min=4, max=60), # 保持等待时间设置 retry=retry_if_exception_type(( ConnectionError, TimeoutError, requests.exceptions.RequestException, requests.exceptions.ConnectionError, requests.exceptions.ChunkedEncodingError )), before_sleep=before_sleep_log(logger, logging.WARNING), ) def _fetch_raw_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame: """ 从 efinance 获取原始数据 根据代码类型自动选择 API: - 美股:不支持,抛出异常让 DataFetcherManager 切换到其他数据源 - 普通股票:使用 ef.stock.get_quote_history() - ETF 基金:使用 ef.fund.get_quote_history() 流程: 1. 判断代码类型(美股/股票/ETF) 2. 设置随机 User-Agent 3. 执行速率限制(随机休眠) 4. 调用对应的 efinance API 5. 处理返回数据 """ # 美股不支持,抛出异常让 DataFetcherManager 切换到 AkshareFetcher/YfinanceFetcher if _is_us_code(stock_code): raise DataFetchError(f"EfinanceFetcher 不支持美股 {stock_code},请使用 AkshareFetcher 或 YfinanceFetcher") # 根据代码类型选择不同的获取方法 if _is_etf_code(stock_code): return self._fetch_etf_data(stock_code, start_date, end_date) else: return self._fetch_stock_data(stock_code, start_date, end_date) def _fetch_stock_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame: """ 获取普通 A 股历史数据 数据来源:ef.stock.get_quote_history() API 参数说明: - stock_codes: 股票代码 - beg: 开始日期,格式 'YYYYMMDD' - end: 结束日期,格式 'YYYYMMDD' - klt: 周期,101=日线 - fqt: 复权方式,1=前复权 """ import efinance as ef # 防封禁策略 1: 随机 User-Agent self._set_random_user_agent() # 防封禁策略 2: 强制休眠 self._enforce_rate_limit() # 格式化日期(efinance 使用 YYYYMMDD 格式) beg_date = start_date.replace('-', '') end_date_fmt = end_date.replace('-', '') logger.info(f"[API调用] ef.stock.get_quote_history(stock_codes={stock_code}, " f"beg={beg_date}, end={end_date_fmt}, klt=101, fqt=1)") try: import time as _time api_start = _time.time() # 调用 efinance 获取 A 股日线数据 # klt=101 获取日线数据 # fqt=1 获取前复权数据 df = ef.stock.get_quote_history( stock_codes=stock_code, beg=beg_date, end=end_date_fmt, klt=101, # 日线 fqt=1 # 前复权 ) api_elapsed = _time.time() - api_start # 记录返回数据摘要 if df is not None and not df.empty: logger.info(f"[API返回] ef.stock.get_quote_history 成功: 返回 {len(df)} 行数据, 耗时 {api_elapsed:.2f}s") logger.info(f"[API返回] 列名: {list(df.columns)}") if '日期' in df.columns: logger.info(f"[API返回] 日期范围: {df['日期'].iloc[0]} ~ {df['日期'].iloc[-1]}") logger.debug(f"[API返回] 最新3条数据:\n{df.tail(3).to_string()}") else: logger.warning(f"[API返回] ef.stock.get_quote_history 返回空数据, 耗时 {api_elapsed:.2f}s") return df except Exception as e: error_msg = str(e).lower() # 检测反爬封禁 if any(keyword in error_msg for keyword in ['banned', 'blocked', '频率', 'rate', '限制']): logger.warning(f"检测到可能被封禁: {e}") raise RateLimitError(f"efinance 可能被限流: {e}") from e raise DataFetchError(f"efinance 获取数据失败: {e}") from e def _fetch_etf_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame: """ 获取 ETF 基金历史数据 数据来源:ef.fund.get_quote_history() Args: stock_code: ETF 代码,如 '512400', '159883' start_date: 开始日期,格式 'YYYY-MM-DD' end_date: 结束日期,格式 'YYYY-MM-DD' Returns: ETF 历史数据 DataFrame """ import efinance as ef # 防封禁策略 1: 随机 User-Agent self._set_random_user_agent() # 防封禁策略 2: 强制休眠 self._enforce_rate_limit() # 格式化日期 beg_date = start_date.replace('-', '') end_date_fmt = end_date.replace('-', '') logger.info(f"[API调用] ef.fund.get_quote_history(fund_code={stock_code})") try: import time as _time api_start = _time.time() # 调用 efinance 获取 ETF 日线数据 # 注意: ef.fund.get_quote_history 不支持 beg/end/klt/fqt 参数 # 它返回的是 NAV 数据: 日期, 单位净值, 累计净值, 涨跌幅 df = ef.fund.get_quote_history(fund_code=stock_code) # 手动过滤日期 if df is not None and not df.empty and '日期' in df.columns: # 确保日期列是字符串格式,且格式匹配筛选条件 # ef 返回的日期通常是 'YYYY-MM-DD' mask = (df['日期'] >= start_date) & (df['日期'] <= end_date) df = df[mask].copy() api_elapsed = _time.time() - api_start # 记录返回数据摘要 if df is not None and not df.empty: logger.info(f"[API返回] ef.fund.get_quote_history 成功: 返回 {len(df)} 行数据, 耗时 {api_elapsed:.2f}s") logger.info(f"[API返回] 列名: {list(df.columns)}") if '日期' in df.columns: logger.info(f"[API返回] 日期范围: {df['日期'].iloc[0]} ~ {df['日期'].iloc[-1]}") logger.debug(f"[API返回] 最新3条数据:\n{df.tail(3).to_string()}") else: logger.warning(f"[API返回] ef.fund.get_quote_history 返回空数据, 耗时 {api_elapsed:.2f}s") return df except Exception as e: error_msg = str(e).lower() # 检测反爬封禁 if any(keyword in error_msg for keyword in ['banned', 'blocked', '频率', 'rate', '限制']): logger.warning(f"检测到可能被封禁: {e}") raise RateLimitError(f"efinance 可能被限流: {e}") from e raise DataFetchError(f"efinance 获取 ETF 数据失败: {e}") from e def _normalize_data(self, df: pd.DataFrame, stock_code: str) -> pd.DataFrame: """ 标准化 efinance 数据 efinance 返回的列名(中文): 股票名称, 股票代码, 日期, 开盘, 收盘, 最高, 最低, 成交量, 成交额, 振幅, 涨跌幅, 涨跌额, 换手率 需要映射到标准列名: date, open, high, low, close, volume, amount, pct_chg """ df = df.copy() # 列名映射(efinance 中文列名 -> 标准英文列名) column_mapping = { '日期': 'date', '开盘': 'open', '收盘': 'close', '最高': 'high', '最低': 'low', '成交量': 'volume', '成交额': 'amount', '涨跌幅': 'pct_chg', '股票代码': 'code', '股票名称': 'name', # ETF 基金可能的列名 '基金代码': 'code', '基金名称': 'name', '单位净值': 'close', } # 重命名列 df = df.rename(columns=column_mapping) # 对于 ETF 数据(只有 close/单位净值),补全其他 OHLC 列 # 这是一个近似处理,因为 efinance 基金接口不提供 OHLC 数据 if 'close' in df.columns and 'open' not in df.columns: df['open'] = df['close'] df['high'] = df['close'] df['low'] = df['close'] # 补全 volume 和 amount,如果缺失 if 'volume' not in df.columns: df['volume'] = 0 if 'amount' not in df.columns: df['amount'] = 0 # 如果没有 code 列,手动添加 if 'code' not in df.columns: df['code'] = stock_code # 只保留需要的列 keep_cols = ['code'] + STANDARD_COLUMNS existing_cols = [col for col in keep_cols if col in df.columns] df = df[existing_cols] return df def get_realtime_quote(self, stock_code: str) -> Optional[UnifiedRealtimeQuote]: """ 获取实时行情数据 数据来源:ef.stock.get_realtime_quotes() ETF 数据源:ef.stock.get_realtime_quotes(['ETF']) Args: stock_code: 股票代码 Returns: UnifiedRealtimeQuote 对象,获取失败返回 None """ # ETF 需要单独请求 ETF 实时行情接口 if _is_etf_code(stock_code): return self._get_etf_realtime_quote(stock_code) import efinance as ef circuit_breaker = get_realtime_circuit_breaker() source_key = "efinance" # 检查熔断器状态 if not circuit_breaker.is_available(source_key): logger.warning(f"[熔断] 数据源 {source_key} 处于熔断状态,跳过") return None try: # 检查缓存 current_time = time.time() if (_realtime_cache['data'] is not None and current_time - _realtime_cache['timestamp'] < _realtime_cache['ttl']): df = _realtime_cache['data'] cache_age = int(current_time - _realtime_cache['timestamp']) logger.debug(f"[缓存命中] 实时行情(efinance) - 缓存年龄 {cache_age}s/{_realtime_cache['ttl']}s") else: # 触发全量刷新 logger.info(f"[缓存未命中] 触发全量刷新 实时行情(efinance)") # 防封禁策略 self._set_random_user_agent() self._enforce_rate_limit() logger.info(f"[API调用] ef.stock.get_realtime_quotes() 获取实时行情...") import time as _time api_start = _time.time() # efinance 的实时行情 API df = ef.stock.get_realtime_quotes() api_elapsed = _time.time() - api_start logger.info(f"[API返回] ef.stock.get_realtime_quotes 成功: 返回 {len(df)} 只股票, 耗时 {api_elapsed:.2f}s") circuit_breaker.record_success(source_key) # 更新缓存 _realtime_cache['data'] = df _realtime_cache['timestamp'] = current_time logger.info(f"[缓存更新] 实时行情(efinance) 缓存已刷新,TTL={_realtime_cache['ttl']}s") # 查找指定股票 # efinance 返回的列名可能是 '股票代码' 或 'code' code_col = '股票代码' if '股票代码' in df.columns else 'code' row = df[df[code_col] == stock_code] if row.empty: logger.warning(f"[API返回] 未找到股票 {stock_code} 的实时行情") return None row = row.iloc[0] # 使用 realtime_types.py 中的统一转换函数 # 获取列名(可能是中文或英文) name_col = '股票名称' if '股票名称' in df.columns else 'name' price_col = '最新价' if '最新价' in df.columns else 'price' pct_col = '涨跌幅' if '涨跌幅' in df.columns else 'pct_chg' chg_col = '涨跌额' if '涨跌额' in df.columns else 'change' vol_col = '成交量' if '成交量' in df.columns else 'volume' amt_col = '成交额' if '成交额' in df.columns else 'amount' turn_col = '换手率' if '换手率' in df.columns else 'turnover_rate' amp_col = '振幅' if '振幅' in df.columns else 'amplitude' high_col = '最高' if '最高' in df.columns else 'high' low_col = '最低' if '最低' in df.columns else 'low' open_col = '开盘' if '开盘' in df.columns else 'open' # efinance 也返回量比、市盈率、市值等字段 vol_ratio_col = '量比' if '量比' in df.columns else 'volume_ratio' pe_col = '市盈率' if '市盈率' in df.columns else 'pe_ratio' total_mv_col = '总市值' if '总市值' in df.columns else 'total_mv' circ_mv_col = '流通市值' if '流通市值' in df.columns else 'circ_mv' quote = UnifiedRealtimeQuote( code=stock_code, name=str(row.get(name_col, '')), source=RealtimeSource.EFINANCE, price=safe_float(row.get(price_col)), change_pct=safe_float(row.get(pct_col)), change_amount=safe_float(row.get(chg_col)), volume=safe_int(row.get(vol_col)), amount=safe_float(row.get(amt_col)), turnover_rate=safe_float(row.get(turn_col)), amplitude=safe_float(row.get(amp_col)), high=safe_float(row.get(high_col)), low=safe_float(row.get(low_col)), open_price=safe_float(row.get(open_col)), volume_ratio=safe_float(row.get(vol_ratio_col)), # 量比 pe_ratio=safe_float(row.get(pe_col)), # 市盈率 total_mv=safe_float(row.get(total_mv_col)), # 总市值 circ_mv=safe_float(row.get(circ_mv_col)), # 流通市值 ) logger.info(f"[实时行情-efinance] {stock_code} {quote.name}: 价格={quote.price}, 涨跌={quote.change_pct}%, " f"量比={quote.volume_ratio}, 换手率={quote.turnover_rate}%") return quote except Exception as e: logger.error(f"[API错误] 获取 {stock_code} 实时行情(efinance)失败: {e}") circuit_breaker.record_failure(source_key, str(e)) return None def _get_etf_realtime_quote(self, stock_code: str) -> Optional[UnifiedRealtimeQuote]: """ 获取 ETF 实时行情 efinance 默认实时接口仅返回股票数据,ETF 需要显式传入 ['ETF']。 """ import efinance as ef circuit_breaker = get_realtime_circuit_breaker() source_key = "efinance_etf" if not circuit_breaker.is_available(source_key): logger.warning(f"[熔断] 数据源 {source_key} 处于熔断状态,跳过") return None try: current_time = time.time() if ( _etf_realtime_cache['data'] is not None and current_time - _etf_realtime_cache['timestamp'] < _etf_realtime_cache['ttl'] ): df = _etf_realtime_cache['data'] cache_age = int(current_time - _etf_realtime_cache['timestamp']) logger.debug(f"[缓存命中] ETF实时行情(efinance) - 缓存年龄 {cache_age}s/{_etf_realtime_cache['ttl']}s") else: self._set_random_user_agent() self._enforce_rate_limit() logger.info("[API调用] ef.stock.get_realtime_quotes(['ETF']) 获取ETF实时行情...") import time as _time api_start = _time.time() df = ef.stock.get_realtime_quotes(['ETF']) api_elapsed = _time.time() - api_start if df is not None and not df.empty: logger.info(f"[API返回] ETF 实时行情成功: {len(df)} 条, 耗时 {api_elapsed:.2f}s") circuit_breaker.record_success(source_key) else: logger.warning(f"[API返回] ETF 实时行情为空, 耗时 {api_elapsed:.2f}s") df = pd.DataFrame() _etf_realtime_cache['data'] = df _etf_realtime_cache['timestamp'] = current_time if df is None or df.empty: logger.warning(f"[实时行情] ETF实时行情数据为空(efinance),跳过 {stock_code}") return None code_col = '股票代码' if '股票代码' in df.columns else 'code' code_series = df[code_col].astype(str).str.zfill(6) target_code = str(stock_code).strip().zfill(6) row = df[code_series == target_code] if row.empty: logger.warning(f"[API返回] 未找到 ETF {stock_code} 的实时行情(efinance)") return None row = row.iloc[0] name_col = '股票名称' if '股票名称' in df.columns else 'name' price_col = '最新价' if '最新价' in df.columns else 'price' pct_col = '涨跌幅' if '涨跌幅' in df.columns else 'pct_chg' chg_col = '涨跌额' if '涨跌额' in df.columns else 'change' vol_col = '成交量' if '成交量' in df.columns else 'volume' amt_col = '成交额' if '成交额' in df.columns else 'amount' turn_col = '换手率' if '换手率' in df.columns else 'turnover_rate' amp_col = '振幅' if '振幅' in df.columns else 'amplitude' high_col = '最高' if '最高' in df.columns else 'high' low_col = '最低' if '最低' in df.columns else 'low' open_col = '开盘' if '开盘' in df.columns else 'open' quote = UnifiedRealtimeQuote( code=target_code, name=str(row.get(name_col, '')), source=RealtimeSource.EFINANCE, price=safe_float(row.get(price_col)), change_pct=safe_float(row.get(pct_col)), change_amount=safe_float(row.get(chg_col)), volume=safe_int(row.get(vol_col)), amount=safe_float(row.get(amt_col)), turnover_rate=safe_float(row.get(turn_col)), amplitude=safe_float(row.get(amp_col)), high=safe_float(row.get(high_col)), low=safe_float(row.get(low_col)), open_price=safe_float(row.get(open_col)), ) logger.info( f"[ETF实时行情-efinance] {target_code} {quote.name}: " f"价格={quote.price}, 涨跌={quote.change_pct}%, 换手率={quote.turnover_rate}%" ) return quote except Exception as e: logger.error(f"[API错误] 获取 ETF {stock_code} 实时行情(efinance)失败: {e}") circuit_breaker.record_failure(source_key, str(e)) return None def get_main_indices(self) -> Optional[List[Dict[str, Any]]]: """ 获取主要指数实时行情 (efinance) """ import efinance as ef indices_map = { '000001': ('上证指数', 'sh000001'), '399001': ('深证成指', 'sz399001'), '399006': ('创业板指', 'sz399006'), '000688': ('科创50', 'sh000688'), '000016': ('上证50', 'sh000016'), '000300': ('沪深300', 'sh000300'), } try: self._set_random_user_agent() self._enforce_rate_limit() logger.info("[API调用] ef.stock.get_realtime_quotes(['沪深系列指数']) 获取指数行情...") import time as _time api_start = _time.time() df = ef.stock.get_realtime_quotes(['沪深系列指数']) api_elapsed = _time.time() - api_start if df is None or df.empty: logger.warning(f"[API返回] 指数行情为空, 耗时 {api_elapsed:.2f}s") return None logger.info(f"[API返回] 指数行情成功: {len(df)} 条, 耗时 {api_elapsed:.2f}s") code_col = '股票代码' if '股票代码' in df.columns else 'code' code_series = df[code_col].astype(str).str.zfill(6) results: List[Dict[str, Any]] = [] for code, (name, full_code) in indices_map.items(): row = df[code_series == code] if row.empty: continue item = row.iloc[0] price_col = '最新价' if '最新价' in df.columns else 'price' pct_col = '涨跌幅' if '涨跌幅' in df.columns else 'pct_chg' chg_col = '涨跌额' if '涨跌额' in df.columns else 'change' open_col = '开盘' if '开盘' in df.columns else 'open' high_col = '最高' if '最高' in df.columns else 'high' low_col = '最低' if '最低' in df.columns else 'low' vol_col = '成交量' if '成交量' in df.columns else 'volume' amt_col = '成交额' if '成交额' in df.columns else 'amount' amp_col = '振幅' if '振幅' in df.columns else 'amplitude' current = safe_float(item.get(price_col, 0)) change_amount = safe_float(item.get(chg_col, 0)) results.append({ 'code': full_code, 'name': name, 'current': current, 'change': change_amount, 'change_pct': safe_float(item.get(pct_col, 0)), 'open': safe_float(item.get(open_col, 0)), 'high': safe_float(item.get(high_col, 0)), 'low': safe_float(item.get(low_col, 0)), 'prev_close': current - change_amount if current or change_amount else 0, 'volume': safe_float(item.get(vol_col, 0)), 'amount': safe_float(item.get(amt_col, 0)), 'amplitude': safe_float(item.get(amp_col, 0)), }) if results: logger.info(f"[efinance] 获取到 {len(results)} 个指数行情") return results if results else None except Exception as e: logger.error(f"[efinance] 获取指数行情失败: {e}") return None def get_market_stats(self) -> Optional[Dict[str, Any]]: """ 获取市场涨跌统计 (efinance) """ import efinance as ef try: self._set_random_user_agent() self._enforce_rate_limit() current_time = time.time() if ( _realtime_cache['data'] is not None and current_time - _realtime_cache['timestamp'] < _realtime_cache['ttl'] ): df = _realtime_cache['data'] else: logger.info("[API调用] ef.stock.get_realtime_quotes() 获取市场统计...") df = ef.stock.get_realtime_quotes() _realtime_cache['data'] = df _realtime_cache['timestamp'] = current_time if df is None or df.empty: logger.warning("[API返回] 市场统计数据为空") return None change_col = '涨跌幅' if '涨跌幅' in df.columns else 'pct_chg' amount_col = '成交额' if '成交额' in df.columns else 'amount' if change_col not in df.columns: return None df[change_col] = pd.to_numeric(df[change_col], errors='coerce') stats = { 'up_count': len(df[df[change_col] > 0]), 'down_count': len(df[df[change_col] < 0]), 'flat_count': len(df[df[change_col] == 0]), 'limit_up_count': len(df[df[change_col] >= 9.9]), 'limit_down_count': len(df[df[change_col] <= -9.9]), 'total_amount': 0.0, } if amount_col in df.columns: df[amount_col] = pd.to_numeric(df[amount_col], errors='coerce') stats['total_amount'] = df[amount_col].sum() / 1e8 return stats except Exception as e: logger.error(f"[efinance] 获取市场统计失败: {e}") return None def get_sector_rankings(self, n: int = 5) -> Optional[Tuple[List[Dict], List[Dict]]]: """ 获取板块涨跌榜 (efinance) """ import efinance as ef try: self._set_random_user_agent() self._enforce_rate_limit() logger.info("[API调用] ef.stock.get_realtime_quotes(['行业板块']) 获取板块行情...") df = ef.stock.get_realtime_quotes(['行业板块']) if df is None or df.empty: logger.warning("[efinance] 板块行情数据为空") return None change_col = '涨跌幅' if '涨跌幅' in df.columns else 'pct_chg' name_col = '股票名称' if '股票名称' in df.columns else 'name' if change_col not in df.columns or name_col not in df.columns: return None df[change_col] = pd.to_numeric(df[change_col], errors='coerce') df = df.dropna(subset=[change_col]) top = df.nlargest(n, change_col) bottom = df.nsmallest(n, change_col) top_sectors = [ {'name': str(row[name_col]), 'change_pct': float(row[change_col])} for _, row in top.iterrows() ] bottom_sectors = [ {'name': str(row[name_col]), 'change_pct': float(row[change_col])} for _, row in bottom.iterrows() ] return top_sectors, bottom_sectors except Exception as e: logger.error(f"[efinance] 获取板块排行失败: {e}") return None def get_base_info(self, stock_code: str) -> Optional[Dict[str, Any]]: """ 获取股票基本信息 数据来源:ef.stock.get_base_info() 包含:市盈率、市净率、所处行业、总市值、流通市值、ROE、净利率等 Args: stock_code: 股票代码 Returns: 包含基本信息的字典,获取失败返回 None """ import efinance as ef try: # 防封禁策略 self._set_random_user_agent() self._enforce_rate_limit() logger.info(f"[API调用] ef.stock.get_base_info(stock_codes={stock_code}) 获取基本信息...") import time as _time api_start = _time.time() info = ef.stock.get_base_info(stock_code) api_elapsed = _time.time() - api_start logger.info(f"[API返回] ef.stock.get_base_info 成功, 耗时 {api_elapsed:.2f}s") if info is None: logger.warning(f"[API返回] 未获取到 {stock_code} 的基本信息") return None # 转换为字典 if isinstance(info, pd.Series): return info.to_dict() elif isinstance(info, pd.DataFrame): if not info.empty: return info.iloc[0].to_dict() return None except Exception as e: logger.error(f"[API错误] 获取 {stock_code} 基本信息失败: {e}") return None def get_belong_board(self, stock_code: str) -> Optional[pd.DataFrame]: """ 获取股票所属板块 数据来源:ef.stock.get_belong_board() Args: stock_code: 股票代码 Returns: 所属板块 DataFrame,获取失败返回 None """ import efinance as ef try: # 防封禁策略 self._set_random_user_agent() self._enforce_rate_limit() logger.info(f"[API调用] ef.stock.get_belong_board(stock_code={stock_code}) 获取所属板块...") import time as _time api_start = _time.time() df = ef.stock.get_belong_board(stock_code) api_elapsed = _time.time() - api_start if df is not None and not df.empty: logger.info(f"[API返回] ef.stock.get_belong_board 成功: 返回 {len(df)} 个板块, 耗时 {api_elapsed:.2f}s") return df else: logger.warning(f"[API返回] 未获取到 {stock_code} 的板块信息") return None except Exception as e: logger.error(f"[API错误] 获取 {stock_code} 所属板块失败: {e}") return None def get_enhanced_data(self, stock_code: str, days: int = 60) -> Dict[str, Any]: """ 获取增强数据(历史K线 + 实时行情 + 基本信息) Args: stock_code: 股票代码 days: 历史数据天数 Returns: 包含所有数据的字典 """ result = { 'code': stock_code, 'daily_data': None, 'realtime_quote': None, 'base_info': None, 'belong_board': None, } # 获取日线数据 try: df = self.get_daily_data(stock_code, days=days) result['daily_data'] = df except Exception as e: logger.error(f"获取 {stock_code} 日线数据失败: {e}") # 获取实时行情 result['realtime_quote'] = self.get_realtime_quote(stock_code) # 获取基本信息 result['base_info'] = self.get_base_info(stock_code) # 获取所属板块 result['belong_board'] = self.get_belong_board(stock_code) return result if __name__ == "__main__": # 测试代码 logging.basicConfig(level=logging.DEBUG) fetcher = EfinanceFetcher() # 测试普通股票 print("=" * 50) print("测试普通股票数据获取 (efinance)") print("=" * 50) try: df = fetcher.get_daily_data('600519') # 茅台 print(f"[股票] 获取成功,共 {len(df)} 条数据") print(df.tail()) except Exception as e: print(f"[股票] 获取失败: {e}") # 测试 ETF 基金 print("\n" + "=" * 50) print("测试 ETF 基金数据获取 (efinance)") print("=" * 50) try: df = fetcher.get_daily_data('512400') # 有色龙头ETF print(f"[ETF] 获取成功,共 {len(df)} 条数据") print(df.tail()) except Exception as e: print(f"[ETF] 获取失败: {e}") # 测试实时行情 print("\n" + "=" * 50) print("测试实时行情获取 (efinance)") print("=" * 50) try: quote = fetcher.get_realtime_quote('600519') if quote: print(f"[实时行情] {quote.name}: 价格={quote.price}, 涨跌幅={quote.change_pct}%") else: print("[实时行情] 未获取到数据") except Exception as e: print(f"[实时行情] 获取失败: {e}") # 测试基本信息 print("\n" + "=" * 50) print("测试基本信息获取 (efinance)") print("=" * 50) try: info = fetcher.get_base_info('600519') if info: print(f"[基本信息] 市盈率={info.get('市盈率(动)', 'N/A')}, 市净率={info.get('市净率', 'N/A')}") else: print("[基本信息] 未获取到数据") except Exception as e: print(f"[基本信息] 获取失败: {e}")