fix: add #1391 Phase 0 diagnostics and A-share code guards (#1435)

* fix(issue-1412): [bug]-stock_list格式问题

* fix(review-feedback-1413): preserve exchange hint for dotted A-share inputs

* fix(review-feedback-1413): Keep normalized A-share codes usable by market routing and preserve

* fix(review-feedback-1413): Limit raw dotted codes to fetchers that can parse them

* fix(review-feedback-1413): Keep Tushare daily input normalized for ETF detection

* fix(review-feedback-1413): 澄清结构化检测中的外部模型/API 与运行时配置迁移风险

* fix(review-feedback-1413): 处理或明确确认该失败与本 PR 无关且已有维护者豁免依据

* fix: keep stock list input as bare codes

* docs: add phase-0 run diagnostics contract

* fix(review-feedback-1435): 修正描述并澄清/补齐运行时代码变更的验证证据

* fix(review-feedback-1435): 补齐前缀提示识别,并增加对应回归测试

* fix(review-feedback-1435): 修正

* fix(review-feedback-1435): 解决冲突并更新描述/验证记录后再合入

* fix(review-feedback-1435): 解决冲突,并在最终 head 上重新确认 python -m pytest tests/test a share fetcher code

* fix(review-feedback-1435): 修复并补齐回归覆盖后再复核最终 head

* fix(review-feedback-1435): data provider/baostock fetcher.py 的 convert stock code 只从 .SH/.SS/.SZ

* fix: preserve A-share exchange hints

* fix(review-feedback-1435): 修正 docs/run-diagnostics-p0.md 对 Tushare 本轮范围的矛盾描述
This commit is contained in:
mumu
2026-05-25 20:38:22 +08:00
committed by GitHub
parent fcfcd3a5b1
commit 09afbf566f
6 changed files with 154 additions and 18 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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} 的市场,默认使用深市")

View File

@@ -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、触发历史、通知和冷却链路。

100
docs/run-diagnostics-p0.md Normal file
View File

@@ -0,0 +1,100 @@
# 运行诊断与数据可靠性 1.0Phase 0
本文档定义 #1391**Phase 0P0**:在不引入新页面、不改变全局分析策略与 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 3Web 侧最小展示(默认折叠),并补齐文档和回滚说明。

View File

@@ -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__":