fix: correct ETF secid routing for efinance (#1535)

Co-authored-by: mumu <42829555+ZhuLinsen@users.noreply.github.com>
This commit is contained in:
Alfred
2026-05-31 09:27:54 +08:00
committed by GitHub
co-authored by mumu
parent 10d22293fc
commit b22d7e0277
4 changed files with 260 additions and 9 deletions
+39 -8
View File
@@ -53,7 +53,18 @@ except (ValueError, TypeError):
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
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,
@@ -134,6 +145,9 @@ _etf_realtime_cache: Dict[str, Any] = {
'ttl': 600 # 10分钟缓存有效期
}
_ETF_SH_PREFIXES = ('51', '52', '56', '58')
_ETF_SZ_PREFIXES = ('15', '16', '18')
def _is_etf_code(stock_code: str) -> bool:
"""
@@ -149,8 +163,19 @@ def _is_etf_code(stock_code: str) -> bool:
Returns:
True 表示是 ETF 代码,False 表示是普通股票代码
"""
etf_prefixes = ('51', '52', '56', '58', '15', '16', '18')
return stock_code.startswith(etf_prefixes) and len(stock_code) == 6
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:
@@ -480,20 +505,26 @@ class EfinanceFetcher(BaseFetcher):
# 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={stock_code}, "
f"beg={beg_date}, end={end_date_fmt}, klt=101, fqt=1) [ETF]")
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=stock_code,
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,
)
@@ -502,7 +533,7 @@ class EfinanceFetcher(BaseFetcher):
if df is not None and not df.empty:
logger.info(
"[API返回] Eastmoney 历史K线成功 [ETF]: "
f"endpoint={EASTMONEY_HISTORY_ENDPOINT}, stock_code={stock_code}, "
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)}")
@@ -512,7 +543,7 @@ class EfinanceFetcher(BaseFetcher):
else:
logger.warning(
"[API返回] Eastmoney 历史K线为空 [ETF]: "
f"endpoint={EASTMONEY_HISTORY_ENDPOINT}, stock_code={stock_code}, "
f"endpoint={EASTMONEY_HISTORY_ENDPOINT}, stock_code={stock_code}, secid={secid}, "
f"range={beg_date}~{end_date_fmt}, elapsed={api_elapsed:.2f}s"
)
+2
View File
@@ -22,6 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [修复] Windows 桌面端自动更新传给 NSIS 的 `/D=` 目录参数在包含空格时自动加引号,避免安装位置注册表被截断。
- [修复] 加固 LLM channel base_url 校验,避免解析差异导致 SSRF 绕过。
- [修复] 修正 efinance ETF 日线 Eastmoney secid 路由,避免沪市 ETF 被按深市 quote id 查询导致日线为空。
- [测试] 补充 ETF 日线数据源路由、输入变体、fallback 与 MA 字段回归覆盖。
- [改进] 优化 Web 报告详情页信息层级,将输入数据块和运行诊断下移为主体内容后的折叠辅助信息。
- [新功能] 市场阶段低敏摘要接入历史详情、同步分析响应和 completed 任务状态的 report metadata。
+203
View File
@@ -0,0 +1,203 @@
import os
import sys
import types
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pandas as pd
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from data_provider.akshare_fetcher import AkshareFetcher
from data_provider.base import BaseFetcher, DataFetchError, DataFetcherManager
from data_provider.efinance_fetcher import EfinanceFetcher
def _make_efinance_fetcher() -> EfinanceFetcher:
with patch(
"data_provider.efinance_fetcher.get_config",
return_value=SimpleNamespace(enable_eastmoney_patch=False),
):
return EfinanceFetcher(sleep_min=0, sleep_max=0)
def _make_akshare_fetcher() -> AkshareFetcher:
with patch(
"data_provider.akshare_fetcher.get_config",
return_value=SimpleNamespace(enable_eastmoney_patch=False),
):
return AkshareFetcher(sleep_min=0, sleep_max=0)
def _history_frame(code: str = "563230") -> pd.DataFrame:
return pd.DataFrame(
{
"股票代码": [code] * 5,
"日期": pd.date_range("2026-01-01", periods=5).strftime("%Y-%m-%d"),
"开盘": [10.0, 10.1, 10.2, 10.3, 10.4],
"收盘": [10.1, 10.2, 10.3, 10.4, 10.5],
"最高": [10.2, 10.3, 10.4, 10.5, 10.6],
"最低": [9.9, 10.0, 10.1, 10.2, 10.3],
"成交量": [1000, 1100, 1200, 1300, 1400],
"成交额": [10100, 11220, 12360, 13520, 14700],
"涨跌幅": [0.0, 0.99, 0.98, 0.97, 0.96],
}
)
def _run_efinance_daily(stock_code: str) -> tuple[pd.DataFrame, MagicMock]:
fetcher = _make_efinance_fetcher()
fake_efinance = types.SimpleNamespace(
stock=types.SimpleNamespace(get_quote_history=MagicMock(name="get_quote_history"))
)
call = MagicMock(return_value=_history_frame())
with patch.dict(sys.modules, {"efinance": fake_efinance}):
with patch("data_provider.efinance_fetcher._ef_call_with_timeout", call):
with patch.object(fetcher, "_set_random_user_agent"), patch.object(
fetcher, "_enforce_rate_limit"
):
df = fetcher.get_daily_data(
stock_code,
start_date="2026-01-01",
end_date="2026-01-05",
)
return df, call
def test_efinance_sh_etf_uses_eastmoney_quote_id_mode() -> None:
df, call = _run_efinance_daily("563230")
kwargs = call.call_args.kwargs
assert kwargs["stock_codes"] == "1.563230"
assert kwargs["quote_id_mode"] is True
assert kwargs["use_id_cache"] is False
assert kwargs["beg"] == "20260101"
assert kwargs["end"] == "20260105"
assert kwargs["klt"] == 101
assert kwargs["fqt"] == 1
assert {"ma5", "ma10", "ma20"}.issubset(df.columns)
def test_efinance_sz_etf_uses_eastmoney_quote_id_mode() -> None:
_, call = _run_efinance_daily("159919")
assert call.call_args.kwargs["stock_codes"] == "0.159919"
assert call.call_args.kwargs["quote_id_mode"] is True
assert call.call_args.kwargs["use_id_cache"] is False
def test_efinance_etf_code_variants_use_sh_secid() -> None:
for stock_code in ("SH563230", "SH.563230", "563230.SH"):
_, call = _run_efinance_daily(stock_code)
assert call.call_args.kwargs["stock_codes"] == "1.563230"
def test_akshare_etf_uses_fund_etf_hist_em() -> None:
fetcher = _make_akshare_fetcher()
fake_akshare = types.SimpleNamespace(fund_etf_hist_em=MagicMock(return_value=_history_frame()))
with patch.dict(sys.modules, {"akshare": fake_akshare}):
with patch.object(fetcher, "_set_random_user_agent"), patch.object(
fetcher, "_enforce_rate_limit"
):
df = fetcher._fetch_raw_data("563230", "2026-01-01", "2026-01-05")
assert df is not None
fake_akshare.fund_etf_hist_em.assert_called_once_with(
symbol="563230",
period="daily",
start_date="20260101",
end_date="20260105",
adjust="qfq",
)
def test_manager_normalizes_prefixed_etf_before_efinance_secid_route() -> None:
fetcher = _make_efinance_fetcher()
manager = DataFetcherManager(fetchers=[fetcher])
fake_efinance = types.SimpleNamespace(
stock=types.SimpleNamespace(get_quote_history=MagicMock(name="get_quote_history"))
)
call = MagicMock(return_value=_history_frame())
with patch.dict(sys.modules, {"efinance": fake_efinance}):
with patch("data_provider.efinance_fetcher._ef_call_with_timeout", call):
with patch.object(fetcher, "_set_random_user_agent"), patch.object(
fetcher, "_enforce_rate_limit"
):
df, source = manager.get_daily_data(
"SH563230",
start_date="2026-01-01",
end_date="2026-01-05",
)
assert source == "EfinanceFetcher"
assert call.call_args.kwargs["stock_codes"] == "1.563230"
assert {"ma5", "ma10", "ma20"}.issubset(df.columns)
class _EmptyEfinanceFetcher(BaseFetcher):
name = "EfinanceFetcher"
priority = 0
def _fetch_raw_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame:
return pd.DataFrame()
def _normalize_data(self, df: pd.DataFrame, stock_code: str) -> pd.DataFrame:
return df
class _FailingEfinanceFetcher(BaseFetcher):
name = "EfinanceFetcher"
priority = 0
def _fetch_raw_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame:
raise DataFetchError("efinance ETF history failed")
def _normalize_data(self, df: pd.DataFrame, stock_code: str) -> pd.DataFrame:
return df
class _SuccessfulAkshareFetcher(BaseFetcher):
name = "AkshareFetcher"
priority = 1
def _fetch_raw_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame:
return pd.DataFrame(
{
"code": [stock_code] * 5,
"date": pd.date_range("2026-01-01", periods=5),
"open": [10.0, 10.1, 10.2, 10.3, 10.4],
"high": [10.2, 10.3, 10.4, 10.5, 10.6],
"low": [9.9, 10.0, 10.1, 10.2, 10.3],
"close": [10.1, 10.2, 10.3, 10.4, 10.5],
"volume": [1000, 1100, 1200, 1300, 1400],
"amount": [10100, 11220, 12360, 13520, 14700],
"pct_chg": [0.0, 0.99, 0.98, 0.97, 0.96],
}
)
def _normalize_data(self, df: pd.DataFrame, stock_code: str) -> pd.DataFrame:
return df
def test_manager_falls_back_and_keeps_etf_ma_columns() -> None:
manager = DataFetcherManager(fetchers=[_EmptyEfinanceFetcher(), _SuccessfulAkshareFetcher()])
df, source = manager.get_daily_data("563230", start_date="2026-01-01", end_date="2026-01-05")
assert source == "AkshareFetcher"
assert {"ma5", "ma10", "ma20"}.issubset(df.columns)
assert df["ma5"].iloc[-1] == 10.3
def test_manager_falls_back_when_efinance_raises_and_keeps_etf_ma_columns() -> None:
manager = DataFetcherManager(fetchers=[_FailingEfinanceFetcher(), _SuccessfulAkshareFetcher()])
df, source = manager.get_daily_data("563230", start_date="2026-01-01", end_date="2026-01-05")
assert source == "AkshareFetcher"
assert {"ma5", "ma10", "ma20"}.issubset(df.columns)
assert df["ma5"].iloc[-1] == 10.3
+16 -1
View File
@@ -142,6 +142,22 @@ class TestTushareFetcherFetchRawData(unittest.TestCase):
fetcher._api.daily.assert_not_called()
fetcher._api.hk_daily.assert_not_called()
def test_fetch_raw_data_56_prefix_etf_uses_sh_fund_daily(self) -> None:
fetcher = self._make_fetcher()
fetcher._api.fund_daily.return_value = pd.DataFrame({"trade_date": ["20260101"]})
with patch.object(fetcher, "_check_rate_limit"):
out = fetcher._fetch_raw_data("563230", "20260101", "20260105")
self.assertIsNotNone(out)
fetcher._api.fund_daily.assert_called_once_with(
ts_code="563230.SH",
start_date="20260101",
end_date="20260105",
)
fetcher._api.daily.assert_not_called()
fetcher._api.hk_daily.assert_not_called()
def test_fetch_raw_data_hk_uses_hk_daily(self) -> None:
fetcher = self._make_fetcher()
fetcher._api.hk_daily.return_value = pd.DataFrame({"trade_date": ["20260102"]})
@@ -274,4 +290,3 @@ class TestTushareFetcherChipDistribution(unittest.TestCase):
if __name__ == "__main__":
unittest.main()