From b954b02e75be8277227f34644dda40b917d8ad44 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Mon, 5 Oct 2026 15:54:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(monitor):=20=E7=9B=91=E6=8E=A7=E7=8F=AD?= =?UTF-8?q?=E6=AC=A1=E4=BA=8C=20P2=20=E6=89=B9=E2=80=94=E2=80=94D-5=20db?= =?UTF-8?q?=5Fsample=20=E6=97=A5=E5=8E=86=E4=B8=8D=E5=8F=AF=E8=BE=BE?= =?UTF-8?q?=E5=8E=BB=20fail-open(=E6=A0=B7=E6=9C=AC=E5=8F=AF=E8=AF=BB?= =?UTF-8?q?=E5=BC=B1=E6=A3=80/=E5=BA=93=E4=B8=8D=E5=8F=AF=E8=BE=BE?= =?UTF-8?q?=E9=BB=84)+D-6=20month=5Fbucket=20=E6=8D=9F=E5=9D=8F=E4=BB=B6?= =?UTF-8?q?=20rows=3DNone=20=E9=BB=84(=E5=AF=B9=E9=BD=90=20per=5Fdate)+D-8?= =?UTF-8?q?=20check=5Fdisk=20=E9=98=88=E5=80=BC=E5=8F=82=E6=95=B0=E5=8C=96?= =?UTF-8?q?=E6=B6=88=E8=B4=B9=E6=B3=A8=E5=86=8C=E8=A1=A8=20warn=5Fgb/crit?= =?UTF-8?q?=5Fgb(=E6=97=A7=E7=A1=AC=E7=BC=96=E7=A0=81=208/20=3D=E6=94=B9?= =?UTF-8?q?=E8=A1=A8=E4=B8=8D=E6=94=B9=E8=A1=8C=E4=B8=BA=E6=A0=A1=E5=87=86?= =?UTF-8?q?=E9=99=B7=E9=98=B1,crit=5Fgb=20=E5=85=A8=E4=BB=93=E6=97=A0?= =?UTF-8?q?=E6=B6=88=E8=B4=B9)+D-9=20vintage/NAS=20ts=20fromisoformat=20?= =?UTF-8?q?=E6=8D=95=20TypeError+D-10=20db=5Fsample=20close=20=E8=BF=9B=20?= =?UTF-8?q?finally+AL-1=20upsert=20=E6=9B=B4=E6=96=B0=20severity(=E5=90=8C?= =?UTF-8?q?=E6=97=A5=E9=BB=84=E2=86=92=E7=BA=A2=E5=8D=87=E7=BA=A7=E5=8D=B3?= =?UTF-8?q?=E6=97=B6=E8=90=BD=E8=A1=A8,=E6=97=A7=20UPDATE=20=E4=B8=8D?= =?UTF-8?q?=E5=8A=A8=3D=E6=AC=A1=E6=97=A5=E6=96=B0=20id=20=E6=89=8D?= =?UTF-8?q?=E7=BA=A0=E6=AD=A3)+purge=20=E6=94=B6=E5=8F=A3=20MON-202610-04(?= =?UTF-8?q?open=20=E9=BB=84=E6=94=B9=20last=5Fseen=2028d=3D=E6=B4=BB?= =?UTF-8?q?=E7=9D=80=E4=B8=8D=E5=88=A0,=E6=97=A7=20first=5Fseen=2014d=20?= =?UTF-8?q?=E7=A1=AC=E5=88=A0=E6=97=A0=E5=A3=B0=E6=8A=B9=E6=8C=81=E7=BB=AD?= =?UTF-8?q?=E9=BB=84+=E6=96=AD=20count=20=E5=8D=87=E7=BA=A7=E9=93=BE;resol?= =?UTF-8?q?ved=20=E7=BB=9F=E4=B8=80=2030d,open=20=E7=BA=A2=E6=B0=B8?= =?UTF-8?q?=E4=B8=8D=E5=88=A0)+N-1=20run=5Fmonitor=5Fnas.sh=20rc=20?= =?UTF-8?q?=E5=85=88=E5=AD=98=E5=90=8E=E6=8B=BC($(date)=20=E5=90=8C?= =?UTF-8?q?=E8=AF=8D=E5=85=88=E5=B1=95=E5=BC=80=E9=87=8D=E7=BD=AE=20$=3F?= =?UTF-8?q?=3Dexit=20=E6=81=92=200,rc=20=E9=9A=94=E7=A6=BB=E4=B8=8B=20NAS?= =?UTF-8?q?=20=E5=B4=A9=E6=BA=83=E5=94=AF=E4=B8=80=E7=8E=B0=E5=9C=BA?= =?UTF-8?q?=E7=BA=BF=E7=B4=A2)+N-2=20NAS=20=E6=97=A5=E5=8E=86=E8=B1=81?= =?UTF-8?q?=E5=85=8D=E7=9F=AD=E6=9C=9F=E6=96=B9=E6=A1=88(last=5Fweekday=20?= =?UTF-8?q?=E6=8E=A5=E4=BC=91=E5=B8=82=E8=A1=A8=E9=95=9C=E5=83=8F+CI=20?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=B5=8B=E9=92=89=E4=B8=8E=20monitor=5Fregis?= =?UTF-8?q?try=20=E7=9B=B8=E7=AD=89=3D7f9b413=20=E9=95=9C=E5=83=8F?= =?UTF-8?q?=E7=BA=AA=E5=BE=8B;10-06=20=E6=97=A7=E7=A0=81=E4=BC=9A=E6=8A=8A?= =?UTF-8?q?=2010-05=20=E5=81=87=E6=97=A5=E5=BD=93=E6=98=A8=E4=BA=A4?= =?UTF-8?q?=E6=98=93=E6=97=A5;corpus=2034h=20=E6=97=A5=E5=8E=86=E6=97=A5?= =?UTF-8?q?=E8=8A=82=E5=A5=8F=E5=AE=9E=E8=AF=81=E6=97=A0=E9=9C=80=E8=B1=81?= =?UTF-8?q?=E5=85=8D)+P3=20=E5=BD=92=E5=9B=A0([FATAL]=20=E7=BA=A7=E5=88=AB?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E4=B8=8D=E5=BD=93=E7=B1=BB=E5=9E=8B=E5=BD=92?= =?UTF-8?q?=20=5Fglobal,scan=5Fsignatures=20=E6=B3=A8=E8=AE=B0=20test-only?= =?UTF-8?q?=20=E5=8B=BF=E6=8E=A5=E7=BA=BF)+T-2=20NAS=20red=20=E5=88=86?= =?UTF-8?q?=E6=94=AF(hot=5Frank=20=E6=96=87=E4=BB=B6=E7=BC=BA)=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E8=A1=A5=E7=BC=BA+migration=5Flogs=20=E4=BF=9D?= =?UTF-8?q?=E6=B4=81(data=5Fmonitor=5Fwrapper=20=E5=8F=8C=E7=8F=AD?= =?UTF-8?q?=E5=89=AA=2014d=20=E5=89=8D=E6=97=A7=E4=BB=B6,13=20wrapper=20?= =?UTF-8?q?=E6=97=A0=E9=99=90=E5=A2=9E=E9=95=BF=E6=94=B6=E5=8F=A3);monitor?= =?UTF-8?q?ing=20spec=20=C2=A75.1/=C2=A75.2/=C2=A75.3-=E2=91=A3/=C2=A75.4/?= =?UTF-8?q?=C2=A75.5-=E2=91=A3/=C2=A77.6/=C2=A712=20=E9=9A=8F=E7=A0=81(C-A?= =?UTF-8?q?1=20universe=20=E5=BC=82=E5=B8=B8=E5=AE=9E=E4=B8=B2/C-A2=20?= =?UTF-8?q?=E7=AD=BE=E5=90=8D=E8=A1=A8=E5=8B=98=E6=AD=A3=E8=A1=A5=20Expect?= =?UTF-8?q?ing=20value/C-A6=20disk=20=E9=98=88=E5=80=BC=E8=A1=A8=E8=BF=B0+?= =?UTF-8?q?=E9=94=9A=E7=82=B9=E8=A1=8C=E6=95=B0=E5=88=B7=E6=96=B0)=20[vps]?= =?UTF-8?q?=20[nas]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/2026-10-03-monitoring-design.md | 28 +++++----- sanguo_data/alerts_store.py | 25 ++++++--- scripts/data_platform/data_monitor.py | 34 +++++++++---- .../data_platform/data_monitor_wrapper.ps1 | 6 +++ scripts/data_platform/monitor_checks.py | 20 ++++++-- scripts/nas_sync/nas_monitor_check.py | 27 ++++++++-- scripts/nas_sync/run_monitor_nas.sh | 5 +- tests/data_platform/test_alerts_store.py | 36 ++++++++++--- tests/data_platform/test_data_monitor.py | 51 +++++++++++++++++++ tests/data_platform/test_monitor_checks.py | 24 +++++++++ tests/data_platform/test_nas_monitor_check.py | 25 ++++++++- 11 files changed, 232 insertions(+), 49 deletions(-) diff --git a/docs/superpowers/specs/2026-10-03-monitoring-design.md b/docs/superpowers/specs/2026-10-03-monitoring-design.md index 3cd6f96f..149f4edf 100644 --- a/docs/superpowers/specs/2026-10-03-monitoring-design.md +++ b/docs/superpowers/specs/2026-10-03-monitoring-design.md @@ -221,7 +221,7 @@ kind 语义(检查分发依据,check_entry data_monitor.py:56-232): | vintage_json | `static/vintage_status.json` | checked_at≤26h + `tables.*.holes_permanent/backfillable` 挖洞数(黄) | | static_exists | 目录含 parquet | 存在性(黄) | | log_only | 无文件 | 仅过程层统计行存在且 failed=0(§5.2 ②;周班项平日无行=正常跳过) | -| disk | — | 数据根剩余 ≥20G 黄 / <8G 红(key=`infra-disk-data`) | +| disk | — | 数据根剩余 <8G 红 / <20G 黄 / ≥20G 绿(key=`infra-disk-data`;阈值=注册表 `warn_gb`/`crit_gb`,D-8 起被 `check_disk` 真消费——旧「≥20G 黄」为笔误,10-05 C-A6 勘正) | **42 项明细**(row_band=(floor, fallback_hi) 语义见 §5.6;生产班次见 fusion §20 调度表): @@ -240,7 +240,7 @@ kind 语义(检查分发依据,check_entry data_monitor.py:56-232): ### §5.2 四层证据链与分类学(fusion §20.15.2 迁入,10-03 起权威在此) 「完全与否」四层(弱→强,**以④为准**): - ①任务 exit 码(类型级失败不抬码,最弱) -- ②类型级统计行 `{"ok":N,"failed":M,"rows":R}` 解析+**失败签名分诊**(monitor_checks.py:185)——**10-05 三修**:①只扫 `ERROR`/[FATAL] 行(重试 WARNING 的 `Expecting value` 良性不再误中 source_changed)②针收紧 `418 Client Error`(裸 418 子串误中 rows=41823 进度行=anti_crawl 假阳性实证)③**签名按日志行 `[type]` 归因**(`signatures_by_type`)——他类型的反爬签名不再把本类型 failed 抬红(跨类型连坐修):`RemoteDisconnected`=断连族红 / `NoneType`=源当日未出黄 / `418 Client Error`=反爬红 / `KeyError/TypeError`=源字段变更红(09-27 P2 起字段形状类 1 次即抛 AkShapeError 豁免断路器;**10-05 起 share_capital 次新股『公告日期』族归 empty 桶不再 failed**,见 §9.B-21)/ 断路器触发=熔断连累红(独立键 `data-akshare-breaker`,exit 2 会话,全局取) +- ②类型级统计行 `{"ok":N,"failed":M,"rows":R}` 解析+**失败签名分诊**(`_error_lines` monitor_checks.py:190 / `signatures_by_type` :216)——**10-05 三修**:①只扫 `ERROR`/[FATAL] 行(重试 WARNING 的 `Expecting value` 良性不再误中 source_changed)②针收紧 `418 Client Error`(裸 418 子串误中 rows=41823 进度行=anti_crawl 假阳性实证)③**签名按日志行 `[type]` 归因**(`signatures_by_type`)——他类型的反爬签名不再把本类型 failed 抬红(跨类型连坐修):`RemoteDisconnected`=断连族红 / `NoneType`=源当日未出黄 / `418 Client Error`=反爬红 / `Expecting value`=源接口形状变更红(source_changed)/ `KeyError`=源字段变更红(field_changed;09-27 P2 起字段形状类 1 次即抛 AkShapeError 豁免断路器;**10-05 起 share_capital 次新股『公告日期』族归 empty 桶不再 failed**,见 §9.B-21)/ 断路器触发=熔断连累红(独立键 `data-akshare-breaker`,exit 2 会话,全局取;**班次二 P3 补**:`[FATAL]` 是级别标记非类型——裸 FATAL 行归 `_global`,`[type] [FATAL]` 双标记行归 `[type]`;`scan_signatures` 生产路径已无调用方=仅测试消费注记) - ③marker 按日比对(标了≠数据对,0×0 案) - ④数据本体(存在+行数带宽+schema+值域) @@ -248,27 +248,29 @@ kind 语义(检查分发依据,check_entry data_monitor.py:56-232): **10-05 监控三 P1(审计 D-1/2/3)**:①**D-1 归并语义**=`collect_log_layer` 跨日志统计行改 **mtime 序后到运行整行胜出**(旧 `max(rows)` 归并=早班 rows=100/failed=0 压住后到 rows=50/failed=3 → 漏报+既有 `data-X-failed` 告警被假 resolve 双失效);②**D-2 整班崩溃网**=main 顶层兜底(裸崩溃时写 red health+`infra-monitor-crash` 告警后 exit 3)+`check_entry` 单检隔离(抛错=黄降级 `data-X-stale` 不阻断全班,对齐 strategy 侧先例)——旧形态=裸传播+wrapper 恒 0+SCHTASKS 年龄判定失明,最坏 ~22h 仅 peer 反盯;③**D-3 日历读侧归一**=`trading_days` 返回改 `[:10]` 截断+None 过滤+sorted(对齐 strategy_checks 同名版,双实现分叉收敛;dbbardata datetime 历史双格式,写侧归一不担保读侧形状)。 +**10-05 班次二 P2 批(审计 D-5/6/8/9/10+AL-1+purge+N-1/N-2)**:①**D-5 db_sample 去 fail-open**=日历不可达(exp=None)不再无条件绿——弱检查=样本可读(对齐 per_date 存在性弱检),库也不可达=黄;②**D-6 month_bucket 损坏件黄**=`parquet_rows=None`(parquet 损坏/pyarrow 不可用)不再静默绿,对齐 per_date rows=None→黄;③**D-8 disk 阈值消费注册表**=`check_disk(path, warn_gb, crit_gb)` 参数化+disk 项传表值(旧硬编码 8/20 与注册表脱钩、crit_gb 全仓无消费=改表不改行为的校准陷阱);④**D-9 vintage TypeError**=`fromisoformat` 捕 `(ValueError, TypeError)`(非字符串 checked_at 原会逸出到 D-2 隔离,黄走错 verdict 键;NAS 静默 ts 同型同修);⑤**D-10 db_sample close 进 finally**=异常路径连接不泄漏;⑥**AL-1 upsert 更新 severity**=同日黄→红升级即时落表(旧 UPDATE 不动 severity=次日新 id 才纠正,前端 open_red 角标少计一晚);⑦**purge 收口(MON-202610-04)**=open 黄改按 **last_seen 28d** 清——旧 first_seen 14d 硬删把「每日重 Sight 的持续黄」(first_seen 老/last_seen 新)无声从值班页抹掉+count 升级链断;last_seen 每次重 Sight 刷新=活着不删,僵死 28d 才清;resolved 统一 30d(resolved 黄原 14d-first_seen 通道删除)、open 红永不删;⑧**N-1**=run_monitor_nas.sh `rc=$?` 先存后拼(旧 `$(date)` 先展开把 `$?` 重置 0,exit= 恒 0=NAS 崩溃唯一现场线索失真);⑨**N-2 NAS 日历豁免(短期方案)**=`last_weekday` 接休市表镜像(`EXCHANGE_HOLIDAYS` 镜像 monitor_registry,CI 同步测钉两表相等=7f9b413 镜像纪律)——旧只剥周末,国庆连休窗 10-06 会把 10-05 假日当「昨交易日」(当前被 hot_rank producer 日历日节奏掩住、语义仍错;收口议=⚖️-3 monitor_common);**corpus 双 lane/xcheck 34h 无需豁免**(producer 日历日节奏=假期照采,10-01~05 分区连续实证);⑩**migration_logs 保洁**=data_monitor_wrapper.ps1 双班顺手剪 14 天前旧件(13 wrapper 各自追加无任何清理=无限增长;无解析消费的诊断残留);⑪T-2 补 NAS red 分支(hot_rank 昨交易日文件缺)+假日窗测试。 + **结果层缺失分级(09-26 中秋反转案定案)**:确认日缺失=红(跨域互证=真是交易日);未确认日缺失=黄(节假日/合并窗/源滞后三态容忍,evidence `unconfirmed_day=True`);**不因时间升红**(f86ed10 升红规则回滚案——时间基线会在连休把全 per_date 域连坐误红);真漏采兜底=确认集推进(复市后 exp 随 confirmed 前移→确认日红接手)+调度/过程层纵深分工。10-03 起(b24f905)已知假日直接零期望(§5.5),黄只剩「表外未知休市+源滞后」两态。 ### §5.3 两班调度与产物(五件套) - **①需求**:当晚红项当晚出(23:50 终检收当日全尾部)+晨间全量补过夜项(08:05 在 bs-daily 马拉松进行中、ak-daily 19:00 已收) - **②两班代码零分叉**:`--shift` 仅入 health 文件名与 `health["shift"]` 字段,无行为分支——「晨班自愈回看」不是独立代码路径,是同一套全量重评在 08:05 时点自然产生(昨夜红项已自愈则本班绿→`resolve_passed` 消解;alerts 层 26h 窗仍见昨夜红回声→由 checks_overall/alerts_overall 拆分消歧) - **③班次表**:`sanguo-data-monitor-morn` 08:05(晨检 agent 08:10 首读 health_latest.json)/ `sanguo-data-monitor-eve` 23:50;wrapper=`data_monitor_wrapper.ps1 -Shift `(清 proxy env+Set-Location 仓根+`-X utf8` 启动+日志落 `data/migration_logs/data_monitor_*.txt`+**恒 exit 0**) -- **④坐标**:main data_monitor.py:419(10-05 D-2 后=parse+`_run_shift`+`_crash_exit` 三段壳);write_health :391;rc `return 3 if overall=="red"` :531;测试口 `--skip-tasks`(跳过 schtasks 子进程)/`--skip-inbox`(跳过 NAS 摄取) +- **④坐标**:main data_monitor.py:461(10-05 D-2 后=parse+`_run_shift` :482+`_crash_exit` :586 三段壳);write_health :433;rc `return 3 if overall=="red"` :583;测试口 `--skip-tasks`(跳过 schtasks 子进程)/`--skip-inbox`(跳过 NAS 摄取) - **⑤坑**:schtasks 控制台 OEM/GBK 输出必须**字节捕获+手动 GBK 解码**(`-X utf8` 下 text=True 按 UTF-8 误解码→中文任务名/状态全乱→09-25 首跑 14 任务全假红,:475-482);判活 schtask 用 `Get-ScheduledTaskInfo`(CSV /nh 无 Last Result 列) ### §5.4 NAS monitor v2(五件套) - **①需求**:NAS 侧数据面(corpus/dmsk/hot_rank/mirror/xcheck/磁盘)VPS 看不到,各看各的(用户令①);NAS→VPS 推送通道现成、反向不通→**事件 JSON scp 到 VPS inbox、由 VPS data_monitor 摄取落库**(单写者纪律:NAS 不写远端 sqlite) -- **②链路**:宿主 bash `run_monitor_nas.sh`(挂 `sanguo-vps-mirror` 16:00 班尾)→ `nas_monitor_check.py`(宿主 python3.8 纯 stdlib)→ `out/nas_health_{day}.json`+`nas_events_{stamp}.json` → scp(必带 `-O`,ssh 必带 `-n` 防吃 stdin)→ `C:/sanguo_vnpy_v2/data/monitor/inbox/` → VPS 两班 `ingest_inbox` 摄取(data_monitor.py:361-386:events upsert→**green_keys 回传 `resolve_passed`**[09-26 补:NAS 绿检查必须回传否则 NAS 告警永不转绿]→文件挪 `inbox/processed/`) -- **③六族检查**(nas_monitor_check.py:120-214):corpus 双 lane(news_meta/ann_meta 分区新鲜 ≤34h)|dmsk 三表**季频锚**(最新 dt 分区 ≥ 最近已过披露截止季末:Q1→04-30/半年→**08-31**/三季→10-31/年报→次年 04-30,五域取 max;0928 改——旧 mtime 80h 对季频必假黄)|hot_rank 三查(昨交易日文件 red+`.pushed` marker+当日 cron.log 无 FAIL)|vps-mirror 完成度(≥17:00 查当日、班前查昨日 DONE fail=0)|xcheck findings 新鲜 ≤34h|磁盘 /volume1 水位 -- **④零事件也写**(`generated_at` 必带)→ VPS 侧静默检测:inbox 最新 ts >30h → `infra-nas-monitor-silent` 黄(data_monitor.py:488-503) -- **⑤坑/纪律**:runner 属**宿主生效位**(`/volume1/stock/monitor_nas/`)CI 不覆盖——改 repo 副本后须 `scp -O` 手动同步(09-28 纪律);DONE 行归属=遍历记 start 段(§9.B-1);`cur_day=None` 防错归日(1010 P3-11) +- **②链路**:宿主 bash `run_monitor_nas.sh`(挂 `sanguo-vps-mirror` 16:00 班尾)→ `nas_monitor_check.py`(宿主 python3.8 纯 stdlib)→ `out/nas_health_{day}.json`+`nas_events_{stamp}.json` → scp(必带 `-O`,ssh 必带 `-n` 防吃 stdin)→ `C:/sanguo_vnpy_v2/data/monitor/inbox/` → VPS 两班 `ingest_inbox` 摄取(data_monitor.py:403-428:events upsert→**green_keys 回传 `resolve_passed`**[09-26 补:NAS 绿检查必须回传否则 NAS 告警永不转绿]→文件挪 `inbox/processed/`) +- **③六族检查**(nas_monitor_check.py:141-235):corpus 双 lane(news_meta/ann_meta 分区新鲜 ≤34h——producer 日历日节奏=假期照采,34h 窗天然安全无需假日豁免,10-01~05 国庆分区连续实证)|dmsk 三表**季频锚**(最新 dt 分区 ≥ 最近已过披露截止季末:Q1→04-30/半年→**08-31**/三季→10-31/年报→次年 04-30,五域取 max;0928 改——旧 mtime 80h 对季频必假黄)|hot_rank 三查(昨交易日文件 red+`.pushed` marker+当日 cron.log 无 FAIL;**N-2 10-05 起 `last_weekday` 接休市表镜像**——旧只剥周末,连休窗把假日当昨交易日,被 producer 日历日节奏掩住;镜像表 CI 同步测钉与 monitor_registry 相等,表缺年 fail-safe 不扣)|vps-mirror 完成度(≥17:00 查当日、班前查昨日 DONE fail=0)|xcheck findings 新鲜 ≤34h|磁盘 /volume1 水位 +- **④零事件也写**(`generated_at` 必带)→ VPS 侧静默检测:inbox 最新 ts >30h → `infra-nas-monitor-silent` 黄(data_monitor.py:540-555) +- **⑤坑/纪律**:runner 属**宿主生效位**(`/volume1/stock/monitor_nas/`)CI 不覆盖——改 repo 副本后须 `scp -O` 手动同步(09-28 纪律);DONE 行归属=遍历记 start 段(§9.B-1);`cur_day=None` 防错归日(1010 P3-11);**rc 先存后拼**(N-1 10-05:`$(date)` 在同 echo 词内先展开会重置 `$?`,旧版 exit= 恒 0=崩溃唯一现场线索失真) ### §5.5 假黄休市表 EXCHANGE_HOLIDAYS(b24f905,五件套) - **①需求由来**:假定集=「近 15 日工作日」把国庆/春节连休的每个工作日都当期望日→三域(valuation_baostock/dbbardata_daily/dbbardata_index)连休天天假黄(0925 中秋折腾全天首案;10-03 联合甄别确认 10-05~08 还会天天黄)→ 用户令「现在落地别等 10-04」 - **②语义判据**:`_calendar_days(confirmed, now, lookback=15)`(data_monitor.py:402-417)——假定集只收「工作日 **且不在休市表**」;**表缺年=不扣**(fail-safe:黄照旧,绝不静默转绿——跨年不补表只丢本次收益不引入漏报);**确认集恒保留**(数据本体是真相,表错不吞真相);已知假日=零期望(当夜直接绿,不再走未确认黄) - **③参数与校准史**:2026 表 19 工作日=元旦 0101~02|春节 0216~20+0223|清明 0406|五一 0501/0504/05|端午 0619|中秋 0925|国庆 1001~02+1005~07(**10-08[周四]复市**——勘正全员「10-09 开盘」旧误记);推导工法=VPS dbbardata 000300 distinct 交易日 diff 工作日(实证至 1002)+交易所 2026-09-17 公告补未来段——**只登实证/公告日,勿凭记忆添**(错登真交易日=掩盖真缺失,危险方向) -- **④坐标**:表 monitor_registry.py:30-40;剔除逻辑 data_monitor.py:402-417(main 调用 :434-436);CI 年更闸 `test_holiday_table_covers_current_year`(tests/data_platform/test_data_monitor.py)——当年无表即 CI 红(25063ca「禁裸墙钟」教条的**故意例外**,docstring 已注明) +- **④坐标**:表 monitor_registry.py:30-40(NAS 镜像=nas_monitor_check.py:30-42,CI 同步测钉相等);剔除逻辑 data_monitor.py:444-459(main 调用 :476-478);CI 年更闸 `test_holiday_table_covers_current_year`(tests/data_platform/test_data_monitor.py)——当年无表即 CI 红(25063ca「禁裸墙钟」教条的**故意例外**,docstring 已注明) - **⑤坑史**:表入册后三个「未确认日容忍」测试场景日失义(0925 已知假日→期望回落 0924 直接绿)→场景日重定向双删 0924+0925;fixture 远跳未来日期会塌日志层/mtime(rc=3 假红)→远窗场景用 `_calendar_days` 单元测直钉 ### §5.6 row-band 滚动分位带(P2-6,2026-10-01 用户拍板「监控问题统一处理」,五件套) @@ -344,7 +346,7 @@ kind 语义(检查分发依据,check_entry data_monitor.py:56-232): ### §7.6 wrapper 退出码纪律与 rc 语义族(调度层判读必读) - **监控 wrapper 恒 exit 0**:红态由 alerts/health JSON 承载——防污染 schtask Last Result 判读(python 侧 3=红,wrapper 转 0) -- **例外=xt-daily rc=1 抬码**(09-29 起):桥板块接口死时 ETF 清单空若仍 rc=0 会静默漏采(0928 全 ETF 停 0924 实案)→ ETF 清单空=仍写北交所但 rc=1,分诊信号=last_result=1+日志「FATAL ETF/基金 universe EMPTY」 +- **例外=xt-daily rc=1 抬码**(09-29 起):桥板块接口死时 ETF 清单空若仍 rc=0 会静默漏采(0928 全 ETF 停 0924 实案)→ ETF 清单空=仍写北交所但 rc=1,分诊信号=last_result=1+日志「FATAL ETF/基金 universe 异常(…)」(xt_eod.py:190-192 实串——**10-05 C-A1 勘正**:原记「universe EMPTY」按 spec grep 零命中会误判无信号;runbook 侧同步勘正=infra 9aa14d1) - **bs-daily rc=2 = graceful 自愈**:baostock 登录失败 3 重试(120s/300s backoff)后退出 2(bs_eod.py:625),次日 LOOKBACK=7 自愈;**连两日 rc=2 才升级**(10-02 假期实案:10002007 网络接收错误) - **0x41301=运行中 / 0x41303=从未跑 / 0x41306=被终止**:schtasks Last Result 三态非错误码,值班勿当报警(ak-weekly 28h 长跑常现 41301) @@ -493,11 +495,11 @@ kind 语义(检查分发依据,check_entry data_monitor.py:56-232): | strategy registry v1.3 | scripts/strategy_monitor/strategy_registry.py | 79 | 十一检 :13-59(闸门/班次/阈值全参数;weekday 键已退役 10-05);EXCHANGE_HOLIDAYS :69-79(与 data 表 CI 强制同步) | | strategy checks | scripts/strategy_monitor/strategy_checks.py | 399 | CHECK_FNS :387-399;needle :22 / LOG_GLOBS :26 / ro 日历 :65-80 / 排 live 桌 :87-98 / 双轨配对+未配对清单 :112-145 / no_restart 空日历降黄 :205-211 / storm unreadable 计数 :247-252 | | strategy wrapper | scripts/strategy_monitor/strategy_monitor_wrapper.ps1 | 16 | 恒 exit 0 :16(env 清代理+迁移日志位) | -| data-monitor | scripts/data_platform/data_monitor.py | 535 | check_entry :56-232 / 日志层 :237-299 / schtask 层 :304-344 / inbox 摄取 :361-386 / `_calendar_days` :402-417 / main :419-531 | +| data-monitor | scripts/data_platform/data_monitor.py | 615 | check_entry 单检隔离 :57(分发 :69-253)/ 日志层 collect_log_layer :264 / schtask 层 :346 / inbox 摄取 :403 / `_calendar_days` :444 / main 三段壳 :461(`_run_shift` :482+`_crash_exit` :586) | | data registry | scripts/data_platform/monitor_registry.py | 182 | EXCHANGE_HOLIDAYS :30-40 / _PER_DATE :50-69 / REGISTRY :152-153 / SCHTASKS 19 项 :162-182 | -| data checks | scripts/data_platform/monitor_checks.py | 228 | expected_daily :37 / expected_weekly :49 / rolling_rows_band :103-133(常量 :98-100)/ parse_schtasks_list :206 | -| alerts_store | sanguo_data/alerts_store.py | 159 | _SCHEMA :12-32 / upsert :57 / resolve_passed :83 / purge :96 / ack :134 / summary :149 | -| NAS monitor v2 | scripts/nas_sync/nas_monitor_check.py + run_monitor_nas.sh | 245+44 | build_checks 六族 :120-214 / main+green_keys :217-243;宿主生效位=/volume1/stock/monitor_nas/(scp -O 同步纪律) | +| data checks | scripts/data_platform/monitor_checks.py | 270 | expected_daily :37 / expected_weekly :49 / rolling_rows_band :106-138(常量 :101-103)/ check_disk 阈值参数化 :150 / `_error_lines` :190 / scan_signatures(仅测试) :196 / signatures_by_type :216 / parse_schtasks_list :248 | +| alerts_store | sanguo_data/alerts_store.py | 168 | _SCHEMA :12-32 / upsert(severity 跟随最新) :59 / resolve_passed :88 / purge(open 黄 last_seen 28d) :101 / ack :143 / summary :158 | +| NAS monitor v2 | scripts/nas_sync/nas_monitor_check.py + run_monitor_nas.sh | 266+47 | EXCHANGE_HOLIDAYS 镜像 :30-42 / last_weekday 交易日版 :43 / build_checks 六族 :141-235 / main+green_keys :238;宿主生效位=/volume1/stock/monitor_nas/(scp -O 同步纪律) | | QMT 哨兵链 | scripts/qmt_relogin/(回库 dbf57f2) | — | sentinel 188/probe 209/gate_common 179/relogin 243 | --- diff --git a/sanguo_data/alerts_store.py b/sanguo_data/alerts_store.py index ed550c40..ad53fc42 100644 --- a/sanguo_data/alerts_store.py +++ b/sanguo_data/alerts_store.py @@ -1,8 +1,10 @@ """alerts 告警事件存储(spec §20.15.4)——监控脚本单写、sanguo_api 只读+ack、NAS 侧经 inbox 摄取. -事件模型: 确定性 alert_id 幂等去重(重 Sight=同 id 再 upsert → count++ 即「第 N 晚」升级); +事件模型: 确定性 alert_id 幂等去重(重 Sight=同 id 再 upsert → count++ 即「第 N 晚」升级; +severity 跟随最新评估——同日黄→红升级即时落表, AL-1 10-05); check_key(去日期维度)驱动转绿自动 resolved;人工只 ack 不 resolved。 -保留期: resolved 30 天 / 黄项 14 天, 每班 purge。 +保留期: resolved 30 天 / open 黄按 last_seen 28 天(MON-202610-04 收口 10-05: +活着不删) / open 红不删, 每班 purge。 """ import json import os @@ -55,15 +57,18 @@ def _now() -> str: def upsert_alert(conn: sqlite3.Connection, event: dict) -> str: - """幂等写入一条告警事件; 重 Sight(同 alert_id 仍 open/acked)→count++/last_seen 刷新.""" + """幂等写入一条告警事件; 重 Sight(同 alert_id 仍 open/acked)→count++/ + last_seen 刷新/severity 跟随最新评估(AL-1 10-05: 同日黄→红升级不再丢, + 旧版 UPDATE 不动 severity=次日新 id 才纠正, open_red 角标少计一晚).""" now = _now() alert_id = event["alert_id"] cur = conn.execute( - "UPDATE alerts SET count=count+1, last_seen=?, detail=?, evidence=?, title=? " + "UPDATE alerts SET count=count+1, last_seen=?, detail=?, evidence=?, " + "title=?, severity=? " "WHERE alert_id=? AND status IN ('open','acked')", (now, event.get("detail", ""), json.dumps(event.get("evidence", {}), ensure_ascii=False), - event["title"], alert_id)) + event["title"], event["severity"], alert_id)) if cur.rowcount == 0: exists = conn.execute( "SELECT 1 FROM alerts WHERE alert_id=?", (alert_id,)).fetchone() @@ -94,14 +99,18 @@ def resolve_passed(conn: sqlite3.Connection, passed_keys, now: str = None) -> in def purge_expired(conn: sqlite3.Connection, now: str = None) -> int: - """resolved 留 30 天 / 黄项留 14 天(spec §20.15.4).""" + """resolved 留 30 天 / open 黄按 last_seen 留 28 天(MON-202610-04 收口 + 10-05: 旧 first_seen 14d 硬删把每日重 Sight 的持续黄无声从值班页抹掉+ + count 升级链断——last_seen 每次重 Sight 刷新=「活着不删」, 僵死 28d 才清; + resolved 黄统一走 resolved_at 30d, open 红永不删).""" now = now or _now() t = datetime.fromisoformat(now) cut_resolved = (t - timedelta(days=30)).isoformat(timespec="seconds") - cut_yellow = (t - timedelta(days=14)).isoformat(timespec="seconds") + cut_open_yellow = (t - timedelta(days=28)).isoformat(timespec="seconds") n = conn.execute( "DELETE FROM alerts WHERE (status='resolved' AND resolved_at 26 * 3600 - except ValueError: - stale = True + except (ValueError, TypeError): + stale = True # D-9(10-05): 非字符串 fromisoformat 抛 TypeError 不炸 if stale: return done("verdict", "yellow", "<=26h", checked) # 结论无顶层字段(static_vintage_check 契约): 挖洞藏在 tables.*.holes_* @@ -237,7 +248,10 @@ def _check_entry_impl(e, ctx): "exists", exists) if kind == "disk": - r = chk.check_disk(ctx["data_root"]) + # D-8(10-05): 阈值消费注册表(旧硬编码 8/20 与注册表脱钩, crit_gb 全仓 + # 无消费=改表不改行为的校准陷阱) + r = chk.check_disk(ctx["data_root"], warn_gb=e.get("warn_gb", 20), + crit_gb=e.get("crit_gb", 8)) return {"key": "infra-disk-data", "status": r["status"], "expected": f">={e['warn_gb']}G", "actual": f"{r['free_gb']}G", "evidence": r} @@ -530,7 +544,7 @@ def _run_shift(args, db_path): try: age_h = (now - dt.datetime.fromisoformat(nas_ts) ).total_seconds() / 3600 - except ValueError: + except (ValueError, TypeError): # D-9(10-05): TypeError 同防 age_h = None if age_h is not None and age_h > 30: events.append(_event(nas_silent_key, "yellow", diff --git a/scripts/data_platform/data_monitor_wrapper.ps1 b/scripts/data_platform/data_monitor_wrapper.ps1 index ebdb5245..1b2ec8af 100644 --- a/scripts/data_platform/data_monitor_wrapper.ps1 +++ b/scripts/data_platform/data_monitor_wrapper.ps1 @@ -10,6 +10,12 @@ $ts = Get-Date -Format 'yyyyMMdd_HHmmss' $logDir = 'C:\sanguo_vnpy_v2\data\migration_logs' if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } $log = Join-Path $logDir "data_monitor_$ts.txt" +# migration_logs 保洁(10-05 infra 插单): 13 个 wrapper 各自追加、无任何清理机制 +# =无限增长; 本 wrapper 双班跑必经, 顺手剪 14 天前旧件(PS 重定向副本无解析消费, +# 监控日志层真身=static/logs/, 诊断残留而已)。 +Get-ChildItem -Path $logDir -File -ErrorAction SilentlyContinue | + Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-14) } | + Remove-Item -ErrorAction SilentlyContinue C:\Python310\python.exe -X utf8 C:\sanguo_vnpy_v2\scripts\data_platform\data_monitor.py --shift $Shift *>> $log $rc = $LASTEXITCODE Add-Content -Path $log -Value "data_monitor shift=$Shift rc=$rc" diff --git a/scripts/data_platform/monitor_checks.py b/scripts/data_platform/monitor_checks.py index 27f8c8db..cbd6d2d2 100644 --- a/scripts/data_platform/monitor_checks.py +++ b/scripts/data_platform/monitor_checks.py @@ -147,13 +147,15 @@ def dir_latest_mtime(directory): return latest -def check_disk(path): +def check_disk(path, warn_gb=20, crit_gb=8): + """磁盘剩余水位; 阈值参数化(D-8 10-05: 注册表 warn_gb/crit_gb 消费——旧 + 硬编码 8/20 与注册表脱钩, crit_gb 曾全仓无消费=改表不改行为的校准陷阱).""" target = path if os.path.exists(path) else os.path.dirname(os.path.abspath(path)) usage = shutil.disk_usage(target) free_gb = usage.free / 1024 ** 3 - if free_gb < 8: + if free_gb < crit_gb: status = "red" - elif free_gb < 20: + elif free_gb < warn_gb: status = "yellow" else: status = "green" @@ -195,7 +197,10 @@ def scan_signatures(text): """10-05 三修: ①只扫 ERROR/[FATAL] 行——'Expecting value' 出现在重试 WARNING 行(financial_abstract/000963 实证), 良性重试不再误中 source_changed; ②'418' 针收紧为 '418 Client Error'——裸 418 子串误中 - rows=41823/ok=19418 进度行(anti_crawl 假阳性实证)。""" + rows=41823/ok=19418 进度行(anti_crawl 假阳性实证)。 + **注(10-05 补充审计)**: 生产路径已无调用方(签名分诊走 signatures_by_type, + data_monitor.log_layer_results/log_only_results)——本函数仅测试消费, + 勿再接线; 与 signatures_by_type 的行为差异=不按 [type] 归因。""" lines = _error_lines(text) seen, out = set(), [] for needle, sig, severity in SIGNATURES: @@ -211,10 +216,15 @@ _TYPE_TAG = re.compile(r"\[(\w+)\]") def signatures_by_type(text): """签名按日志行 [type] 归因(10-05): 'ERROR [share_capital] ...' 行的 签名只归 share_capital——防跨类型连坐(xueqiu 的 418 把 share_capital - 的 failed 抬红实证)。断路器行归触发类型, data_monitor 侧全局取。""" + 的 failed 抬红实证)。断路器行归触发类型, data_monitor 侧全局取。 + [FATAL] 是级别标记不是类型(P3 补充审计): 裸 FATAL 行 + (akshare_static_download.py:2721 实格式)归 _global, [type] [FATAL] + 双标记行(:2160 实格式)归 [type]。""" out = {} for ln in _error_lines(text): m = _TYPE_TAG.search(ln) + while m and m.group(1) == "FATAL": + m = _TYPE_TAG.search(ln, m.end()) t = m.group(1) if m else "_global" sigs = out.setdefault(t, []) for needle, sig, severity in SIGNATURES: diff --git a/scripts/nas_sync/nas_monitor_check.py b/scripts/nas_sync/nas_monitor_check.py index a10ff077..2065336f 100644 --- a/scripts/nas_sync/nas_monitor_check.py +++ b/scripts/nas_sync/nas_monitor_check.py @@ -21,13 +21,34 @@ XCHECK_REPORTS = os.path.join(CORPUS_ROOT, "xcheck", "reports") OUT_DIR = "/volume1/stock/monitor_nas/out" DISK_WARN_GB = 100 # 初值, 首周观察校准(spec §20.15.1 磁盘水位 NAS 侧) +# 沪深北休市表镜像(N-2 10-05)——=monitor_registry.EXCHANGE_HOLIDAYS, CI 同步测 +# (test_holiday_table_in_sync_with_vps_registry)钉两表相等, 单边改表即红(同 +# 7f9b413 strategy 侧镜像纪律); 年更义务经 VPS 侧年更闸+同步测传导到本表。 +# 旧 last_weekday 只剥周末: 0925 中秋/国庆连休窗把假日当「昨交易日」——当前被 +# hot_rank producer 的日历日节奏掩住(files 假期照落), 语义仍错。表缺年=不扣 +# (fail-safe, 黄照旧绝不静默转绿)。 +EXCHANGE_HOLIDAYS = { + 2026: { + "20260101", "20260102", # 元旦 + "20260216", "20260217", "20260218", "20260219", "20260220", "20260223", # 春节 + "20260406", # 清明 + "20260501", "20260504", "20260505", # 劳动节 + "20260619", # 端午 + "20260925", # 中秋 + "20261001", "20261002", "20261005", "20261006", "20261007", # 国庆(1008 复市) + }, +} + def last_weekday(before): - """strictly before 且为周一~五的最近一天(YYYYMMDD)——16:00 查时当日 17:35 班未采.""" + """strictly before 且为交易日(周一~五且非休市表日)的最近一天(YYYYMMDD) + ——16:00 查时当日 17:35 班未采; N-2: 接休市表镜像, 跳过连休假日.""" d = before - dt.timedelta(days=1) - while d.weekday() >= 5: + while True: + hol = EXCHANGE_HOLIDAYS.get(d.year) + if d.weekday() < 5 and (hol is None or d.strftime("%Y%m%d") not in hol): + return d.strftime("%Y%m%d") d -= dt.timedelta(days=1) - return d.strftime("%Y%m%d") def mtime_age_hours(path, now): diff --git a/scripts/nas_sync/run_monitor_nas.sh b/scripts/nas_sync/run_monitor_nas.sh index c6079a6c..fbb63713 100755 --- a/scripts/nas_sync/run_monitor_nas.sh +++ b/scripts/nas_sync/run_monitor_nas.sh @@ -21,7 +21,10 @@ mkdir -p "$ROOT/out" { echo "=== $(date '+%F %T') nas monitor start ===" "$PY" "$ROOT/nas_monitor_check.py" - echo "=== $(date '+%F %T') nas monitor exit=$? ===" + rc_nas=$? + # N-1(10-05): rc 先存后拼——旧版 $(date) 在同一 echo 词内先展开把 $? 重置 + # 为 0, exit= 恒 0; rc 隔离下这是 NAS 体检崩溃在 cron.log 的唯一现场线索 + echo "=== $(date '+%F %T') nas monitor exit=$rc_nas ===" # 远端 inbox 目录幂等建好; ssh 必带 -n(vps-mirror 坑: 不带会吃 stdin) ssh -n "${SCP_OPTS[@]}" "$VPS" \ "powershell -Command \"New-Item -ItemType Directory -Force -Path '$VPS_INBOX' | Out-Null\"" \ diff --git a/tests/data_platform/test_alerts_store.py b/tests/data_platform/test_alerts_store.py index fda913e8..2612a46e 100644 --- a/tests/data_platform/test_alerts_store.py +++ b/tests/data_platform/test_alerts_store.py @@ -53,6 +53,14 @@ class TestUpsert: rows, _ = store.list_alerts(conn) assert rows[0]["evidence"] == _event()["evidence"] + def test_resighting_updates_severity(self, conn): + """AL-1(10-05): 同日黄→红升级不再在 alerts 层丢失(旧 UPDATE 不更 + severity, 次日新 id 才纠正=前端 open_red 角标少计一晚)。""" + store.upsert_alert(conn, _event(severity="yellow")) + store.upsert_alert(conn, _event()) # 同 alert_id, severity=red + rows, _ = store.list_alerts(conn) + assert rows[0]["severity"] == "red" and rows[0]["count"] == 2 + class TestResolveAndPurge: def test_resolve_passed_closes_open_and_acked(self, conn): @@ -66,18 +74,31 @@ class TestResolveAndPurge: store.upsert_alert(conn, _event()) assert store.resolve_passed(conn, ["data-other"], "2026-09-26T00:00:00") == 0 - def test_purge_resolved_30d_and_yellow_14d(self, conn): - store.upsert_alert(conn, _event()) # red open 留 + def test_purge_resolved_30d_open_yellow_by_last_seen(self, conn): + """MON-202610-04 收口(10-05 P2 批): open 黄改按 last_seen 28d 清—— + 旧 first_seen 14d 硬删把「每日重 Sight 的持续黄」(first_seen 老/ + last_seen 新)无声从值班页抹掉+count 升级链断; last_seen 每次重 Sight + 刷新=活着不删, 僵死 28d 才清。resolved 统一 30d, open 红永不删。""" + store.upsert_alert(conn, _event()) # red open: 永不 purge + # 活着的 open 黄: first_seen 31d 前, last_seen=今日(每日重 Sight) store.upsert_alert(conn, _event( alert_id="data-static-m-stale-20260901", check_key="data-static-m-stale", severity="yellow")) + conn.execute( + "UPDATE alerts SET first_seen='2026-08-25T00:00:00'" + " WHERE check_key='data-static-m-stale'") + # 僵死的 open 黄: last_seen 停在 28d 窗外 + store.upsert_alert(conn, _event( + alert_id="data-static-s-stale-20260801", check_key="data-static-s-stale", + severity="yellow")) + conn.execute( + "UPDATE alerts SET first_seen='2026-08-10T00:00:00'," + " last_seen='2026-08-20T00:00:00'" + " WHERE check_key='data-static-s-stale'") + # resolved 30d 前 store.upsert_alert(conn, _event( alert_id="data-static-n-stale-20260901", check_key="data-static-n-stale", severity="red")) - # 回拨时间位: 黄项 first_seen 拨到 14 天前, 红项拨成 30 天前已 resolved - conn.execute( - "UPDATE alerts SET first_seen='2026-08-01T00:00:00'" - " WHERE check_key='data-static-m-stale'") conn.execute( "UPDATE alerts SET first_seen='2026-08-01T00:00:00'," " status='resolved', resolved_at='2026-08-20T00:00:00'" @@ -85,7 +106,8 @@ class TestResolveAndPurge: conn.commit() n = store.purge_expired(conn, "2026-09-25T00:00:00") keys = {r["check_key"] for r in store.list_alerts(conn)[0]} - assert n == 2 and keys == {"data-static-xueqiu_hot-stale"} + assert n == 2 and keys == {"data-static-xueqiu_hot-stale", + "data-static-m-stale"} class TestAckAndSummary: diff --git a/tests/data_platform/test_data_monitor.py b/tests/data_platform/test_data_monitor.py index e2c7a6b0..7deb8065 100644 --- a/tests/data_platform/test_data_monitor.py +++ b/tests/data_platform/test_data_monitor.py @@ -677,3 +677,54 @@ def test_trading_days_readside_normalization(tmp_path): conn.close() days = dm.chk.trading_days(str(db), dt.date(2026, 9, 26)) assert days == ["2026-09-24", "2026-09-25"] + + +# ---------- 10-05 班次二 P2 批(D-5/6/8/9) ---------- + +def test_db_sample_no_calendar_weak_check_not_fail_open(tree, tmp_path): + """D-5: 日历不可达(exp=None)不再无条件绿——弱检查=样本可读(对齐 per_date + 存在性弱检): 库可读且样本有数据=绿, 库不可达=黄, 不 fail-open。""" + e = next(x for x in reg.REGISTRY if x["name"] == "dbbardata_daily") + ctx = {"data_root": str(tree), "days": [], "now": FAKE_NOW, + "confirmed_days": set()} + assert dm.check_entry(e, ctx)["status"] == "green" + (tree / "quant_trading.db").rename(tree / "quant_trading.db.bak") + r = dm.check_entry(e, ctx) + assert r["status"] == "yellow" + assert r["actual"] == "db-unreadable" + + +def test_month_bucket_corrupt_parquet_is_yellow(tree): + """D-6: month_bucket 最新桶损坏(parquet_rows=None)不再静默绿—— + 对齐 per_date rows=None→黄(audit: 与 per_date 判定不一致)。""" + e = next(x for x in reg.REGISTRY if x["kind"] == "month_bucket") + p = tree / "static" / e["subdir"] / "202609_pledge_detail.parquet" + p.write_bytes(b"PAR1garbage-truncated") + r = dm.check_entry(e, _ctx(tree)) + assert r["key"] == "data-pledge_detail-rows" and r["status"] == "yellow" + + +def test_disk_consumes_registry_thresholds(tmp_path, monkeypatch): + """D-8: check_disk 消费注册表 warn_gb/crit_gb(旧硬编码 8/20 与注册表脱钩, + crit_gb 全仓无消费=改注册表不改变行为的校准陷阱)。""" + from collections import namedtuple + usage = namedtuple("usage", "total used free") + monkeypatch.setattr(dm.chk.shutil, "disk_usage", + lambda p: usage(100, 0, 10 * 1024 ** 3)) + e = next(x for x in reg.REGISTRY if x["name"] == "disk") + ctx = {"data_root": str(tmp_path)} + assert dm.check_entry(e, ctx)["status"] == "yellow" # 10G: <20 黄 >=8 不红 + monkeypatch.setitem(e, "crit_gb", 12) + assert dm.check_entry(e, ctx)["status"] == "red" # 改表即改行为 + + +def test_vintage_checked_at_nonstring_degrades_local_yellow(tree): + """D-9: checked_at 非字符串(fromisoformat TypeError)被本地捕获走 verdict + 黄——旧版只捕 ValueError, TypeError 逸出靠 D-2 单检隔离兜(键变 + data-vintage-stale), 本修复让黄走对的 verdict 键。""" + p = tree / "static" / "vintage_status.json" + p.write_text(json.dumps({"checked_at": 12345, "tables": {}}), + encoding="utf-8") + e = next(x for x in reg.REGISTRY if x["name"] == "vintage") + r = dm.check_entry(e, _ctx(tree)) + assert r["key"] == "data-vintage-verdict" and r["status"] == "yellow" diff --git a/tests/data_platform/test_monitor_checks.py b/tests/data_platform/test_monitor_checks.py index 534490ea..b76c4145 100644 --- a/tests/data_platform/test_monitor_checks.py +++ b/tests/data_platform/test_monitor_checks.py @@ -128,6 +128,18 @@ class TestLogParse: assert {d["sig"] for d in by_type["xueqiu_hot"]} == {"anti_crawl"} assert {d["sig"] for d in by_type["share_capital"]} == {"field_changed"} + def test_signatures_by_type_fatal_marker_not_a_type(self): + """补充审计 P3: [FATAL] 是级别标记不是类型——裸 FATAL 行 + (akshare_static_download.py:2721 实格式)归 _global 不再入幽灵 + 'FATAL' 类型桶; [type] [FATAL] 双标记行(:2160 实格式)仍归 [type]。""" + bare = "2026-10-05 12:00:00 [FATAL] 获取股票列表失败: timeout" + out = mc.signatures_by_type(bare) + assert "FATAL" not in out and "_global" in out + typed = ("2026-10-05 12:00:00 [margin] [FATAL] 断路器触发: " + "连续 30 个 unit 失败, akshare 疑似不可达") + out = mc.signatures_by_type(typed) + assert "margin" in out and out["margin"][0]["sig"] == "breaker" + class TestSchtaskParse: def test_bilingual_and_na(self): @@ -155,3 +167,15 @@ class TestDisk: def test_check_disk_returns_status(self, tmp_path): r = mc.check_disk(str(tmp_path)) assert r["status"] in ("green", "yellow", "red") and r["free_gb"] > 0 + + def test_check_disk_consumes_threshold_params(self, tmp_path, monkeypatch): + """D-8: 阈值参数化——旧硬编码 8/20 改表不改行为(校准陷阱)。""" + from collections import namedtuple + usage = namedtuple("usage", "total used free") + monkeypatch.setattr(mc.shutil, "disk_usage", + lambda p: usage(100, 0, 10 * 1024 ** 3)) + assert mc.check_disk(str(tmp_path))["status"] == "yellow" # 10G: 默认 8/20 + assert mc.check_disk(str(tmp_path), warn_gb=50, + crit_gb=30)["status"] == "red" # 10<30 crit + assert mc.check_disk(str(tmp_path), warn_gb=50, + crit_gb=5)["status"] == "yellow" # 5<=10<50 warn diff --git a/tests/data_platform/test_nas_monitor_check.py b/tests/data_platform/test_nas_monitor_check.py index 40007893..c2fd84df 100644 --- a/tests/data_platform/test_nas_monitor_check.py +++ b/tests/data_platform/test_nas_monitor_check.py @@ -12,13 +12,34 @@ import nas_monitor_check as nmc # noqa: E402 @pytest.mark.parametrize("today, want", [ (dt.date(2026, 9, 25), "20260924"), # 五→四 - (dt.date(2026, 9, 28), "20260925"), # 一→上五 - (dt.date(2026, 9, 27), "20260925"), # 日→五 + (dt.date(2026, 9, 28), "20260924"), # 一→跳过 0925 中秋休市(N-2 后=真昨交易日) + (dt.date(2026, 9, 27), "20260924"), # 日→跳周末+0925 休市 + (dt.date(2026, 10, 6), "20260930"), # 国庆连休窗内: 期望钉在节前末交易日 + (dt.date(2026, 10, 8), "20260930"), # 复市日 16:00 查: 当日 17:35 未采 + (dt.date(2027, 1, 2), "20270101"), # 表缺年 fail-safe: 不扣(黄照旧语义) ]) def test_last_weekday(today, want): assert nmc.last_weekday(today) == want +def test_holiday_table_in_sync_with_vps_registry(): + """N-2: NAS 休市表镜像与 VPS monitor_registry 同源(CI 钉两表相等, + 单边改表即红——同 7f9b413 strategy 侧镜像纪律)。""" + import monitor_registry as reg + assert nmc.EXCHANGE_HOLIDAYS == reg.EXCHANGE_HOLIDAYS + + +def test_hot_rank_file_missing_is_red(tmp_path, monkeypatch): + """T-2: NAS 侧唯一 red 分支(hot_rank 昨交易日文件缺)补测试覆盖—— + audit: 该分支零覆盖, rc 隔离下它是 NAS 崩溃/异常外唯一 red 源。""" + monkeypatch.setattr(nmc, "HOTRANK_DIR", str(tmp_path)) + checks, events = nmc.build_checks(dt.datetime(2026, 9, 21, 16)) + f = [c for c in checks if c["key"] == "nas-hot_rank-file"][0] + assert f["status"] == "red" and f["evidence"]["expected"] == "20260918" + assert any(e["check_key"] == "nas-hot_rank-file" and e["severity"] == "red" + for e in events) + + def test_mtime_age_hours(tmp_path): f = tmp_path / "f" f.touch()