mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
* fix: clarify market review logging context * docs: update changelog for logging context
1297 lines
51 KiB
Python
1297 lines
51 KiB
Python
# -*- 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 concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
|
||
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,
|
||
)
|
||
|
||
# Timeout (seconds) for efinance library calls that go through eastmoney APIs
|
||
# with no built-in timeout. Prevents indefinite hangs when hosts are unreachable.
|
||
try:
|
||
_EF_CALL_TIMEOUT = int(os.environ.get("EFINANCE_CALL_TIMEOUT", "30"))
|
||
except (ValueError, TypeError):
|
||
import logging as _logging
|
||
_logging.getLogger(__name__).warning(
|
||
"EFINANCE_CALL_TIMEOUT is not a valid integer, using default 30s"
|
||
)
|
||
_EF_CALL_TIMEOUT = 30
|
||
|
||
from src.patches.eastmoney_patch import eastmoney_patch
|
||
from src.config import get_config
|
||
from .base import (
|
||
BaseFetcher,
|
||
DataFetchError,
|
||
RateLimitError,
|
||
STANDARD_COLUMNS,
|
||
is_bse_code,
|
||
is_st_stock,
|
||
is_kc_cy_stock,
|
||
normalize_stock_code,
|
||
_is_hk_market,
|
||
_is_etf_code as _is_a_share_etf_code,
|
||
)
|
||
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__)
|
||
|
||
EASTMONEY_HISTORY_ENDPOINT = "push2his.eastmoney.com/api/qt/stock/kline/get"
|
||
|
||
|
||
# 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分钟缓存有效期
|
||
}
|
||
|
||
_ETF_SH_PREFIXES = ('51', '52', '56', '58')
|
||
_ETF_SZ_PREFIXES = ('15', '16', '18')
|
||
|
||
|
||
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 表示是普通股票代码
|
||
"""
|
||
return _is_a_share_etf_code(stock_code)
|
||
|
||
|
||
def _build_eastmoney_etf_secid(stock_code: str) -> str:
|
||
"""Build Eastmoney secid for A-share ETF historical K-line queries."""
|
||
code = normalize_stock_code(stock_code)
|
||
if not _is_etf_code(code):
|
||
raise DataFetchError(f"无法识别 ETF 代码 {stock_code}")
|
||
if code.startswith(_ETF_SH_PREFIXES):
|
||
return f"1.{code}"
|
||
if code.startswith(_ETF_SZ_PREFIXES):
|
||
return f"0.{code}"
|
||
raise DataFetchError(f"无法确定 ETF {stock_code} 的 Eastmoney 市场前缀")
|
||
|
||
|
||
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))
|
||
|
||
|
||
def _ef_call_with_timeout(func, *args, timeout=None, **kwargs):
|
||
"""Run an efinance library call in a thread with a timeout.
|
||
|
||
efinance internally uses requests/urllib3 with no timeout, so when
|
||
eastmoney hosts are unreachable the call can hang for many minutes.
|
||
This helper caps the *calling thread's* wait time. Note: Python threads
|
||
cannot be forcibly killed, so the worker thread may continue running in
|
||
the background until the OS-level TCP timeout fires or the process exits.
|
||
This is acceptable — the calling thread returns promptly on timeout.
|
||
"""
|
||
if timeout is None:
|
||
timeout = _EF_CALL_TIMEOUT
|
||
# Do NOT use 'with ThreadPoolExecutor(...)' here: the context manager calls
|
||
# shutdown(wait=True) on __exit__, which would re-block on the hung thread.
|
||
executor = ThreadPoolExecutor(max_workers=1)
|
||
try:
|
||
future = executor.submit(func, *args, **kwargs)
|
||
return future.result(timeout=timeout)
|
||
finally:
|
||
# wait=False: calling thread returns immediately; worker cleans up later
|
||
executor.shutdown(wait=False)
|
||
|
||
|
||
def _classify_eastmoney_error(exc: Exception) -> Tuple[str, str]:
|
||
"""
|
||
Classify Eastmoney request failures into stable log categories.
|
||
"""
|
||
message = str(exc).strip()
|
||
lowered = message.lower()
|
||
|
||
remote_disconnect_keywords = (
|
||
'remotedisconnected',
|
||
'remote end closed connection without response',
|
||
'connection aborted',
|
||
'connection broken',
|
||
'protocolerror',
|
||
)
|
||
timeout_keywords = (
|
||
'timeout',
|
||
'timed out',
|
||
'readtimeout',
|
||
'connecttimeout',
|
||
)
|
||
rate_limit_keywords = (
|
||
'banned',
|
||
'blocked',
|
||
'频率',
|
||
'rate limit',
|
||
'too many requests',
|
||
'429',
|
||
'限制',
|
||
'forbidden',
|
||
'403',
|
||
)
|
||
|
||
if any(keyword in lowered for keyword in remote_disconnect_keywords):
|
||
return "remote_disconnect", message
|
||
if isinstance(exc, (TimeoutError, requests.exceptions.Timeout)) or any(
|
||
keyword in lowered for keyword in timeout_keywords
|
||
):
|
||
return "timeout", message
|
||
if any(keyword in lowered for keyword in rate_limit_keywords):
|
||
return "rate_limit_or_anti_bot", message
|
||
if isinstance(exc, requests.exceptions.RequestException):
|
||
return "request_error", message
|
||
return "unknown_request_error", message
|
||
|
||
|
||
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
|
||
# 东财补丁开启才执行打补丁操作
|
||
if get_config().enable_eastmoney_patch:
|
||
eastmoney_patch()
|
||
|
||
@staticmethod
|
||
def _build_history_failure_message(
|
||
stock_code: str,
|
||
beg_date: str,
|
||
end_date: str,
|
||
exc: Exception,
|
||
elapsed: float,
|
||
is_etf: bool = False,
|
||
) -> Tuple[str, str]:
|
||
category, detail = _classify_eastmoney_error(exc)
|
||
instrument_type = "ETF" if is_etf else "stock"
|
||
message = (
|
||
"Eastmoney 历史K线接口失败: "
|
||
f"endpoint={EASTMONEY_HISTORY_ENDPOINT}, stock_code={stock_code}, "
|
||
f"market_type={instrument_type}, range={beg_date}~{end_date}, "
|
||
f"category={category}, error_type={type(exc).__name__}, elapsed={elapsed:.2f}s, detail={detail}"
|
||
)
|
||
return category, message
|
||
|
||
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.stock.get_quote_history()(ETF 是交易所证券,使用股票 K 线接口)
|
||
|
||
流程:
|
||
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")
|
||
|
||
# efinance 的历史 K 线接口在港股代码上可能返回非预期市场数据,
|
||
# 明确跳过并交给 AkShare/Tushare/YFinance/Longbridge 等港股路径兜底。
|
||
if _is_hk_market(stock_code):
|
||
raise DataFetchError(f"EfinanceFetcher 不支持港股日线 {stock_code},请使用 AkshareFetcher 或其他港股数据源")
|
||
|
||
# 根据代码类型选择不同的获取方法
|
||
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)")
|
||
|
||
api_start = time.time()
|
||
try:
|
||
# 调用 efinance 获取 A 股日线数据
|
||
# klt=101 获取日线数据
|
||
# fqt=1 获取前复权数据
|
||
df = _ef_call_with_timeout(
|
||
ef.stock.get_quote_history,
|
||
stock_codes=stock_code,
|
||
beg=beg_date,
|
||
end=end_date_fmt,
|
||
klt=101, # 日线
|
||
fqt=1, # 前复权
|
||
timeout=60,
|
||
)
|
||
|
||
api_elapsed = time.time() - api_start
|
||
|
||
# 记录返回数据摘要
|
||
if df is not None and not df.empty:
|
||
logger.info(
|
||
"[API返回] Eastmoney 历史K线成功: "
|
||
f"endpoint={EASTMONEY_HISTORY_ENDPOINT}, stock_code={stock_code}, "
|
||
f"range={beg_date}~{end_date_fmt}, rows={len(df)}, elapsed={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(
|
||
"[API返回] Eastmoney 历史K线为空: "
|
||
f"endpoint={EASTMONEY_HISTORY_ENDPOINT}, stock_code={stock_code}, "
|
||
f"range={beg_date}~{end_date_fmt}, elapsed={api_elapsed:.2f}s"
|
||
)
|
||
|
||
return df
|
||
|
||
except Exception as e:
|
||
api_elapsed = time.time() - api_start
|
||
category, failure_message = self._build_history_failure_message(
|
||
stock_code=stock_code,
|
||
beg_date=beg_date,
|
||
end_date=end_date_fmt,
|
||
exc=e,
|
||
elapsed=api_elapsed,
|
||
)
|
||
|
||
if category == "rate_limit_or_anti_bot":
|
||
logger.warning(failure_message)
|
||
raise RateLimitError(f"efinance 可能被限流: {failure_message}") from e
|
||
|
||
logger.error(failure_message)
|
||
raise DataFetchError(f"efinance 获取数据失败: {failure_message}") from e
|
||
|
||
def _fetch_etf_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame:
|
||
"""
|
||
获取 ETF 基金历史数据
|
||
|
||
Exchange-traded ETFs have OHLCV data just like regular stocks, so we use
|
||
ef.stock.get_quote_history (the stock K-line API) which returns full
|
||
open/high/low/close/volume data.
|
||
|
||
Previously this method used ef.fund.get_quote_history which only returns
|
||
NAV data (单位净值/累计净值) without volume or OHLC, causing:
|
||
- Issue #541: 'got an unexpected keyword argument beg'
|
||
- Issue #527: ETF volume/turnover always showing 0
|
||
|
||
Args:
|
||
stock_code: ETF code, e.g. '512400', '159883', '515120'
|
||
start_date: Start date, format 'YYYY-MM-DD'
|
||
end_date: End date, format 'YYYY-MM-DD'
|
||
|
||
Returns:
|
||
ETF historical OHLCV DataFrame
|
||
"""
|
||
import efinance as ef
|
||
|
||
# Anti-ban strategy 1: random User-Agent
|
||
self._set_random_user_agent()
|
||
|
||
# Anti-ban strategy 2: enforce rate limit
|
||
self._enforce_rate_limit()
|
||
|
||
# Format dates (efinance uses YYYYMMDD)
|
||
beg_date = start_date.replace('-', '')
|
||
end_date_fmt = end_date.replace('-', '')
|
||
secid = _build_eastmoney_etf_secid(stock_code)
|
||
|
||
logger.info(
|
||
f"[API调用] ef.stock.get_quote_history(stock_codes={secid}, "
|
||
f"beg={beg_date}, end={end_date_fmt}, klt=101, fqt=1, "
|
||
f"quote_id_mode=True, use_id_cache=False) [ETF stock_code={stock_code}]"
|
||
)
|
||
|
||
api_start = time.time()
|
||
try:
|
||
# ETFs are exchange-traded securities; use the stock API to get full OHLCV data
|
||
df = _ef_call_with_timeout(
|
||
ef.stock.get_quote_history,
|
||
stock_codes=secid,
|
||
beg=beg_date,
|
||
end=end_date_fmt,
|
||
klt=101, # daily
|
||
fqt=1, # forward-adjusted
|
||
quote_id_mode=True,
|
||
use_id_cache=False,
|
||
timeout=60,
|
||
)
|
||
|
||
api_elapsed = time.time() - api_start
|
||
|
||
if df is not None and not df.empty:
|
||
logger.info(
|
||
"[API返回] Eastmoney 历史K线成功 [ETF]: "
|
||
f"endpoint={EASTMONEY_HISTORY_ENDPOINT}, stock_code={stock_code}, secid={secid}, "
|
||
f"range={beg_date}~{end_date_fmt}, rows={len(df)}, elapsed={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(
|
||
"[API返回] Eastmoney 历史K线为空 [ETF]: "
|
||
f"endpoint={EASTMONEY_HISTORY_ENDPOINT}, stock_code={stock_code}, secid={secid}, "
|
||
f"range={beg_date}~{end_date_fmt}, elapsed={api_elapsed:.2f}s"
|
||
)
|
||
|
||
return df
|
||
|
||
except Exception as e:
|
||
api_elapsed = time.time() - api_start
|
||
category, failure_message = self._build_history_failure_message(
|
||
stock_code=stock_code,
|
||
beg_date=beg_date,
|
||
end_date=end_date_fmt,
|
||
exc=e,
|
||
elapsed=api_elapsed,
|
||
is_etf=True,
|
||
)
|
||
|
||
if category == "rate_limit_or_anti_bot":
|
||
logger.warning(failure_message)
|
||
raise RateLimitError(f"efinance 可能被限流: {failure_message}") from e
|
||
|
||
logger.error(failure_message)
|
||
raise DataFetchError(f"efinance 获取 ETF 数据失败: {failure_message}") 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()
|
||
|
||
# Column mapping (efinance Chinese column names -> standard English column names)
|
||
column_mapping = {
|
||
'日期': 'date',
|
||
'开盘': 'open',
|
||
'收盘': 'close',
|
||
'最高': 'high',
|
||
'最低': 'low',
|
||
'成交量': 'volume',
|
||
'成交额': 'amount',
|
||
'涨跌幅': 'pct_chg',
|
||
'股票代码': 'code',
|
||
'股票名称': 'name',
|
||
}
|
||
|
||
# 重命名列
|
||
df = df.rename(columns=column_mapping)
|
||
|
||
# Fallback: if OHLC columns are missing (e.g. very old data path), fill from close
|
||
if 'close' in df.columns and 'open' not in df.columns:
|
||
df['open'] = df['close']
|
||
df['high'] = df['close']
|
||
df['low'] = df['close']
|
||
|
||
# Fill volume and amount if missing
|
||
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.info(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 (with timeout to avoid indefinite hangs)
|
||
df = _ef_call_with_timeout(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.info(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 FuturesTimeoutError:
|
||
logger.info(f"[超时] ef.stock.get_realtime_quotes() 超过 {_EF_CALL_TIMEOUT}s,跳过 {stock_code}")
|
||
circuit_breaker.record_failure(source_key, "timeout")
|
||
return None
|
||
except Exception as e:
|
||
logger.info(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.info(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_call_with_timeout(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.info(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.info(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.info(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.info(f"[API错误] 获取 ETF {stock_code} 实时行情(efinance)失败: {e}")
|
||
circuit_breaker.record_failure(source_key, str(e))
|
||
return None
|
||
|
||
def get_main_indices(self, region: str = "cn") -> Optional[List[Dict[str, Any]]]:
|
||
"""
|
||
获取主要指数实时行情 (efinance),仅支持 A 股
|
||
"""
|
||
if region != "cn":
|
||
return None
|
||
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_call_with_timeout(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_cols = [column for column in ('今开', '开盘', 'open') if column in df.columns]
|
||
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))
|
||
open_price = 0.0
|
||
for column in open_cols:
|
||
candidate = safe_float(item.get(column), default=None)
|
||
if candidate not in (None, 0.0):
|
||
open_price = candidate
|
||
break
|
||
if open_price == 0.0 and open_cols:
|
||
open_price = safe_float(item.get(open_cols[0], 0), 0)
|
||
|
||
results.append({
|
||
'code': full_code,
|
||
'name': name,
|
||
'current': current,
|
||
'change': change_amount,
|
||
'change_pct': safe_float(item.get(pct_col, 0)),
|
||
'open': open_price,
|
||
'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']
|
||
logger.info(
|
||
"[MarketStats] component=market_stats provider=EfinanceFetcher "
|
||
"api=ef.stock.get_realtime_quotes action=cache_hit cache_age=%.0fs",
|
||
current_time - _realtime_cache['timestamp'],
|
||
)
|
||
else:
|
||
started_at = time.monotonic()
|
||
logger.info(
|
||
"[MarketStats] component=market_stats provider=EfinanceFetcher "
|
||
"api=ef.stock.get_realtime_quotes action=request_start"
|
||
)
|
||
df = _ef_call_with_timeout(ef.stock.get_realtime_quotes)
|
||
elapsed = time.monotonic() - started_at
|
||
logger.info(
|
||
"[MarketStats] component=market_stats provider=EfinanceFetcher "
|
||
"api=ef.stock.get_realtime_quotes action=request_complete elapsed=%.2fs",
|
||
elapsed,
|
||
)
|
||
_realtime_cache['data'] = df
|
||
_realtime_cache['timestamp'] = current_time
|
||
|
||
if df is None or df.empty:
|
||
logger.warning(
|
||
"[MarketStats] component=market_stats provider=EfinanceFetcher "
|
||
"api=ef.stock.get_realtime_quotes action=parse status=empty"
|
||
)
|
||
return None
|
||
|
||
return self._calc_market_stats(df)
|
||
except Exception as e:
|
||
logger.error(
|
||
"[MarketStats] component=market_stats provider=EfinanceFetcher "
|
||
"api=ef.stock.get_realtime_quotes action=failed error=%s",
|
||
e,
|
||
)
|
||
return None
|
||
|
||
def _calc_market_stats(
|
||
self,
|
||
df: pd.DataFrame,
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""从行情 DataFrame 计算涨跌统计。"""
|
||
import numpy as np
|
||
|
||
df = df.copy()
|
||
|
||
# 1. 提取基础比对数据:最新价、昨收
|
||
# 兼容不同接口返回的列名 sina/em efinance tushare xtdata
|
||
code_col = next((c for c in ['代码', '股票代码', 'ts_code','stock_code'] if c in df.columns), None)
|
||
name_col = next((c for c in ['名称', '股票名称','name','name'] if c in df.columns), None)
|
||
close_col = next((c for c in ['最新价', '最新价', 'close','lastPrice'] if c in df.columns), None)
|
||
pre_close_col = next((c for c in ['昨收', '昨日收盘', 'pre_close','lastClose'] if c in df.columns), None)
|
||
amount_col = next((c for c in ['成交额', '成交额', 'amount','amount'] if c in df.columns), None)
|
||
|
||
limit_up_count = 0
|
||
limit_down_count = 0
|
||
up_count = 0
|
||
down_count = 0
|
||
flat_count = 0
|
||
|
||
for code, name, current_price, pre_close, amount in zip(
|
||
df[code_col], df[name_col], df[close_col], df[pre_close_col], df[amount_col]
|
||
):
|
||
|
||
# 停牌过滤 efinance 的停牌数据有时候会缺失价格显示为 '-',em 显示为none
|
||
if pd.isna(current_price) or pd.isna(pre_close) or current_price in ['-'] or pre_close in ['-'] or amount == 0:
|
||
continue
|
||
|
||
# em、efinance 为str 需要转换为float
|
||
current_price = float(current_price)
|
||
pre_close = float(pre_close)
|
||
|
||
# 获取去除前缀的纯数字代码
|
||
pure_code = normalize_stock_code(str(code))
|
||
|
||
# A. 确定每只股票的涨跌幅比例 (使用纯数字代码判断)
|
||
if is_bse_code(pure_code):
|
||
ratio = 0.30
|
||
elif is_kc_cy_stock(pure_code): #pure_code.startswith(('688', '30')):
|
||
ratio = 0.20
|
||
elif is_st_stock(name): #'ST' in str_name:
|
||
ratio = 0.05
|
||
else:
|
||
ratio = 0.10
|
||
|
||
# B. 严格按照 A 股规则计算涨跌停价:昨收 * (1 ± 比例) -> 四舍五入保留2位小数
|
||
limit_up_price = np.floor(pre_close * (1 + ratio) * 100 + 0.5) / 100.0
|
||
limit_down_price = np.floor(pre_close * (1 - ratio) * 100 + 0.5) / 100.0
|
||
|
||
limit_up_price_Tolerance = round(abs(pre_close * (1 + ratio) - limit_up_price), 10)
|
||
limit_down_price_Tolerance = round(abs(pre_close * (1 - ratio) - limit_down_price), 10)
|
||
|
||
# C. 精确比对
|
||
if current_price > 0 :
|
||
is_limit_up = (current_price > 0) and (abs(current_price - limit_up_price) <= limit_up_price_Tolerance)
|
||
is_limit_down = (current_price > 0) and (abs(current_price - limit_down_price) <= limit_down_price_Tolerance)
|
||
|
||
if is_limit_up:
|
||
limit_up_count += 1
|
||
if is_limit_down:
|
||
limit_down_count += 1
|
||
|
||
if current_price > pre_close:
|
||
up_count += 1
|
||
elif current_price < pre_close:
|
||
down_count += 1
|
||
else:
|
||
flat_count += 1
|
||
|
||
# 统计数量
|
||
stats = {
|
||
'up_count': up_count,
|
||
'down_count': down_count,
|
||
'flat_count': flat_count,
|
||
'limit_up_count': limit_up_count,
|
||
'limit_down_count': limit_down_count,
|
||
'total_amount': 0.0,
|
||
}
|
||
|
||
# 成交额统计
|
||
if amount_col and 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
|
||
|
||
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_call_with_timeout(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_call_with_timeout(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_call_with_timeout(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 FuturesTimeoutError:
|
||
logger.warning(f"[超时] ef.stock.get_belong_board({stock_code}) 超过 {_EF_CALL_TIMEOUT}s,跳过")
|
||
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}")
|
||
|
||
# 测试市场统计
|
||
print("\n" + "=" * 50)
|
||
print("Testing get_market_stats (efinance)")
|
||
print("=" * 50)
|
||
try:
|
||
stats = fetcher.get_market_stats()
|
||
if stats:
|
||
print(f"Market Stats successfully computed:")
|
||
print(f"Up: {stats['up_count']} (Limit Up: {stats['limit_up_count']})")
|
||
print(f"Down: {stats['down_count']} (Limit Down: {stats['limit_down_count']})")
|
||
print(f"Flat: {stats['flat_count']}")
|
||
print(f"Total Amount: {stats['total_amount']:.2f} 亿 (Yi)")
|
||
else:
|
||
print("Failed to compute market stats.")
|
||
except Exception as e:
|
||
print(f"Failed to compute market stats: {e}")
|