diff --git a/docs/factors/d5_c5_audit_report.md b/docs/factors/d5_c5_audit_report.md index f373443..50bed37 100644 --- a/docs/factors/d5_c5_audit_report.md +++ b/docs/factors/d5_c5_audit_report.md @@ -1,9 +1,11 @@ # D5 C5 —— 四腿全量对账审计报告 -> **状态**:**骨架**。结果区全部 TBD,由全量对账(11 因子 × panels/reports/anchors) -> 跑完后填充。填充纪律:每处差异必须落在 §二 预登记清单的具名条目内;**未编目的 -> 差异 = 验收不通过**(设计 v3.2 §五腿 3:「允许差异,但每处差异必须归因到具名原因; -> 变好的差异优先怀疑泄漏」)。 +> **状态**:**已填充**(2026-07-30)。11 因子 × panels/reports/anchors 全量对账已跑完, +> 结果在 §三/§四/§五。填充纪律(已执行):每处差异必须落在 §二 预登记清单或 §二之三 的 +> 本轮新增登记内;**未编目的差异 = 验收不通过**(设计 v3.2 §五腿 3:「允许差异,但每处 +> 差异必须归因到具名原因;变好的差异优先怀疑泄漏」)。**类外差异为 0。** +> ⚠️ 本轮的每一条新增登记都不是"放宽",而是**原登记表建立在一个不完整的证据基上**—— +> 详见 §六.6/6.7 与编目 §七之七/§七之八。 > **执行环境**:11 因子 × {no_book, with_book} 统一 exec-only runner > (`qt/factor_eval_runner.py`)+ 对账 harness(`qt/factor_eval_reconcile.py` 三模式)。 @@ -29,10 +31,18 @@ sha256 manifest 入 git),**绝不读 live 路径**——新 runner 写同名文件,跑一次就毁掉 唯一基线。 - **cache-only**:每次 service 取数后断言 `stk_mins_live_calls == 0`,非零即 ABORT。 -- **harness 加固(本准备步)**:bounded `warmup_left_extension` 类加**结构性 cell 数 - ceiling** =(w−1 个 warmup 交易日)×(冻结面板 symbol 数)——该类按定义不可能超过 - 此界,越界即 FAIL(C4a 评审 LOW-1;类内**幅度仍不设限**的既有裁定不变,理由:两种 - 加载几何在该区本来就合法地不同,区外仍由 1e-12 把门)。 +- **bounded `warmup_left_extension` 的 cell ceiling 是 backstop,不是主闸门(措辞已修正)**: + ceiling =(w−1 个 warmup 交易日)×(冻结面板 symbol 数)是该判据下 warmup 格数的 + **结构上限**——只要 `_in_warmup` 的**日期边界判据本身**没坏,它在**数学上就不可能被 + 超过**。所以它**既不是量级闸门也不是密度闸门**,而是"日期边界判据自身有没有坏"的 + **兜底**;**只有在另一个守卫已经失效时它才会响**。 + 实测占用率 91.4%–91.5%(8198/8955、17289/18905、17272/18905)**不是"还有 8.5% 余量" + 的意思**:占用率 =(warmup 区内真有差异的票数)/(全部票数),结构上本就该接近 1, + 它既不是警报也不是头寸。 + ⚠️ **C4a 评审 LOW-1 的量级缺口仍然敞着**:bounded `warmup_left_extension` 类**至今 + 没有任何量级上限**,本轮实测落在 warmup 类里的 max rel 高达 **1.996**(符号翻转)。 + 区外仍由 1e-12 把门,区内不设限是既有裁定(两种加载几何在该区本来就合法地不同)—— + **但读者不许把上面那个 ceiling 当成填补了这个缺口**。 ## 二、预登记漂移清单(对账白名单) @@ -58,61 +68,302 @@ unclassified → FAIL。** | 13 | `book_view_effect`(with_book(decision) 格) | decision 书 vs 冻结 close 书的 Incremental 轴数值差:**全量报告、不设闸**;闸在 `_bookclose` 格(见 §四) | harness 设计(a)/(b) 分解;交接 §3 D5 C4 ⚠️ | | 14 | `hand_anchors_d2.json` 报 `all_ok_frozen14: False` | 70 行 5 处失配**全部且仅有 jump**(手算已截断 vs 冻结 `panels_d2` 未截断)——正确信号非回归 | 交接 §3 #5 | -## 三、每因子结果表(TBD) - -填写口径:panels = 具名类 cell 计数 + unclassified 计数(必须为 0);reports = -no_book / with_book(decision) / with_book(bookclose) 三格各自的登记类计数与 -unregistered 计数(strict 格必须为 0);anchors = ok/warmup/failed 行数(failed 必须 -为 0)。`stk_mins_live_calls` 每因子必须为 0。 - -| 因子 | panels: warmup / float_tail / flip_tail / flip_contam / footprint / unclassified / ceiling | reports: no_book | reports: with_book(decision) | reports: with_book(bookclose) | anchors: ok / warmup / failed | live_calls | -|---|---|---|---|---|---|---| -| jump_amount_corr_20 | TBD | TBD | TBD | TBD | TBD | TBD | -| minute_ideal_amp_10 | TBD | TBD | TBD | TBD | TBD | TBD | -| amp_marginal_anomaly_vol_20 | TBD | TBD | TBD | TBD | TBD | TBD | -| volume_peak_count_20 | TBD | TBD | TBD | TBD | TBD | TBD | -| intraday_amp_cut_10 | TBD | TBD | TBD | TBD | TBD | TBD | -| peak_interval_kurtosis_20 | TBD | TBD | TBD | TBD | TBD | TBD | -| valley_relative_vwap_20 | TBD | TBD | TBD | TBD | TBD | TBD | -| valley_ridge_vwap_ratio_20 | TBD | TBD | TBD | TBD | TBD | TBD | -| ridge_minute_return_20 | TBD | TBD | TBD | TBD | TBD | TBD | -| valley_price_quantile_20 | TBD | TBD | TBD | TBD | TBD | TBD | -| peak_ridge_amount_ratio_20 | TBD | TBD | TBD | TBD | TBD | TBD | - -## 四、with_book 差异分解表(TBD) - -交接 §3 的硬要求:with_book 的差异必须可分解、**整体归因不可接受**(「with_book 变了 -是因为书视图改了」会把引擎回归藏在 intended change 后面)。三分量: - -- **A = no_book 引擎效应**:no_book 格的登记类差异(干净的「有没有弄坏东西」测试, - 先对干净)。 -- **B = close 书引擎效应**:`with_book(bookclose)` 格(legacy-faithful close 书,strict) - 的登记类差异——引擎在带书条件下的效应,与 A 同口径可比。 -- **C−B = 书视图修正**:`with_book(decision)` 格的 `book_view_effect` 叶子(decision 书 - vs close 书的预期差异,全量列出不设闸)——即 §1.1 活缺陷(close(d) 书)的修正幅度。 - -| 因子 | A(no_book 各类计数) | B(bookclose 各类计数) | C−B(book_view_effect 叶子数 / 涉及轴) | 分解闭合?(B 超 A 的部分是否全部具名) | +## 二之三、本轮新增的登记项(§二 清单的增补) + +以下六条是 C5 修复轮新增的具名登记。**每一条都配有正反向测试(越界必 FAIL)与 mutation +证据**,出处见编目 §七之七 / §七之八。**#17 / #19 / #20 带反棘轮约束:不得再次放宽—— +再有新成员出现即说明该类在描述残差而非机制,停下上报。** + +| # | 名称 | 边界 | 出处 | +|---|---|---|---| +| 15 | `threshold_flip_contamination` bars-only 臂 | 同窗同 symbol;**相对**界按因子(kurtosis ≤5e-3 / relative_vwap ≤1e-5);≤25 cell/因子 | 编目 §七之七 F2 | +| 16 | `warmup_left_extension` 新方向 `frozen_finite_new_nan` | 仅 valid-day pooled 因子 + 早区窗内 | 编目 §七之七 F3① | +| 17 | `warmup_sparse_valid_day_tail` | 仅 pooled;[2021-11-01, 2021-11-15];**9 票白名单即成员**(**枚举类 + 必要条件守卫,不是机制类**:密度守卫在 **p25**、18/18 通过,但该谓词单独允许 **1,250** 对 ≈ 本类 **69 倍** ⇒ 约束成员集但不决定它);≤20 cell/因子;**反棘轮 + 禁止照密度推导新成员** | 编目 §七之七 F3② | +| 18 | 绝对 float-dust 前移到区域分支之前 | `abs ≤ 1e-12` 按机制分类,不按位置 | 编目 §七之七 F4 | +| 19 | 诊断 sink 披露节(三个因子) | `old=None` 纯新增 + verdict 标签不变 + **无索引位移**,逐 grid 复验 **12/12**(4 因子 × 3 grid);**反棘轮** | 编目 §七之八 A | +| 20 | `spec.description` 的 D2 provenance 改写 | **逐对精确枚举**(3 因子);其它一律 FAIL;不 bump 版本、不触发更正承载 | 编目 §七之八 B | + +## 二之二、C5 首轮全量跑的原始结果(pre-fix)—— 誊自日志,**耐久化** + +> **为什么誊在这里**:这些数字原本只存在于 `artifacts/logs/`(gitignored、只在本机), +> 而 C5 的重跑会把同名日志**全部覆盖**——`qt.pipeline._make_logger` 以 truncate 模式打开。 +> 本项目已经因为 `/tmp` 被清理丢过一次全量跑的总日志。**报告是耐久载体,日志不是。** +> 下表是 F1/F2/F3/F4/F5 全部修复**之前**的状态,B 阶段的结果表(§三)与它对照阅读。 + +**运行环境**:11 因子 × {panels, reports, anchors},2026-07-28 01:50→05:52 一轮 sweep, +cache-only(每行 `live_calls=0`)。**⚠️ 该轮跑在 PR #109 之前的判据上**(见 §六.6)。 + +### panels 腿(`unclassified` 与 `ok` 为判定值,其余为分类计数) + +| 因子 | frozen / new | warmup | float_tail | flip | contam | footprint | **unclassified** | max_rel | **ok** | +|---|---|---|---|---|---|---|---|---|---| +| minute_ideal_amp_10 | 1159263 / 1205160 | 8198 | 0 | 0 | 0 | 45897 | **0** | 1.996e+00 | **True** | +| jump_amount_corr_20 | 1159263 / 1205160 | 17289 | 101 | 0 | 0 | 45897 | **0** | 1.787e+00 | **True** | +| volume_peak_count_20 | 1149313 / 1205160 | 34441 | 0 | 20 | 0 | 46788 | **0** | 8.453e-01 | **True** | +| valley_price_quantile_20 | 1146878 / 1205160 | 24568 | 883 | 0 | 19177 | 58282 | **0** | 1.999e+00 | **True** | +| amp_marginal_anomaly_vol_20 | 1159263 / — | — | — | — | — | — | **729029** | — | **False** (F1) | +| peak_interval_kurtosis_20 | 1149313 / 1205160 | 35152 | 0 | 0 | 0 | 46790 | **20** | 1.233e+00 | **False** (F2) | +| valley_relative_vwap_20 | 1146878 / 1205160 | 35205 | 0 | 0 | 0 | 49235 | **20** | 8.041e-03 | **False** (F2) | +| valley_ridge_vwap_ratio_20 | 591524 / 1205160 | 28161 | 0 | 0 | 0 | 608309 | **20** | 1.293e-02 | **False** (F3) | +| ridge_minute_return_20 | 588536 / 1205160 | 27811 | 0 | 0 | 0 | 611318 | **20** | 1.999e+00 | **False** (F3) | +| intraday_amp_cut_10 | 1154288 / 1158842 | 8221 | 758 | 0 | 17 | 0 | **0** | 1.976e+00 | **False** (F4,按月计数非单调) | +| peak_ridge_amount_ratio_20 | 579030 / 1205160 | 27896 | 0 | 0 | 0 | 620916 | **30** | 7.360e-01 | **False** (F3 第三个因子) | + +**出处(逐条)**:8 行誊自幸存日志原文(快照 `tmp/context/cc_c5_audit/c5_run_logs_preserved/`, +mtime 保留);`peak_interval_kurtosis_20` 与 `intraday_amp_cut_10` 两行的日志**已被本轮 +判据验证重跑覆盖**,其值出自 ① 覆盖前的逐字抓取与 ② 用同一批 served 面板在 **main 判据** +下的离线独立重算——两者一致;`amp_marginal_anomaly_vol_20` 一行的日志已被 F1 修复重跑 +覆盖,其值出自编目 §七之六(该行本就是 F1 的证据,非本轮新测)。 + +**交叉印证**:离线独立重算(不读日志,直接对 dump 的 served 面板跑 main 判据)在 **9 个 +非 amp_marginal 因子上逐格复现上表**——这使离线重算可以独立当证据基用,而不必信任日志。 + +### anchors 腿 + +| 因子 | rows | ok | warmup | failed | 判定 | +|---|---|---|---|---|---| +| minute_ideal_amp_10 / jump / volume_peak / ridge / valley_ridge / peak_ridge | 5 | 3 | 2 | 0 | **True** | +| valley_price_quantile_20 / valley_relative_vwap_20 / intraday_amp_cut_10 | 5 | 4 | 1 | 0 | **True** | +| peak_interval_kurtosis_20 | 5 | 2 | 3 | 0 | **True** | +| amp_marginal_anomaly_vol_20 | 5 | — | — | **3** | **False** (F1;出处编目 §七之六) | + +### reports 腿 + +**11/11 全部 rc=1,且每一个都是 F5**——`{stem}_exec_with_book.json` 被 close 模式的 +事后改名销毁,`run_reports_mode` 抛 `FileNotFoundError`。**这不是判定结果**:该腿在 C5 +首轮**没有对任何因子产生过一个判定**(详见 §六.7)。 + +## 三、每因子结果表 + +**运行环境**:11 因子 × {no_book, with_book} × {decision, close} eval(22 次 rc 全 0)+ +三模式对账,cache-only(`stk_mins_live_calls` 非零命中 **0**)。panels 与 anchors 出自 phase B +全量跑;reports 出自其后的 reports-only 重跑(改动经 AST 逐函数比对**只触及 reports 腿**, +panels/anchors 的入口函数逐字节未变——见 §六.9)。 + +### 三之1 panels 腿(全部 `unclassified=0`) + +| 因子 | warmup | sparse_tail | float_tail (cap) | flip | contam | footprint | unclassified | +|---|---|---|---|---|---|---|---| +| jump_amount_corr_20 | 17289 | 0 | 101 (101) | 0 | 0 | 45897 | **0** | +| minute_ideal_amp_10 | 8198 | 0 | 0 (101) | 0 | 0 | 45897 | **0** | +| amp_marginal_anomaly_vol_20 | 17272 | 0 | 0 (101) | 0 | 0 | 45897 | **0** | +| volume_peak_count_20 | 34441 | 0 | 0 (101) | 20 | 0 | 46788 | **0** | +| intraday_amp_cut_10 | 8198 | 0 | 798 (1000) | 0 | 0 | 0 | **0** | +| peak_interval_kurtosis_20 | 35152 | 0 | 0 (101) | 0 | 20 | 46790 | **0** | +| valley_relative_vwap_20 | 35205 | 0 | 0 (101) | 0 | 20 | 49235 | **0** | +| valley_ridge_vwap_ratio_20 | 28168 | 13 | 0 (101) | 0 | 0 | 608309 | **0** | +| ridge_minute_return_20 | 27818 | 13 | 0 (101) | 0 | 0 | 611318 | **0** | +| valley_price_quantile_20 | 24531 | 0 | 925 (1000) | 0 | 19172 | 58282 | **0** | +| peak_ridge_amount_ratio_20 | 27907 | 19 | 0 (101) | 0 | 0 | 620916 | **0** | + +### 三之2 anchors 腿(全部 `failed=0`) + +| 因子 | rows | ok | warmup | failed | |---|---|---|---|---| -| jump_amount_corr_20 | TBD | TBD | TBD | TBD | -| minute_ideal_amp_10 | TBD | TBD | TBD | TBD | -| amp_marginal_anomaly_vol_20 | TBD | TBD | TBD | TBD | -| volume_peak_count_20 | TBD | TBD | TBD | TBD | -| intraday_amp_cut_10 | TBD | TBD | TBD | TBD | -| peak_interval_kurtosis_20 | TBD | TBD | TBD | TBD | -| valley_relative_vwap_20 | TBD | TBD | TBD | TBD | -| valley_ridge_vwap_ratio_20 | TBD | TBD | TBD | TBD | -| ridge_minute_return_20 | TBD | TBD | TBD | TBD | -| valley_price_quantile_20 | TBD | TBD | TBD | TBD | -| peak_ridge_amount_ratio_20 | TBD | TBD | TBD | TBD | - -## 五、判定(TBD) +| jump_amount_corr_20 | 5 | 3 | 2 | **0** | +| minute_ideal_amp_10 | 5 | 3 | 2 | **0** | +| amp_marginal_anomaly_vol_20 | 5 | 4 | 1 | **0** | +| volume_peak_count_20 | 5 | 3 | 2 | **0** | +| intraday_amp_cut_10 | 5 | 4 | 1 | **0** | +| peak_interval_kurtosis_20 | 5 | 2 | 3 | **0** | +| valley_relative_vwap_20 | 5 | 4 | 1 | **0** | +| valley_ridge_vwap_ratio_20 | 5 | 3 | 2 | **0** | +| ridge_minute_return_20 | 5 | 3 | 2 | **0** | +| valley_price_quantile_20 | 5 | 4 | 1 | **0** | +| peak_ridge_amount_ratio_20 | 5 | 3 | 2 | **0** | + +### 三之3 reports 腿(三格;全部 rc=0、零类外叶子) + +| 因子 | rc | 登记类计数(no_book / bookclose / decision) | +|---|---|---| +| jump_amount_corr_20 | **0** | 108 diffs / 124 diffs / 124 diffs | +| minute_ideal_amp_10 | **0** | 121 diffs / 122 diffs / 137 diffs | +| amp_marginal_anomaly_vol_20 | **0** | 121 diffs / 122 diffs / 137 diffs | +| volume_peak_count_20 | **0** | 122 diffs / 137 diffs / 138 diffs | +| intraday_amp_cut_10 | **0** | 126 diffs / 127 diffs / 142 diffs | +| peak_interval_kurtosis_20 | **0** | 127 diffs / 142 diffs / 143 diffs | +| valley_relative_vwap_20 | **0** | 128 diffs / 144 diffs / 144 diffs | +| valley_ridge_vwap_ratio_20 | **0** | 163 diffs / 179 diffs / 179 diffs | +| ridge_minute_return_20 | **0** | 158 diffs / 172 diffs / 174 diffs | +| valley_price_quantile_20 | **0** | 105 diffs / 121 diffs / 121 diffs | +| peak_ridge_amount_ratio_20 | **0** | 163 diffs / 179 diffs / 179 diffs | + +**总计:33 格(11 因子 × 三腿)全部 rc=0**;panels 的 `unclassified` 与 anchors 的 `failed` +全为 0,reports 三格零类外叶子。 + +### 三之四、贴边的边界(登记为脆弱点) + +反棘轮裁定使这些"紧"是**有意的牙**——但读者得知道它们有多紧: + +| 边界 | 上限 | 实测 | 余量 | +|---|---|---|---| +| `jump_amount_corr_20` float_tail cap | 101 | **101** | **0 格** | +| `warmup_sparse_valid_day_tail` 窗口右端 | 2021-11-15 | 该日**恰有 1 格** | **0 天** | +| `warmup_sparse_valid_day_tail` cell cap | 20 | **19**(peak_ridge) | **1 格** | +| **sparse tail 密度守卫(p25)** | **门槛 36 天** | **35 天**(`300857.SZ` × peak_ridge) | **1 天** | +| bars-only flip contamination cap | 25 | 20 | 5 格 | +| kurtosis 相对界 | 5e-3 | 2.904e-3 | 1.72× | +| relative_vwap 相对界 | 1e-5 | 1.530e-6 | 6.54× | + +**前四行是零/近零余量**: +- **jump 的 101/101**:该 cap 当初按这个因子的观测值标定,**再多一格就 FAIL**。本轮判据改动没有 + 往里加格(它那 101 格全在 warmup 区外,F4 前移一格未动);但下次任何触及 rolling 求和顺序的 + 改动都可能顶破它,而那时失败信息会是"cap 超了",**不是"哪里错了"**。 +- **sparse-tail 窗口右端 0 天余量**:窗口收在 2021-11-15,**恰恰因为该日有且只有 1 格** + (`688183.SH`)。窗口不是留了余地画的,是**贴着最后一格画的**。 +- **sparse-tail cap 只剩 1 格**:19/20。 +- **sparse-tail 的密度守卫也只剩 1 天**:门槛是各因子的 p25 = 36 个发行日,而最贴边的成员 + `300857.SZ`(在 `peak_ridge_amount_ratio_20` 上)发行 **35** 天。⚠️ **这道门红了就是 + STOP-AND-REPORT,不许把门槛放回中位**——那正是反棘轮明令禁止的动作,**而它红的时候恰恰 + 最诱人**(中位门会放行,于是"改一个数就绿了")。这道门是本轮返工**自己新加的**,性质与 + 上面三条零/近零余量完全相同,所以一并登记在此。 + +这四处**都不许因为"贴边"而放宽**——反棘轮的裁定正是"顶破了就停下上报",贴边意味着**下一次 +触发会很快到来且必须被当真**。 + +## 四、with_book 差异分解(逐指标) + +分解口径:**A** = `no_book`(引擎效应,先对干净的)· **B** = `_bookclose`(legacy-faithful +close 书 + 新引擎,与 A 同口径可比)· **C** = decision 书 · **C−B = 书视图修正**。 +Δ 一律相对**冻结基线的同名格**。 + +> ⚠️ **参照物只有冻结 exec 基线**:C5 首轮 reports 腿对 11 个因子一个判定都没产生过 +> (全是 F5),所以本表**不是**与上一轮 C5 的对比。见 §六.7。 + +| 因子 | 指标 | 冻结 no_book | **A** 新 no_book (Δ) | 冻结 with_book | **B** bookclose (Δ) | **C** decision (Δ) | **C−B**(书视图修正) | +|---|---|---|---|---|---|---|---| +| jump_amount_corr_20 | IC mean | -0.030840 | -0.030387 (+0.000453) | -0.030840 | -0.030387 (+0.000453) | -0.030387 (+0.000453) | **+0.000000** | +| jump_amount_corr_20 | ICIR | -0.425348 | -0.423262 (+0.002086) | -0.425348 | -0.423262 (+0.002086) | -0.423262 (+0.002086) | **+0.000000** | +| jump_amount_corr_20 | NW-t | -15.191082 | -15.123855 (+0.067227) | -15.191082 | -15.123855 (+0.067227) | -15.123855 (+0.067227) | **+0.000000** | +| jump_amount_corr_20 | N_eff | 1162.206183 | 1164.076757 (+1.870574) | 1162.206183 | 1164.076757 (+1.870574) | 1164.076757 (+1.870574) | **+0.000000** | +| jump_amount_corr_20 | incr. ICIR | n/a | n/a (—) | -0.300601 | -0.299010 (+0.001591) | -0.295001 (+0.005600) | **+0.004009** | +| jump_amount_corr_20 | **verdict** | Watch | Watch (same) | Watch | Watch (same) | Watch (same) | 同 | +| jump_amount_corr_20 | **三轴标签** | PASS/NOT_/NOT_ | PASS/NOT_/NOT_ (same) | PASS/PASS/NOT_ | PASS/PASS/NOT_ (same) | PASS/PASS/NOT_ (same) | 同 | +| minute_ideal_amp_10 | IC mean | -0.029644 | -0.029508 (+0.000136) | -0.029644 | -0.029508 (+0.000136) | -0.029508 (+0.000136) | **+0.000000** | +| minute_ideal_amp_10 | ICIR | -0.473361 | -0.471797 (+0.001564) | -0.473361 | -0.471797 (+0.001564) | -0.471797 (+0.001564) | **+0.000000** | +| minute_ideal_amp_10 | NW-t | -16.476304 | -16.393063 (+0.083241) | -16.476304 | -16.393063 (+0.083241) | -16.393063 (+0.083241) | **+0.000000** | +| minute_ideal_amp_10 | N_eff | 1205.000000 | 1209.000000 (+4.000000) | 1205.000000 | 1209.000000 (+4.000000) | 1209.000000 (+4.000000) | **+0.000000** | +| minute_ideal_amp_10 | incr. ICIR | n/a | n/a (—) | -0.260375 | -0.260375 (+0.000000) | -0.257123 (+0.003252) | **+0.003252** | +| minute_ideal_amp_10 | **verdict** | Watch | Watch (same) | Watch | Watch (same) | Watch (same) | 同 | +| minute_ideal_amp_10 | **三轴标签** | PASS/NOT_/NOT_ | PASS/NOT_/NOT_ (same) | PASS/PASS/NOT_ | PASS/PASS/NOT_ (same) | PASS/PASS/NOT_ (same) | 同 | +| amp_marginal_anomaly_vol_20 | IC mean | -0.042601 | -0.042341 (+0.000260) | -0.042601 | -0.042341 (+0.000260) | -0.042341 (+0.000260) | **+0.000000** | +| amp_marginal_anomaly_vol_20 | ICIR | -0.446484 | -0.443614 (+0.002870) | -0.446484 | -0.443614 (+0.002870) | -0.443614 (+0.002870) | **+0.000000** | +| amp_marginal_anomaly_vol_20 | NW-t | -16.154643 | -16.132296 (+0.022347) | -16.154643 | -16.132296 (+0.022347) | -16.132296 (+0.022347) | **+0.000000** | +| amp_marginal_anomaly_vol_20 | N_eff | 1116.017042 | 1115.866033 (-0.151009) | 1116.017042 | 1115.866033 (-0.151009) | 1115.866033 (-0.151009) | **+0.000000** | +| amp_marginal_anomaly_vol_20 | incr. ICIR | n/a | n/a (—) | -0.311985 | -0.311985 (+0.000000) | -0.304787 (+0.007198) | **+0.007198** | +| amp_marginal_anomaly_vol_20 | **verdict** | Reject | Reject (same) | Reject | Reject (same) | Reject (same) | 同 | +| amp_marginal_anomaly_vol_20 | **三轴标签** | FAIL/NOT_/NOT_ | FAIL/NOT_/NOT_ (same) | FAIL/FAIL/NOT_ | FAIL/FAIL/NOT_ (same) | FAIL/FAIL/NOT_ (same) | 同 | +| volume_peak_count_20 | IC mean | 0.018587 | 0.018475 (-0.000112) | 0.018587 | 0.018475 (-0.000112) | 0.018475 (-0.000112) | **+0.000000** | +| volume_peak_count_20 | ICIR | 0.224592 | 0.223694 (-0.000898) | 0.224592 | 0.223694 (-0.000898) | 0.223694 (-0.000898) | **+0.000000** | +| volume_peak_count_20 | NW-t | 7.758584 | 7.765443 (+0.006859) | 7.758584 | 7.765443 (+0.006859) | 7.765443 (+0.006859) | **+0.000000** | +| volume_peak_count_20 | N_eff | 1055.757320 | 1068.478323 (+12.721003) | 1055.757320 | 1068.478323 (+12.721003) | 1068.478323 (+12.721003) | **+0.000000** | +| volume_peak_count_20 | incr. ICIR | n/a | n/a (—) | 0.120357 | 0.121800 (+0.001443) | 0.112905 (-0.007452) | **-0.008895** | +| volume_peak_count_20 | **verdict** | Reject | Reject (same) | Reject | Reject (same) | Reject (same) | 同 | +| volume_peak_count_20 | **三轴标签** | FAIL/NOT_/NOT_ | FAIL/NOT_/NOT_ (same) | FAIL/FAIL/NOT_ | FAIL/FAIL/NOT_ (same) | FAIL/FAIL/NOT_ (same) | 同 | +| intraday_amp_cut_10 | IC mean | -0.035941 | -0.035806 (+0.000135) | -0.035941 | -0.035806 (+0.000135) | -0.035806 (+0.000135) | **+0.000000** | +| intraday_amp_cut_10 | ICIR | -0.454801 | -0.453101 (+0.001700) | -0.454801 | -0.453101 (+0.001700) | -0.453101 (+0.001700) | **+0.000000** | +| intraday_amp_cut_10 | NW-t | -15.653555 | -15.615023 (+0.038532) | -15.653555 | -15.615023 (+0.038532) | -15.615023 (+0.038532) | **+0.000000** | +| intraday_amp_cut_10 | N_eff | 959.099290 | 955.010692 (-4.088598) | 959.099290 | 955.010692 (-4.088598) | 955.010692 (-4.088598) | **+0.000000** | +| intraday_amp_cut_10 | incr. ICIR | n/a | n/a (—) | -0.258500 | -0.258500 (+0.000000) | -0.262065 (-0.003565) | **-0.003565** | +| intraday_amp_cut_10 | **verdict** | INSUFFICIENT-DATA | INSUFFICIENT-DATA (same) | Watch | Watch (same) | Watch (same) | 同 | +| intraday_amp_cut_10 | **三轴标签** | INSU/NOT_/NOT_ | INSU/NOT_/NOT_ (same) | INSU/PASS/NOT_ | INSU/PASS/NOT_ (same) | INSU/PASS/NOT_ (same) | 同 | +| peak_interval_kurtosis_20 | IC mean | 0.000030 | -0.000078 (-0.000108) | 0.000030 | -0.000078 (-0.000108) | -0.000078 (-0.000108) | **+0.000000** | +| peak_interval_kurtosis_20 | ICIR | 0.000815 | -0.002072 (-0.002887) | 0.000815 | -0.002072 (-0.002887) | -0.002072 (-0.002887) | **+0.000000** | +| peak_interval_kurtosis_20 | NW-t | 0.033214 | -0.084467 (-0.117681) | 0.033214 | -0.084467 (-0.117681) | -0.084467 (-0.117681) | **+0.000000** | +| peak_interval_kurtosis_20 | N_eff | 1190.000000 | 1209.000000 (+19.000000) | 1190.000000 | 1209.000000 (+19.000000) | 1209.000000 (+19.000000) | **+0.000000** | +| peak_interval_kurtosis_20 | incr. ICIR | n/a | n/a (—) | 0.045255 | 0.042346 (-0.002909) | 0.035443 (-0.009812) | **-0.006903** | +| peak_interval_kurtosis_20 | **verdict** | Reject | Reject (same) | Reject | Reject (same) | Reject (same) | 同 | +| peak_interval_kurtosis_20 | **三轴标签** | FAIL/NOT_/NOT_ | FAIL/NOT_/NOT_ (same) | FAIL/FAIL/NOT_ | FAIL/FAIL/NOT_ (same) | FAIL/FAIL/NOT_ (same) | 同 | +| valley_relative_vwap_20 | IC mean | 0.033656 | 0.033325 (-0.000331) | 0.033656 | 0.033325 (-0.000331) | 0.033325 (-0.000331) | **+0.000000** | +| valley_relative_vwap_20 | ICIR | 0.526483 | 0.519995 (-0.006488) | 0.526483 | 0.519995 (-0.006488) | 0.519995 (-0.006488) | **+0.000000** | +| valley_relative_vwap_20 | NW-t | 17.580989 | 17.495268 (-0.085721) | 17.580989 | 17.495268 (-0.085721) | 17.495268 (-0.085721) | **+0.000000** | +| valley_relative_vwap_20 | N_eff | 822.812580 | 829.057881 (+6.245301) | 822.812580 | 829.057881 (+6.245301) | 829.057881 (+6.245301) | **+0.000000** | +| valley_relative_vwap_20 | incr. ICIR | n/a | n/a (—) | 0.348870 | 0.348049 (-0.000821) | 0.350587 (+0.001717) | **+0.002538** | +| valley_relative_vwap_20 | **verdict** | Watch | Watch (same) | Watch | Watch (same) | Watch (same) | 同 | +| valley_relative_vwap_20 | **三轴标签** | PASS/NOT_/NOT_ | PASS/NOT_/NOT_ (same) | PASS/PASS/NOT_ | PASS/PASS/NOT_ (same) | PASS/PASS/NOT_ (same) | 同 | +| valley_ridge_vwap_ratio_20 | IC mean | 0.034667 | 0.034648 (-0.000019) | 0.034667 | 0.034648 (-0.000019) | 0.034648 (-0.000019) | **+0.000000** | +| valley_ridge_vwap_ratio_20 | ICIR | 0.439482 | 0.440794 (+0.001312) | 0.439482 | 0.440794 (+0.001312) | 0.440794 (+0.001312) | **+0.000000** | +| valley_ridge_vwap_ratio_20 | NW-t | 14.350975 | 14.629276 (+0.278301) | 14.350975 | 14.629276 (+0.278301) | 14.629276 (+0.278301) | **+0.000000** | +| valley_ridge_vwap_ratio_20 | N_eff | 815.607462 | 842.525194 (+26.917732) | 815.607462 | 842.525194 (+26.917732) | 842.525194 (+26.917732) | **+0.000000** | +| valley_ridge_vwap_ratio_20 | incr. ICIR | n/a | n/a (—) | 0.295070 | 0.295828 (+0.000758) | 0.296398 (+0.001328) | **+0.000570** | +| valley_ridge_vwap_ratio_20 | **verdict** | INSUFFICIENT-DATA | INSUFFICIENT-DATA (same) | Watch | Watch (same) | Watch (same) | 同 | +| valley_ridge_vwap_ratio_20 | **三轴标签** | INSU/NOT_/NOT_ | INSU/NOT_/NOT_ (same) | INSU/PASS/NOT_ | INSU/PASS/NOT_ (same) | INSU/PASS/NOT_ (same) | 同 | +| ridge_minute_return_20 | IC mean | -0.032373 | -0.031852 (+0.000521) | -0.032373 | -0.031852 (+0.000521) | -0.031852 (+0.000521) | **+0.000000** | +| ridge_minute_return_20 | ICIR | -0.397967 | -0.391910 (+0.006057) | -0.397967 | -0.391910 (+0.006057) | -0.391910 (+0.006057) | **+0.000000** | +| ridge_minute_return_20 | NW-t | -13.690742 | -13.552495 (+0.138247) | -13.690742 | -13.552495 (+0.138247) | -13.552495 (+0.138247) | **+0.000000** | +| ridge_minute_return_20 | N_eff | 1058.906714 | 1059.087960 (+0.181246) | 1058.906714 | 1059.087960 (+0.181246) | 1059.087960 (+0.181246) | **+0.000000** | +| ridge_minute_return_20 | incr. ICIR | n/a | n/a (—) | -0.150500 | -0.150937 (-0.000437) | -0.155878 (-0.005378) | **-0.004941** | +| ridge_minute_return_20 | **verdict** | INSUFFICIENT-DATA | INSUFFICIENT-DATA (same) | INSUFFICIENT-DATA | INSUFFICIENT-DATA (same) | INSUFFICIENT-DATA (same) | 同 | +| ridge_minute_return_20 | **三轴标签** | INSU/NOT_/NOT_ | INSU/NOT_/NOT_ (same) | INSU/INSU/NOT_ | INSU/INSU/NOT_ (same) | INSU/INSU/NOT_ (same) | 同 | +| valley_price_quantile_20 | IC mean | 0.026937 | 0.027028 (+0.000091) | 0.026937 | 0.027028 (+0.000091) | 0.027028 (+0.000091) | **+0.000000** | +| valley_price_quantile_20 | ICIR | 0.424804 | 0.426259 (+0.001455) | 0.424804 | 0.426259 (+0.001455) | 0.426259 (+0.001455) | **+0.000000** | +| valley_price_quantile_20 | NW-t | 13.358750 | 13.417417 (+0.058667) | 13.358750 | 13.417417 (+0.058667) | 13.417417 (+0.058667) | **+0.000000** | +| valley_price_quantile_20 | N_eff | 761.173410 | 764.155900 (+2.982490) | 761.173410 | 764.155900 (+2.982490) | 764.155900 (+2.982490) | **+0.000000** | +| valley_price_quantile_20 | incr. ICIR | n/a | n/a (—) | 0.309642 | 0.311285 (+0.001643) | 0.305919 (-0.003723) | **-0.005366** | +| valley_price_quantile_20 | **verdict** | Watch | Watch (same) | Watch | Watch (same) | Watch (same) | 同 | +| valley_price_quantile_20 | **三轴标签** | PASS/NOT_/NOT_ | PASS/NOT_/NOT_ (same) | PASS/PASS/NOT_ | PASS/PASS/NOT_ (same) | PASS/PASS/NOT_ (same) | 同 | +| peak_ridge_amount_ratio_20 | IC mean | 0.041017 | 0.041098 (+0.000081) | 0.041017 | 0.041098 (+0.000081) | 0.041098 (+0.000081) | **+0.000000** | +| peak_ridge_amount_ratio_20 | ICIR | 0.510337 | 0.513597 (+0.003260) | 0.510337 | 0.513597 (+0.003260) | 0.513597 (+0.003260) | **+0.000000** | +| peak_ridge_amount_ratio_20 | NW-t | 16.523066 | 16.884838 (+0.361772) | 16.523066 | 16.884838 (+0.361772) | 16.884838 (+0.361772) | **+0.000000** | +| peak_ridge_amount_ratio_20 | N_eff | 933.277963 | 958.265755 (+24.987792) | 933.277963 | 958.265755 (+24.987792) | 958.265755 (+24.987792) | **+0.000000** | +| peak_ridge_amount_ratio_20 | incr. ICIR | n/a | n/a (—) | 0.308282 | 0.315855 (+0.007573) | 0.321288 (+0.013006) | **+0.005433** | +| peak_ridge_amount_ratio_20 | **verdict** | Watch | Watch (same) | Watch | Watch (same) | Watch (same) | 同 | +| peak_ridge_amount_ratio_20 | **三轴标签** | PASS/NOT_/NOT_ | PASS/NOT_/NOT_ (same) | PASS/PASS/NOT_ | PASS/PASS/NOT_ (same) | PASS/PASS/NOT_ (same) | 同 | + +**分解读数**: + +1. **A 与 B 逐指标同号同量级**:IC / ICIR / NW-t / N_eff 四个指标在 A 与 B 两格的 Δ + **完全相同**(书不进入这些指标的计算,44/44 取值逐格相同),差异全部来自已登记的 + `warmup_aggregate_effect`——即 panels 腿那些 warmup 格的下游聚合效应。 +2. **书视图的爆炸半径被限制住了**:artifact 层 C−B 非零的叶子共 **174 个**,且**全部**落在 + `sections[3]`(purity / incremental)+ `book_view` + verdict reasons —— + **174/174 通过**。这是这张表真正说得出的事:**换书视图动到的东西,恰好只在它应该动到的 + 地方**。 +3. **verdict 与三轴标签:11/11 因子、每一格全部未变**(A、B、C 三格各自与冻结同名格比较)。 + 174 个叶子的书视图修正**没有翻动任何一个 Incremental 轴标签**。 + +> ### ⚠️ 一条曾被写成检验、实为**恒真**的推论(留档,不悄悄换掉) +> +> 本节初版在读数 2 里写过:「**若引擎回归藏在别的指标里,上表会显示 C−B 在那些行非零**」。 +> **这句话是假的**,评审用 mutation 直接演示:把 `ic_mean` 在 **A / B / C 三本书里同时 ×1.5**, +> **C−B 仍精确为 0.0**。机制很直白——A/B/C 在 IC/ICIR/NW-t/N_eff 上**取值完全相同**(书对这些 +> 指标**构造性免疫**),所以**引擎回归会让 A、B、C 一起动,然后在 C−B 里对消**。C−B 检不出 +> 引擎回归,一格都检不出。 +> +> 同一句里的「C−B 非零恰好 11 格 / 57 行」也不该当成事实陈述:**11 与 57 是这张表自己选了哪些 +> 行的结果**,artifact 层的真实数字是上面的 **174** 个叶子。 +> +> **真正检出引擎回归的是别的东西**:`no_book` 与 `bookclose` 两格在 **strict 模式**下对冻结基线 +> 的逐叶子闸门(未登记的变化即 FAIL)。**它存在、而且通过了**——本 PR 的引擎结论站在它上面, +> **不站在 C−B 上**。 +> +> **审计结论不受影响,错的是归因**:数字一个没变,变的是"哪个装置在把关"。留档而不是改写, +> 与 `compare_postmerge.py` 那次空对账同一处理——**被证否的推理留在案上**。 + +## 五、判定 | 因子 | 腿 1 性质映射 | 腿 2 anchors | 腿 3 reports | 腿 4 panels | 判定 | |---|---|---|---|---|---| -| (11 行,每行 PASS/FAIL + 一句话归因) | 59/59(本准备步已立) | TBD | TBD | TBD | TBD | +| jump_amount_corr_20 | 59/59 | PASS (failed=0) | PASS (rc=0) | PASS (unclassified=0) | **PASS** | +| minute_ideal_amp_10 | 59/59 | PASS | PASS | PASS | **PASS** | +| amp_marginal_anomaly_vol_20 | 59/59 | PASS | PASS | PASS | **PASS**(F1 修复后) | +| volume_peak_count_20 | 59/59 | PASS | PASS | PASS | **PASS** | +| intraday_amp_cut_10 | 59/59 | PASS | PASS | PASS | **PASS**(F4 判据修正后) | +| peak_interval_kurtosis_20 | 59/59 | PASS | PASS | PASS | **PASS**(F2 + 成因 B 登记后) | +| valley_relative_vwap_20 | 59/59 | PASS | PASS | PASS | **PASS**(同上) | +| valley_ridge_vwap_ratio_20 | 59/59 | PASS | PASS | PASS | **PASS**(F3 + 成因 A/B 登记后) | +| ridge_minute_return_20 | 59/59 | PASS | PASS | PASS | **PASS**(F3 + 成因 A) | +| valley_price_quantile_20 | 59/59 | PASS | PASS | PASS | **PASS** | +| peak_ridge_amount_ratio_20 | 59/59 | PASS | PASS | PASS | **PASS**(F3 重新推导 + 成因 A) | + +(腿 1 的 59/59 来自 `docs/factors/d5_property_test_migration_map.md`,本步未改动它。) + +**整体结论:11 因子四腿全过,C5 通过** ⇒ 满足 C6(删除 11 个旧 runner)的前置条件之一。 +每一处差异都落在 §二 的预登记清单或 §二之三 的本轮新增登记内,**类外差异为 0**。 -**整体结论**:TBD(全 11 因子四腿全过才进入 C6 删除旧 runner;任一不过则旧 runner -不删、迭代新 runner——设计 v3.2 §九 D5 行回滚条款)。 +⚠️ **本判定覆盖的是"新引擎与冻结基线的差异是否全部可归因",不是因子的研究结论。** +十一个因子的 verdict 与三轴标签本轮**一个都没变**,研究侧结论(全部封顶 Watch、无一 Adopt) +不受本步影响。 ## 六、已知局限(登记,不阻塞) @@ -129,3 +380,29 @@ unregistered 计数(strict 格必须为 0);anchors = ok/warmup/failed 行 (限行 + 省略标记已落地,C5 重渲染后残留形态需目视确认)。 5. **census 守卫 cwd 陷阱**:读真实语料的 census 测试在无语料的 worktree 里静默 skip——全量对账的 gate 行必须报「语料可达/不可达」两组数字,别只看总数。 +6. **C5 首轮全量跑执行在 PR #109 之前的判据上 ⇒ cell ceiling 一次都没上过场**(登记, + 不补跑)。`warmup_ceiling=` 字段由 `35de346`(2026-07-28 **02:08**)引入,而 sweep 跑在 + **01:50→05:52**;**幸存的 8 条日志行没有一条带这个字段**(带的三条全是 07-30 的重跑) + ⇒ 整轮 sweep 用的是 #109 之前的分类器。**影响已结清**:ceiling 只对 bounded 因子有效, + 在当前 HEAD(ceiling 生效)上重算,三个 bounded 因子全过——8198/8955、17289/18905、 + 17272/18905。**没有藏住任何失败。** 不补跑的理由:B 阶段本来就在当前 HEAD 上重跑全部 + 11 个因子,ceiling 届时真正上场。但**首轮那一列 panels 结果是另一套判据产出的**, + §二之二 的表要这么读。 +7. **reports 腿在 C5 首轮零观测**(登记):11 个 `rc=1` **全部是 F5**(缺输入文件抛异常), + 没有一个是判定结果。⇒ 该腿至今只被真正观测过**一个**因子(`amp_marginal_anomaly_vol_20`, + F1 修复后重跑 rc=0,六格差异全落在已登记类内)。**B 阶段是这一腿的首次观测**;§四的 + A/B/C 分解因此**只有冻结基线一个参照物**(见 §四的告示)。B 中若 reports 出现类外差异: + **停下上报,不扩类**。 +8. **jump 与 `with_book(decision)` 两格对"值改动"实际不设闸**(背景,**非本 PR 引入**,登记 + 即可、本 PR 不改):jump 的 reports 腿 593 个 diff 里 **518 个**归入既有登记 #11 的 + `registered_correction_effect`(契约 v1.1 的更正承载一旦存在,值差异即被接受); + `with_book(decision)` 格同理是 report-only(登记 #13,`book_view_effect` 全量报告不设闸)。 + **结构性增删(未登记的新增/删除、未登记的 section)仍然设闸。** 读判定时应以 + `no_book` / `bookclose` 两个 strict 格 + panels 腿为值级依据。 +9. **`_make_logger` 是 truncate 模式 —— 重跑会静默销毁同名日志的唯一副本**(登记为已知 + 运维风险,**本 PR 不改**:那是行为改动,属另一个 PR)。本轮已被它咬过一次:为验证判据 + 改动而重跑 `intraday_amp_cut_10` / `peak_interval_kurtosis_20` 的 panels 腿,覆盖掉了这 + 两条 C5 原始日志行(值经"覆盖前逐字抓取 + 离线独立重算"双路复原,见 §二之二 出处)。 + 与 D5a 抓到的"跑一次就毁掉唯一冻结基线"是**同一族失效模式**:**产物是唯一副本,而写它 + 的动作没有意识到这一点**。缓解:`tmp/context/cc_c5_audit/c5_run_logs_preserved/` 存了 + B 之前的快照,且 §二之二 已把数字誊进**被提交的**报告。 diff --git a/docs/factors/d5_runner_difference_catalogue.md b/docs/factors/d5_runner_difference_catalogue.md index dda052d..e8f22f7 100644 --- a/docs/factors/d5_runner_difference_catalogue.md +++ b/docs/factors/d5_runner_difference_catalogue.md @@ -580,3 +580,272 @@ NW-t −16.1546 → −16.1323、periods 1199 → 1209;增量 ICIR:`_bookclo **0/1,213 日不同**、max rel **0.0**。评审独立复测(另写脚本、用 `git show main:` 把修复 前后两个模块载进同一进程喂逐字节相同输入)得 max rel **1.457e-01**、且**整日几何 5/5 逐位相等**——"已发布值不移动"由此**直接证得,不靠推理**。 + +### 七之七、C5 全量对账(11 因子 × 三模式)暴露的五类失败 —— 具名类扩展登记(2026-07-30) + +C5 全量跑(`artifacts/logs/factor_eval_reconcile_*.log`,2026-07-28 那批)暴露五类失败 +F1–F5。F1 是**真引擎缺陷**、已由独立 correctness PR 消除(见 §七之六,**永不作为具名类**); +F5 是 harness/runner 的 artifact 销毁缺陷(修码,见本节末)。**只有 F2/F3/F4 是判据缺口**, +本节登记它们的边界与越界反例。 + +**方法**:先把 11 个因子的 served 面板一次性 dump 下来(`tmp/context/cc_c5_audit/`, +cache-only、`live_calls=0`),再离线对**同一批 served 值**跑改动前后的分类器——同一份输入、 +两套判据,差异才可归因到判据本身而不是两次引擎运行。 + +#### F2 —— `threshold_flip_contamination` 的 bars-only 臂(新增) + +与 §七之五裁定 2 的截面臂**同一物理事件**:600623.SH 2023-06-15 13:07 一根 +`volume=13300.0` 整数 bar 压在 same-slot μ+σ 阈值上,pandas rolling 浮点累加**路径依赖 +加载起点**(thr 差 ~4e-13)⇒ `vol > thr` 翻转。bars-only 因子**没有逐日 OLS 去传播它**, +所以只有**直接受影响的那只 symbol** 能动;窗口内任何**别的** symbol 仍然掉进后面的通用尾部, +量级不够就照样 unclassified 失败。 + +| 参数 | 值 | 实测 | +|---|---|---| +| 窗口 | [2023-06-01, 2023-07-14](**与截面臂同窗,未改**) | 实际落点 2023-06-15..07-14 | +| symbol | 仅 600623.SH(**与截面臂同 symbol,未改**) | 两因子的 20 格全在这一只 | +| 判据 | **相对**(截面臂是绝对) | 见下 | +| `peak_interval_kurtosis_20` | rel ≤ **5e-3** | 实测 max rel **2.904e-3**(max abs 1.07e-2) | +| `valley_relative_vwap_20` | rel ≤ **1e-5** | 实测 max rel **1.530e-6**(恒定绝对偏移) | +| cell 数 | ≤ **25**/因子 | 实测各 **20** 格 | + +**为什么 bars-only 臂用相对判据而截面臂用绝对**:这两个因子的值尺度互不相干——kurtosis 的 +20 格 |diff| 到 **1.07e-2**(若沿用截面臂 2e-04 的绝对界会全数越界),relative_vwap 的 +|diff| 只有 1.53e-6。在其中一个上标定的绝对界对另一个没有意义,故按因子登记**相对**界。 +**未登记的因子拿不到这个类**(jump 在同窗同量级仍 unclassified,有反向测试)。 + +#### F3 —— anchor 截断的两个新表象 + +① **`warmup_left_extension` 方向集新增 `frozen_finite_new_nan`**,**仅** valid-day pooled +因子、**仅**早区窗内。机制:这些因子只在有效日发行、且需要 `min_valid_days` 个有效日, +anchor 边缘两种几何**积累到的有效日个数**可以不同(实证 688276.SH 2021-08-20:旧锚 10 个 +有效日 → 出值,饱和加载 8 个 → NaN)。bounded 因子没有这个计数闸门,其 finite→NaN +**仍然**是 unclassified(反向测试锁定);pooled 因子在早区窗**之外**的 finite→NaN 同样仍失败。 +实测:ridge/valley_ridge 各 7 格、peak_ridge 11 格,全在 2021-07-28..08-23(早区窗内)。 + +② **新具名类 `warmup_sparse_valid_day_tail`**:同一 anchor 截断在**稀疏发行**的 valid-day +pooled 因子上的长尾——有效日少的票把早区差异**带出早区窗**,落在 2021 年 11 月上旬一小簇。 + +**这是一个「枚举类 + 必要条件守卫」,不是机制类。** 这个标签差别是**操作性**的,不是措辞 +洁癖:读到"机制类"的人会以为**可以照机制推导着扩**,读到"枚举类"才知道**不能**。成员就是 +下面那张白名单;密度性质是每个成员都必须满足的**守卫**,不是生成这张名单的**规则**。 + +密度性质说的是三件事: + +1. **稀疏发行是差异"能够"被带出早区窗的前提**——这样的票发行日太少,早区差异来不及在早区窗内 + 摊平。**必要,但远不充分**:实测该谓词("发行密度低于该因子自身中位数")在本窗口内允许 + **420 + 404 + 426 = 1,250** 个 (因子, symbol) 对,而本类实际只覆盖 **18** 个——**约 69 倍**。 + (分母口径:低于中位数**且在窗口内至少发行过一天**的票;窗口内一天都不发行的票根本产生不了格。) +2. **一只稀疏票"是否真的"带出差异**,还取决于它早区**有没有**差异、以及有效日怎么落 + ⇒ 谓词**约束**成员集,**不决定**它。 +3. 所以**白名单是"观测格"的枚举**;可证伪并已检验的是那条**必要条件**:每个成员都满足它 + (18/18)。**本类刻意窄于机制** —— 这正是重点,见下面的反棘轮。 + +| 参数 | 值 | 实测 | +|---|---|---| +| 因子形态 | 仅 valid-day pooled | bounded 因子拿不到(反向测试) | +| 窗口 | [2021-11-01, **2021-11-15**] | 三因子落点的并集 | +| symbol 白名单 | 000034.SZ / 000402.SZ / 000999.SZ / **002281.SZ** / 002375.SZ / 002653.SZ / **300857.SZ** / **600906.SH** / 688183.SH(**9 只**) | 三因子各 5 / 5 / 8 只,并集 9 只 | +| 幅度 | **不设界**(与 warmup 同理由:两种加载几何在该处本就合法地不同) | ridge 最大 rel **1.96**(符号翻转) | +| cell 数 | ≤ **20**/因子 | 实测 13 / 13 / **19** 格 | + +**证伪检查(做了,不是假设)——门槛已从中位收到 p25**:中位那道门按定义就有约一半的票能过, +几乎不可能失败;实测 **18/18** 个 (因子, symbol) 对全部低于该因子自身的 **p25**(窗口 +[2021-07-01, 2021-11-30];最差百分位秩 **21.7%**,**15/18 在 3% 以下**),**0 处违反**。 +收紧后仍 18/18,但守卫从"几乎恒真"变成"能失败"。 + +**mutation 证人(这一条才打中"p25 严格更紧")**:低于中位但 **≥ p25** 的带内共有 **151 只票** +——这些正是**中位门放行、p25 门该拒**的票。取 `000031.SZ`(三因子各发行 **38** 天,vs p25 **36**、 +中位 41/41/40)注入白名单: + +| 配置 | 结果 | +|---|---| +| 白名单干净、守卫 p25 | PASS | +| 注入 `000031.SZ`、守卫 **p25** | **FAIL** | +| 注入 `000031.SZ`、守卫放回**中位** | **PASS** | + +⚠️ **先前在案的两条 mutation 都打不中这个主张**,如实记下:「把门槛放回中位 → 仍全过」只证明 +两道门在当前**已登记成员**上都清关;「塞稠密票 `000009.SZ`(48/41、49/41、47/40)→ 转红」—— +**那只票在中位门下也会红**。两条都没证明"p25 拒掉了中位会放行的东西"。**这与本节记录的其它 +几次是同一个病:一份证据支持的不是它被摆在那里要支持的那个主张。** + +**不对称证据,连同它的射程一起写**:600906.SH 在 peak_ridge 上发行 29 日(低于其 p25)、在 +ridge 上 42 日——它也**只**出现在 peak_ridge 的簇里、**不**出现在 ridge 的。这是一条**条件性** +预言,且**只有否定方向成立**:**稠密 ⇒ 不进簇**(对照组 `volume_peak_count_20`,稠密 pooled、 +92 天发行 83 天,这 9 只票**一格不差**,佐证同一方向)。**稀疏 ⇒ 进簇并不成立**——上面那个 +69 倍量的就是它。而且这是**单点证据**,其稠密一侧只比中位高 **1 天**(42 vs 41),**别让它撑起 +比它能撑的更多**。 + +**⚠️ 反棘轮约束(lead 裁定):本类不得再次放宽。** 一直放宽下去,`unclassified` 总能归零, +而那时具名类就不再是任何主张、只是**残差的描述**,「未编目的差异算失败」(设计 §六.5)也就被 +悄悄废掉了。**若日后又有因子或格需要它,那件事本身就是这个证据 ⇒ 停下上报,不要再打补丁; +尤其不要照密度性质"推导"出新成员——那条谓词允许的是本类覆盖量的 69 倍。** + +**并且:既然没有任何规则决定成员集,反棘轮就是这一类唯一的守卫**,这也把**反向测试从配角提到 +主力**——"白名单外的票必 FAIL / 窗口外的日期必 FAIL / bounded 因子必 FAIL"这三条断言,才是让 +这份枚举保持为枚举的东西。 + +**这组参数是怎么被推错、又怎么重新推的(记录在案)**:初版是 6 只票 / 窗口收在 **11-12**, +**只从两个因子推出来**——因为第三个因子 `peak_ridge_amount_ratio_20` 当时**正在 FAIL +(`unclassified=30`)却被交接文档的结果总表记成通过**(表记 panels=0,而 +`artifacts/logs/factor_eval_reconcile_peak_ridge_amount_ratio_20.log` 白纸黑字 +`unclassified=30 ... ok=False`)。**漏记是怎么发生的**:reports 腿当时对全部 11 个因子都 +rc=1(F5 的 artifact 销毁),三列里有一列整体失效,逐因子核对的注意力被"哪些因子有 F1–F4 +标签"牵走,`peak_ridge` 这一行的 panels 值没有回到日志复核。**所以现在这组参数不是"为了 +覆盖 peak_ridge 而放宽的",而是"原参数推自一个残缺的证据基、现在从三个因子重新推的"。** +后续为防同类问题,11 因子 × 3 模式的「表所记 vs 日志实测」全量对照已做(33 格中仅此 1 格 +不符)并誊进审计报告 §二之二。 + +#### F4 —— 绝对 float-dust 谓词前移到所有区域分支之前 + +`|diff| ≤ 1e-12`(§七之五裁定 3 的绝对臂)原先排在 `_in_warmup` **之后**,于是恰好落在 +warmup 区的几格机器精度尘埃被计成 warmup cell,其**按月计数**把 pooled 非递增闸门顶翻—— +实测 `intraday_amp_cut_10` 月计数 `{07: 8198, 08: 11, 09: 3, 10: 9}`,3→9 判非单调,而 +8/9/10 月那 23 格**全部** `abs ≤ 1e-12`。**独立复算的稳态对照**:该因子改动后的 798 格 +float 尾铺满窗口**全部 59 个月**,每月 **3–28 格(中位 12)**、**100% 走绝对臂**——被争议的 +8/9/10 三个月(11 / 3 / 9 格)**正落在这个分布之内**,与任何别的月份区分不开。 + +裁定:**按机制识别,不按位置**——绝对臂前移到梯子最前(紧跟 1e-12 相对容差之后)。 +它同时先于截面污染窗口,这是有意的:实测 `intraday_amp_cut_10` 那 17 格"污染"**全部**是尘埃, +现在如实归入 float 尾。相对臂(rel ≤ 5e-12)**仍在原位**(区域分支之后),未动。 + +**回归实测(同一批 served 值,改动前 → 改动后)**: + +| 因子 | warmup | 月计数 | float_tail(cap) | contamination | ok | +|---|---|---|---|---|---| +| `intraday_amp_cut_10` | 8221 → **8198** | `{07:8198, 08:11, 09:3, 10:9}` → **`{07:8198}`** | 758 → **798**(1000) | 17 → **0** | False → **True** | +| `valley_price_quantile_20` | 24568 → **24531** | `{07:902, 08:19980, 09:3667, 10:19}` → `{07:902, 08:19980, 09:3649}` | 883 → **925**(1000) | 19177 → 19172 | True → True | +| `jump_amount_corr_20` | 17289(不变) | — | **101 → 101**(cap 101,**恰在界上、未被顶破**) | 0 → 0 | True → True | +| 其余 8 个因子 | 不变 | 不变 | 0 → 0 | 不变 | 不变 | + +尘埃只在两个因子身上落进过区域分支;两者改动后都**远在** float cap 之内。**恰在 cap 上的 +jump 一格未增**(它的 101 格全在 warmup 区外)。 + +#### F5 —— runner 的 artifact 销毁(修码,不是具名类) + +`qt/factor_eval_runner.py::_apply_bookclose_suffix` 原先**先**用共享 stem 写 +`{stem}_exec_with_book.*`、**再** `os.replace` 移到 `_bookclose` ⇒ 按 decision→close 顺序跑完, +decision 的 with-book 三件套被**覆盖后移走**,全 11 因子的 `exec_with_book.*` 在 C5 全量跑里 +全部消失,reconcile reports 腿死在裸 `FileNotFoundError`。修法:后缀**传进写出层** +(`run_exec_basis_evaluation(..., with_book_suffix=)`,默认 `""` ⇒ 既有调用方逐字节不变), +**不再事后移动**;reconcile reports 模式增加前置检查,缺文件时给出"先跑 +`run-factor-eval --book-mode decision`"的可读错误并列出缺了哪几个。半写的 `_bookclose` +配对(只有 json 或只有 md)同样是可读错误——那说明 close 模式跑到一半死了。 + +#### 本节未覆盖、需 lead 另行裁定的一处 + +(本节初稿在此留过一处待裁定项:`peak_ridge_amount_ratio_20` 是 F3 同族的第三个因子, +按当时的窄参数仍有 6 格类外。已由 lead 裁定为**重新推导**而非扩张,处置见上文的 +「**这是一个「枚举类 + 必要条件守卫」,不是机制类**」与「这组参数是怎么被推错、又怎么 +重新推的」两段。⚠️ 这条回指原先指向的是该段的**旧标题**(它把成员判据说成机制)——标题 +连同那个主张已在返工中改掉,于是这条回指一度成为**指向不存在段落的悬空指针,同时又把已被 +撤回的主张复述了一遍**。此处**刻意不逐字引用那句旧话**:把它照抄进来,既会让扫描守卫永远 +归不了零,也会让快速浏览的读者把它当成现行主张。) + +#### 落地后的实测(同一批 served 面板,最终判据) + +**11/11 因子 panels 腿 `unclassified=0`、全部 `ok=True`**: + +| 因子 | warmup | sparse_tail | float_tail (cap) | flip | contam | 月单调 | +|---|---|---|---|---|---|---| +| minute_ideal_amp_10 | 8198 | 0 | 0 (101) | 0 | 0 | — | +| jump_amount_corr_20 | 17289 | 0 | **101 (101)** | 0 | 0 | — | +| amp_marginal_anomaly_vol_20 | 17272 | 0 | 0 (101) | 0 | 0 | — | +| volume_peak_count_20 | 34441 | 0 | 0 (101) | 20 | 0 | True | +| valley_price_quantile_20 | 24531 | 0 | 925 (1000) | 0 | 19172 | True | +| intraday_amp_cut_10 | 8198 | 0 | 798 (1000) | 0 | 0 | True | +| peak_interval_kurtosis_20 | 35152 | 0 | 0 (101) | 0 | **20** | True | +| valley_relative_vwap_20 | 35205 | 0 | 0 (101) | 0 | **20** | True | +| valley_ridge_vwap_ratio_20 | 28168 | **13** | 0 (101) | 0 | 0 | True | +| ridge_minute_return_20 | 27818 | **13** | 0 (101) | 0 | 0 | True | +| peak_ridge_amount_ratio_20 | 27907 | **19** | 0 (101) | 0 | 0 | True | + +⚠️ **`jump_amount_corr_20` 的 float_tail 恰好等于它的 cap(101/101),零余量**——这个 cap +当初就是按该因子的观测值定死的,**再多一格就 FAIL**。本轮的判据改动没有往里加格(它那 101 +格全在 warmup 区外),但**这个零余量本身是登记在案的脆弱点**,别等它某天自己炸。 + +**判据改动经真实 harness 复核(非只跑离线分类器)**:`run-factor-eval-reconcile --mode panels` +在真实缓存上重跑两个代表因子,数字与离线分析逐项一致—— +`intraday_amp_cut_10` **rc=0**(`warmup=8198 float_tail=798 flip_contamination=0 +unclassified=0 monotonic=True`)、`peak_interval_kurtosis_20` **rc=0** +(`warmup=35152 flip_contamination=20 unclassified=0`)。新的逐类 max 行同时给出 +`max_rel by class: float_reordering_tail=2.946e-09, warmup_left_extension=inf`—— +headline 的 `1.976e+00` 完全属于 warmup 类,闸门类只有 2.9e-09,这正是 NIT-1 要消除的误读。 + +### 七之八、C5 phase B 首次观测 reports 腿暴露的两类漂移 —— 登记(2026-07-30) + +**为什么到现在才出现**:C5 首轮 reports 腿对**全部 11 个因子**都 rc=1,而每一个都是 F5 +(artifact 被 close 模式的事后改名销毁 → 抛 `FileNotFoundError`),**没有一个是判定结果**。 +也就是说这一腿在 phase B 之前**从未对这 10 个因子产生过任何观测**。下面两类漂移不是"新出现 +的",是**第一次被人看见**——与 peak_ridge 同一形状:**登记表原本就不完整,因为它是在一个 +样本量为 1 的证据基上建的**。 + +#### 成因 A:诊断 sink 的覆盖率披露作为**追加节**出现(`REGISTERED_EXTRA_SECTIONS` 补全) + +| 因子 | 新增节 | +|---|---| +| `valley_ridge_vwap_ratio_20` | `ridge_scarcity_coverage` | +| `ridge_minute_return_20` | `ridge_scarcity_coverage` | +| `peak_ridge_amount_ratio_20` | `peak_scarcity_coverage` | +| (已登记的先例)`valley_price_quantile_20` | `neutralization_coverage` | + +与 §七之四 #1 的 vpq 完全同形:统一 runner 经 §3.6 扩展点把机制 A 的披露装配进报告,冻结 +artifact 早于它。**vpq 之所以是唯一一条,正因为 vpq 是唯一跑过 reports 腿的因子。** + +**逐因子 × 逐 grid 重新验证(裁定要求,实测非假定)**: + +| 检查 | 结果 | +|---|---| +| ① 追加下标的每个叶子都是 `old=None` 纯新增、无值改写 | **12/12 grid 全过,non_pure=0** | +| ② verdict 标签与冻结基线一致 | **12/12 全过,label_diff=none** | +| ③ **无索引位移** | **12/12:sections `8 → 9`,前 8 个名字逐位对齐,披露节追加在最后** | + +(分母是 **12** = 带追加节的 4 个因子 × 3 个 grid;实现方初版写成 8/8,漏算了 +`with_book(decision)` 那一格,评审逐格复核 12 个全过。) + +③ 的重要性:叶子路径是**按下标**展平比对的,中途插入一节会让其后每一节错位,"其余节全同" +就成了假象。**位移是被检出而非被吸收的**——错位后 `sections[k].name` 会成为 unregistered +**change** 而失败,有专测钉住(并有 mutation:把 `.name` 改动登记掉 → 该测试转红)。 +⚠️ 该测试初版只断言"有 unregistered 差异",**mutation 实测它是靠尾部多出的下标过的、不是靠 +名字错位**——即**为错误的理由通过**;已改成断言存在 `sections[k].name` 的 unregistered +change。这是本轮第 3 次"测试绿但理由不对",同样不是被测试抓住的,是被 mutation 抓住的。 + +#### 成因 B:`spec.description` 值改写(**逐对精确枚举,不用谓词**) + +**这与 §七 登记的 spec 漂移是不同的类**:那条覆盖**新增键**(16→20),这条是**已有键的值 +改写**,两者不合并进同一节。 + +涉及 3 个因子(`peak_interval_kurtosis_20` / `valley_relative_vwap_20` / +`valley_ridge_vwap_ratio_20`),登记方式是**按因子逐条写死确切的 (old, new) 字符串对** +(`REGISTERED_SPEC_DESCRIPTION_REWRITES`)。**任何其它 description 改写——第四个因子,或这 +三个上的另一段新文本——一律不登记、照样 FAIL**(有正反向测试 + mutation:把精确对改成 +"description 改写一律放行"的谓词 → 反向测试转红)。*理由*:`spec.description` 正是读者用来 +知道这个因子算什么的字段;一个宽泛谓词会把**真正改了因子定义陈述**的那一天一起放行。 +**精确枚举在这里是优点,不是笨拙。** + +**语义判定:provenance 改写,不是语义改写** ⇒ 不触发 `spec.version` bump、不触发更正承载 +(与 §七之六 F1 同一判据)。成因是 **D2 迁移**把峰/谷/岭分类从 +`data.clean.intraday_volume_prv` 挪到 `factors.compute.minute.primitives`,三个因子的 +description 相应改名(`REUSED from ...` → `SHARED taxonomy in ...`);而 **D2 本身实测逐位 +一致**(PR #89:14/14 面板 `max_rel_diff=0.0`),因子算的东西一个字节没变。 + +登记的字符串是 artifact 里**已被报告写出层截断**的值(两侧各 214 字符、以截断标记收尾)—— +**故意如此**:登记必须匹配消费者真正打开的东西。截断上限若变,这些对就不再匹配、本腿**响亮 +失败**,这是正确方向。另有一条测试拿**盘上的冻结 artifact** 逐字符核对登记的 old 串 +(mutation:改一个字符 → 转红)。 + +#### 两条共同约束 + +- **正反向测试齐备**:登记项恰好命中 → 归类;未登记的 section 名 / 未登记的 description 对 + / 别的因子的 section → **全部 FAIL 成类外**。 +- ⚠️ **反棘轮**:这两条**都不得再次放宽**。再有新成员出现,说明它在描述残差而非机制 + (成因 A 的机制 = 诊断 sink 的纯新增节;成因 B 的机制 = D2 的一次性 provenance 改写) + ⇒ **停下上报,不要再打补丁。** + +#### 重跑范围(裁定要求:先确认是否触及共用代码) + +改动**只在 reports 腿**:AST 逐函数比对 phase B 那个 commit(`6beb8d8`)与本次 HEAD,变化的 +函数**恰为** `_classify_value_change` / `diff_report_json` / `run_reports_mode` 三个; +`classify_panel_differences` / `classify_anchor_row` / `run_panels_mode` / `run_anchors_mode` +/ `load_anchor_rows` / `frozen_panel_path` / `_build_bundle` **一个字节未变** ⇒ panels 与 +anchors 的结论不受影响,**只重跑 reports 腿**。 diff --git a/qt/cli.py b/qt/cli.py index 6a1e44d..e8f59da 100644 --- a/qt/cli.py +++ b/qt/cli.py @@ -632,6 +632,7 @@ def _cmd_run_factor_eval_reconcile(args: argparse.Namespace) -> int: f"frozen={result.rows_frozen} new={result.rows_new} " f"equal={result.equal} within_tol={result.within_tolerance} " f"warmup={len(result.by_class('warmup_left_extension'))} " + f"sparse_tail={len(result.by_class('warmup_sparse_valid_day_tail'))} " f"float_tail={len(result.by_class('float_reordering_tail'))} " f"threshold_flip={len(result.by_class('threshold_flip_tail'))} " f"flip_contamination={len(result.by_class('threshold_flip_contamination'))} " @@ -639,6 +640,18 @@ def _cmd_run_factor_eval_reconcile(args: argparse.Namespace) -> int: f"unclassified={len(unclassified)} " f"max_rel_diff={result.max_rel_diff:.3e}" ) + # The headline max is taken before any bucketing, so print it per + # class too: a big number that sits entirely inside a registered + # class must not read as an ungated tolerance (review NIT-1). + per_class = result.max_rel_by_class() + print( + " max_rel by class: " + + ( + ", ".join(f"{k}={v:.3e}" for k, v in sorted(per_class.items())) + if per_class + else "(no differing cells)" + ) + ) if result.warmup_by_direction: print(f" warmup by direction: {result.warmup_by_direction}") if result.warmup_by_month: diff --git a/qt/exec_basis_eval.py b/qt/exec_basis_eval.py index 3802f91..88bc673 100644 --- a/qt/exec_basis_eval.py +++ b/qt/exec_basis_eval.py @@ -281,6 +281,7 @@ def run_exec_basis_evaluation( stem: str, force_rebuild: bool = False, book_view: str = View.CLOSE.value, + with_book_suffix: str = "", extra_sections: Sequence[SectionLike] | None = None, ) -> ExecBasisEvaluation: """Build the exec-to-exec returns, sanity-check them, evaluate twice, report. @@ -296,6 +297,18 @@ def run_exec_basis_evaluation( ``decision`` explicitly. Only the caller knows this, so it is a parameter rather than something guessed here. + ``with_book_suffix`` (default ``""`` -> the legacy paths, byte-for-byte) + is appended to the WITH-BOOK artifact stem only, so a caller that runs the + same factor under two book views writes them to two different files IN ONE + STEP. It is a write-time parameter and never a post-hoc move: the D5 C5 F5 + defect was exactly that — the close-book run wrote the shared + ``{stem}_exec_with_book.*`` names first and renamed them afterwards, so the + decision-book artifacts written by an earlier run were destroyed between + the write and the rename, and the reconcile's reports leg then failed on a + file that had existed minutes before. The no-book artifacts carry no book + at all and the sanity report is book-independent, so both keep the shared + stem regardless of this suffix. + ``extra_sections`` (default None -> the legacy behavior, byte-for-byte) are add-Sections appended to BOTH exec reports before they are written — the contract's §3.6 extension point ("may ADD sections but never drop a @@ -425,15 +438,14 @@ def run_exec_basis_evaluation( report_no_book = _with_extra_sections(report_no_book, extra_sections) report_with_book = _with_extra_sections(report_with_book, extra_sections) + with_book_stem = f"{stem}_exec_with_book{with_book_suffix}" nb_md, nb_json = _write_report(report_no_book, report_dir, f"{stem}_exec_no_book") - wb_md, wb_json = _write_report( - report_with_book, report_dir, f"{stem}_exec_with_book" - ) + wb_md, wb_json = _write_report(report_with_book, report_dir, with_book_stem) nb_png = render_factor_dashboard( report_no_book, ir_no_book, report_dir / f"{stem}_exec_no_book_dashboard.png" ) wb_png = render_factor_dashboard( - report_with_book, ir_with_book, report_dir / f"{stem}_exec_with_book_dashboard.png" + report_with_book, ir_with_book, report_dir / f"{with_book_stem}_dashboard.png" ) return ExecBasisEvaluation( spec=exec_spec, diff --git a/qt/factor_eval_disclosures.py b/qt/factor_eval_disclosures.py index 561685c..698380d 100644 --- a/qt/factor_eval_disclosures.py +++ b/qt/factor_eval_disclosures.py @@ -579,6 +579,17 @@ def summarize_neutralization( # --------------------------------------------------------------------------- # # The add-Section bridge (contract §3.6: may ADD, never drop a mandatory one) # --------------------------------------------------------------------------- # +#: Payload keys that are computed PROPERTIES rather than dataclass fields, so +#: ``asdict`` alone does not produce them. Author-once: the reconcile harness +#: derives an add-Section's Markdown line prefixes from this same tuple, and a +#: property listed in only one of the two places would silently unregister a +#: rendered line (measured: three such lines failed the reports leg). +DERIVED_PAYLOAD_PROPERTIES: tuple[str, ...] = ( + "validity_rate", + "return_guard_attrition", +) + + def to_section(name: str, coverage) -> Section: """Pack a coverage disclosure dataclass into an add-Section. @@ -590,7 +601,7 @@ def to_section(name: str, coverage) -> Section: section added this way cannot move a verdict — pinned by test. """ payload: dict[str, object] = dict(asdict(coverage)) - for prop in ("validity_rate", "return_guard_attrition"): + for prop in DERIVED_PAYLOAD_PROPERTIES: if hasattr(coverage, prop): payload[prop] = getattr(coverage, prop) return Section(name=name, payload=payload, note=coverage.render()) @@ -652,6 +663,7 @@ def publishes_neutralization_disclosure(factor) -> bool: __all__ = [ + "DERIVED_PAYLOAD_PROPERTIES", "DisclosureBinding", "NEUTRALIZATION_SECTION_NAME", "NeutralizationCoverage", diff --git a/qt/factor_eval_reconcile.py b/qt/factor_eval_reconcile.py index 770bb9d..6fe225a 100644 --- a/qt/factor_eval_reconcile.py +++ b/qt/factor_eval_reconcile.py @@ -49,21 +49,42 @@ class is CEILED at ``(lookback_depth - 1) x |frozen symbols|`` cells shape still FAILS. If the frozen panel has no finite value at all there is no exemption (the conservative direction). Months after the exempt month stay strictly gated. + 1b. ``warmup_sparse_valid_day_tail`` — the SAME anchor-truncation family on + valid-day POOLED factors with a sparse emission grid, whose early-region + difference is carried forward past the early window instead of averaging + out inside it. AN ENUMERATION CLASS WITH A NECESSARY-CONDITION GUARD, not + a mechanism class — the full statement, with its measured numbers, is on + ``SPARSE_VALID_DAY_TAIL_SYMBOLS`` and is deliberately NOT repeated here. + Registered window + [2021-11-01, 2021-11-15], a symbol whitelist, at most 20 cells per + factor, pooled factors only; amplitude unbounded inside (the geometries + legitimately differ, as in the early region). MUST NOT BE WIDENED AGAIN, + and must NOT be extended by deriving from the density property — see the + constants' anti-ratchet note. 2. ``float_reordering_tail`` — scattered finite-vs-finite cells with rel diff <= 5e-12 (rolling-correlation summation order; the JC1 1e-12 gate is the attributable floor, this is the measured tail above it), OR with abs diff <= 1e-12 regardless of rel (lead ruling 3: on near-zero cross-sectional OLS residuals the rel criterion is meaningless — the - measured dust is ~1e-16 machine precision on ~1e-5 residuals). The - class is CAPPED — 101 cells for bars-only factors (the measured jump - count), 1000 for cross-sectional ones (measured vpq 707 + headroom); - more fails. + measured dust is ~1e-16 machine precision on ~1e-5 residuals). The ABS + arm is evaluated FIRST in the ladder, ahead of every region branch + (C5 F4): dust is uniform across the grid, and letting the region decide + first made a handful of dust cells count as warmup cells and fail the + pooled monthly gate on noise. The class is CAPPED — 101 cells for + bars-only factors (the measured jump count), 1000 for cross-sectional + ones (measured vpq 707 + headroom); more fails. 3. ``threshold_flip_tail`` — count factors (volume_peak): rolling-sigma float noise (~4e-10) times an integer volume sitting on the peak threshold flips the count by EXACTLY +/-1 on a sparse (symbol, day) cluster (measured: 20 cells, 600623.SH 2023-06-15..07-14). Bounds: |delta| == 1 exactly, rel <= 1e-2, at most 25 cells; more fails. - 4. ``threshold_flip_contamination`` — CROSS-SECTIONAL factors only (lead + 4. ``threshold_flip_contamination`` — two arms of one physical event. + BARS-ONLY arm (C5 F2): only the directly affected symbol (600623.SH) + inside the same window, bounded per factor by a RELATIVE tolerance + (``BARS_ONLY_FLIP_CONTAMINATION_REL_TOL``, at most 25 cells) — a + bars-only factor has no per-date OLS to spread the flip, so any other + symbol moving there is a new fact. + CROSS-SECTIONAL arm — (lead ruling 2, catalogue §七之五): the same PRV critical-bar flip family as (3), observed on vpq as a CONTINUOUS value change (valley VWAP -> q_day -> qbar) plus second-order contamination of the whole cross @@ -80,9 +101,15 @@ class is CAPPED — 101 cells for bars-only factors (the measured jump served one by the migrated primitives. 5. the jump cutoff reference — handled by the reference-path selection above. - Anything else (finite->NaN, finite-vs-finite beyond every named tail, a - finite value on a row the frozen panel does not have outside the warmup - boundary) is UNCLASSIFIED and fails the mode. Extra all-NaN rows in the + Anything else (finite->NaN outside the one registered direction below, + finite-vs-finite beyond every named tail, a finite value on a row the frozen + panel does not have outside the warmup boundary) is UNCLASSIFIED and fails + the mode. The one registered finite->NaN direction is + ``warmup_left_extension``'s ``frozen_finite_new_nan`` (C5 F3), available to + VALID-DAY POOLED factors inside the early region only: their emission is + gated by a count of valid days, so the two geometries can accumulate a + different number of them at the anchor edge. A bounded factor has no such + gate and its finite->NaN stays unclassified. Extra all-NaN rows in the served panel are the D4c NaN footprint (registered drift #3) and are counted, not failed. @@ -242,9 +269,114 @@ class is CAPPED — 101 cells for bars-only factors (the measured jump #: measured 19,143 cells -> cap 20,000 THRESHOLD_FLIP_CONTAMINATION_MAX_CELLS = 20_000 +#: ``threshold_flip_contamination``, BARS-ONLY arm (C5 F2, lead ruling): the +#: SAME physical event as the cross-sectional arm — the 600623.SH 2023-06-15 +#: 13:07 ``volume=13300.0`` integer bar sitting on the same-slot mu+sigma +#: threshold, where pandas' rolling float accumulation is path-dependent on the +#: load start (threshold differs by ~4e-13) and flips ``vol > thr``. On a +#: bars-only factor there is no per-date OLS to propagate it, so ONLY the +#: directly affected symbol can move; every other symbol inside the window +#: keeps falling through to the generic tails and fails there if it is a real +#: change. The bound is RELATIVE here (the cross-sectional arm's bound is +#: absolute) because these two factors live on unrelated value scales: measured +#: kurtosis rel 8.2e-5..2.9e-3 with |diff| up to 1.07e-02, relative_vwap a +#: constant rel ~1.53e-06. An absolute bound calibrated on one is meaningless +#: for the other. Per factor, so a factor that is not in this map gets NO +#: bars-only contamination class at all. +BARS_ONLY_FLIP_CONTAMINATION_REL_TOL: dict[str, float] = { + #: measured max rel 2.90e-03 -> bound 5e-03 + "peak_interval_kurtosis_20": 5e-03, + #: measured max rel 1.53e-06 -> bound 1e-05 + "valley_relative_vwap_20": 1e-05, +} +#: measured 20 cells per factor (the same 20 trading days as +#: ``threshold_flip_tail``'s volume_peak cluster) -> cap 25 +BARS_ONLY_FLIP_CONTAMINATION_MAX_CELLS = 25 + EARLY_REGION_LO = pd.Timestamp("2021-07-01") EARLY_REGION_HI = pd.Timestamp("2021-10-31") +#: ``warmup_sparse_valid_day_tail`` (C5 F3 ②, lead ruling): the SECOND surface +#: of the same anchor-truncation family as ``warmup_left_extension``, on +#: valid-day POOLED factors whose emission grid is sparse. +#: +#: THIS IS AN ENUMERATION CLASS WITH A NECESSARY-CONDITION GUARD — NOT a +#: mechanism class. The distinction is operational, not stylistic: a mechanism +#: class could be EXTENDED BY DERIVING from the mechanism, and this one must +#: not be. Membership is the enumerated list below; the density property is a +#: guard every member must satisfy, not a rule that produces the list. +#: +#: What the density property says: +#: +#: 1. Sparse emission is the PRECONDITION that lets an early-region +#: difference be carried PAST the early window: such a name publishes on +#: too few days for the difference to average out inside it. Necessary, +#: and NOWHERE NEAR SUFFICIENT — measured, the predicate ("emission below +#: that factor's own median") admits 420 + 404 + 426 = 1,250 +#: (factor, symbol) pairs inside this window, against the 18 the class +#: actually covers. Roughly 69x. +#: 2. Whether a sparse name ACTUALLY carries a difference forward also +#: depends on whether it had an early-region difference at all and on how +#: its valid days fall. So the predicate CONSTRAINS the member set and +#: does not DETERMINE it. +#: 3. The whitelist is therefore the enumeration of the OBSERVED cells. What +#: is falsifiable and checked is the necessary condition: every member +#: satisfies it (18/18). THE CLASS IS DELIBERATELY NARROWER THAN THE +#: MECHANISM, and that is the point — see the anti-ratchet note. +#: +#: The guard is checked at the 25th PERCENTILE, not the median. A median gate +#: is nearly vacuous by construction (half the universe passes it); measured, +#: all 18 in-class pairs sit below their factor's own p25, with a worst +#: percentile rank of 21.7% and 15 of 18 below 3%. Tightening it turns a guard +#: that almost could not fail into one that can. +#: +#: Supporting ASYMMETRY, with its reach stated: 600906.SH emits 29 days on +#: peak_ridge (below its p25) and 42 on ridge — and it appears in the former's +#: cluster and NOT in the latter's. This is a CONDITIONAL prediction and only +#: its NEGATIVE direction holds: dense => absent (corroborated by the control +#: group volume_peak_count_20, a dense pooled factor at 83 of 92 days, which +#: has zero cells on all nine names). Sparse => present does NOT hold — that +#: is what the 69x above measures. And it is a SINGLE data point whose dense +#: side clears its median by ONE DAY (42 vs 41), so it cannot carry more than +#: it is carrying here. +#: ``tests/test_factor_eval_reconcile.py`` re-runs the guard against the real +#: frozen panels whenever the corpus is on disk. +#: +#: AMPLITUDE IS NOT BOUNDED inside the class (the measured ridge cells include a +#: sign flip, rel 1.96): the two loading geometries legitimately disagree there +#: for the same reason they do inside the early region. The teeth are the +#: window, the symbol whitelist and the cell cap — all three are checked, and +#: the class is only available to pooled factors. +#: +#: ⚠️ ANTI-RATCHET (lead ruling, C5): THIS CLASS MUST NOT BE WIDENED AGAIN. +#: Keep relaxing a class and ``unclassified`` can always be driven to zero, at +#: which point the class stops being a claim at all and becomes a description +#: of the residual — and "an uncatalogued difference is a failure" (design +#: §六.5) has been quietly repealed. If a future factor or cell needs this +#: class, that need is ITSELF evidence of exactly that: STOP AND REPORT, do +#: not patch the bounds — and in particular do NOT "derive" new members from +#: the density property, which admits 69x what the class covers. +#: Because no rule determines membership, this constraint is the ONLY guard +#: this class has, which also PROMOTES THE REVERSE TESTS from a supporting +#: role to the primary one: the assertions that a name outside the whitelist, +#: a date outside the window, or a bounded factor must FAIL are what keep the +#: enumeration an enumeration. +SPARSE_VALID_DAY_TAIL_LO = pd.Timestamp("2021-11-01") +SPARSE_VALID_DAY_TAIL_HI = pd.Timestamp("2021-11-15") +#: THE MEMBER SET ITSELF — the observed cells across the THREE affected factors +#: (ridge_minute_return / valley_ridge_vwap_ratio / peak_ridge_amount_ratio). +#: Not derived from the density property and not derivable from it: see the +#: three numbered points above. +#: ⚠️ An earlier version of this constant held SIX names and ran to 11-12, +#: derived from only TWO of those factors — because the third was FAILING at +#: the time and had been recorded as passing (see catalogue §七之七). +SPARSE_VALID_DAY_TAIL_SYMBOLS: frozenset[str] = frozenset({ + "000034.SZ", "000402.SZ", "000999.SZ", "002281.SZ", "002375.SZ", + "002653.SZ", "300857.SZ", "600906.SH", "688183.SH", +}) +#: measured 13 / 13 / 19 cells across the three factors -> cap 20 +SPARSE_VALID_DAY_TAIL_MAX_CELLS = 20 + #: factor_id -> the frozen exec artifact's report name (qt.exec_baseline_freeze #: FACTORS). Closed map: an unknown factor id is a readable error, never a guess. _FACTOR_TO_REPORT_NAME: dict[str, str] = { @@ -303,45 +435,166 @@ class is CAPPED — 101 cells for bars-only factors (the measured jump #: mechanism B — never an add-Section), so the unified runner's new section is #: an ADDITION against the frozen artifact, registered here per factor; any #: other added section stays unregistered and fails. +#: ⚠️ THE ENUMERATION WAS INCOMPLETE, and for a reason worth stating: until the +#: D5 C5 phase-B run, this leg had NEVER produced a judgement for ten of the +#: eleven factors (every rc=1 was the F5 artifact destruction), so the four +#: OTHER disclosure sections had simply never been seen by anyone. vpq was the +#: only entry because vpq was the only factor whose reports leg had ever run. +#: Registering them is not "widening": the map was fitted to a sample of one. +#: +#: Each addition below was RE-VERIFIED per factor and per grid, not assumed +#: (lead ruling): (1) every leaf under the appended index is ``old=None``, a +#: pure addition with no value rewrite; (2) the verdict labels are unchanged; +#: (3) NO INDEX DISPLACEMENT — measured sections 8 -> 9 with the first eight +#: names pairwise identical and the disclosure appended LAST. (3) matters +#: because the leaf paths are indexed: a section inserted mid-list would shift +#: every later one and "the rest of the sections match" would be an illusion. +#: It is detected rather than assumed — a shifted ``sections[k].name`` shows up +#: as an unregistered CHANGE and fails — and there is a test for that. +#: +#: ⚠️ ANTI-RATCHET (lead ruling): do NOT widen this map again. A further +#: factor needing an entry is evidence that the registration is describing +#: residuals rather than the diagnostics-sink mechanism: STOP AND REPORT. REGISTERED_EXTRA_SECTIONS: dict[str, tuple[str, ...]] = { "valley_price_quantile_20": ("neutralization_coverage",), + "valley_ridge_vwap_ratio_20": ("ridge_scarcity_coverage",), + "ridge_minute_return_20": ("ridge_scarcity_coverage",), + "peak_ridge_amount_ratio_20": ("peak_scarcity_coverage",), } -#: The MD note-line prefix of ``NeutralizationCoverage.render()`` — the -#: disclosure's one-line summary, rendered as the section's note. Kept as a -#: constant with a pinning test (the alternative — re-rendering a zero -#: instance — would couple the reconcile gate to the renderer's format by -#: construction instead of by assertion). -_NEUTRALIZATION_NOTE_PREFIX = "neutralization (T-1 rev20):" +#: ``spec.description`` value rewrites — a DIFFERENT class from the §七 +#: registered spec drift, which covers ADDED KEYS only. This is an existing +#: key whose VALUE changed, and `spec.description` is the field a reader uses +#: to learn what the factor computes, so the registration is a per-factor +#: EXACT PAIR rather than a predicate (lead ruling). A blanket "description +#: rewrites are acceptable" rule would wave through the day someone really +#: changes what a factor claims to compute; exact enumeration is the point, +#: not clumsiness. ANY other rewrite — a fourth factor, or a different new +#: text on these three — stays unregistered and fails. +#: +#: Cause: the D2 migration moved the peak/valley/ridge taxonomy out of +#: ``data.clean.intraday_volume_prv`` into +#: ``factors.compute.minute.primitives``, and each description was updated to +#: name its new home. D2 itself was measured BITWISE identical (PR #89: 14/14 +#: panels at ``max_rel_diff=0.0``), so this is a PROVENANCE rewrite, not a +#: semantic one — which is why it triggers no version bump and no correction +#: carrier, by the same criterion applied to F1. +#: +#: The strings are the artifacts' stored values, i.e. already truncated by the +#: report writer's value cap (both sides 214 chars, ending in the truncation +#: marker). That is deliberate: the registration must match what a consumer +#: opens. If the cap ever changes these pairs stop matching and this leg fails +#: loudly, which is the correct direction. +#: +#: ⚠️ ANTI-RATCHET: do NOT add a fourth entry. See above. +REGISTERED_SPEC_DESCRIPTION_REWRITES: dict[str, tuple[str, str]] = { + "peak_interval_kurtosis_20": ( + "Volume-peak interval kurtosis (Kaiyuan microstructure series #27, " + "SECOND factor 量峰间隔峰度). SAME peak identification as PR-F " + "volume_peak_count (REUSED from data.clean.intraday_volume_prv, not " + "re-implemen...[truncated]", + "Volume-peak interval kurtosis (Kaiyuan microstructure series #27, " + "SECOND factor 量峰间隔峰度). SAME peak identification as PR-F " + "volume_peak_count (SHARED taxonomy in factors.compute.minute." + "primitives, not r...[truncated]", + ), + "valley_relative_vwap_20": ( + "Valley-relative VWAP (Kaiyuan microstructure series #27, THIRD factor " + "量谷相对加权价格). SAME minute classification as PR-F volume_peak_count and " + "PR-H peak_interval_kurtosis (REUSED from data.clean.intraday_v..." + "[truncated]", + "Valley-relative VWAP (Kaiyuan microstructure series #27, THIRD factor " + "量谷相对加权价格). SAME minute classification as PR-F volume_peak_count and " + "PR-H peak_interval_kurtosis (SHARED taxonomy in factors.comput..." + "[truncated]", + ), + "valley_ridge_vwap_ratio_20": ( + "Valley/ridge VWAP ratio (Kaiyuan microstructure series #27, FOURTH " + "factor 谷岭加权价格比). SAME minute classification as PR-F " + "volume_peak_count / PR-H peak_interval_kurtosis / PR-I " + "valley_relative_vwap (REUS...[truncated]", + "Valley/ridge VWAP ratio (Kaiyuan microstructure series #27, FOURTH " + "factor 谷岭加权价格比). SAME minute classification as PR-F " + "volume_peak_count / PR-H peak_interval_kurtosis / PR-I " + "valley_relative_vwap (SHAR...[truncated]", + ), +} + +#: The MD note-line prefix of each registered disclosure's ``render()`` — the +#: one-line summary rendered as the section's note. Kept as constants with a +#: pinning test (the alternative — re-rendering a zero instance — would couple +#: the reconcile gate to the renderer's format by construction instead of by +#: assertion). An unlisted section name is a readable error, never a guess. +_SECTION_NOTE_PREFIXES: dict[str, str] = { + "neutralization_coverage": "neutralization (T-1 rev20):", + "ridge_scarcity_coverage": "ridge scarcity:", + "peak_scarcity_coverage": "peak scarcity:", +} -def _registered_section_md_prefixes(section_names: tuple[str, ...]) -> tuple[str, ...]: +def _registered_section_md_prefixes( + factor_id: str, section_names: tuple[str, ...] +) -> tuple[str, ...]: """The MD line prefixes belonging to a registered add-Section's rendering. An extra section renders (``analytics/eval/render.py._render_section``) as an unnumbered ``## + `` heading, the note line, and one - ``- :`` payload line per dataclass field (derived from the - dataclass, so a field rename breaks the pinning test instead of silently - unregistering). An unknown section name is a readable error — registering - a section whose MD rendering is not catalogued would be a guess. + ``- :`` payload line per dataclass field. The payload dataclass is + DERIVED, not listed again here: mechanism B is + ``NeutralizationCoverage``; mechanism A comes from the factor's own entry + in ``qt.factor_eval_disclosures._DISCLOSURE_BY_CLASS`` via its summarizer's + return annotation. That matters because ``ridge_scarcity_coverage`` is + published by TWO factors with DIFFERENT payloads (``RidgeCoverage`` vs + ``RidgeReturnCoverage``), so the prefixes are per FACTOR and a + section-name-only lookup would be wrong for one of them. A renamed field + or a summarizer returning something else breaks the pinning test instead + of silently unregistering. + + An unknown section name is a readable error — registering a section whose + MD rendering is not catalogued would be a guess. """ - from dataclasses import fields as dc_fields + from dataclasses import fields as dc_fields, is_dataclass + from typing import get_type_hints from qt.factor_eval_disclosures import ( + DERIVED_PAYLOAD_PROPERTIES, NEUTRALIZATION_SECTION_NAME, NeutralizationCoverage, + disclosure_binding_for, ) prefixes: list[str] = [] for name in section_names: - if name == NEUTRALIZATION_SECTION_NAME: - prefixes.append(f"## + {NEUTRALIZATION_SECTION_NAME}") - prefixes.append(_NEUTRALIZATION_NOTE_PREFIX) - prefixes.extend(f"- {f.name}:" for f in dc_fields(NeutralizationCoverage)) - else: + note = _SECTION_NOTE_PREFIXES.get(name) + if note is None: raise ValueError( f"no MD rendering is registered for added section {name!r}." ) + if name == NEUTRALIZATION_SECTION_NAME: + payload: type = NeutralizationCoverage + else: + binding = disclosure_binding_for(factor_registry.build(factor_id)) + if binding is None or binding.section_name != name: + raise ValueError( + f"{factor_id!r} does not publish the add-Section {name!r}; " + "the registration and the disclosure table disagree." + ) + payload = get_type_hints(binding.summarize)["return"] + if not is_dataclass(payload): + raise ValueError( + f"the summarizer for {name!r} does not return a dataclass " + f"({payload!r}), so its MD fields cannot be derived." + ) + prefixes.append(f"## + {name}") + prefixes.append(note) + prefixes.extend(f"- {f.name}:" for f in dc_fields(payload)) + # ... plus the payload's DERIVED properties, which asdict does not + # produce and which therefore have no dataclass field to be read from. + prefixes.extend( + f"- {prop}:" + for prop in DERIVED_PAYLOAD_PROPERTIES + if hasattr(payload, prop) + ) return tuple(prefixes) @@ -467,6 +720,7 @@ def _classify_value_change( correction_expected: bool, corrections_present: bool, strict: bool, + description_rewrite: tuple[str, str] | None = None, ) -> str: """Classify one changed leaf (both sides present, values differ). @@ -498,6 +752,15 @@ def _classify_value_change( return "registered_sanity_stem_rename" if path.endswith(".exec_price_artifact_reused") and old_v is False and new_v is True: return "registered_run_order_artifact" + if ( + path == "spec.description" + and description_rewrite is not None + and (old_v, new_v) == description_rewrite + ): + # The D2 provenance rewrite, matched as an EXACT PAIR. A different old + # or a different new on the same factor is a different fact and stays + # unregistered — which is the whole reason this is not a predicate. + return "registered_d2_provenance_rewrite" if correction_expected and corrections_present: return "registered_correction_effect" if not strict: @@ -517,6 +780,7 @@ def diff_report_json( strict: bool, correction_expected: bool, registered_sections: tuple[str, ...] = (), + description_rewrite: tuple[str, str] | None = None, ) -> ReportDiff: """Diff one (frozen, new) JSON pair leaf by leaf against the registered list. @@ -528,6 +792,8 @@ def diff_report_json( correction — accepted ONLY if the new JSON carries a ``corrections`` block. ``registered_sections``: add-Section names whose whole subtree is a registered addition (§七之四 — the frozen artifact predates the section). + ``description_rewrite``: this factor's registered EXACT (old, new) + ``spec.description`` pair (§七之八). Anything else on that path fails. """ result = ReportDiff(name=name, strict=strict) old_flat, new_flat = _flatten(old), _flatten(new) @@ -548,6 +814,7 @@ def diff_report_json( correction_expected=correction_expected, corrections_present=corrections_present, strict=strict, + description_rewrite=description_rewrite, ) result.diffs.append(LeafDiff(path, old_v, new_v, cls)) elif in_new: @@ -752,6 +1019,32 @@ class PanelDiff: def by_class(self, classification: str) -> list[PanelCellDiff]: return [d for d in self.diffs if d.classification == classification] + def max_rel_by_class(self) -> dict[str, float]: + """Per-class max relative difference, for the run summary. + + ``max_rel_diff`` above is the headline over the WHOLE grid and is + updated before any bucketing, so it is routinely dominated by a + registered class (measured: a headline ``9.03e-01`` that sits entirely + inside ``warmup_left_extension``) while printed next to + ``unclassified=0``. Read alone that invites "the tolerance has no + teeth"; per class it is unambiguous, and since ``diffs`` is never + persisted it would otherwise have to be recomputed from a rerun + (D5 C4a review NIT-1). + + A cell with a missing side (``nan_to_finite`` / ``new_only_finite`` / + ``frozen_finite_new_nan``) has no defined ratio and reports ``inf`` — + never 0.0, which would read as "these cells agree". + """ + out: dict[str, float] = {} + for d in self.diffs: + if d.frozen is None or d.new is None: + rel = float("inf") + else: + denom = max(abs(d.frozen), abs(d.new)) + rel = abs(d.frozen - d.new) / denom if denom > 0 else 0.0 + out[d.classification] = max(out.get(d.classification, 0.0), rel) + return out + def classify_panel_differences( new_values: pd.Series, @@ -768,6 +1061,10 @@ def classify_panel_differences( float_tail_max: int | None = None, flip_rel_tol: float = THRESHOLD_FLIP_REL_TOL, flip_max: int = THRESHOLD_FLIP_MAX_CELLS, + sparse_tail_lo: pd.Timestamp = SPARSE_VALID_DAY_TAIL_LO, + sparse_tail_hi: pd.Timestamp = SPARSE_VALID_DAY_TAIL_HI, + sparse_tail_symbols: frozenset[str] = SPARSE_VALID_DAY_TAIL_SYMBOLS, + sparse_tail_max: int = SPARSE_VALID_DAY_TAIL_MAX_CELLS, ) -> PanelDiff: """Classify every cell difference between the served and the frozen panel. @@ -817,6 +1114,17 @@ def classify_panel_differences( if is_cross_sectional else FLOAT_TAIL_MAX_CELLS ) + # The bars-only contamination arm is per factor and NEVER available to a + # cross-sectional factor (that one has its own arm, with absolute bounds). + bars_only_flip_tol = ( + None if is_cross_sectional + else BARS_ONLY_FLIP_CONTAMINATION_REL_TOL.get(factor_id) + ) + contamination_max = ( + THRESHOLD_FLIP_CONTAMINATION_MAX_CELLS + if is_cross_sectional + else BARS_ONLY_FLIP_CONTAMINATION_MAX_CELLS + ) result = PanelDiff(factor_id=factor_id) frozen = frozen.copy() frozen["date"] = pd.to_datetime(frozen["date"]) @@ -902,8 +1210,40 @@ def _warmup_cell(date, symbol, frozen_v, new_v, direction: str) -> None: result.max_rel_diff = max(result.max_rel_diff, rel) if rel <= tol: result.within_tolerance += 1 + elif abs_diff <= FLOAT_TAIL_ABS_TOL: + # C5 F4 (lead ruling): the absolute float-dust predicate is + # evaluated BEFORE any REGION branch, so a cell is classified + # by its MECHANISM rather than by where it happens to sit. + # Machine-precision dust is uniform across the whole grid — + # measured on intraday_amp_cut: 798 cells spread over all 59 + # months of the window, 3..28 per month (median 12), 100% of + # them on this absolute arm. When the region branches ran + # first, the cells that happened to land in the warmup region + # were counted as WARMUP cells, and their month counts (11, 3, + # 9 for 2021-08/09/10 — squarely inside that 3..28 spread) + # then failed the pooled non-increasing gate on pure noise. + # Moving the predicate up removes the dust from the warmup + # counts instead of weakening the gate, and puts it under the + # float-tail cap wherever it lands. + result.diffs.append( + PanelCellDiff(str(date.date()), str(symbol), float(frozen_v), + float(new_v), "float_reordering_tail") + ) elif _in_warmup(date, symbol): _warmup_cell(date, symbol, frozen_v, new_v, "finite_to_finite") + elif ( + is_pooled + and symbol in sparse_tail_symbols + and sparse_tail_lo <= date <= sparse_tail_hi + ): + # C5 F3 (2) — the sparse valid-day tail of the same anchor + # truncation. Pooled factors only, inside the registered + # window, on a registered sparse name; amplitude unbounded + # inside (see the constant's docstring), cell count capped. + result.diffs.append( + PanelCellDiff(str(date.date()), str(symbol), float(frozen_v), + float(new_v), "warmup_sparse_valid_day_tail") + ) elif ( is_cross_sectional and THRESHOLD_FLIP_CONTAMINATION_LO @@ -930,10 +1270,34 @@ def _warmup_cell(date, symbol, frozen_v, new_v, direction: str) -> None: PanelCellDiff(str(date.date()), str(symbol), float(frozen_v), float(new_v), cls) ) - elif abs_diff <= FLOAT_TAIL_ABS_TOL or rel <= float_tail_tol: - # Lead ruling 3: |diff| <= 1e-12 is float dust REGARDLESS of - # rel (the rel criterion is meaningless on near-zero - # cross-sectional OLS residuals). + elif ( + bars_only_flip_tol is not None + and symbol == THRESHOLD_FLIP_CONTAMINATION_SYMBOL + and THRESHOLD_FLIP_CONTAMINATION_LO + <= date + <= THRESHOLD_FLIP_CONTAMINATION_HI + ): + # C5 F2 (lead ruling): the bars-only arm of the same class. + # Only the DIRECTLY affected symbol qualifies — a bars-only + # factor has no per-date OLS to contaminate the rest of the + # cross section, so any other symbol moving inside this window + # would be a new fact and keeps falling through below. An + # overshoot of the per-factor bound is UNCLASSIFIED here and + # never falls through to the generic tails, exactly as in the + # cross-sectional arm. + cls = ( + "threshold_flip_contamination" + if rel <= bars_only_flip_tol + else "unclassified_finite_vs_finite" + ) + result.diffs.append( + PanelCellDiff(str(date.date()), str(symbol), float(frozen_v), + float(new_v), cls) + ) + elif rel <= float_tail_tol: + # The REL arm of the float tail. (Lead ruling 3's ABS arm now + # runs at the top of the ladder — see the C5 F4 branch — so a + # dust cell is classified the same way wherever it lands.) result.diffs.append( PanelCellDiff(str(date.date()), str(symbol), float(frozen_v), float(new_v), "float_reordering_tail") @@ -949,11 +1313,26 @@ def _warmup_cell(date, symbol, frozen_v, new_v, direction: str) -> None: float(new_v), "unclassified_finite_vs_finite") ) continue - if pd.notna(frozen_v): # finite -> NaN: never in an allowed class - result.diffs.append( - PanelCellDiff(str(date.date()), str(symbol), float(frozen_v), None, - "unclassified_frozen_finite_new_nan") - ) + if pd.notna(frozen_v): + # frozen finite -> new NaN. C5 F3 (1) (lead ruling): this joins the + # warmup direction set for VALID-DAY POOLED factors INSIDE the + # early region, and nowhere else. Mechanism: those factors publish + # only on valid days and need ``min_valid_days`` of them, so at the + # anchor edge the two loading geometries can accumulate a different + # NUMBER of valid days (measured 688276.SH 2021-08-20: 10 under the + # old anchor -> a value, 8 under the saturated load -> NaN). It is + # the same left-extension geometry difference as the other three + # directions, in the one direction that can only appear on a + # factor whose emission is gated by a count. For a BOUNDED factor, + # or outside the early region, finite -> NaN remains unclassified: + # there is no counting gate that could legitimately drop a value. + if is_pooled and _in_warmup(date, symbol): + _warmup_cell(date, symbol, frozen_v, new_v, "frozen_finite_new_nan") + else: + result.diffs.append( + PanelCellDiff(str(date.date()), str(symbol), float(frozen_v), None, + "unclassified_frozen_finite_new_nan") + ) continue # frozen NaN -> new finite: the warmup class or unclassified. if _in_warmup(date, symbol): @@ -997,8 +1376,8 @@ def _warmup_cell(date, symbol, frozen_v, new_v, direction: str) -> None: ) and len(result.by_class("float_reordering_tail")) <= float_tail_max and len(result.by_class("threshold_flip_tail")) <= flip_max - and len(result.by_class("threshold_flip_contamination")) - <= THRESHOLD_FLIP_CONTAMINATION_MAX_CELLS + and len(result.by_class("threshold_flip_contamination")) <= contamination_max + and len(result.by_class("warmup_sparse_valid_day_tail")) <= sparse_tail_max ) return result @@ -1171,24 +1550,73 @@ def run_panels_mode(config_path: str, factor_id: str, repo_root: Path) -> PanelD ) logger.info( "panels %s: rows frozen=%d new=%d equal=%d tol=%d warmup=%d(%s) " - "warmup_ceiling=%s exempt_month=%s " + "warmup_ceiling=%s exempt_month=%s sparse_tail=%d " "float_tail=%d threshold_flip=%d flip_contamination=%d footprint=%d " - "unclassified=%d max_rel=%.3e live_calls=%d ok=%s", + "unclassified=%d max_rel=%.3e max_rel_by_class=%s live_calls=%d ok=%s", factor_id, result.rows_frozen, result.rows_new, result.equal, result.within_tolerance, len(result.by_class("warmup_left_extension")), result.warmup_by_direction, result.warmup_max_cells, result.warmup_exempt_month, + len(result.by_class("warmup_sparse_valid_day_tail")), len(result.by_class("float_reordering_tail")), len(result.by_class("threshold_flip_tail")), len(result.by_class("threshold_flip_contamination")), result.nan_footprint_rows, len([d for d in result.diffs if d.classification.startswith("un")]), - result.max_rel_diff, live_calls, result.ok, + result.max_rel_diff, + {k: f"{v:.3e}" for k, v in sorted(result.max_rel_by_class().items())}, + live_calls, result.ok, ) return result +def require_report_inputs(report_dir: Path, factor_id: str, config_path: str) -> None: + """Fail readably if the reports leg's inputs are not all on disk. + + The four DECISION-book-mode artifacts are required. Before the D5 C5 F5 + fix, running the two book modes in the order decision -> close DESTROYED + the decision with-book artifacts (the close run wrote the shared stem and + renamed it afterwards), and this leg then died on a bare + ``FileNotFoundError`` naming one file — which reads like a run that was + never done, not like a run whose output was deleted after the fact. Naming + every missing file and the command that produces it turns "which of the + six do I have?" into something the message answers. + + The ``_bookclose`` trio stays OPTIONAL (that is the existing contract: a + decision-only run reconciles two grids instead of three), but a HALF + present pair is not — that state means a close-mode run died mid-write. + """ + stem = f"factor_eval_{factor_id}" + required = [ + report_dir / f"{stem}_exec_no_book.json", + report_dir / f"{stem}_exec_no_book.md", + report_dir / f"{stem}_exec_with_book.json", + report_dir / f"{stem}_exec_with_book.md", + ] + missing = [p for p in required if not p.exists()] + if missing: + raise ReconciliationError( + f"reports mode needs the decision-book artifacts of {factor_id!r}, " + f"but {len(missing)} of {len(required)} are absent: " + f"{[p.name for p in missing]}. Produce them first with: " + f"run-factor-eval --config {config_path} --factor {factor_id} " + "--book-mode decision" + ) + bookclose = [ + report_dir / f"{stem}_exec_with_book_bookclose.json", + report_dir / f"{stem}_exec_with_book_bookclose.md", + ] + present = [p for p in bookclose if p.exists()] + if len(present) == 1: + raise ReconciliationError( + f"{factor_id!r} has a HALF-written _bookclose pair " + f"({present[0].name} exists, its counterpart does not). Re-run: " + f"run-factor-eval --config {config_path} --factor {factor_id} " + "--book-mode close" + ) + + def run_reports_mode( config_path: str, factor_id: str, repo_root: Path, *, report_dir: Path | None = None ) -> list[ReportDiff]: @@ -1198,9 +1626,13 @@ def run_reports_mode( cfg = load_config(config_path) report_dir = report_dir or Path(cfg.output.report_dir) report_name = _report_name(factor_id) + require_report_inputs(report_dir, factor_id, config_path) correction_expected = factor_id == "jump_amount_corr_20" registered_sections = REGISTERED_EXTRA_SECTIONS.get(factor_id, ()) - section_md_prefixes = _registered_section_md_prefixes(registered_sections) + section_md_prefixes = _registered_section_md_prefixes( + factor_id, registered_sections + ) + description_rewrite = REGISTERED_SPEC_DESCRIPTION_REWRITES.get(factor_id) stem = f"factor_eval_{factor_id}" results: list[ReportDiff] = [] @@ -1225,6 +1657,7 @@ def run_reports_mode( frozen_json, new_json, name=f"{stem}_exec_{book}.json[{label}]", strict=strict, correction_expected=correction_expected, registered_sections=registered_sections, + description_rewrite=description_rewrite, ) ) new_md = (report_dir / f"{stem}_exec_{book}{'_bookclose' if 'bookclose' in label else ''}.md").read_text() @@ -1338,6 +1771,8 @@ def run_anchors_mode(config_path: str, factor_id: str, repo_root: Path) -> Ancho "ALLOWED_ADDED_JSON_PATHS", "ALLOWED_ADDED_MD_PREFIXES", "AnchorsDiff", + "BARS_ONLY_FLIP_CONTAMINATION_MAX_CELLS", + "BARS_ONLY_FLIP_CONTAMINATION_REL_TOL", "EARLY_REGION_HI", "EARLY_REGION_LO", "FLOAT_TAIL_ABS_TOL", @@ -1349,8 +1784,13 @@ def run_anchors_mode(config_path: str, factor_id: str, repo_root: Path) -> Ancho "PANEL_REL_TOL", "PanelDiff", "REGISTERED_EXTRA_SECTIONS", + "REGISTERED_SPEC_DESCRIPTION_REWRITES", "ReconciliationError", "ReportDiff", + "SPARSE_VALID_DAY_TAIL_HI", + "SPARSE_VALID_DAY_TAIL_LO", + "SPARSE_VALID_DAY_TAIL_MAX_CELLS", + "SPARSE_VALID_DAY_TAIL_SYMBOLS", "THRESHOLD_FLIP_CONTAMINATION_CROSS_ABS_TOL", "THRESHOLD_FLIP_CONTAMINATION_DIRECT_ABS_TOL", "THRESHOLD_FLIP_CONTAMINATION_HI", @@ -1368,6 +1808,7 @@ def run_anchors_mode(config_path: str, factor_id: str, repo_root: Path) -> Ancho "load_anchor_rows", "load_verified_baseline", "require_baseline_verified", + "require_report_inputs", "run_anchors_mode", "run_panels_mode", "run_reports_mode", diff --git a/qt/factor_eval_runner.py b/qt/factor_eval_runner.py index edb1002..5501221 100644 --- a/qt/factor_eval_runner.py +++ b/qt/factor_eval_runner.py @@ -69,9 +69,8 @@ from __future__ import annotations -import os import time -from dataclasses import dataclass, replace +from dataclasses import dataclass from pathlib import Path import pandas as pd @@ -348,23 +347,25 @@ def _coverage_split( return tuple(sorted(covered)), tuple(empty) -def _apply_bookclose_suffix( - exec_basis: ExecBasisEvaluation, report_dir: Path, stem: str -) -> ExecBasisEvaluation: - """``book_mode='close'`` artifact isolation: ``..._exec_with_book_bookclose.*``. +#: ``book_mode='close'`` artifact isolation: the with-book artifacts are +#: written straight to ``..._exec_with_book_bookclose.*``. The no-book +#: artifacts carry no book at all, so they keep the shared stem; the sanity +#: report is book-independent for the same reason. +#: +#: D5 C5 F5: this used to be a POST-HOC ``os.replace`` of files first written +#: under the shared ``_exec_with_book`` stem — which silently destroyed the +#: DECISION-book artifacts of any earlier run, because the close run wrote +#: over them before moving them aside. Running the two book modes in the order +#: decision -> close therefore left no decision artifacts at all, and the +#: reconcile's reports leg died on a bare FileNotFoundError. The suffix now +#: travels INTO the write-out layer, so each book mode only ever writes its +#: own file names and the run order stops mattering. +_BOOKCLOSE_SUFFIX = "_bookclose" - The no-book artifacts carry no book at all, so they keep the shared stem; - the sanity report is book-independent for the same reason. - """ - mapping = { - "with_book_md": report_dir / f"{stem}_exec_with_book_bookclose.md", - "with_book_json": report_dir / f"{stem}_exec_with_book_bookclose.json", - "with_book_dashboard": report_dir - / f"{stem}_exec_with_book_bookclose_dashboard.png", - } - for field_name, dst in mapping.items(): - os.replace(getattr(exec_basis, field_name), dst) - return replace(exec_basis, **mapping) + +def _with_book_suffix(book_mode: str) -> str: + """The with-book artifact suffix for a book mode (see ``_BOOKCLOSE_SUFFIX``).""" + return _BOOKCLOSE_SUFFIX if book_mode == View.CLOSE.value else "" # --------------------------------------------------------------------------- # @@ -477,12 +478,9 @@ def run_factor_eval( report_dir=Path(cfg.output.report_dir), stem=stem, book_view=book_mode, + with_book_suffix=_with_book_suffix(book_mode), extra_sections=extra_sections or None, ) - if book_mode == View.CLOSE.value: - exec_basis = _apply_bookclose_suffix( - exec_basis, Path(cfg.output.report_dir), stem - ) logger.info( "verdict exec no-book: %s (predictive=%s); with-book: %s (incremental=%s)", diff --git a/tests/test_exec_basis_eval.py b/tests/test_exec_basis_eval.py index c9e9aa8..29c4a22 100644 --- a/tests/test_exec_basis_eval.py +++ b/tests/test_exec_basis_eval.py @@ -317,3 +317,48 @@ def test_exec_basis_cli_line_survives_an_absent_metric(tmp_path): # the measured facts are still reported assert "stk_mins_live_calls=0" in line assert "no_bar=" in line + + +def test_with_book_suffix_isolates_the_two_book_views_at_WRITE_time(tmp_path): + """D5 C5 F5: a second book view must not destroy the first one's artifacts. + + The suffix used to be applied AFTER the write, by renaming files that had + already been written under the shared ``_exec_with_book`` stem — so a + close-book run silently overwrote and then moved away the decision-book + run's three artifacts, and the reconcile's reports leg died on files that + had existed minutes earlier. Two runs into ONE report dir, in the order + that used to be destructive; the first run's bytes must survive. + """ + cfg, panel, factor, book, spec, eval_cfg, report_dir = _fixture(tmp_path) + first = run_exec_basis_evaluation( + factor, spec, eval_cfg, book, + cfg=cfg, panel=panel, symbols=list(SYMBOLS), logger=LOGGER, + report_dir=report_dir, stem="demo", book_view="decision", + ) + assert first.with_book_md.name == "demo_exec_with_book.md" + before = { + p: p.read_bytes() + for p in (first.with_book_md, first.with_book_json, first.with_book_dashboard) + } + + second = run_exec_basis_evaluation( + factor, spec, eval_cfg, book, + cfg=cfg, panel=panel, symbols=list(SYMBOLS), logger=LOGGER, + report_dir=report_dir, stem="demo", book_view="close", + with_book_suffix="_bookclose", + ) + + assert second.with_book_md.name == "demo_exec_with_book_bookclose.md" + assert second.with_book_json.name == "demo_exec_with_book_bookclose.json" + assert second.with_book_dashboard.name == "demo_exec_with_book_bookclose_dashboard.png" + for path in (second.with_book_md, second.with_book_json, second.with_book_dashboard): + assert path.exists() + # ... and the first run's artifacts are untouched, byte for byte. + for path, blob in before.items(): + assert path.exists(), f"{path.name} was destroyed by the second run" + assert path.read_bytes() == blob + # The no-book artifacts and the sanity report carry no book and keep the + # shared stem in BOTH runs (they are rewritten, not renamed). + assert second.no_book_md.name == "demo_exec_no_book.md" + assert second.sanity_report_path.name == "demo_exec_basis_sanity.md" + assert second.no_book_md == first.no_book_md diff --git a/tests/test_factor_eval_reconcile.py b/tests/test_factor_eval_reconcile.py index 714edeb..1a6fd7a 100644 --- a/tests/test_factor_eval_reconcile.py +++ b/tests/test_factor_eval_reconcile.py @@ -15,6 +15,9 @@ import pytest from qt.factor_eval_reconcile import ( + REGISTERED_EXTRA_SECTIONS, + REGISTERED_SPEC_DESCRIPTION_REWRITES, + SPARSE_VALID_DAY_TAIL_SYMBOLS, ReconciliationError, check_new_pair_consistency, classify_anchor_row, @@ -23,6 +26,7 @@ diff_report_md, frozen_panel_path, require_baseline_verified, + require_report_inputs, ) # --------------------------------------------------------------------------- # @@ -481,7 +485,9 @@ def test_json_section_addition_is_matched_by_NAME_not_index(): def test_md_registered_section_lines_pass_for_vpq(): from qt.factor_eval_reconcile import _registered_section_md_prefixes - prefixes = _registered_section_md_prefixes(("neutralization_coverage",)) + prefixes = _registered_section_md_prefixes( + "valley_price_quantile_20", ("neutralization_coverage",) + ) result = diff_report_md( _MD_OLD, _MD_OLD + _NEUTRALIZATION_MD, name="t", correction_expected=False, registered_section_lines=prefixes, @@ -505,7 +511,9 @@ def test_md_section_prefixes_are_derived_from_the_dataclass_fields(): from qt.factor_eval_disclosures import NeutralizationCoverage from qt.factor_eval_reconcile import _registered_section_md_prefixes - prefixes = _registered_section_md_prefixes(("neutralization_coverage",)) + prefixes = _registered_section_md_prefixes( + "valley_price_quantile_20", ("neutralization_coverage",) + ) for f in dc_fields(NeutralizationCoverage): assert f"- {f.name}:" in prefixes # the note prefix really is the render() format's head (not a stale copy) @@ -521,7 +529,9 @@ def test_md_section_prefixes_reject_an_unknown_section(): from qt.factor_eval_reconcile import _registered_section_md_prefixes with pytest.raises(ValueError, match="no MD rendering is registered"): - _registered_section_md_prefixes(("bogus_coverage",)) + _registered_section_md_prefixes( + "valley_price_quantile_20", ("bogus_coverage",) + ) # --------------------------------------------------------------------------- # @@ -1236,3 +1246,931 @@ def test_anchor_jump_mismatch_inside_warmup_is_warmup_not_definition_failure(): hand=0.46, service=0.35, is_pooled=False, tol=1e-12, warmup_dates=warmup, ) assert row.classification == "warmup_left_extension" + + +# --------------------------------------------------------------------------- # +# D5 C5 F4 — the ABSOLUTE float-dust predicate runs BEFORE every region branch. +# +# Machine-precision dust exists uniformly across the grid; when the region +# branches ran first, the few dust cells that happened to land in the warmup +# region were counted as warmup cells and their monthly counts failed the +# pooled non-increasing gate on pure noise (measured intraday_amp_cut: +# 2021-08/09/10 = 8/3/9 cells, every one |diff| <= 1e-12). +# --------------------------------------------------------------------------- # +def test_panels_float_dust_inside_the_warmup_boundary_is_dust_not_warmup(): + # A 1e-13 absolute move on the grid's first w-1 dates: dust, whatever + # region it sits in. + frozen = _frozen_panel( + [("2021-07-01", "A", 1e-5), ("2021-07-02", "A", 1.0), ("2021-07-03", "A", 1.0)], + "f", + ) + new = _new_series( + [("2021-07-01", "A", 1e-5 + 1e-13), ("2021-07-02", "A", 1.0), ("2021-07-03", "A", 1.0)] + ) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=False, lookback_depth=3 + ) + assert result.ok, result.diffs + assert len(result.by_class("float_reordering_tail")) == 1 + assert result.by_class("warmup_left_extension") == [] + + +def test_panels_a_real_move_inside_the_warmup_boundary_is_still_warmup(): + # REVERSE of the above: raise the SAME cell's move above the dust floor + # and it goes back to the warmup class. The reorder must not have turned + # the warmup class off. + frozen = _frozen_panel( + [("2021-07-01", "A", 1.0), ("2021-07-02", "A", 1.0), ("2021-07-03", "A", 1.0)], + "f", + ) + new = _new_series( + [("2021-07-01", "A", 1.05), ("2021-07-02", "A", 1.0), ("2021-07-03", "A", 1.0)] + ) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=False, lookback_depth=3 + ) + assert result.ok, result.diffs + assert len(result.by_class("warmup_left_extension")) == 1 + assert result.by_class("float_reordering_tail") == [] + + +def test_panels_pooled_monthly_gate_is_not_failed_by_float_dust(): + # The measured F4 shape: a big genuine warmup month, then later months + # carrying ONLY dust. Under the old ordering those dust cells were warmup + # cells and 1 -> 2 across months failed the non-increasing gate. + rows_f = [("2021-07-01", "A", 1.0), ("2021-07-02", "A", 1.0)] + rows_f += [(f"2021-08-{d:02d}", "A", 1e-5) for d in (2, 3)] + rows_f += [(f"2021-09-{d:02d}", "A", 1e-5) for d in (1, 2, 3)] + rows_f += [("2021-12-01", "A", 1.0)] + frozen = _frozen_panel(rows_f, "f") + # July: the structurally exempt month (first finite value) + a real warmup + # move on 07-02; Aug/Sep: dust only, in a RISING count (2 then 3). + rows_n = [("2021-07-01", "A", 1.0), ("2021-07-02", "A", 1.4)] + rows_n += [(f"2021-08-{d:02d}", "A", 1e-5 + 1e-13) for d in (2, 3)] + rows_n += [(f"2021-09-{d:02d}", "A", 1e-5 + 1e-13) for d in (1, 2, 3)] + rows_n += [("2021-12-01", "A", 1.0)] + new = _new_series(rows_n) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=True, lookback_depth=40 + ) + assert result.ok, result.diffs + assert result.warmup_by_month == {"2021-07": 1} + assert result.warmup_monotonic is True + assert len(result.by_class("float_reordering_tail")) == 5 + + +def test_panels_pooled_monthly_gate_still_fails_on_real_rising_counts(): + # REVERSE: the same shape with the Aug/Sep cells moved ABOVE the dust + # floor. The gate must still fire — the reorder removed noise from the + # counts, it did not weaken the gate. + rows_f = [("2021-07-01", "A", 1.0), ("2021-07-02", "A", 1.0)] + rows_f += [(f"2021-08-{d:02d}", "A", 1.0) for d in (2, 3)] + rows_f += [(f"2021-09-{d:02d}", "A", 1.0) for d in (1, 2, 3)] + rows_f += [("2021-12-01", "A", 1.0)] + frozen = _frozen_panel(rows_f, "f") + rows_n = [("2021-07-01", "A", 1.0), ("2021-07-02", "A", 1.4)] + rows_n += [(f"2021-08-{d:02d}", "A", 1.2) for d in (2, 3)] + rows_n += [(f"2021-09-{d:02d}", "A", 1.2) for d in (1, 2, 3)] + rows_n += [("2021-12-01", "A", 1.0)] + new = _new_series(rows_n) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=True, lookback_depth=40 + ) + assert not result.ok + assert result.warmup_by_month == {"2021-07": 1, "2021-08": 2, "2021-09": 3} + assert result.warmup_monotonic is False + + +def test_panels_float_dust_inside_the_contamination_window_is_dust(): + # The reorder also precedes the contamination window (measured: all 17 of + # intraday_amp_cut's "contamination" cells were dust and now say so). + frozen = _frozen_panel( + [("2023-06-15", "600623.SH", 1e-5), ("2023-06-16", "600623.SH", 1.0)], "f" + ) + new = _new_series( + [("2023-06-15", "600623.SH", 1e-5 + 1e-13), ("2023-06-16", "600623.SH", 1.0)] + ) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=False, lookback_depth=1, + is_cross_sectional=True, + ) + assert result.ok, result.diffs + assert len(result.by_class("float_reordering_tail")) == 1 + assert result.by_class("threshold_flip_contamination") == [] + + +# --------------------------------------------------------------------------- # +# D5 C5 F2 — threshold_flip_contamination, BARS-ONLY arm (per-factor REL bound) +# --------------------------------------------------------------------------- # +def _flip_cells(factor_id: str, rel: float, n: int, *, symbol="600623.SH"): + """n cells on ``symbol`` inside the registered flip window, at ~``rel``.""" + dates = pd.date_range("2023-06-15", periods=n, freq="D").strftime("%Y-%m-%d") + frozen = _frozen_panel([(d, symbol, 1.0) for d in dates], factor_id) + new = _new_series([(d, symbol, 1.0 / (1.0 - rel)) for d in dates]) + return new, frozen + + +def test_panels_bars_only_flip_contamination_within_the_per_factor_bound(): + new, frozen = _flip_cells("peak_interval_kurtosis_20", 2.9e-03, 3) + result = classify_panel_differences( + new, frozen, factor_id="peak_interval_kurtosis_20", is_pooled=True, + lookback_depth=1, + ) + assert result.ok, result.diffs + assert len(result.by_class("threshold_flip_contamination")) == 3 + + +def test_panels_bars_only_flip_above_the_per_factor_bound_fails(): + # REVERSE: one order of magnitude past kurtosis's 5e-3 bound. + new, frozen = _flip_cells("peak_interval_kurtosis_20", 5e-02, 3) + result = classify_panel_differences( + new, frozen, factor_id="peak_interval_kurtosis_20", is_pooled=True, + lookback_depth=1, + ) + assert not result.ok + assert len(result.by_class("unclassified_finite_vs_finite")) == 3 + + +def test_panels_bars_only_flip_bound_is_PER_FACTOR(): + # The same 1e-3 cell passes for kurtosis (bound 5e-3) and FAILS for + # valley_relative_vwap (bound 1e-5) — a single shared bound calibrated on + # either factor would be wrong about the other. + kurt_new, kurt_frozen = _flip_cells("peak_interval_kurtosis_20", 1e-03, 2) + kurt = classify_panel_differences( + kurt_new, kurt_frozen, factor_id="peak_interval_kurtosis_20", + is_pooled=True, lookback_depth=1, + ) + vwap_new, vwap_frozen = _flip_cells("valley_relative_vwap_20", 1e-03, 2) + vwap = classify_panel_differences( + vwap_new, vwap_frozen, factor_id="valley_relative_vwap_20", + is_pooled=True, lookback_depth=1, + ) + assert kurt.ok and len(kurt.by_class("threshold_flip_contamination")) == 2 + assert not vwap.ok + assert len(vwap.by_class("unclassified_finite_vs_finite")) == 2 + + +def test_panels_bars_only_flip_is_not_available_to_an_unregistered_factor(): + # REVERSE: jump lives in the same window with the same magnitude and is + # NOT in the registry -> it never gets the class. + new, frozen = _flip_cells("jump_amount_corr_20", 2.9e-03, 3) + result = classify_panel_differences( + new, frozen, factor_id="jump_amount_corr_20", is_pooled=False, + lookback_depth=1, + ) + assert not result.ok + assert result.by_class("threshold_flip_contamination") == [] + assert len(result.by_class("unclassified_finite_vs_finite")) == 3 + + +def test_panels_bars_only_flip_is_only_the_directly_affected_symbol(): + # REVERSE: a bars-only factor has no per-date OLS to spread the flip, so + # another symbol moving inside the window is a NEW FACT, not contamination. + new, frozen = _flip_cells("peak_interval_kurtosis_20", 2.9e-03, 3, symbol="600000.SH") + result = classify_panel_differences( + new, frozen, factor_id="peak_interval_kurtosis_20", is_pooled=True, + lookback_depth=1, + ) + assert not result.ok + assert result.by_class("threshold_flip_contamination") == [] + + +def test_panels_bars_only_flip_outside_the_window_fails(): + # REVERSE: same symbol, same magnitude, one day past the window's end. + frozen = _frozen_panel([("2023-07-17", "600623.SH", 1.0)], "peak_interval_kurtosis_20") + new = _new_series([("2023-07-17", "600623.SH", 1.0029)]) + result = classify_panel_differences( + new, frozen, factor_id="peak_interval_kurtosis_20", is_pooled=True, + lookback_depth=1, + ) + assert not result.ok + assert len(result.by_class("unclassified_finite_vs_finite")) == 1 + + +def test_panels_bars_only_flip_cell_cap(): + at_cap_new, at_cap_frozen = _flip_cells("peak_interval_kurtosis_20", 2.9e-03, 25) + at_cap = classify_panel_differences( + at_cap_new, at_cap_frozen, factor_id="peak_interval_kurtosis_20", + is_pooled=True, lookback_depth=1, + ) + over_new, over_frozen = _flip_cells("peak_interval_kurtosis_20", 2.9e-03, 26) + over = classify_panel_differences( + over_new, over_frozen, factor_id="peak_interval_kurtosis_20", + is_pooled=True, lookback_depth=1, + ) + assert at_cap.ok and len(at_cap.by_class("threshold_flip_contamination")) == 25 + # every cell still CLASSIFIES; the cap is a count gate on the class + assert len(over.by_class("threshold_flip_contamination")) == 26 + assert not over.ok + + +def test_panels_bars_only_bound_never_applies_to_a_cross_sectional_factor(): + # Exclusivity: a cross-sectional factor uses the ABSOLUTE arm even if its + # id is in the bars-only registry. rel 2.9e-3 on a value of 1.0 is + # |diff| 2.9e-3, past the direct-symbol 2e-4 absolute bound -> FAIL. + new, frozen = _flip_cells("peak_interval_kurtosis_20", 2.9e-03, 3) + result = classify_panel_differences( + new, frozen, factor_id="peak_interval_kurtosis_20", is_pooled=True, + lookback_depth=1, is_cross_sectional=True, + ) + assert not result.ok + assert len(result.by_class("unclassified_finite_vs_finite")) == 3 + + +# --------------------------------------------------------------------------- # +# D5 C5 F3 (1) — frozen-finite -> new-NaN joins the warmup direction set, for +# VALID-DAY POOLED factors inside the early region only. +# --------------------------------------------------------------------------- # +def test_panels_pooled_frozen_finite_new_nan_in_the_early_region_is_warmup(): + frozen = _frozen_panel( + [("2021-08-20", "688276.SH", 0.42), ("2021-12-01", "688276.SH", 0.5)], "f" + ) + new = _new_series( + [("2021-08-20", "688276.SH", NAN), ("2021-12-01", "688276.SH", 0.5)] + ) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=True, lookback_depth=40 + ) + assert result.ok, result.diffs + assert result.warmup_by_direction == {"frozen_finite_new_nan": 1} + assert result.warmup_by_month == {"2021-08": 1} + + +def test_panels_bounded_frozen_finite_new_nan_is_never_warmup(): + # REVERSE: a bounded factor has no valid-day counting gate that could + # legitimately drop a value — inside its own warmup boundary or not. + frozen = _frozen_panel( + [("2021-08-20", "688276.SH", 0.42), ("2021-12-01", "688276.SH", 0.5)], "f" + ) + new = _new_series( + [("2021-08-20", "688276.SH", NAN), ("2021-12-01", "688276.SH", 0.5)] + ) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=False, lookback_depth=40 + ) + assert not result.ok + assert len(result.by_class("unclassified_frozen_finite_new_nan")) == 1 + + +def test_panels_pooled_frozen_finite_new_nan_outside_the_early_region_fails(): + # REVERSE: the same disappearance one month past the early region. + frozen = _frozen_panel( + [("2021-11-20", "688276.SH", 0.42), ("2021-12-01", "688276.SH", 0.5)], "f" + ) + new = _new_series( + [("2021-11-20", "688276.SH", NAN), ("2021-12-01", "688276.SH", 0.5)] + ) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=True, lookback_depth=40 + ) + assert not result.ok + assert len(result.by_class("unclassified_frozen_finite_new_nan")) == 1 + + +# --------------------------------------------------------------------------- # +# D5 C5 F3 (2) — warmup_sparse_valid_day_tail +# --------------------------------------------------------------------------- # +def _sparse_tail_cells(n: int, *, symbol="000402.SZ", start="2021-11-01", value=-0.5): + dates = pd.bdate_range(start, periods=n).strftime("%Y-%m-%d") + frozen = _frozen_panel([(d, symbol, 0.5) for d in dates], "f") + new = _new_series([(d, symbol, value) for d in dates]) + return new, frozen + + +def test_panels_sparse_valid_day_tail_is_registered_for_pooled_factors(): + # Amplitude is deliberately unbounded inside the class: the measured + # ridge_minute_return cells include a sign flip (rel 1.96). + new, frozen = _sparse_tail_cells(3) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=True, lookback_depth=40 + ) + assert result.ok, result.diffs + assert len(result.by_class("warmup_sparse_valid_day_tail")) == 3 + + +def test_panels_sparse_valid_day_tail_is_not_available_to_bounded_factors(): + new, frozen = _sparse_tail_cells(3) + result = classify_panel_differences( + # lookback_depth=1 -> no warmup dates at all, so the only class that + # could take these cells is the sparse tail, and a bounded factor + # must not get it. + new, frozen, factor_id="f", is_pooled=False, lookback_depth=1 + ) + assert not result.ok + assert result.by_class("warmup_sparse_valid_day_tail") == [] + assert len(result.by_class("unclassified_finite_vs_finite")) == 3 + + +def test_panels_sparse_valid_day_tail_symbol_whitelist_has_teeth(): + # REVERSE: a name that is not on the sparse whitelist, same window. + new, frozen = _sparse_tail_cells(3, symbol="600519.SH") + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=True, lookback_depth=40 + ) + assert not result.ok + assert len(result.by_class("unclassified_finite_vs_finite")) == 3 + + +def test_panels_sparse_valid_day_tail_window_has_teeth(): + # REVERSE, re-cut for the re-derived boundary: the window now ends + # 2021-11-15 (a Monday), so the first date OUTSIDE it is 2021-11-16. + frozen = _frozen_panel([("2021-11-16", "000402.SZ", 0.5)], "f") + new = _new_series([("2021-11-16", "000402.SZ", -0.5)]) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=True, lookback_depth=40 + ) + assert not result.ok + assert len(result.by_class("unclassified_finite_vs_finite")) == 1 + + +def test_panels_sparse_valid_day_tail_cell_cap(): + at_cap_new, at_cap_frozen = _sparse_tail_cells(20) + over_new, over_frozen = _sparse_tail_cells(21) + at_cap = classify_panel_differences( + at_cap_new, at_cap_frozen, factor_id="f", is_pooled=True, lookback_depth=40, + sparse_tail_hi=pd.Timestamp("2021-12-31"), + ) + over = classify_panel_differences( + over_new, over_frozen, factor_id="f", is_pooled=True, lookback_depth=40, + sparse_tail_hi=pd.Timestamp("2021-12-31"), + ) + assert at_cap.ok and len(at_cap.by_class("warmup_sparse_valid_day_tail")) == 20 + assert len(over.by_class("warmup_sparse_valid_day_tail")) == 21 + assert not over.ok + + +def test_panels_sparse_valid_day_tail_does_not_enter_the_monthly_warmup_counts(): + # It sits OUTSIDE the early region by construction; feeding it into the + # monthly non-increasing machinery would fail that gate by definition. + new, frozen = _sparse_tail_cells(3) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=True, lookback_depth=40 + ) + assert result.warmup_by_month == {} + assert result.warmup_by_direction == {} + + +# --------------------------------------------------------------------------- # +# D5 C4a review NIT-1 — per-class max rel in the run summary +# --------------------------------------------------------------------------- # +def test_max_rel_by_class_separates_a_registered_headline_from_the_gate(): + # The headline max_rel_diff is taken before any bucketing; here it is + # 0.5 and belongs ENTIRELY to the warmup class, while the only other + # differing cell is dust. Printed alone the headline reads like an + # ungated tolerance. + frozen = _frozen_panel( + [("2021-07-01", "A", 1.0), ("2021-07-02", "A", 1.0), ("2021-09-01", "A", 1e-5)], + "f", + ) + new = _new_series( + [("2021-07-01", "A", 2.0), ("2021-07-02", "A", 1.0), ("2021-09-01", "A", 1e-5 + 1e-13)] + ) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=False, lookback_depth=3 + ) + assert result.ok, result.diffs + assert result.max_rel_diff == pytest.approx(0.5) + by_class = result.max_rel_by_class() + assert by_class["warmup_left_extension"] == pytest.approx(0.5) + # ... while the other differing cell is seven orders of magnitude smaller + # (its RATIO is 1e-8 — it qualifies on the absolute arm, which is exactly + # why reading the headline as "the tolerance" is wrong). + assert by_class["float_reordering_tail"] < 1e-6 + + +def test_max_rel_by_class_reports_inf_for_one_sided_cells(): + # A NaN -> finite cell has no ratio; reporting 0.0 would read as "these + # cells agree". + frozen = _frozen_panel([("2021-07-01", "A", NAN), ("2021-07-02", "A", 1.0)], "f") + new = _new_series([("2021-07-01", "A", 0.7), ("2021-07-02", "A", 1.0)]) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=False, lookback_depth=3 + ) + assert result.max_rel_by_class()["warmup_left_extension"] == float("inf") + + +# --------------------------------------------------------------------------- # +# D5 C5 F5 — the reports leg names its missing inputs instead of dying on the +# first one it happens to open. +# --------------------------------------------------------------------------- # +def _write_report_set(report_dir: Path, factor_id: str, *, bookclose: bool): + report_dir.mkdir(parents=True, exist_ok=True) + stem = f"factor_eval_{factor_id}" + names = [ + f"{stem}_exec_no_book.json", f"{stem}_exec_no_book.md", + f"{stem}_exec_with_book.json", f"{stem}_exec_with_book.md", + ] + if bookclose: + names += [ + f"{stem}_exec_with_book_bookclose.json", + f"{stem}_exec_with_book_bookclose.md", + ] + for name in names: + (report_dir / name).write_text("{}", encoding="utf-8") + return stem + + +def test_report_inputs_complete_decision_set_passes_with_or_without_bookclose(tmp_path): + for bookclose in (False, True): + d = tmp_path / f"bc_{bookclose}" + _write_report_set(d, "jump_amount_corr_20", bookclose=bookclose) + require_report_inputs(d, "jump_amount_corr_20", "config/x.yaml") + + +def test_report_inputs_names_every_missing_decision_artifact(tmp_path): + stem = _write_report_set(tmp_path, "jump_amount_corr_20", bookclose=True) + (tmp_path / f"{stem}_exec_with_book.json").unlink() + (tmp_path / f"{stem}_exec_with_book.md").unlink() + with pytest.raises(ReconciliationError) as excinfo: + require_report_inputs(tmp_path, "jump_amount_corr_20", "config/x.yaml") + message = str(excinfo.value) + # BOTH missing files are named (the bare FileNotFoundError named one) ... + assert f"{stem}_exec_with_book.json" in message + assert f"{stem}_exec_with_book.md" in message + # ... and the message says what to run, with the config it was given. + assert "--book-mode decision" in message + assert "config/x.yaml" in message + + +def test_report_inputs_reject_a_half_written_bookclose_pair(tmp_path): + stem = _write_report_set(tmp_path, "jump_amount_corr_20", bookclose=True) + (tmp_path / f"{stem}_exec_with_book_bookclose.md").unlink() + with pytest.raises(ReconciliationError, match="HALF-written"): + require_report_inputs(tmp_path, "jump_amount_corr_20", "config/x.yaml") + + +def test_panels_sparse_valid_day_tail_last_registered_day_is_inside(): + # The re-derived window's LAST day must be inside it (the boundary moved + # to 2021-11-15 because one real cell sat there); paired with the reverse + # test above, the two pin the edge from both sides. + frozen = _frozen_panel([("2021-11-15", "000402.SZ", 0.5)], "f") + new = _new_series([("2021-11-15", "000402.SZ", -0.5)]) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=True, lookback_depth=40 + ) + assert result.ok, result.diffs + assert len(result.by_class("warmup_sparse_valid_day_tail")) == 1 + + +#: The re-derived membership, written out INDEPENDENTLY of the constant. +#: Parametrizing over ``SPARSE_VALID_DAY_TAIL_SYMBOLS`` itself cannot detect a +#: name being dropped — the case simply disappears and the suite still passes +#: (measured: removing 600906.SH took the run from 9 passed to 8 passed, green +#: both times). A test whose expectations are read from the thing under test +#: is the shape this repo has been bitten by repeatedly. +_EXPECTED_SPARSE_TAIL_SYMBOLS = ( + "000034.SZ", "000402.SZ", "000999.SZ", "002281.SZ", "002375.SZ", + "002653.SZ", "300857.SZ", "600906.SH", "688183.SH", +) + + +def test_the_sparse_tail_whitelist_is_exactly_the_re_derived_membership(): + assert SPARSE_VALID_DAY_TAIL_SYMBOLS == frozenset(_EXPECTED_SPARSE_TAIL_SYMBOLS) + assert len(_EXPECTED_SPARSE_TAIL_SYMBOLS) == 9 + + +@pytest.mark.parametrize("symbol", _EXPECTED_SPARSE_TAIL_SYMBOLS) +def test_panels_sparse_tail_whitelist_members_are_each_accepted(symbol): + # Every registered name is really usable (a typo'd ticker would sit in the + # constant looking authoritative while never matching anything). + frozen = _frozen_panel([("2021-11-02", symbol, 0.5)], "f") + new = _new_series([("2021-11-02", symbol, -0.5)]) + result = classify_panel_differences( + new, frozen, factor_id="f", is_pooled=True, lookback_depth=40 + ) + assert result.ok, result.diffs + assert len(result.by_class("warmup_sparse_valid_day_tail")) == 1 + + +# --------------------------------------------------------------------------- # +# The sparse-tail class's NECESSARY-CONDITION GUARD, checked against the real +# frozen panels. The claim it guards is stated ONCE, on +# ``SPARSE_VALID_DAY_TAIL_SYMBOLS`` in qt/factor_eval_reconcile.py; this banner +# deliberately does not restate it, because the same sentence written in four +# places is how three of the four got corrected and the fourth kept asserting +# a median gate fifteen lines above code that reads p25. +# --------------------------------------------------------------------------- # +_SPARSE_TAIL_FACTORS = ( + "ridge_minute_return_20", + "valley_ridge_vwap_ratio_20", + "peak_ridge_amount_ratio_20", +) +#: The emission window the percentiles are taken over: the early region plus +#: the November cluster the class covers. +_DENSITY_LO, _DENSITY_HI = "2021-07-01", "2021-11-30" +_FROZEN_PANELS = Path("artifacts/refactor_baseline/panels") + +requires_frozen_panels = pytest.mark.skipif( + not all((_FROZEN_PANELS / f"{f}.parquet").exists() for f in _SPARSE_TAIL_FACTORS), + reason="frozen D1 panels not on disk (artifacts/ is gitignored)", +) + + +def _emission_density(factor_id: str) -> tuple[dict[str, int], float]: + """(symbol -> emitted days, the factor's p25) over the density window. + + The threshold is the 25th percentile, NOT the median. A median gate is + nearly vacuous by construction — half the universe passes it — so a guard + built on it could hardly fail. Measured, all in-class pairs sit below p25 + (worst percentile rank 21.7%, 15 of 18 below 3%), so tightening costs + nothing here and turns an almost-unfailable check into one with teeth. + """ + frame = pd.read_parquet(_FROZEN_PANELS / f"{factor_id}.parquet") + frame["date"] = pd.to_datetime(frame["date"]) + window = frame[(frame["date"] >= _DENSITY_LO) & (frame["date"] <= _DENSITY_HI)] + emitted = window.groupby("symbol")[factor_id].apply(lambda s: int(s.notna().sum())) + return emitted.to_dict(), float(emitted.quantile(0.25)) + + +@requires_frozen_panels +def test_every_sparse_tail_name_is_sparse_on_some_affected_factor(): + """Each whitelisted name must satisfy the NECESSARY condition somewhere. + + This is a guard on the enumeration, not a rule that produces it: the + predicate admits roughly 69x as many (factor, symbol) pairs as the class + covers, so passing it says only that no member contradicts the story. + A name at or above p25 on all three affected factors WOULD contradict it — + the class would then be admitting that name for some reason nobody has + stated. The ruling on that is explicit: STOP, do not widen. + """ + density = {f: _emission_density(f) for f in _SPARSE_TAIL_FACTORS} + offenders = {} + for symbol in sorted(SPARSE_VALID_DAY_TAIL_SYMBOLS): + seen = { + f: (emitted.get(symbol), median) + for f, (emitted, median) in density.items() + if symbol in emitted + } + if not any(e < m for e, m in seen.values()): + offenders[symbol] = seen + assert not offenders, ( + "whitelisted name(s) are NOT sparse on any affected factor, so the " + f"class is not describing the mechanism it claims: {offenders}" + ) + + +@requires_frozen_panels +def test_the_density_criterion_predicts_where_600906_appears_and_where_it_does_not(): + """The asymmetry, with its reach: only the NEGATIVE direction holds. + + 600906.SH is below p25 on peak_ridge and above the median on ridge — and it + shows up in the former's November cluster and not in the latter's. What + that supports is "dense => absent" (the control group volume_peak_count_20 + corroborates it: zero cells on all nine names). It does NOT support + "sparse => present", which is false by roughly 69x. It is also ONE data + point, dense by a single day. + """ + peak_emitted, peak_p25 = _emission_density("peak_ridge_amount_ratio_20") + ridge_emitted, _ridge_p25 = _emission_density("ridge_minute_return_20") + assert peak_emitted["600906.SH"] < peak_p25 + # The dense side is stated against the MEDIAN and clears it by ONE DAY + # (42 vs 41) — a single data point, recorded with its reach rather than + # leaned on. Only the negative direction of the prediction holds + # (dense => absent); sparse => present is false, by 69x. + assert ridge_emitted["600906.SH"] >= 41 + + +# --------------------------------------------------------------------------- # +# D5 C5 phase B — the two drifts the reports leg had never shown anyone, +# because until phase B it had never produced a judgement for these factors. +# +# A: the diagnostics-sink coverage disclosures arriving as an appended section. +# B: the D2 provenance rewrite of spec.description, registered as EXACT PAIRS. +# --------------------------------------------------------------------------- # +def _json_with_sections(names: list[str]) -> dict: + doc = _frozen_like() + doc["sections"] = [{"name": n, "status": "ok", "payload": {"v": 1}} for n in names] + return doc + + +def test_registered_disclosure_sections_are_additions_for_each_factor(): + for factor_id, section in ( + ("valley_ridge_vwap_ratio_20", "ridge_scarcity_coverage"), + ("ridge_minute_return_20", "ridge_scarcity_coverage"), + ("peak_ridge_amount_ratio_20", "peak_scarcity_coverage"), + ("valley_price_quantile_20", "neutralization_coverage"), + ): + old = _json_with_sections(["ic", "quantiles"]) + new = _with_registered_additions(_json_with_sections(["ic", "quantiles", section])) + result = diff_report_json( + old, new, name=factor_id, strict=True, correction_expected=False, + registered_sections=REGISTERED_EXTRA_SECTIONS[factor_id], + ) + assert result.ok, (factor_id, [d for d in result.diffs if "unregistered" in d.classification]) + assert result.by_class("registered_section_addition") + + +def test_an_unregistered_disclosure_section_still_fails(): + # REVERSE: a section that is not in this factor's registered tuple. + old = _json_with_sections(["ic", "quantiles"]) + new = _with_registered_additions( + _json_with_sections(["ic", "quantiles", "some_other_coverage"]) + ) + result = diff_report_json( + old, new, name="ridge_minute_return_20", strict=True, correction_expected=False, + registered_sections=REGISTERED_EXTRA_SECTIONS["ridge_minute_return_20"], + ) + assert not result.ok + assert result.by_class("unregistered_addition") + + +def test_a_section_registered_for_ANOTHER_factor_is_not_accepted_here(): + # The map is per factor: ridge's disclosure must not pass for a factor + # whose registered tuple names a different section. + old = _json_with_sections(["ic", "quantiles"]) + new = _with_registered_additions( + _json_with_sections(["ic", "quantiles", "ridge_scarcity_coverage"]) + ) + result = diff_report_json( + old, new, name="valley_price_quantile_20", strict=True, + correction_expected=False, + registered_sections=REGISTERED_EXTRA_SECTIONS["valley_price_quantile_20"], + ) + assert not result.ok + + +def test_a_section_INSERTED_mid_list_is_detected_not_absorbed(): + """The check the lead asked for: index displacement must not pass silently. + + Leaf paths are indexed, so a section inserted before the end shifts every + later one. If that were absorbed, "the other sections all match" would be + an illusion. Measured on the real artifacts the disclosures are APPENDED + (sections 8 -> 9, first eight names pairwise identical), and this test pins + what happens if that ever stops being true. + """ + old = _json_with_sections(["ic", "quantiles", "verdict_inputs"]) + new = _with_registered_additions( + _json_with_sections(["ic", "ridge_scarcity_coverage", "quantiles", "verdict_inputs"]) + ) + result = diff_report_json( + old, new, name="ridge_minute_return_20", strict=True, correction_expected=False, + registered_sections=REGISTERED_EXTRA_SECTIONS["ridge_minute_return_20"], + ) + assert not result.ok + # Pin the MECHANISM, not just the failure: leaves are compared position by + # position, so a displaced section shows up as a changed `sections[k].name`. + # Asserting only "something was unregistered" passes for the wrong reason — + # the trailing extra index alone would satisfy it (measured: registering + # name changes left that weaker assertion green). + displaced_names = [ + d for d in result.diffs + if d.path.endswith(".name") + and d.classification.startswith("unregistered") + and d.old is not None + and d.new is not None + ] + assert displaced_names, ( + "a section inserted mid-list must surface as a changed sections[k].name; " + f"got {[(d.path, d.classification) for d in result.diffs]}" + ) + + +def test_registered_d2_description_rewrite_is_accepted_as_an_exact_pair(): + for factor_id, (old_text, new_text) in REGISTERED_SPEC_DESCRIPTION_REWRITES.items(): + old = _frozen_like() + old["spec"]["description"] = old_text + new = _with_registered_additions(_frozen_like()) + new["spec"]["description"] = new_text + result = diff_report_json( + old, new, name=factor_id, strict=True, correction_expected=False, + description_rewrite=REGISTERED_SPEC_DESCRIPTION_REWRITES[factor_id], + ) + assert result.ok, (factor_id, result.diffs) + assert len(result.by_class("registered_d2_provenance_rewrite")) == 1 + + +def test_a_DIFFERENT_description_rewrite_on_a_registered_factor_fails(): + # REVERSE: the registered OLD text but some other new text. This is the + # case the exact pair exists for — the day a factor's stated meaning + # really changes must not ride in on a provenance registration. + factor_id = "peak_interval_kurtosis_20" + old_text, _ = REGISTERED_SPEC_DESCRIPTION_REWRITES[factor_id] + old = _frozen_like() + old["spec"]["description"] = old_text + new = _with_registered_additions(_frozen_like()) + new["spec"]["description"] = "Now computes something else entirely." + result = diff_report_json( + old, new, name=factor_id, strict=True, correction_expected=False, + description_rewrite=REGISTERED_SPEC_DESCRIPTION_REWRITES[factor_id], + ) + assert not result.ok + assert len(result.by_class("unregistered_change")) == 1 + + +def test_an_unregistered_factors_description_rewrite_fails(): + # REVERSE: a factor with no registered pair at all (the default None). + old = _frozen_like() + old["spec"]["description"] = "A" + new = _with_registered_additions(_frozen_like()) + new["spec"]["description"] = "B" + result = diff_report_json( + old, new, name="jump_amount_corr_20", strict=True, correction_expected=False, + ) + assert not result.ok + assert len(result.by_class("unregistered_change")) == 1 + + +@requires_frozen_panels +def test_the_registered_description_pairs_match_the_frozen_artifacts_exactly(): + """The transcribed constants must equal the bytes on disk, character for + character. A pair that is one character off would leave the leg failing + while the registration looked right in review.""" + from qt.exec_baseline_freeze import ( + DEFAULT_FROZEN_ROOT, + DEFAULT_MANIFEST, + FrozenExecBaseline, + ) + from qt.factor_eval_reconcile import _FACTOR_TO_REPORT_NAME + + repo = Path(".").resolve() + baseline = FrozenExecBaseline(repo / DEFAULT_FROZEN_ROOT, repo / DEFAULT_MANIFEST) + for factor_id, (old_text, _new_text) in REGISTERED_SPEC_DESCRIPTION_REWRITES.items(): + frozen = baseline.report_json(_FACTOR_TO_REPORT_NAME[factor_id], "no_book") + assert frozen["spec"]["description"] == old_text, factor_id + + +def test_md_prefixes_are_derived_per_FACTOR_not_per_section_name(): + """`ridge_scarcity_coverage` is published by two factors with DIFFERENT + payloads (RidgeCoverage vs RidgeReturnCoverage), so a section-name-only + lookup would hand one of them the other's field list.""" + from qt.factor_eval_reconcile import _registered_section_md_prefixes + + a = _registered_section_md_prefixes( + "valley_ridge_vwap_ratio_20", ("ridge_scarcity_coverage",) + ) + b = _registered_section_md_prefixes( + "ridge_minute_return_20", ("ridge_scarcity_coverage",) + ) + assert a[:2] == b[:2] == ("## + ridge_scarcity_coverage", "ridge scarcity:") + assert set(a) != set(b), "the two payloads must not produce the same fields" + assert "- valley_median:" in a and "- valley_median:" not in b + assert "- ridge_return_mean:" in b and "- ridge_return_mean:" not in a + + +def test_md_prefixes_reject_a_factor_that_does_not_publish_that_section(): + from qt.factor_eval_reconcile import _registered_section_md_prefixes + + with pytest.raises(ValueError, match="does not publish the add-Section"): + _registered_section_md_prefixes( + "jump_amount_corr_20", ("ridge_scarcity_coverage",) + ) + + +@pytest.mark.parametrize( + "section_name, payload_factory", + [ + ("ridge_scarcity_coverage", "ridge"), + ("peak_scarcity_coverage", "peak"), + ("neutralization_coverage", "neutralization"), + ], +) +def test_the_registered_note_prefix_matches_what_render_actually_emits( + section_name, payload_factory +): + """Pin the note prefixes by ASSERTION against a real rendering. + + The constants exist so the gate is not coupled to the renderer's format by + construction; that only works if something checks they still agree. + """ + from qt.factor_eval_reconcile import _SECTION_NOTE_PREFIXES + + frame = pd.DataFrame( + { + "classifiable_bars": [240, 240], + "valley_bars": [200, 200], + "ridge_bars": [25, 30], + "peak_bars": [12, 14], + "ridge_return_bars": [25, 30], + "valid": [True, True], + }, + index=pd.MultiIndex.from_tuples( + [(pd.Timestamp("2021-07-01"), "A"), (pd.Timestamp("2021-07-02"), "A")], + names=["date", "symbol"], + ), + ) + if payload_factory == "ridge": + from qt.factor_eval_disclosures import summarize_ridge_coverage as fn + + coverage = fn([frame]) + elif payload_factory == "peak": + from qt.factor_eval_disclosures import summarize_peak_coverage as fn + + coverage = fn([frame]) + else: + from qt.factor_eval_disclosures import summarize_neutralization as fn + + idx = pd.MultiIndex.from_tuples( + [(pd.Timestamp("2021-07-01"), f"S{i}") for i in range(12)], + names=["date", "symbol"], + ) + series = pd.Series(range(12), index=idx, dtype=float) + coverage = fn(series, series, series, min_cross_section=10) + assert coverage.render().startswith(_SECTION_NOTE_PREFIXES[section_name]) + + +@pytest.mark.parametrize( + "factor_id, section_name", + [ + ("valley_ridge_vwap_ratio_20", "ridge_scarcity_coverage"), + ("ridge_minute_return_20", "ridge_scarcity_coverage"), + ("peak_ridge_amount_ratio_20", "peak_scarcity_coverage"), + ("valley_price_quantile_20", "neutralization_coverage"), + ], +) +def test_md_prefixes_cover_EVERY_key_the_real_section_payload_carries( + factor_id, section_name +): + """The registration must cover the payload a real run writes, key for key. + + Deriving the prefixes from ``dataclasses.fields`` alone missed the two + COMPUTED properties ``to_section`` adds on top of ``asdict``, so three + rendered Markdown lines stayed unregistered and the reports leg kept + failing with the JSON side already green. + + ⚠️ REACH, measured: this test does NOT guard + ``DERIVED_PAYLOAD_PROPERTIES`` itself. Both sides now read that tuple, so + deleting an entry from it leaves this test GREEN by construction (measured: + dropping ``validity_rate`` -> 4 passed). What catches that is the REAL + reports leg (all three Markdown grids fail) and + ``test_derived_payload_properties_are_exactly_the_coverage_properties`` + below, which pins the tuple against the payload classes themselves. This + test's own job is narrower: a payload key with no dataclass field AND no + entry in that tuple fails here rather than on a two-hour run. + """ + from qt.factor_eval_disclosures import ( + NEUTRALIZATION_SECTION_NAME, + disclosure_binding_for, + summarize_neutralization, + to_section, + ) + from qt.factor_eval_reconcile import _registered_section_md_prefixes + from factors import registry as factor_registry + + frame = pd.DataFrame( + { + "classifiable_bars": [240, 240], + "valley_bars": [200, 200], + "ridge_bars": [25, 30], + "peak_bars": [12, 14], + "ridge_return_bars": [25, 30], + "valid": [True, True], + }, + index=pd.MultiIndex.from_tuples( + [(pd.Timestamp("2021-07-01"), "A"), (pd.Timestamp("2021-07-02"), "A")], + names=["date", "symbol"], + ), + ) + if section_name == NEUTRALIZATION_SECTION_NAME: + idx = pd.MultiIndex.from_tuples( + [(pd.Timestamp("2021-07-01"), f"S{i}") for i in range(12)], + names=["date", "symbol"], + ) + series = pd.Series(range(12), index=idx, dtype=float) + coverage = summarize_neutralization(series, series, series, min_cross_section=10) + else: + binding = disclosure_binding_for(factor_registry.build(factor_id)) + coverage = binding.summarize([frame]) + + payload_keys = set(to_section(section_name, coverage).payload) + prefixes = set(_registered_section_md_prefixes(factor_id, (section_name,))) + missing = {k for k in payload_keys if f"- {k}:" not in prefixes} + assert not missing, ( + f"{factor_id}/{section_name}: payload keys with no registered Markdown " + f"prefix -> their rendered lines would fail the reports leg: {sorted(missing)}" + ) + + +def test_derived_payload_properties_are_exactly_the_coverage_properties(): + """Pin ``DERIVED_PAYLOAD_PROPERTIES`` to the payload classes themselves. + + Both ``to_section`` and the reconcile prefix derivation read that tuple, so + every test comparing one against the other is equal BY CONSTRUCTION and + stays green when an entry is deleted (measured). The tuple therefore needs + an anchor that is not itself derived from it: the union of ``@property`` + names declared on the coverage dataclasses. Delete an entry and this goes + red; add a property to a coverage class without registering it and this + goes red too — which is the direction that would otherwise ship an + unregistered Markdown line. + """ + from qt.factor_eval_disclosures import ( + DERIVED_PAYLOAD_PROPERTIES, + NeutralizationCoverage, + PeakCoverage, + RidgeCoverage, + RidgeReturnCoverage, + ) + + declared: set[str] = set() + for cls in (RidgeCoverage, RidgeReturnCoverage, PeakCoverage, NeutralizationCoverage): + declared |= { + name + for name, attr in vars(cls).items() + if isinstance(attr, property) and not name.startswith("_") + } + # ``render`` is a method, not a property, and is carried as the section's + # note rather than as a payload key — the set below is payload-bearing only. + assert set(DERIVED_PAYLOAD_PROPERTIES) == declared, ( + "DERIVED_PAYLOAD_PROPERTIES must equal the coverage classes' property " + f"names; registered={sorted(DERIVED_PAYLOAD_PROPERTIES)} " + f"declared={sorted(declared)}" + ) diff --git a/tests/test_factor_eval_runner.py b/tests/test_factor_eval_runner.py index 506de3e..40ea529 100644 --- a/tests/test_factor_eval_runner.py +++ b/tests/test_factor_eval_runner.py @@ -382,13 +382,26 @@ def earliest_available(self, symbols): return DATES[0] -def _stub_exec_basis(report_dir, stem): +def _stub_exec_basis(report_dir, stem, with_book_suffix="", book_view=""): + """Stand-in for the write-out layer, honouring its ``with_book_suffix``. + + The suffix is applied HERE, at write time, exactly as + ``qt.exec_basis_eval.run_exec_basis_evaluation`` applies it — so a runner + that passes the wrong suffix writes the wrong file names and the callers' + assertions fail. The written content names the book view, so a test can + tell whose artifact a given file is. + """ report_dir.mkdir(parents=True, exist_ok=True) paths = {} for kind in ("no_book", "with_book"): + stem_for_kind = ( + f"{stem}_exec_with_book{with_book_suffix}" + if kind == "with_book" + else f"{stem}_exec_no_book" + ) for suffix, key in ((".md", "md"), (".json", "json"), ("_dashboard.png", "dashboard")): - p = report_dir / f"{stem}_exec_{kind}{suffix}" - p.write_text("stub", encoding="utf-8") + p = report_dir / f"{stem_for_kind}{suffix}" + p.write_text(f"stub {kind} book_view={book_view}", encoding="utf-8") paths[f"{kind}_{key}"] = p return ExecBasisEvaluation( spec=None, params=None, artifact_path=report_dir / "a.parquet", @@ -424,7 +437,11 @@ def fake_exec_eval(factor_panel, spec, eval_cfg, book, **kwargs): captured.update( factor_panel=factor_panel, spec=spec, eval_cfg=eval_cfg, book=book, **kwargs ) - return _stub_exec_basis(kwargs["report_dir"], kwargs["stem"]) + return _stub_exec_basis( + kwargs["report_dir"], kwargs["stem"], + with_book_suffix=kwargs.get("with_book_suffix", ""), + book_view=kwargs.get("book_view", ""), + ) monkeypatch.setattr( "qt.factor_eval_runner.run_exec_basis_evaluation", fake_exec_eval @@ -515,3 +532,42 @@ def fake_panel(factor_ids, universe, decisions, **kwargs): # every requested symbol had a finite value in the fake panel assert result.covered_symbols == 2 assert result.empty_symbols == 0 + + +def test_running_decision_then_close_leaves_the_decision_artifacts_intact( + monkeypatch, tmp_path +): + """D5 C5 F5: the run ORDER must not decide which artifacts survive. + + The close-book run used to write the shared ``_exec_with_book`` names and + rename them afterwards, so this exact order destroyed the decision run's + three with-book artifacts — every one of the eleven factors lost them in + the C5 full run, and the reconcile's reports leg then died on a bare + FileNotFoundError. The suffix now travels into the write-out layer, so + each book mode only ever writes its own names. + """ + captured: dict = {} + _wire(monkeypatch, tmp_path, captured) + + decision = run_factor_eval( + "ignored.yaml", "jump_amount_corr_20", book_mode="decision" + ) + assert captured["with_book_suffix"] == "" + kept = { + p: p.read_bytes() + for p in ( + decision.exec_basis.with_book_md, + decision.exec_basis.with_book_json, + decision.exec_basis.with_book_dashboard, + ) + } + assert all(b"book_view=decision" in blob for blob in kept.values()) + + close = run_factor_eval("ignored.yaml", "jump_amount_corr_20", book_mode="close") + assert captured["with_book_suffix"] == "_bookclose" + + for path, blob in kept.items(): + assert path.exists(), f"the close run destroyed {path.name}" + assert path.read_bytes() == blob + assert close.exec_basis.with_book_md.name.endswith("_exec_with_book_bookclose.md") + assert b"book_view=close" in close.exec_basis.with_book_md.read_bytes()