mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
fix(issue-1894): [bug]-回测时获取数据异常 (#1895)
This commit is contained in:
@@ -76,6 +76,7 @@ def normalize_stock_code(stock_code: str) -> str:
|
||||
- 'SH600519' -> '600519' (strip SH prefix)
|
||||
- 'SH.600519' -> '600519' (strip SH. prefix)
|
||||
- 'SZ000001' -> '000001' (strip SZ prefix)
|
||||
- 'SS600519' -> '600519' (strip legacy Yahoo Shanghai prefix)
|
||||
- 'SZ.000001' -> '000001' (strip SZ. prefix)
|
||||
- 'BJ920748' -> '920748' (strip BJ prefix, BSE)
|
||||
- 'BJ.920748' -> '920748' (strip BJ. prefix, BSE)
|
||||
@@ -103,15 +104,15 @@ def normalize_stock_code(stock_code: str) -> str:
|
||||
if candidate.isdigit() and 1 <= len(candidate) <= 5:
|
||||
return f"HK{candidate.zfill(5)}"
|
||||
|
||||
# Strip SH/SZ prefix (e.g. SH600519 -> 600519)
|
||||
if upper.startswith(('SH', 'SZ')) and not upper.startswith('SH.') and not upper.startswith('SZ.'):
|
||||
# Strip SH/SZ/SS prefix (e.g. SH600519 -> 600519, SS600519 -> 600519)
|
||||
if upper.startswith(('SH', 'SZ', 'SS')) and not upper.startswith(('SH.', 'SZ.', 'SS.')):
|
||||
candidate = code[2:]
|
||||
# Only strip if the remainder looks like a valid numeric code
|
||||
if candidate.isdigit() and len(candidate) in (5, 6):
|
||||
return candidate
|
||||
|
||||
# Strip dotted SH/SZ prefix (e.g. SH.600519 -> 600519)
|
||||
if upper.startswith(('SH.', 'SZ.')):
|
||||
# Strip dotted SH/SZ/SS prefix (e.g. SH.600519 -> 600519)
|
||||
if upper.startswith(('SH.', 'SZ.', 'SS.')):
|
||||
candidate = code[3:]
|
||||
if candidate.isdigit() and len(candidate) in (5, 6):
|
||||
return candidate
|
||||
|
||||
@@ -37,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
- [改进] 台股报告完整消费三大法人:tw 个股报告的 `institution` 区块现会在报告中渲染三大法人净买卖超表格,并注入 LLM 分析 prompt 作为台股筹码过滤器(此前仅接入数据层,报告与 prompt 均未消费,导致报告出现「筹码结构:数据缺失」);同时三大法人整市场抓取改用剩余 stage 预算而非较小的 per-symbol fetch 超时,避免单股/首档分析因冷抓取(~4-5s)超时而降级为 not_supported。tw-only、严格 additive、fail-open。
|
||||
- [修复] 台股财务金额币别标示:TWD 金额此前落入默认「元」(在 A 股语境易误读为人民币),`_CURRENCY_SUFFIX` 补入 TWD→「新台币」,营业收入/归母净利润/经营现金流/每股现金分红均正确标注新台币。
|
||||
- [改进] 台股三大法人 fetcher 韧性加固:(1) 接入熔断器(复用 `realtime_types.CircuitBreaker`,按市场 twse/tpex 分流,连续失败 3 次→冷却 ~5min→半开探测),TWSE/TPEx 端点异常时快速跳过网络往返并 fail-open,避免端点故障时每档个股都付 timeout+throttle;(2) TPEx OpenAPI 仅服务最新交易日,调用方传入与服务日期不符的明确日期时改为 fail-open(返回无数据),避免静默返回错日资料。
|
||||
- [修复] 回测日线补全将 `605066.SH`、`SS605066`、`SS.605066` 等 A 股等价代码归一为裸代码抓取和写入,避免误向数据源请求 `SS605066` 导致回测数据不足。
|
||||
|
||||
- [修复] 台股(tw)市场阶段(`market_phase`)新增收盘集合竞价识别:`_CLOSING_AUCTION_WINDOW_MINUTES` 缺 `tw` 键时 `.get(market, 0)` 得零宽窗口,TWSE/TPEx 13:25–13:30 的 5 分钟收盘竞价此前永远无法判定为 `closing_auction`(收盘前一刻仍 `intraday`、13:30 直接 `postmarket`);补 `"tw": 5` 修正,附阶段边界回归测试。仅 tw 加项,cn/hk/us 与 jp/kr 行为不变。
|
||||
- [新功能] 新增 AI 建议决策风格重评估预览接口与页面预览。
|
||||
|
||||
@@ -154,7 +154,7 @@ class BacktestService:
|
||||
)
|
||||
|
||||
if len(forward_bars) < int(eval_window_days):
|
||||
for fill_code in self._ordered_candidate_codes(
|
||||
for fill_code in self._ordered_daily_refill_codes(
|
||||
code_candidates=daily_code_candidates,
|
||||
preferred_code=matched_daily_code,
|
||||
):
|
||||
@@ -495,6 +495,33 @@ class BacktestService:
|
||||
return [normalized_preferred] + [code for code in ordered if code != normalized_preferred]
|
||||
return ordered
|
||||
|
||||
@staticmethod
|
||||
def _normalize_daily_refill_code(code: Optional[str]) -> str:
|
||||
raw_code = str(code or "").strip()
|
||||
if not raw_code:
|
||||
return ""
|
||||
return canonical_stock_code(normalize_stock_code(raw_code))
|
||||
|
||||
@staticmethod
|
||||
def _ordered_daily_refill_codes(
|
||||
*,
|
||||
code_candidates: List[str],
|
||||
preferred_code: Optional[str] = None,
|
||||
) -> List[str]:
|
||||
ordered = BacktestService._ordered_candidate_codes(
|
||||
code_candidates=code_candidates,
|
||||
preferred_code=preferred_code,
|
||||
)
|
||||
refill_codes: List[str] = []
|
||||
seen: set[str] = set()
|
||||
for code in ordered:
|
||||
refill_code = BacktestService._normalize_daily_refill_code(code)
|
||||
if not refill_code or refill_code in seen:
|
||||
continue
|
||||
seen.add(refill_code)
|
||||
refill_codes.append(refill_code)
|
||||
return refill_codes
|
||||
|
||||
def _get_forward_bars_by_candidates(
|
||||
self,
|
||||
*,
|
||||
@@ -908,6 +935,10 @@ class BacktestService:
|
||||
return None
|
||||
|
||||
def _try_fill_daily_data(self, *, code: str, analysis_date: date, eval_window_days: int) -> None:
|
||||
refill_code = self._normalize_daily_refill_code(code)
|
||||
if not refill_code:
|
||||
return
|
||||
|
||||
try:
|
||||
from data_provider.base import DataFetcherManager
|
||||
|
||||
@@ -915,16 +946,16 @@ class BacktestService:
|
||||
end_date = analysis_date + timedelta(days=max(eval_window_days * 2, 30))
|
||||
manager = DataFetcherManager()
|
||||
df, source = manager.get_daily_data(
|
||||
stock_code=code,
|
||||
stock_code=refill_code,
|
||||
start_date=analysis_date.strftime("%Y-%m-%d"),
|
||||
end_date=end_date.strftime("%Y-%m-%d"),
|
||||
days=eval_window_days * 2,
|
||||
)
|
||||
if df is None or df.empty:
|
||||
return
|
||||
self.db.save_daily_data(df, code=code, data_source=source)
|
||||
self.db.save_daily_data(df, code=refill_code, data_source=source)
|
||||
except Exception as exc:
|
||||
logger.warning(f"补全日线数据失败({code}): {exc}")
|
||||
logger.warning(f"补全日线数据失败({refill_code}): {exc}")
|
||||
|
||||
def _recompute_summaries(self, *, touched_codes: List[str], eval_window_days: int, engine_version: str) -> None:
|
||||
with self.db.get_session() as session:
|
||||
|
||||
@@ -117,6 +117,9 @@ class TestNormalizeStockCode(unittest.TestCase):
|
||||
self.assertEqual(normalize_stock_code("sh.600519"), "600519")
|
||||
self.assertEqual(normalize_stock_code("SZ.000001"), "000001")
|
||||
self.assertEqual(normalize_stock_code("sz.000001"), "000001")
|
||||
self.assertEqual(normalize_stock_code("SS600519"), "600519")
|
||||
self.assertEqual(normalize_stock_code("SS.600519"), "600519")
|
||||
self.assertEqual(normalize_stock_code("600519.SS"), "600519")
|
||||
self.assertEqual(normalize_stock_code("BJ.920748"), "920748")
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ import unittest
|
||||
from datetime import date, datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from data_provider.base import normalize_stock_code
|
||||
from src.config import Config
|
||||
from src.core.backtest_engine import OVERALL_SENTINEL_CODE
|
||||
from src.repositories.backtest_repo import BacktestRepository
|
||||
@@ -558,6 +561,59 @@ class BacktestServiceTestCase(unittest.TestCase):
|
||||
us_suffix_variants = BacktestRepository._build_market_code_variants("AAPL.US", "AAPL.US")
|
||||
self.assertIn("AAPL", us_suffix_variants)
|
||||
|
||||
def test_daily_refill_codes_normalize_legacy_ss_aliases_once(self) -> None:
|
||||
candidates = BacktestService._build_daily_code_candidates("605066.SH")
|
||||
|
||||
self.assertIn("SS605066", candidates)
|
||||
self.assertEqual(normalize_stock_code("SS605066"), "605066")
|
||||
for alias in ("605066.SH", "605066", "SH605066", "SH.605066", "605066.SS", "SS.605066"):
|
||||
with self.subTest(alias=alias):
|
||||
self.assertEqual(BacktestService._normalize_daily_refill_code(alias), "605066")
|
||||
self.assertEqual(
|
||||
BacktestService._ordered_daily_refill_codes(
|
||||
code_candidates=candidates,
|
||||
preferred_code="605066.SH",
|
||||
),
|
||||
["605066"],
|
||||
)
|
||||
|
||||
def test_try_fill_daily_data_uses_normalized_a_share_code_for_legacy_ss_alias(self) -> None:
|
||||
requested_codes = []
|
||||
|
||||
class FakeDataFetcherManager:
|
||||
def get_daily_data(self, stock_code, start_date=None, end_date=None, days=30):
|
||||
requested_codes.append(stock_code)
|
||||
return (
|
||||
pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"date": date(2024, 7, 1),
|
||||
"open": 10.0,
|
||||
"high": 11.0,
|
||||
"low": 9.0,
|
||||
"close": 10.5,
|
||||
"volume": 1000,
|
||||
}
|
||||
]
|
||||
),
|
||||
"FakeFetcher",
|
||||
)
|
||||
|
||||
service = BacktestService(self.db)
|
||||
with patch("data_provider.base.DataFetcherManager", FakeDataFetcherManager):
|
||||
service._try_fill_daily_data(
|
||||
code="SS605066",
|
||||
analysis_date=date(2024, 7, 1),
|
||||
eval_window_days=1,
|
||||
)
|
||||
|
||||
self.assertEqual(requested_codes, ["605066"])
|
||||
self.assertEqual(
|
||||
[row.code for row in self.db.get_data_range("605066", date(2024, 7, 1), date(2024, 7, 1))],
|
||||
["605066"],
|
||||
)
|
||||
self.assertEqual(self.db.get_data_range("SS605066", date(2024, 7, 1), date(2024, 7, 1)), [])
|
||||
|
||||
def test_build_market_code_variants_rejects_hk_suffix_with_6_digit_base(self) -> None:
|
||||
invalid_variants = BacktestRepository._build_market_code_variants("600519.HK", "600519.HK")
|
||||
self.assertNotIn("600519", invalid_variants)
|
||||
|
||||
Reference in New Issue
Block a user