mirror of
https://github.com/ZhuLinsen/daily_stock_analysis.git
synced 2026-10-06 14:33:11 +08:00
fix: correct ETF secid routing for efinance (#1535)
Co-authored-by: mumu <42829555+ZhuLinsen@users.noreply.github.com>
This commit is contained in:
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user