Skip to content

Commit 96411b8

Browse files
committed
fix(review-feedback-961): address latest review comments
1 parent 3be5396 commit 96411b8

3 files changed

Lines changed: 113 additions & 33 deletions

File tree

data_provider/base.py

Lines changed: 58 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -902,12 +902,54 @@ def get_daily_data(
902902

903903
# Normalize code (strip SH/SZ prefix etc.)
904904
stock_code = normalize_stock_code(stock_code)
905+
should_retry_with_akshare_ambiguous_fallback = (
906+
stock_code.isdigit() and len(stock_code) == 5
907+
)
905908

906909
fetchers = self._get_fetchers_snapshot()
907910
errors = []
908911
total_fetchers = len(fetchers)
909912
request_start = time.time()
910913

914+
def _run_fetcher_chain(target_code: str, log_prefix: str = "数据源") -> Tuple[Optional[pd.DataFrame], Optional[str], List[str], bool]:
915+
local_errors: List[str] = []
916+
for attempt, fetcher in enumerate(fetchers, start=1):
917+
try:
918+
logger.info(f"[{log_prefix}尝试 {attempt}/{total_fetchers}] [{fetcher.name}] 获取 {target_code}...")
919+
df = self._call_fetcher_method(
920+
fetcher,
921+
"get_daily_data",
922+
stock_code=target_code,
923+
start_date=start_date,
924+
end_date=end_date,
925+
days=days,
926+
)
927+
928+
if df is not None and not df.empty:
929+
elapsed = time.time() - request_start
930+
logger.info(
931+
f"[{log_prefix}完成] {target_code} 使用 [{fetcher.name}] 获取成功: "
932+
f"rows={len(df)}, elapsed={elapsed:.2f}s"
933+
)
934+
return df, fetcher.name, local_errors, False
935+
936+
except Exception as e:
937+
error_type, error_reason = summarize_exception(e)
938+
error_msg = f"[{fetcher.name}] ({error_type}) {error_reason}"
939+
logger.warning(
940+
f"[{log_prefix}失败 {attempt}/{total_fetchers}] [{fetcher.name}] {target_code}: "
941+
f"error_type={error_type}, reason={error_reason}"
942+
)
943+
local_errors.append(error_msg)
944+
if attempt < total_fetchers:
945+
next_fetcher = fetchers[attempt]
946+
if log_prefix == "数据源":
947+
logger.info(f"[数据源切换] {target_code}: [{fetcher.name}] -> [{next_fetcher.name}]")
948+
else:
949+
logger.info(f"[{log_prefix}切换] {target_code}: [{fetcher.name}] -> [{next_fetcher.name}]")
950+
951+
return None, None, local_errors, True
952+
911953
# 快速路径:美股指数与美股股票直接路由到 YfinanceFetcher
912954
if is_us_index_code(stock_code) or is_us_stock_code(stock_code):
913955
for attempt, fetcher in enumerate(fetchers, start=1):
@@ -947,39 +989,24 @@ def get_daily_data(
947989
logger.error(f"[数据源终止] {stock_code} 获取失败: elapsed={elapsed:.2f}s\n{error_summary}")
948990
raise DataFetchError(error_summary)
949991

950-
for attempt, fetcher in enumerate(fetchers, start=1):
951-
try:
952-
logger.info(f"[数据源尝试 {attempt}/{total_fetchers}] [{fetcher.name}] 获取 {stock_code}...")
953-
df = self._call_fetcher_method(
954-
fetcher,
955-
"get_daily_data",
956-
stock_code=stock_code,
957-
start_date=start_date,
958-
end_date=end_date,
959-
days=days
992+
df, source_name, chain_errors, _ = _run_fetcher_chain(stock_code)
993+
if df is not None:
994+
return df, source_name
995+
errors.extend(chain_errors)
996+
997+
if should_retry_with_akshare_ambiguous_fallback:
998+
fallback_stock_code = stock_code.zfill(6)
999+
if fallback_stock_code != stock_code:
1000+
logger.info(
1001+
f"[数据源回退] {stock_code} 首轮无可用数据,尝试补零后按 A 股路径重试 {fallback_stock_code}"
9601002
)
961-
962-
if df is not None and not df.empty:
963-
elapsed = time.time() - request_start
964-
logger.info(
965-
f"[数据源完成] {stock_code} 使用 [{fetcher.name}] 获取成功: "
966-
f"rows={len(df)}, elapsed={elapsed:.2f}s"
967-
)
968-
return df, fetcher.name
969-
970-
except Exception as e:
971-
error_type, error_reason = summarize_exception(e)
972-
error_msg = f"[{fetcher.name}] ({error_type}) {error_reason}"
973-
logger.warning(
974-
f"[数据源失败 {attempt}/{total_fetchers}] [{fetcher.name}] {stock_code}: "
975-
f"error_type={error_type}, reason={error_reason}"
1003+
fallback_df, fallback_source_name, fallback_errors, _ = _run_fetcher_chain(
1004+
fallback_stock_code,
1005+
log_prefix="数据源回退",
9761006
)
977-
errors.append(error_msg)
978-
if attempt < total_fetchers:
979-
next_fetcher = fetchers[attempt]
980-
logger.info(f"[数据源切换] {stock_code}: [{fetcher.name}] -> [{next_fetcher.name}]")
981-
# 继续尝试下一个数据源
982-
continue
1007+
if fallback_df is not None:
1008+
return fallback_df, fallback_source_name
1009+
errors.extend([f"fallback:{msg}" for msg in fallback_errors])
9831010

9841011
# 所有数据源都失败
9851012
error_summary = f"所有数据源获取 {stock_code} 失败:\n" + "\n".join(errors)

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
1515
- [修复] 移除 `HistoryItem``ReportSummary` 响应 Schema 中 `sentiment_score``ge=0/le=100` 约束(fixes #942)——历史库中存储的超范围负值或大于 100 的情绪评分不再触发 Pydantic ValidationError,历史列表与详情接口恢复正常返回。
1616
- [修复] 修复输入 5 位裸数字代码(如 '02714')被误路由到港股路径导致分析错误股票的问题:`normalize_stock_code` 现在对首位为 '0'、补全后落入深市 A 股 000–003 前缀范围且不在已知港股白名单内的 5 位代码自动补前导 '0' 并打印歧义警告;如本意为港股,应使用 'HK02714' 或 '02714.HK' 格式消除歧义。
1717
- [修复] 修复 5 位裸数字港股代码(如 '02319' 蒙牛乳业)因不在旧 STOCK_NAME_MAP 白名单中被误补全为 A 股代码的回归问题:`normalize_stock_code` 现在对所有 5 位纯数字代码保持"纯语法"规范化,不再做市场推断或自动补零,由上层 DataFetcherManager / 管线在首轮查询无数据时决定是否以补零 A 股代码重试;如需明确指定港股,请使用 'HK02319' 或 '02319.HK' 格式。
18+
- [修复] 修复 5 位裸数字代码误路由:`normalize_stock_code` 保持纯语法处理,不再在规范化层自动补零;`DataFetcherManager.get_daily_data` 先按港股通道取数,若 5 位裸码在首轮仍无数据,再尝试补零后按 6 位 A 股码重试(如 02714→002714)。
1819

1920
## [3.12.0] - 2026-04-01
2021

tests/test_stock_code_five_digit_ambiguity.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@
77
88
Covered acceptance criteria:
99
1. '002714' → '002714' (6-digit A-share, unchanged)
10-
2. '02714' → '02714' (5-digit, kept as-is; upstream decides routing)
10+
2. '02714' → '02714' (5-digit, kept as-is; manager 在港股路径无数据时可尝试补零到 '002714')
1111
3. '00700' → '00700' (known HK, kept as-is)
1212
4. 'HK02714' → 'HK02714' (explicit HK prefix, stays HK)
1313
5. '02714.HK' → 'HK02714' (explicit HK suffix, stays HK)
1414
6. '02319' → '02319' (real HK code not in old STOCK_NAME_MAP, must not be padded)
1515
"""
1616

1717
import sys
18+
import pandas as pd
1819
import unittest
1920
from unittest.mock import MagicMock
2021

@@ -25,14 +26,41 @@
2526
sys.modules["json_repair"] = MagicMock()
2627

2728
try:
28-
from data_provider.base import normalize_stock_code, _KNOWN_HK_BARE_CODES
29+
from data_provider.base import DataFetchError, DataFetcherManager, normalize_stock_code, _KNOWN_HK_BARE_CODES
2930
_IMPORTS_OK = True
3031
_IMPORT_ERROR = ""
3132
except ImportError as e:
3233
_IMPORTS_OK = False
3334
_IMPORT_ERROR = str(e)
3435

3536

37+
def _sample_df() -> pd.DataFrame:
38+
return pd.DataFrame(
39+
{
40+
"date": ["2026-03-01", "2026-03-02"],
41+
"open": [1.0, 1.1],
42+
"high": [1.2, 1.2],
43+
"low": [0.9, 1.0],
44+
"close": [1.1, 1.05],
45+
"volume": [1000, 1000],
46+
"amount": [10000, 10000],
47+
"pct_chg": [0.5, 0.5],
48+
}
49+
)
50+
51+
52+
class _MockDailyFetcher:
53+
def __init__(self, name: str, priority: int, result_provider):
54+
self.name = name
55+
self.priority = priority
56+
self._result_provider = result_provider
57+
self.calls = []
58+
59+
def get_daily_data(self, stock_code: str, start_date: str | None = None, end_date: str | None = None, days: int = 30):
60+
self.calls.append(stock_code)
61+
return self._result_provider(stock_code=stock_code, start_date=start_date, end_date=end_date, days=days)
62+
63+
3664
@unittest.skipIf(not _IMPORTS_OK, f"imports failed: {_IMPORT_ERROR}")
3765
class TestFiveDigitAmbiguity(unittest.TestCase):
3866
"""
@@ -114,6 +142,30 @@ def test_known_hk_bare_codes_contains_02319(self):
114142
"""'02319' (蒙牛乳业, not in old STOCK_NAME_MAP) must be in the HK whitelist."""
115143
self.assertIn("02319", _KNOWN_HK_BARE_CODES)
116144

145+
def test_manager_fallback_to_ashare_after_hk_empty(self):
146+
"""港股路径无数据时会补零重试 A 股候选码。"""
147+
148+
def result_provider(stock_code: str, **_):
149+
if stock_code == "002714":
150+
return _sample_df()
151+
return None
152+
153+
fetcher = _MockDailyFetcher("AkshareFetcher", 1, result_provider)
154+
manager = DataFetcherManager(fetchers=[fetcher])
155+
df, source = manager.get_daily_data("02714", start_date="2026-03-01", end_date="2026-03-02")
156+
self.assertEqual(source, "AkshareFetcher")
157+
self.assertEqual(fetcher.calls, ["02714", "002714"])
158+
self.assertEqual(len(df), 2)
159+
160+
def test_manager_does_not_retry_padded_for_explicit_hk_code(self):
161+
"""显式 HK 代码不会触发 6 位补零兜底。"""
162+
163+
fetcher = _MockDailyFetcher("AkshareFetcher", 1, lambda **_: None)
164+
manager = DataFetcherManager(fetchers=[fetcher])
165+
with self.assertRaises(DataFetchError):
166+
manager.get_daily_data("HK02714", start_date="2026-03-01", end_date="2026-03-02")
167+
self.assertEqual(fetcher.calls, ["HK02714"])
168+
117169
# --- Other normalize_stock_code behaviours must be preserved ---
118170

119171
def test_sh_prefix_stripped(self):

0 commit comments

Comments
 (0)