Files
daily_stock_analysis/tests/test_yfinance_hk_bare_code.py
Nicholas-Xiong 02717771a1 fix: 修复 YfinanceFetcher 4-5 位裸港股码路由到 .SZ 的 bug(fixes #2091) (#2097)
* fix: route 4-5 digit bare HK codes to .HK in YfinanceFetcher (fixes #2091)

Issue #2091: 5-digit HK listings without an explicit 'HK' prefix (e.g.
02513 for Zhipu) fell through to the 'cannot determine market, default
to .SZ' tail branch in YfinanceFetcher._convert_stock_code(), producing
'02513.SZ' which Yahoo Finance rejects with 404, breaking the daily OHLC
chain and leaving LLM-driven stop-loss / MA levels unreliable.

Fix: insert a new branch ahead of the .SZ fallback that routes 4-5 digit
pure-numeric codes to '.HK' using the same zero-padding logic as the
existing HK-prefix branch (lstrip leading zeros, zfill to 4).

Safety:
  - A-share codes are always 6 digits, so no A-share rule is shadowed.
  - BSE codes are 6 digits (4xxxxx / 8xxxxx / 920xxx) and routed earlier
    via is_bse_code(); the new branch only sees 4-5 digit inputs.
  - ETF branches (15xx/16xx/18xx/51xx/52xx/56xx/58xx) all expect 6-digit
    codes and run earlier; no overlap.
  - JP/KR/TW/US suffix codes are routed earlier; unaffected.
  - Codes already carrying .SS/.SZ/.HK/.BJ pass through verbatim earlier.
  - 1-3 digit numerics continue to fall through to the .SZ default to
    preserve prior behaviour; the fix is intentionally scoped to 4-5
    digits per maintainer note in issue #2091, avoiding speculative
    expansion of the HK rule to inputs users never fetch in practice.

Tests: tests/test_yfinance_hk_bare_code.py covers:
  - HK-prefix still routes to .HK (4 cases, regression guard)
  - Bare 4-5 digit numeric -> .HK with correct zero padding (5 cases)
  - A-share 600/601/603/688 (.SS) and 000/002/300 (.SZ) unchanged (7 cases)
  - BSE 4xxxxx / 8xxxxx / 920xxx routes to .BJ unchanged (3 cases)
  - ETF (510300.SS / 159915.SZ), JP/KR/US suffix, and pre-suffixed codes
    pass through unchanged (7 cases)
All 23 new tests pass; 71 yfinance / convert_stock_code tests total
across the wider related test set pass with no regression.

* fix: 修复 _is_hk_market 4位裸港股码路由 + DataFetcherManager 回归测试

修复 maintainer 在 PR #2097 review 中指出的 OR-COR-bfddfd66 blocker:
_DataFetcherManager.get_daily_data() 仍按 A 股链路路由 4 位裸港股码。

根因:data_provider/base.py::_is_hk_market() 只把 5 位裸数字识别
为港股,4 位裸数字 (0001 长和 / 0941 中国移动) 被路由到 cn 分支,
导致 AkshareFetcher 走 stock_zh_a_hist、BaostockFetcher 兜成 sz.0001、
TushareFetcher 转成 0001.SZ——issue #2091 在主调用路径上未真正关闭。

修复:_is_hk_market 的裸数字分支从 len(normalized) == 5 改为
4 <= len(normalized) <= 5,与 YfinanceFetcher._convert_stock_code
的 4-5 位分支保持一致。A 股 (6 位) / BSE (6 位) / ETF (51/15 开头)
不受影响,因为它们走不同前缀或位数判定。

回归测试:新增 tests/test_data_fetcher_manager_hk_bare_code.py
- _is_hk_market('0001'/'0941'/'0078') -> True
- 4位裸港股码经 DataFetcherManager 只路由到 HK-capable fetcher
  (YfinanceFetcher/AkshareFetcher/TushareFetcher),Efinance/Tencent/
  TickFlow/Pytdx/Baostock 完全不被调用
- 5位裸港股码行为不变 (00700 -> YfinanceFetcher)
- 6位 A 股仍路由到 CN-only fetcher,YfinanceFetcher 不被调用

跨 7 个相关测试文件 186 passed,全量 135 passed,无回归。

* fix(#2091): 同步 akshare_fetcher._is_hk_code 到 4-5 位裸港股码

Review blocker OR-COR-ea09dfe8 (#2097): manager 层 _is_hk_market()
已在前序 commit 放开到 4-5 位裸数字,但 AkshareFetcher._is_hk_code()
仍只接受 5 位。DataFetcherManager 默认优先级下 AkshareFetcher 优先
于 YfinanceFetcher 执行,0001 在 manager 被判为 HK 后于
AkshareFetcher 内部因 _is_hk_code('0001')==False 落到 _fetch_stock_data
A股链路,两套市场契约冲突。

同步放宽 _is_hk_code:
- 无前缀裸数字从 len==5 改为 4<=len<=5
- docstring 补充 OR-COR-ea09dfe8 说明

新增两类 provider-level regression tests:
1. TestAkshareFetcherIsHkCodeContract: 直接断言 _is_hk_code 对 4/5
   位裸码、6位裸码(排除)、前缀后缀的解码结果
2. TestAkshareFetcherRoutingCallsHkBranch: patch _fetch_hk_data /
   _fetch_stock_data,驱动 _fetch_raw_data('0001') 验证真实分流到
   HK 分支而非 A股分支,防止 _is_hk_code 被重收紧后静默回归

* fix(#2091): 同步 longbridge_fetcher._is_hk_code 到 4-5 位裸港股码

OR-COR-ea09dfe8 关闭:DataFetcherManager 路由层 (data_provider/base.py)
已放宽到 4-5 位裸港股码,把 LongbridgeFetcher 视为 HK-capable provider
保留进港股链路;但 longbridge_fetcher._is_hk_code 仍只接受 5 位裸数字,
导致 4 位裸港股 (0001 长和 / 0941 中国移动) 在配置了 Longbridge 的真实
日线/实时链路上 _to_longbridge_symbol 返回 None,日线 fallback 抛
ValueError、实时兜底被静默跳过。

修复与 base._is_hk_market 的市场契约对齐:
- _is_hk_code: 4-5 位裸数字判定为港股 (与 base._is_hk_market 一致);
  .HK 后缀严格校验后缀 base 部分为 1-5 位数字 (之前无条件 True,会把
  类似 "XXX.HK" 也误判,本次顺带收紧);HK 前缀分支保持不变。
- _to_longbridge_symbol: 在 _is_hk_code 通过后的 .HK 后缀输入仍走
  原路径直接 return upper,行为不变;4 位裸码现在能正确 zfill(4) 到
  "0001.HK" / "0941.HK"。

回归测试:
- tests/test_longbridge_fetcher.py 新增 TestSymbolConversion
  .test_hk_stock_4digit_bare_code_issue_2091: 显式 assert 0001/0941
  _is_hk_code=True + _to_longbridge_symbol="0001.HK"/"0941.HK"。
- 现有 TestSymbolConversion 7 测试 + 全文件 30 测试 全通过。
- tests/test_yfinance_hk_bare_code.py + test_data_fetcher_manager_hk_bare_code.py
  共 39 测试 全通过。
- 总计新相关回归 69/69 pass。

本地确定性复现 (reviewer 报告的 head 行为):
- 修复前: _is_hk_code("0001") == False, _to_longbridge_symbol("0001") == None
- 修复后: _is_hk_code("0001") == True,  _to_longbridge_symbol("0001") == "0001.HK"
- _is_hk_code("0941") == True,  _to_longbridge_symbol("0941") == "0941.HK"
- _is_hk_code("00700") == True, _to_longbridge_symbol("00700") == "0700.HK"
- _is_hk_code("00700.HK") == True, _to_longbridge_symbol("00700.HK") == "00700.HK"
- _is_hk_code("HK00700") == True, _to_longbridge_symbol("HK00700") == "0700.HK"
- _is_hk_code("600690") == False (6 位 A 股不误判), _to_longbridge_symbol("600690") == None
- _is_hk_code("AAPL") == False,    _to_longbridge_symbol("AAPL") == "AAPL.US"

注:本轮只关闭 OR-COR-ea09dfe8 阻断;非阻断建议 (CHANGELOG #2063 条目
移出、PR 描述 sync、docstring 中 review blocker 编号清理) 将在下一条
commit 单独处理。

* docs(#2097): 清理评审叙事 + 收敛 CHANGELOG 范围

回应 reviewer 非阻断建议 (上一条 commit a251e4f8 提到的"下一 commit 处理"):

1. docs/CHANGELOG.md
   - 移除本 PR 主题 (#2091 yfinance/HK bare-code 路由) 之外混入的
     "parse_analysis_target() / #2063 Phase 1" 条目;该 feat 属于
     commit 0313dd3c (issue #2063 Phase 1) 单独 PR 的范围,不应在本 PR
     中夹带。
   - 把 #2091 条目改写为反映本 PR 实际范围:三处 _is_hk_code 同步
     (YfinanceFetcher 在 4e915a4f、AkshareFetcher 在 e2bf3a88、
     LongbridgeFetcher 在 a251e4f8) + DataFetcherManager 港股路由
     回归测试 (9c473787),统一描述为"DataFetcherManager 港股路由"
     而非只提 yfinance 单侧,与当前累计 diff 一致。

2. data_provider/base.py:_is_hk_market / akshare_fetcher.py:_is_hk_code
   docstring 清理:移除"Review blocker OR-COR-... (PR #2097 / issue
   #2091)"评审叙事、PR 编号、blocker 编号、过程性"之前只接受..."。
   改为稳定的市场识别规则说明:支持哪种形式 (.HK 后缀 / HK 前缀 /
   4-5 位裸数字)、与 provider 内 _is_hk_code 的位数契约对齐即可。
   长期保留的产品代码不应写入评审过程叙事。

行为无变化:仅 docstring + CHANGELOG 文本修改,无代码路径修改。
回归:tests/test_longbridge_fetcher.py + tests/test_yfinance_hk_bare_code.py
+ tests/test_data_fetcher_manager_hk_bare_code.py 共 69/69 pass。
2026-07-26 11:20:12 +08:00

111 lines
4.4 KiB
Python

# -*- coding: utf-8 -*-
"""Regression tests for YfinanceFetcher HK bare-code routing.
Covers issue #2091: 4-5 digit pure numeric codes (e.g. 02513, 00700, 0001)
must route to ``.HK`` rather than fall through to the ``.SZ`` default,
otherwise Yahoo Finance returns 404 and the daily-data chain breaks.
"""
from data_provider.yfinance_fetcher import YfinanceFetcher
class TestHKPrefixStillRoutesToHK:
"""Existing HK-prefix behaviour must remain unchanged."""
def test_hk_prefix_4digit(self) -> None:
assert YfinanceFetcher()._convert_stock_code("hk00700") == "0700.HK"
def test_hk_prefix_5digit(self) -> None:
assert YfinanceFetcher()._convert_stock_code("HK02513") == "2513.HK"
def test_hk_prefix_short(self) -> None:
# 1-3 digit HK prefix (e.g. hk0001 -> 0001.HK)
assert YfinanceFetcher()._convert_stock_code("hk0001") == "0001.HK"
class TestBareHKCodeRoutesToHK:
"""Regression for issue #2091: bare 4-5 digit numeric codes -> ``.HK``.
Previously the bare 5-digit path fell through to the ``.SZ`` default and
Yahoo Finance returned 404 for codes like ``02513``. The new routing
shunts 4-5 digit pure numeric codes to ``.HK`` ahead of the .SZ fallback,
because A-share codes are always 6 digits and BSE codes are 6 digits
starting with 4 / 8 / 920 — there is no A-share / BSE ambiguity for
4-5 digit numeric codes.
"""
def test_bare_5digit_new_listing(self) -> None:
# 智谱 02513 — the exact case from issue #2091
assert YfinanceFetcher()._convert_stock_code("02513") == "2513.HK"
def test_bare_5digit_tencent(self) -> None:
assert YfinanceFetcher()._convert_stock_code("00700") == "0700.HK"
def test_bare_5digit_keeps_leading_zero_padding(self) -> None:
# Alibaba 9988 -> padded to 9988 (already 4 digit, no extra padding)
assert YfinanceFetcher()._convert_stock_code("09988") == "9988.HK"
def test_bare_4digit_legacy_hk(self) -> None:
# 长江和记 0001 — 4-digit legacy HK code (per maintainer note)
assert YfinanceFetcher()._convert_stock_code("0001") == "0001.HK"
def test_bare_4digitmobile_carrier(self) -> None:
# 中国移动 0941 — 4-digit HK code from issue context
assert YfinanceFetcher()._convert_stock_code("0941") == "0941.HK"
class TestAShareRoutingUnchanged:
"""A-share / BSE routing must remain unchanged after the HK bare fix."""
def test_sh_600519(self) -> None:
assert YfinanceFetcher()._convert_stock_code("600519") == "600519.SS"
def test_sz_000001(self) -> None:
assert YfinanceFetcher()._convert_stock_code("000001") == "000001.SZ"
def test_sz_300750(self) -> None:
assert YfinanceFetcher()._convert_stock_code("300750") == "300750.SZ"
def test_sz_002594(self) -> None:
assert YfinanceFetcher()._convert_stock_code("002594") == "002594.SZ"
def test_kcb_688981(self) -> None:
assert YfinanceFetcher()._convert_stock_code("688981") == "688981.SS"
def test_bse_920xxx(self) -> None:
# BSE 920xxx is 6 digit — must NOT be caught by the 4-5 digit HK rule
assert YfinanceFetcher()._convert_stock_code("920019") == "920019.BJ"
def test_bse_8xxxxx(self) -> None:
assert YfinanceFetcher()._convert_stock_code("830799") == "830799.BJ"
def test_bse_4xxxxx(self) -> None:
# BSE 4xxxxx is 6 digit — must NOT match 4-digit HK rule
assert YfinanceFetcher()._convert_stock_code("430047") == "430047.BJ"
class TestETFAndSuffixUnchanged:
"""ETF and suffix codes must route exactly as before."""
def test_sh_etf_510300(self) -> None:
assert YfinanceFetcher()._convert_stock_code("510300") == "510300.SS"
def test_sz_etf_159915(self) -> None:
assert YfinanceFetcher()._convert_stock_code("159915") == "159915.SZ"
def test_us_ticker(self) -> None:
assert YfinanceFetcher()._convert_stock_code("AAPL") == "AAPL"
def test_jp_suffix(self) -> None:
assert YfinanceFetcher()._convert_stock_code("7203.T") == "7203.T"
def test_kr_suffix(self) -> None:
assert YfinanceFetcher()._convert_stock_code("005930.KS") == "005930.KS"
def test_with_hk_suffix(self) -> None:
# Already suffixed codes pass through verbatim
assert YfinanceFetcher()._convert_stock_code("0700.HK") == "0700.HK"
def test_with_ss_suffix(self) -> None:
assert YfinanceFetcher()._convert_stock_code("600519.SS") == "600519.SS"