diff --git a/data_provider/baostock_fetcher.py b/data_provider/baostock_fetcher.py index 73f79188b..a1d2ff7d5 100644 --- a/data_provider/baostock_fetcher.py +++ b/data_provider/baostock_fetcher.py @@ -155,9 +155,9 @@ class BaostockFetcher(BaseFetcher): return raw_code.lower() exchange_hint = None - if upper.endswith(('.SH', '.SS')): + if upper.startswith(('SH', 'SS')) or upper.endswith(('.SH', '.SS')): exchange_hint = 'sh' - elif upper.endswith('.SZ'): + elif upper.startswith('SZ') or upper.endswith('.SZ'): exchange_hint = 'sz' code = normalize_stock_code(raw_code) diff --git a/data_provider/base.py b/data_provider/base.py index 656811fc5..8204bfd92 100644 --- a/data_provider/base.py +++ b/data_provider/base.py @@ -71,8 +71,11 @@ def normalize_stock_code(stock_code: str) -> str: Accepted formats and their normalized results: - '600519' -> '600519' (already clean) - 'SH600519' -> '600519' (strip SH prefix) + - 'SH.600519' -> '600519' (strip SH. prefix) - 'SZ000001' -> '000001' (strip SZ prefix) + - 'SZ.000001' -> '000001' (strip SZ. prefix) - 'BJ920748' -> '920748' (strip BJ prefix, BSE) + - 'BJ.920748' -> '920748' (strip BJ. prefix, BSE) - 'sh600519' -> '600519' (case-insensitive) - '600519.SH' -> '600519' (strip .SH suffix) - '000001.SZ' -> '000001' (strip .SZ suffix) @@ -100,12 +103,24 @@ def normalize_stock_code(stock_code: str) -> str: 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.')): + candidate = code[3:] + if candidate.isdigit() and len(candidate) in (5, 6): + return candidate + # Strip BJ prefix (e.g. BJ920748 -> 920748) if upper.startswith('BJ') and not upper.startswith('BJ.'): candidate = code[2:] if candidate.isdigit() and len(candidate) == 6: return candidate + # Strip dotted BJ prefix (e.g. BJ.920748 -> 920748) + if upper.startswith('BJ.'): + candidate = code[3:] + if candidate.isdigit() and len(candidate) == 6: + return candidate + # Strip .SH/.SZ/.BJ suffix (e.g. 600519.SH -> 600519, 920748.BJ -> 920748) if '.' in code: base, suffix = code.rsplit('.', 1) diff --git a/data_provider/tushare_fetcher.py b/data_provider/tushare_fetcher.py index a6596ef87..37138d03b 100644 --- a/data_provider/tushare_fetcher.py +++ b/data_provider/tushare_fetcher.py @@ -398,11 +398,11 @@ class TushareFetcher(BaseFetcher): return f"{code}.BJ" # Regular stocks - # Shanghai: 600xxx, 601xxx, 603xxx, 688xxx (STAR Market) - # Shenzhen: 000xxx, 002xxx, 300xxx (ChiNext) - if code.startswith(('600', '601', '603', '688')): + # Shanghai: 600xxx, 601xxx, 603xxx, 605xxx, 688xxx (STAR Market) + # Shenzhen: 000xxx, 001xxx, 002xxx, 003xxx, 300xxx, 301xxx (ChiNext) + if code.startswith(('600', '601', '603', '605', '688')): return f"{code}.SH" - elif code.startswith(('000', '002', '300')): + elif code.startswith(('000', '001', '002', '003', '300', '301')): return f"{code}.SZ" else: logger.warning(f"无法确定股票 {code} 的市场,默认使用深市") diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f24dd62ec..36dca3f52 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -14,8 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - [改进] `scripts/fetch_tushare_stock_list.py` 可对 A 股中带 `XD`/`XR`/`DR`/`N`/`C` 前缀的名称进行回填修正,供自动补全刷新流程默认使用。 - [修复] 股票自动补全索引生成缺少 `pypinyin` 时改为直接失败,避免写出缺失拼音字段的降级索引。 - [修复] 归一腾讯实时行情成交量为股口径,避免量能变化倍数被放大并误导分析报告。 +- [文档] 新增 #1391 Phase 0 运行诊断契约文档,明确 trace_id、诊断摘要、关键链路范围与脱敏/fail-open/retention 边界。 - [改进] Web 路由页面改为按需加载,降低首包体积并增加路由加载失败恢复提示。 - [修复] Docker 默认部署移除 `.env` 单文件挂载,避免 WebUI 保存配置时因 `os.replace` 更新挂载点触发 `Device or resource busy`。 +- [修复] 收敛 #1391 Phase 0 A 股代码归属边界:补齐 `SH`/`SZ` 前缀场景的归属一致性,明确 `data_provider/baostock_fetcher.py`、`data_provider/pytdx_fetcher.py`、`data_provider/tushare_fetcher.py` 的本轮修复范围。 - [改进] Web 完整报告 Markdown 抽屉改为按需加载。 - [改进] 新增市场阶段推断基线并明确盘前、盘中、午休、临近收盘、盘后和非交易日语义。 - [新功能] 告警中心新增 P7 大盘红绿灯结构化规则,支持 `market_light_status` 与 `market_light_score_drop` 并复用现有 worker、触发历史、通知和冷却链路。 diff --git a/docs/run-diagnostics-p0.md b/docs/run-diagnostics-p0.md new file mode 100644 index 000000000..b90d151ca --- /dev/null +++ b/docs/run-diagnostics-p0.md @@ -0,0 +1,100 @@ +# 运行诊断与数据可靠性 1.0(Phase 0) + +本文档定义 #1391 的 **Phase 0(P0)**:在不引入新页面、不改变全局分析策略与 fallback 核心语义前提下,收敛契约边界并限定本轮运行时修复范围。 + +## 目标 + +- 给后续实现提供统一术语:`trace_id`、关键链路记录、诊断摘要、脱敏排障信息。 +- 明确第一阶段范围,避免把需求扩成“完整可观测平台”。 +- 固化 fail-open、安全与 retention 基线,降低回归风险。 + +## 当前文档范围(本轮) + +- 本文件为 Phase 0 合同与验收边界文档,当前 PR 为 docs + runtime fix,本轮同步补齐 `baostock_fetcher.py`、`pytdx_fetcher.py`、`tushare_fetcher.py` 的 A 股代码归属边界,并配套由 `tests/test_a_share_fetcher_code_conversion.py` 做回归验证。 +- 归属边界必须覆盖裸码与前缀码(如 `000001`、`000001.SZ`、`SH000001`、`SH.000001`、`SZ000001`、`SZ.000001`),避免把 SH/SZ 前缀语义误归类。 +- 若无新增 LLM 相关 provider/model/Base URL 语义迁移需求,本轮收敛 Tushare A 股归属范围至:`600/601/603/605/688`、`000/001/002/003/300/301`,并同步回归 `605`、`001`、`003`、`301` 场景;该范围变更不视为 provider 配置/路由策略扩展。 + +## 非目标 + +- 不做 OpenTelemetry / APM / Grafana 风格监控系统。 +- 不在首版展示 p95、全量 Provider 调用明细、完整运维面板。 +- 不改变现有数据源优先级、分析策略、通知策略。 +- 不变更 LLM provider 列表、Base URL、`llm_call` 运行时参数、`REPORT_*` 配置语义与迁移路径;本轮改动限定在 A 股代码归属解析与诊断字段边界。 + +### 验收边界(本轮) + +- 本轮为 `fix`(docs + runtime fix),变更仅收敛 A 股代码归属语义,不改 provider 列表、Base URL、`llm_call` 运行时语义与 `REPORT_*` 配置迁移路径。 +- `data_provider/baostock_fetcher.py`、`data_provider/pytdx_fetcher.py`、`data_provider/tushare_fetcher.py` 本轮只处理: + - 裸码与后缀码:`000001`、`000001.SH`、`000001.SZ` + - 前缀码:`SH000001`、`SH.000001`、`SZ000001`、`SZ.000001` +- `SH000001`/`SH.000001`/`SZ000001`/`SZ.000001` 场景为 correctness blocker,需由 `tests/test_a_share_fetcher_code_conversion.py` 覆盖回归。 +- 回归最小口径为 `python -m pytest tests/test_a_share_fetcher_code_conversion.py` 与 `./scripts/ci_gate.sh`,并在 PR 描述同步结果与阻塞。 +- 回滚优先级为恢复本轮三文件变更到合并前提交;其余范围不应一并回退。 + +## 术语与契约(P0 草案) + +### 1) `trace_id` + +- 含义:一次分析运行链路的统一关联 ID。 +- 要求: + - 每次分析任务仅有一个 `trace_id`。 + - 可由入口生成,或由已有任务 ID 映射(例如 Web 任务)。 + - 出现在日志/结构化诊断中用于排障关联。 + +### 2) `RunDiagnosticSummary` + +- 含义:给用户看的简短运行诊断摘要。 +- 建议字段(首版保持最小): + - `trace_id` + - `status`:`ok` / `degraded` / `failed` + - `data_status`:关键数据路径是否降级 + - `notify_status`:通知结果摘要 + - `error_hint`:脱敏后的简要原因 +- 说明:这是用户可感知能力,不等于内部全量事件日志。 + +### 3) 关键链路记录(最小集合) + +首版只要求记录以下关键节点结果(成功/失败/降级 + 简短原因): + +- `realtime_quote` +- `daily_data` +- `llm_call` +- `report_persist` +- `notification_dispatch` + +> 说明:`news`、`fundamental`、`capital_flow` 等放到后续扩展,不作为首版阻断项。 + +## 安全与稳定性边界(P0 必须遵守) + +### Fail-open + +- 诊断记录失败不应阻断主分析流程。 +- 即使诊断写入失败,也必须继续产出分析结果(除非主流程本身失败)。 + +### 脱敏 + +- 复制排障信息中禁止包含密钥、token、完整 webhook URL、用户账号标识。 +- 错误文案输出以摘要为主,避免泄露第三方返回的敏感原文。 + +### Retention + +- 诊断数据保留周期应可配置或可统一清理。 +- 默认策略优先保守(例如仅保留必要时间窗),避免无限增长。 + +### 兼容性 + +- 新字段应优先追加,不破坏现有 API / Web / Desktop 读取路径。 +- 旧历史记录缺少新字段时应可安全回退。 + +## Phase 0 交付清单 + +- [x] 明确目标/非目标,防止范围失控。 +- [x] 定义 `trace_id` 与 `RunDiagnosticSummary` 最小契约。 +- [x] 明确首版关键链路覆盖范围。 +- [x] 固化 fail-open、脱敏、retention、兼容性基线。 + +## 后续阶段(仅说明,不在 P0 实现) + +- Phase 1:`trace_id` 贯通与关键链路最小记录落地。 +- Phase 2:生成并持久化 `RunDiagnosticSummary`,支持复制脱敏排障信息。 +- Phase 3:Web 侧最小展示(默认折叠),并补齐文档和回滚说明。 diff --git a/tests/test_a_share_fetcher_code_conversion.py b/tests/test_a_share_fetcher_code_conversion.py index bf13c856a..dcdec60a3 100644 --- a/tests/test_a_share_fetcher_code_conversion.py +++ b/tests/test_a_share_fetcher_code_conversion.py @@ -7,11 +7,10 @@ import unittest import pandas as pd -from data_provider.base import DataFetcherManager -from data_provider.base import normalize_stock_code +from data_provider.base import DataFetcherManager, normalize_stock_code from data_provider.baostock_fetcher import BaostockFetcher -from data_provider.tushare_fetcher import TushareFetcher from data_provider.pytdx_fetcher import PytdxFetcher +from data_provider.tushare_fetcher import TushareFetcher class _RecordingDailyFetcher: @@ -64,6 +63,15 @@ class TestBaostockAShareCodeConversion(unittest.TestCase): self.assertEqual(fetcher._convert_stock_code("600519.SH"), "sh.600519") self.assertEqual(fetcher._convert_stock_code("000001.SZ"), "sz.000001") + def test_convert_prefix_code_preserves_explicit_exchange_hint(self) -> None: + fetcher = BaostockFetcher() + + self.assertEqual(fetcher._convert_stock_code("SH000001"), "sh.000001") + self.assertEqual(fetcher._convert_stock_code("SH.000001"), "sh.000001") + self.assertEqual(fetcher._convert_stock_code("SZ600519"), "sz.600519") + self.assertEqual(fetcher._convert_stock_code("SZ.600519"), "sz.600519") + self.assertEqual(fetcher._convert_stock_code("ss.600519"), "sh.600519") + class TestPytdxAShareCodeConversion(unittest.TestCase): def test_get_market_code_for_bare_stock_codes(self) -> None: @@ -74,23 +82,33 @@ class TestPytdxAShareCodeConversion(unittest.TestCase): self.assertEqual(fetcher._get_market_code("000001"), (0, "000001")) self.assertEqual(fetcher._get_market_code("300750"), (0, "300750")) - def test_get_market_code_for_prefixed_codes(self) -> None: + def test_get_market_code_preserves_explicit_exchange_hint(self) -> None: fetcher = PytdxFetcher(hosts=[]) - self.assertEqual(fetcher._get_market_code("SH.600519"), (1, "600519")) - self.assertEqual(fetcher._get_market_code("sh.600519"), (1, "600519")) - self.assertEqual(fetcher._get_market_code("SZ.000001"), (0, "000001")) + self.assertEqual(fetcher._get_market_code("SH000001"), (1, "000001")) + self.assertEqual(fetcher._get_market_code("SH.000001"), (1, "000001")) + self.assertEqual(fetcher._get_market_code("SZ600519"), (0, "600519")) + self.assertEqual(fetcher._get_market_code("SZ.600519"), (0, "600519")) self.assertEqual(fetcher._get_market_code("ss.600519"), (1, "600519")) class TestTushareAShareCodeConversion(unittest.TestCase): - def test_convert_prefixed_dot_code_to_tushare_format(self) -> None: + def test_convert_bare_stock_codes_to_tushare_format(self) -> None: fetcher = TushareFetcher() - self.assertEqual(fetcher._convert_stock_code("SH.600519"), "600519.SH") - self.assertEqual(fetcher._convert_stock_code("sh.600519"), "600519.SH") - self.assertEqual(fetcher._convert_stock_code("SZ.000001"), "000001.SZ") - self.assertEqual(fetcher._convert_stock_code("sz.000001"), "000001.SZ") + self.assertEqual(fetcher._convert_stock_code("605499"), "605499.SH") + self.assertEqual(fetcher._convert_stock_code("001979"), "001979.SZ") + self.assertEqual(fetcher._convert_stock_code("003816"), "003816.SZ") + self.assertEqual(fetcher._convert_stock_code("301012"), "301012.SZ") + + def test_convert_prefix_code_preserves_explicit_exchange_hint(self) -> None: + fetcher = TushareFetcher() + + self.assertEqual(fetcher._convert_stock_code("SH000001"), "000001.SH") + self.assertEqual(fetcher._convert_stock_code("SH.000001"), "000001.SH") + self.assertEqual(fetcher._convert_stock_code("SZ600519"), "600519.SZ") + self.assertEqual(fetcher._convert_stock_code("SZ.600519"), "600519.SZ") + self.assertEqual(fetcher._convert_stock_code("ss.600519"), "600519.SH") class TestNormalizeStockCode(unittest.TestCase): @@ -99,6 +117,7 @@ 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("BJ.920748"), "920748") if __name__ == "__main__":