fix(data): ak-stock top_holders KeyError 'sdltgd' 根治(_safe 捕 KeyError+匹配sdltgd,跳北交所920/83/87/43)

This commit is contained in:
2026-07-27 21:08:53 +08:00
parent 8b2693423a
commit 955c05357a
2 changed files with 140 additions and 13 deletions
@@ -196,3 +196,117 @@ class TestFetchTopHoldersOnePeriod:
)
# 重试耗尽返空 df (无 schema, 因为确实是失败)
assert isinstance(df, pd.DataFrame)
class TestSafeTop10EmKeyErrorSdltgd:
"""_safe_top_10_em 同样捕获 KeyError('sdltgd') → 返空 df。
bug 根因 (实证 akshare 新版):
北交所等不支持的标的, akshare 内部返缺 'sdltgd' 字段 → 抛 KeyError('sdltgd')。
KeyError 不是 ValueError 子类, 旧版只 except ValueError 捕不到, 透传到
call_ak_with_retry 走 3 次重试 (14s 退避) + 噪声日志。
本测试集覆盖: KeyError('sdltgd') 被吞返空 df / 其他 KeyError 不被吞 / 集成路径无重试。
"""
def test_keyerror_sdltgd_returns_empty_df_with_schema(self):
"""akshare 抛 KeyError('sdltgd') → 返空 df 带 8 列 schema。"""
from akshare_static_download import _safe_top_10_em
# akshare 实际抛 KeyError('sdltgd'), str(err) == "'sdltgd'" (含引号)
err = KeyError("sdltgd")
with patch.object(mod.ak, "stock_gdfx_free_top_10_em", side_effect=err):
df = _safe_top_10_em(symbol="sh920510", date="20250930")
assert isinstance(df, pd.DataFrame)
assert len(df) == 0
assert df.columns.tolist() == EXPECTED_COLS
def test_keyerror_sdltgd_does_not_raise(self):
"""KeyError('sdltgd') 必须被吞掉 (call_ak_with_retry 才不会重试)。"""
from akshare_static_download import _safe_top_10_em
err = KeyError("sdltgd")
with patch.object(mod.ak, "stock_gdfx_free_top_10_em", side_effect=err):
# 不应抛
df = _safe_top_10_em(symbol="sh920510", date="20250930")
assert df.empty
def test_other_keyerror_reraises(self):
"""'sdltgd' 的 KeyError 不应被吞 (让重试机制处理)。"""
from akshare_static_download import _safe_top_10_em
# 任意其他 KeyError (网络层错误等), 不应被吞
other_err = KeyError("some_other_field")
with patch.object(mod.ak, "stock_gdfx_free_top_10_em", side_effect=other_err):
with pytest.raises(KeyError, match="some_other_field"):
_safe_top_10_em(symbol="sh920510", date="20250930")
def test_keyerror_sdltgd_no_retry_in_fetch(self, monkeypatch):
"""KeyError('sdltgd') 走集成层也不重试 (call_ak_with_retry 只调一次)。"""
monkeypatch.setattr(mod, "RETRY_BACKOFF", [0, 0, 0])
call_count = {"n": 0}
def side_effect(*args, **kwargs):
call_count["n"] += 1
raise KeyError("sdltgd")
with patch.object(mod.ak, "stock_gdfx_free_top_10_em", side_effect=side_effect):
df = mod.fetch_top_holders_one_period(symbol="sh920510", period="20250930")
# 关键: 只调 1 次 (KeyError 被吞, 不重试)
assert call_count["n"] == 1, (
f"KeyError('sdltgd') 应该被 _safe_top_10_em 一次吞掉, 但调了 {call_count['n']}"
)
assert df.empty
assert df.columns.tolist() == EXPECTED_COLS
class TestBuildTopHoldersUnitsSkipBJ:
"""build_top_holders_units 跳过北交所 920xxx / 83xxx / 87xxx / 43xxx。
根因: akshare 东财 stock_gdfx_free_top_10_em 不支持北交所。每只北交所 × 20 期 × 3 retry
≈ 60 次失败调用/股 → weekly 跑不完。在 build 阶段直接跳过, 双保险。
"""
def _make_args(self, codes: str = ""):
import argparse
return argparse.Namespace(codes=codes, limit=0)
def test_skips_920_prefix(self):
"""920xxx (北交所新段) 不进 units。"""
codes = [("920510", "BJ"), ("600519", "SH")]
units = mod.build_top_holders_units(codes, self._make_args())
unit_ids = [uid for uid, _ in units]
# 920510 应该被跳, 不出现
assert not any("920510" in uid for uid in unit_ids), (
f"北交所 920510 不该进 units, 但出现了: {unit_ids}"
)
# 600519 应该在 (每报告期一个 unit)
assert any("600519" in uid for uid in unit_ids)
def test_skips_all_bj_prefixes(self):
"""83xx/87xx/43xx (北交所历史段) 全跳。"""
codes = [
("830789", "BJ"),
("870866", "BJ"),
("430139", "BJ"),
("000001", "SZ"),
]
units = mod.build_top_holders_units(codes, self._make_args())
unit_ids = [uid for uid, _ in units]
for bj_code in ("830789", "870866", "430139"):
assert not any(bj_code in uid for uid in unit_ids), (
f"北交所 {bj_code} 不该进 units"
)
# 000001 应该在
assert any("000001" in uid for uid in unit_ids)
def test_no_bj_codes_arg_mixed(self):
"""--codes "920001,600519" 时 920001 跳, 600519 拉。"""
units = mod.build_top_holders_units([], self._make_args(codes="920001,600519"))
unit_ids = [uid for uid, _ in units]
assert not any("920001" in uid for uid in unit_ids)
assert any("600519" in uid for uid in unit_ids)