股票觀測站台指期 · 題材 · 美股 · 論文

MARKET RESEARCH

研究紀錄

線性基線三條路徑均未過事先判準;四系統比較見四輪一節

TimesFM 影子預報(2026-10-13,record_only)累積 28 天,約 11 月比高下
horizonTimesFM 80% 帶我們的 GARCH 80% 帶TimesFM 中位
1 日-2.22% … +1.90%-1.47% … +1.56%+0.23%
3 日-3.47% … +3.27%-2.64% … +2.81%+0.28%
5 日-4.09% … +4.13%-3.40% … +3.74%+0.48%

這是 Google 開放的通用預測模型,每天跟我們自己的模型比同一題:誰預測的範圍又準又不會太寬。兩邊都只記錄、不下單;約 11 月用事先寫好的標準比出高下。目前累積 28 天。

目錄(68 節)⚠️ 這裡有兩套輪次編號:日期寫在標題開頭的「十輪~十五輪」是 2026-08-25 起的逐日紀錄輪次;日期寫在括號裡的「一輪~四十五輪」是 2026-08-20 起的建置輪次。所以「十輪」比「四十五輪」新。目錄由新到舊排,不是照輪次號排——看到跳號不是有幾十節不見了。

  1. 2026-09-03 日韓研究地圖(5 路偵察 → 兩份 draft prereg + 資料落地)
  2. 2026-09-02 成本查證(官方成分)+同曝險基準草稿
  3. 2026-09-01 論文研究部細分 — 缺口帳、衰減面、經典第二批
  4. 2026-08-26 十五輪 — 樣本延伸到資料末端(保留集正式歸零)
  5. 2026-08-25 十四輪 — 缺口的真相,與 /backtest 補到三個 horizon
  6. 2026-08-25 十三輪 — 命中率與校準改用大樣本
  7. 2026-08-25 十二輪 — 把逐日紀錄補成連續、三個 horizon 都有
  8. 2026-08-25 十一輪 — 研究段逐日 OOS 紀錄(3,811 天)
  9. 2026-08-25 十輪 — 2026 保留集重播(153 天逐日紀錄)
  10. 四十五輪:執行 c-grid + 改善驗證履歷(2026-08-25)
  11. 四十四輪:三張 prereg——寫的過程否決了其中一個提案(2026-08-25)
  12. 四十三輪:把「看到結果就想改」變成有紀律的閉環(2026-08-25)
  13. 四十二輪:回測結果頁 + 偏空傾斜的 prereg(2026-08-25)
  14. 四十一輪:新增成本敏感度回測——退掉一整個假說(2026-08-25)
  15. 四十輪:報告反映「這次執行」而不是「系統紀錄」(2026-08-25)
  16. 三十九輪:多日標籤恆等式 + 從 twii_forecast 借一條檢查(2026-08-25)
  17. 三十八輪:報告標題寫「每日預測」,卻可以沒有預測(2026-08-25)
  18. 三十七輪:/rerun 的身份檢查可以被偽造(2026-08-24)
  19. 三十六輪:「準確率輸給猜震盪」這個說法不公平(2026-08-24)
  20. 三十五輪:超參數被釘在網格邊界(2026-08-24)
  21. 三十四輪:故障偽裝成空狀態 + GARCH 多步把關(2026-08-24)
  22. 三十三輪:對品質哨兵本身做變異測試(2026-08-24)
  23. 三十二輪:十條鐵律逐條稽核(2026-08-24)
  24. 三十一輪:結算報酬違反鐵律 8(2026-08-24)
  25. 三十輪:兩張 prereg 都套用了,而我的預測錯了(2026-08-24)
  26. 二十九輪:樣本數——43% 的交易日根本沒有預測(2026-08-24)
  27. 二十八輪:涵蓋率 0.7594——閘門檢的不是線上那個模型(2026-08-24)
  28. 二十七輪:ECE 卡在 0.05——門檻的一半是估計量偏誤(2026-08-24)
  29. 二十六輪:回測用的成交價一直是「成交不到的價格」(2026-08-24)
  30. 二十五輪:稽核接進 daily_run,每天自動查(2026-08-24)
  31. 二十四輪:歷史改寫稽核——指紋終於有人讀了(2026-08-24)
  32. 二十三輪:file_hash 補齊到全部 11 個資料寫入點(2026-08-24)
  33. 二十二輪:ETA 推算 + 系統性掃描「寫了沒接上」的缺陷(2026-08-24)
  34. 十九輪:「退化預測器」是誤診——假說被推翻(2026-08-24)
  35. 二十輪:晉升階梯自動追蹤(2026-08-24)
  36. 二十一輪:把降級規則從靜態文案接成真數字(2026-08-24)
  37. 十八輪:投資顧問視角審查——三個主題的共同病灶(2026-08-24)
  38. 十七輪:網站專業度補強 + 一個資料完整性 bug(2026-08-24)
  39. ⛔ 研究凍結宣言(2026-08-21 起生效)
  40. 十四輪:網站三大板塊定調(2026-08-21,使用者定調)
  41. 十三輪:樣本考古——40 筆變 342 筆,診斷翻案(2026-08-21)
  42. 十二輪:品質哨兵 + 趨勢圖 + 異地備份 + conviction 鏡子(2026-08-21)
  43. 十一輪:美股研究區 + ai-stock-agent 排程恢復(2026-08-21)
  44. 十輪:TWSE 現貨直抓 + 清單收尾(2026-08-21)
  45. 九輪:維運加固 + 保留集終檢(2026-08-21)
  46. 八輪:GARCH 接管每日區間(2026-08-20,使用者核准「開始」)
  47. 七輪:改進項 2–5(2026-08-20)
  48. 五輪:網站(2026-08-20)
  49. 六輪:上線收尾(2026-08-20)
  50. 四輪:四系統比較 + 多 horizon 閉環(2026-08-20)
  51. 三輪:讓閉環真的能每天跑(2026-08-20)
  52. 二輪:特徵層、模型基線、閉環(2026-08-20)
  53. 一、開工前探針的三項事實(推翻現有文件兩項)
  54. 二、施工中發現並修掉的五個真實缺陷
  55. 三、假跳空的實測規模
  56. 四、一個必須反覆強調的比較基準問題
  57. 五、目前待辦
  58. 抽查:agent 學習迴路 ↔ 匯出面板(2026-09-02)
  59. 補全回測與籌碼層:FinMind 回補 + 四個順路挖到的(2026-09-22)
  60. 主動式 ETF「最近買了什麼」(2026-09-23)
  61. 主動式 ETF 接進題材真實性那張卡(2026-09-23 第二輪)
  62. 側欄換成頂部導覽(2026-09-23 第三輪)
  63. 主動式 ETF:toalpha 式熱圖+逐檔點開+選日期(2026-09-23 第五輪)
  64. 主動式 ETF 補上原始封存(2026-09-23 第六輪)
  65. FinMind 歷史往前補到來源的最早日(2026-09-23 第七輪)
  66. FinMind 原件的獨立查核與三個漏洞(2026-09-23 第八輪)
  67. 券商推薦=會員首頁、站名改「股票觀測站」(2026-09-24)
  68. 看盤軟體改版(2026-09-24)

status.md(實驗與缺陷紀錄,最新一節 2026-09-03)

tx_forecast — 建置狀態

最後更新:2026-09-03(自動取自最新一節的日期標題)


2026-09-03 日韓研究地圖(5 路偵察 → 兩份 draft prereg + 資料落地)

使用者:「研究一下日經和韓國指數有什麼可以研究的地方幫助預測台指期」。 完整地圖:reports/jp-kr-research-map-2026-09-03.md。要點:

  • 已知邊界盤清:亞洲跳空條件均值已死(prereg-09-01b,n=1,943 全滅,開盤價吃光 45 分領先);日韓在 117 欄與 889 欄是零欄 ——「跨市場已測過」只對美股成立,這是本輪最重要的事實更正。
  • 結構發現:日韓現貨收 14:30(台北)晚台指日盤收盤 45 分鐘 ——早晨窗已死,但傍晚反向窗的「波動/尾部」形式不受那份結論管轄。
  • 兩份 draft prereg 等一句「開始跑」:A. 日韓 RV → 台指次日 RV (HAR+VIX 控制,消費端=區間模型);B. 日圓大動日的 GARCH 區間 條件涵蓋(不重跑回測,走「新想法新 prereg」合法路徑)。 兩份都 written_before_any_data=true(落後結構一眼沒看過; 已看過的同日相關 0.703/0.690 記在案當動機)。
  • 零判準變更的落地:pulse 加 JPY=X/KRW=X(盤中路徑不存就沒有); asia_daily 加 FX+^NKVI.OS(日經 VI,探針實測 2018 起 2,095 列; VKOSPI Yahoo 四代碼全 404,誠實不自動化);論文部 TICKERS 加 ^N225/^KS11(擴可測宇宙,判準沒動)。實跑驗證全部在收。
  • 誠實記「沒有消費端」的:KOSPI 配對交易(無基礎設施)、 傍晚窗條件均值(先驗被 09-01b 壓著,排最後)。

794 pytest 全綠。研究凍結、walk-forward 封刀、Yahoo 邊界全部未動。

2026-09-02 成本查證(官方成分)+同曝險基準草稿

使用者:「那現在做」(今日行事曆的可動手項)。

成本查證——數字不動,成分逐項對官方出處

成分值出處狀態
台股證交稅(股票賣出)3‰證券交易稅條例第 2 條(law.moj.gov.tw G0340078,修正 114-01-02)✅ 已查證
台股證交稅(ETF)1‰同條例第 2 條第 2 款✅ 已查證
現股當沖稅1.5‰(至 116-12-31)同條例第 2-2 條✅ 已查證
台股手續費1.425‰ 是申報門檻不是硬上限(逾此須委託前通知),券商自訂+折讓TWSE 營業細則第 94 條(臺證交字第 0970003165 號)✅ 已查證
台股折數—只有使用者的券商知道⏳ 待使用者
美股 ETF 成本佣金近零+SPY 價差 ~1bp(無官方單一出處)取決於使用者券商⏳ 待使用者

拆解結論:30bp/邊對「台股個股、無折讓」幾乎剛好 ((14.25+14.25+30)/2≈29.25bp);對台股 ETF 高估 ~1.5×; 對美股 ETF 高估約一個量級——而三篇 cost_bound 全是 ^GSPC, 成本假設落地後那三篇的診斷強度會再變。查證紀錄寫進 papers.toml [cost_verification_2026_09_02] 註解;數字刻意不動—— 動了是判準修訂,且美股那半沒拿到券商資訊前仍是 assumed, 用一個 assumed 換另一個 assumed 只是把尺調鬆。 依 amendment_2026_08_31 同例:日後正式分拆時舊判定不回頭翻案。

同曝險基準 prereg 草稿(等使用者拍板)

config/draft-prereg-same-exposure.toml——回答 gh2004 那類 「有擇時、輸在空手」回答不了的原命題。誠實聲明 written_before_any_data = false;門檻全部機械推導 (p95=全站 α=0.05 慣例、n_boot 500 讓尾端穩定), 只適用啟用日之後的新論文、不取代既有判準(兩把尺並排各自標名)。 檔名刻意不用 prereg- 前綴、沒有程式讀它——啟用要使用者同意。

2026-09-01 論文研究部細分 — 缺口帳、衰減面、經典第二批

使用者:「論文研究還有需要細分的地方嗎?或是需要什麼 skill」。 先查帳再回答:開部 5 天 arXiv 挑出 0 篇(帳上 6 筆全是經典補位)、 經典隊列 6/6 已走完(補位機制已耗盡,空手日部門會空轉)、 「挑不出的為什麼」零紀錄、判準 v2 在判前後半但沒有座位 own 衰減。

一、篩選缺口帳(paper_screen_log.jsonl,append-only)

每天把沒被挑走的論文由 LLM 歸進 7 類白名單(P.SKIP_REASONS), 只記彙總——單篇歸類是語意判斷、機械驗證不了,與輿情面同一條線。 LLM 發明的類別丟掉並回報(dropped_keys):「發明新類別」與 「白名單真的少一類」要分得開。第一天實測 40 篇加總正好 40: 深度學習 13、橫斷面 12、理論/定價 5、盤中資料 4、選擇權 3、 標的不在單 2、DSL 表達不了 1。該擴哪種訊號從此有底率可依—— 目前的答案是「缺口不在 DSL(只 1 篇),在方法類型(DL/橫斷面 25 篇, 那些本來就不該測)」。

二、衰減面(pp_decay,38 → 39 席)

owns「測過的策略後半段還剩多少」:decay_stats() 從 halves 算 「後半優勢 − 前半優勢」(優勢 = 策略 Sharpe − 買進持有)。 第一天:後半仍勝過買進持有 1 / 6 篇、衰減中位 −0.06 Sharpe。 刻意不標多空(衰減是文獻的性質,不是今天的訊號);量別新增 sharpe_diff (−5, 5)(與 tstat 分開——t 可以到 ±20,Sharpe 差不行)。 席位插在回測面之後、多方之前(量測席在多空前,題材部加輿情面同例)。

三、DSL 加 2 種日曆訊號 + 經典第二批 3 篇

seasonal_months(跨年窗用 or——跨半夜同一條規則)與 turn_of_month(交易日不是日曆日)。🔴 日曆訊號枝內先 shift(-1) 抵銷統一的 shift(1):日曆成員資格不含價格資訊、事先已知,沒有前視; 但月轉換窗只有 4 天,歪一天就是測另一個宣稱——測試逐月比對窗的 每一天,並驗「價格換成暴漲序列,部位一根都不能變」。

第二批經典(規矩不變:一生一次、吃同一額度、同一把尺)當天測了兩篇:

Bouman-Jacobsen (2002) Sell in May   ^GSPC 99 年  淨 0.365 vs BH 0.422  ❌
Lakonishok-Smidt (1988) 月轉換        ^GSPC 99 年  淨 −0.141 vs BH 0.422 ❌
黃金交叉 50/200(坊間,^TWII)        額度用完,明天 cron 補

帳上 8 篇 → 底率 2/8(25%)。誠實的複製率,照預期。

「需要什麼 skill」的答案

不需要新的 Claude Code skill——缺的能力(缺口記帳、衰減量化、 日曆訊號)都補在管線裡了。若日後要「手動丟一篇論文進來測」, 可以做成走同一把尺、吃同一額度的入口,不另開後門。

761 pytest 全綠(+7 條新測試)。

四、GitHub 補貨(同日,使用者:「也可以從 github 上找找看」)

從 paperswithbacktest/awesome-systematic-trading(14.1k stars)與 je-suis-tm/quant-trading 交叉比對,收穫三項:

  • 外部底率可引:他們複製 4,843 篇——中位 Sharpe 0.37(未扣 成本)、只有 48% 過 t=1.96。這是「低底率是預期」的外部佐證。 ⚠️ 他們也報「控制市場時段後量不到發表後衰減」,與 McLean-Pontiff 的 26% 衰減相左——兩說並存,衰減面只報自己量的。
  • DSL 第二波 3 種訊號(只加 close-only 算得出的——_price() 沒有 OHLC,Heikin-Ashi/Dual Thrust/SAR 表達了也是假的): macd_cross(嚴格大於,平盤 macd==line==0 的坑同 Donchian)、 bollinger_reversal(跌破下軌進、回中軌出,σ=0 平盤不進場)、 weekday_skip(日曆訊號,同 shift(-1) 抵銷)。
  • 經典第三批 5 篇進補位隊列(一生一次、同額度):Appel MACD 12/26/9、Bollinger 20/2、French (1980) 週末效應、Rozeff-Kinney (1976) 一月效應、Erb-Harvey (2006) 商品動能(GLD 近似)。 隊列 1 → 6 篇,cron 每天消化 2 篇。

他們的策略目錄可當日後的第二漏斗(arXiv 之外的論文來源), 但那是新資料源——要先探針、開單,今天不做。

五、隨機對照:「過了 2 篇」終於有東西可以比

底率(測 N 篇過 M 篇)一直缺一個對照:隨機下本來就會過幾篇? 沒有它,「8 篇過 2 篇」既可能是文獻真有東西,也可能正好是這把尺 在雜訊上的通過率——兩者在畫面上長得一模一樣。

papers.null_pass_rate():把同一條序列的報酬做 block bootstrap (block=20,保留分布與波動叢聚、打散時間結構),用同一個規格、 同一把尺重測 200 次。🔴 不消耗任何自由度——參數沒動、判準沒動, 動的只有序列的時間順序,量的是判準本身有多寬鬆。

mop2012            0.5%     ✅ 過        faber2007        0.0%   ✅ 過
turtle_donchian    0.5%     ❌          connors_rsi2     0.0%   ❌
mm2017            12.5%     ❌          gh2004           0.0%   ❌
bouman_jacobsen    1.5%     ❌          lakonishok_smidt 0.0%   ❌

實際過 2 篇 隨機預期 0.15 篇 p = 0.003(Poisson-binomial 尾機率)

判準是嚴的(平均雜訊通過率 1.9%)——mm2017 的 12.5% 是唯一 例外,vol_managed 的連續部位本來就容易靠運氣壓低波動勝過 BH。

#### 🔴 但這個 p 值不算證據,而閘門是機械的

那 8 篇裡 8 篇都是我按名氣挑的經典——而一個策略之所以有名, 部分正是因為它當年真的有效。在倖存者上算出來的顯著性是選擇偏誤, 不是文獻的性質。 所以 null_summary() 回一個 unbiased (名篇佔比 ≤ 50%),座位的 direction 由它決定、不由 p 值決定: 現在這條事實 direction="",多方席引用不到它。 無偏的檢定要等 arXiv 漏斗累積出樣本(目前 0 篇)。 同題材部的 chase.supported/confidence.interpretable—— 判定寫進程式,不靠我記得。

其他紀律:種子綁 arxiv_id(同一篇永遠得到同一個對照值, 對照本身不可以是抽獎;hash() 不行——PYTHONHASHSEED 隨機化, 版面掃參數那次踩過);算不出來的寫一筆 error 留痕(少一筆會讓 分母安靜地變小);calibrate_pending() 每日鏈路與補算腳本共用 (兩邊各寫一份就會有兩個答案);load_price 同理從 paper_hunter 搬進 papers.py。

六、順手抓到一句每天都在說的假話

論文部的多空席兩邊都是 0 條(沒有任何事實標 direction), 而研究經理報的是「⚠️ 一面倒:空方無證據」—— n_bull == 0 or n_bear == 0 這一支在兩邊都是 0 時算出 side="空方",於是畫面上暗示多方有證據,而多方也是 0。

0 對 0 不是一面倒,是這場辯論今天沒有發生。 已拆成獨立的 「⚠️ 沒有多空證據」並說明兩種可能(今天的數字不談這件事/ 這個部門的事實從來沒標過方向——後者是接線問題)。 這是 audit() 共用函式,四個部門都受惠。

773 pytest 全綠(+9 條)。/papers 加三張卡(缺口橫條、隨機對照、 衰減),對照放在逐筆帳之前——底率要先有尺,讀的人才知道 2/8 是什麼意思。

七、新欄位「換個方向」——判準只說撐不撐得住,而撐不住有好幾種形狀

使用者:「抓到我們之前論文可以換個方向去思考的」。

papers.pivot_of():機械判定失敗形狀(判準檢查、前後半優勢、 曝險、交易次數、雜訊 Sharpe 分布),逐筆顯示在論文帳的新欄位。 地基是隨機對照擴出來的 null_sharpe_p10/p50/p90——判準問「贏不贏 得了買進持有」,而一個大部分時間空手的策略在多頭裡結構上就贏不了 (實測曝險 0.14~0.87)。拿實際 Sharpe 比同曝險、同部位生成過程 的雜訊分布,量到的才是「擇時本身有沒有資訊」。

撐得住            2  mop2012、faber2007
🚫 訊號本身沒東西   3  turtle / connors_rsi2 / bouman(落在雜訊 p10–p90)
年代效應          1  mm2017(前半 −0.023、後半 +0.008,符號相反)
有擇時、輸在空手    1  gh2004(淨 0.400 ≥ 雜訊 p90 0.356,曝險 67%,BH 0.65)
淨報酬為負        1  lakonishok_smidt
                     → 可換方向 2 / 8 篇

🔴 三條線讓這一欄不變成重試產生器(無界的「失敗就換個角度再試 一次」正是資料窺探機器):形狀機械判定不經 LLM;不觸發任何回測 (AST 測試守 pivot_of/pivot_stats 不得呼叫 backtest/verdict/ validate_spec);要動手只能走假說血統(額度不變、三代封頂)或 另開 prereg。而且一定要有一個「這篇死了,不要換方向」的形狀 ——no_signal 明說不建議,且不計入「可換方向」的分子 (含進去這個數字就只是「測過幾篇」改個名字)。

backtest() 新增 gross/turnover/cost_drag:毛的贏、淨的輸 (cost_bound)與「訊號本身沒東西」是完全不同的診斷,而少了毛報酬 在帳上分不出來。判準只看淨的,這三欄純粹給形狀判定用。

#### 🔴 差點讓一個賠錢的策略掛上正面標籤

第一版的分支順序讓 lakonishok_smidt(淨 Sharpe −0.141、 2,369 次交易)拿到「有擇時、輸在空手」——因為它「贏過雜訊 p90」, 而那個 p90 是 −0.547(同曝險的隨機擇時被成本咬得更慘)。 贏過一個更慘的基準不是擇時能力,而那句話會印在公開頁面上。 已把 net_positive 擋在雜訊比較之前,測試釘住「賠錢的不可拿到 正面標籤」。

#### 兩個接線缺陷(都是這一輪自己製造又自己抓到的)

  • --dry-run 會說謊:加 null_schema 欄位後,補算腳本自己那份 「還要算誰」只看 arxiv_id,於是它印「沒有需要補算的」然後算了 8 篇。已抽成共用述詞 pending_calibration()。 (重算驗證:同種子逐筆數值與上次完全相同,帳上沒有兩個答案。)
  • 座位寫入 NameError,而 pytest 全綠:cfg 寫成吃 main() 的 區域變數——沒有任何測試真的呼叫 _write_paper_seats,它會直接掛在 無人看管的 07:20 cron。已補 test_paper_seats_builder_actually_runs (逐一真的呼叫,同 test_every_html_route_actually_returns_html 的教訓:測元件不等於測接線)。

784 pytest 全綠(+11 條)。/papers 新增「換個方向」卡與帳上的欄位。

八、失敗論文逐篇診斷+延伸管道(同日,使用者:「看哪邊有問題並延伸思考方向」)

診斷回填(paper_diag.jsonl + scripts/paper_diag_backfill.py): 舊筆補毛報酬/換手/成本拖累——同凍結規格重算本來就會算的欄位, 零自由度(先例:題材補 10/20 日),逐筆對帳最大漂移 0.001。 回填直接改判三篇:

Connors RSI2     毛 0.813 > BH 0.650(成本吃 247%)  no_signal → cost_bound
月轉換 ToM       毛 0.751 > BH 0.422(成本吃 711%!) loses_money → cost_bound
GH 52週高        毛 0.655 險勝 0.650                skill → cost_bound
Sell in May      毛 0.413 連 BH 都沒贏——宣稱本身不成立,維持 no_signal

形狀分布:撐得住 2/訊號沒東西 2/毛的贏淨的輸 3/年代效應 1, 可換方向 4/8。逐篇報告在 reports/paper-failures-2026-09-01.md。

🔴 分支順序修正:毛的贏要判在「賠錢」之前——淨為負而毛勝過 BH 正是成本咬死最極端的樣子(ToM),照舊順序會被判成「沒有方向可換」。

延伸管道:P.paper_followup_candidate() 接進 research_council, 自家血統優先、只收 PIVOT_SHAPES 標 council 的形狀(目前只有 cost_bound——其他形狀的 next 自己寫「另開 prereg」「不要換」, 佇列與文案說相反的話就是兩個答案)、一篇一生延伸一次、同日不追、 衍生照吃每日假說額度、血統 parent_hid=paper:<id> 三代封頂。 新 PAPER_FOLLOWUP 提示詞把形狀的 reading/next 與論文的帳 (毛/淨/成本,經 paper_parent_facts 進引用池,成本從 TOML 讀不寫死) 餵給 council。佇列下一位:月轉換 on tx_ret_roll 方向(1.5bp/邊)。

791 pytest 全綠(+7 條,含「淨為負毛的贏=cost_bound 不是死路」、 「佇列只收 council 形狀」、回填對帳 drift 標記、接線測試)。

額度守門實測:加完隊列後重跑鏈路,「今天已回測 2 篇,達上限」 ——新隊列繞不過統計紀律。764 pytest 全綠(+3 條)。 web 服務已重啟載入新程式碼。


2026-08-26 十五輪 — 樣本延伸到資料末端(保留集正式歸零)

使用者:「樣本不能抓到今天的嗎?」

紀錄其實已經到 2026-08 了——停在 2025-08-06 的只有 oos_walkforward 那一段,因為那是保留集的邊界。要讓 walk-forward 本身走到末端, run_experiment(touch_holdout=True) 就是為此而設。

⚠️ 這一步與前四次動用保留集不同

前四次是看分數。這一次是把保留集納入常規 walk-forward。 2026-08-26 起,這個專案沒有任何未動用的資料,唯一乾淨的裁判只剩 往後累積的事前 live 樣本(目前 8 筆)。

保留意見已寫進 prereg 的 [amendment_2026_08_26]:分數已看過四次、 逐日明細也已上網頁,邊際損失確實不大——但「不大」不等於「沒有」, 這是最後一次會有這個選項。使用者已知悉並指示執行。

為什麼新那一段在方法上更接近 production

holdout_replay 的模型凍結在 2025-08-06,到 2026-08 已經 12 個月 沒更新;而 production(daily_run.py)每天重新擬合。 walk-forward 至末端會隨資料前進重新訓練——那才是 production 實際在 做的事。所以這一段不只是樣本更多,也更誠實。

segment(h=1)筆數期間涵蓋率平衡命中ECE excess
walk-forward(研究段)3,8122010-01-18 → 2025-08-060.8280.38220.0310
保留集重播2492025-08-14 → 2026-08-240.7390.40490.0171
walk-forward(至末端)4,0662010-01-18 → 2026-08-240.8170.38660.0367

/performance 預設改成「至末端」。原兩段原樣保留, /backtest 的四條事先判準仍然只在研究段上評——把保留集算進去 會讓「0 過 4 不過」的意義變掉。

缺口從 7 天降到 2 天(purge 空窗被至末端那一段填掉, 只剩 2025-04-07/04-10 兩個鎖死日,原因見十四輪)。

🔴 加區段時漏同步顯示迴圈

build_record.py 的顯示迴圈寫死 ("oos_walkforward", "holdout_replay"), 加第三段時沒同步。合計是對的、每一段也都算對了,但表上就是少一列 ——「數字對不起來但看起來完全正常」是最難發現的那種錯。 新測試 test_every_segment_has_its_own_row_not_just_the_total 直接比對 record.csv 裡的 segment 集合與頁面顯示的集合,兩邊必須相等。

483 → 485 pytest 全綠。

2026-08-25 十四輪 — 缺口的真相,與 /backtest 補到三個 horizon

使用者:「把那個 2025-08-06 到 2026-01-01 的缺口也補起來,回測結果 那邊是幾筆再跑。」

那個缺口已經在十二輪補掉了。 build_record.py 的保留集段是 2025-08-13 → 2026-08-24(250 天),把先前 149 天的洞縮成 7 天。 剩下的 7 天分兩類,而兩類的意義完全不同:

天數類別能不能補
5purge/embargo 空窗(2025-08-06 ~ 08-12)❌ 補了就是洩漏,這是設計
2漲跌停鎖死日(2025-04-07、04-10)⚠️ 可修,但目前刻意不修

那 2 天正好是全期最極端的兩天

2025 年關稅衝擊,−5.10% 與 +3.29%,而同期平均區間寬度只有 4.14% ——這兩天幾乎確定是涵蓋失敗,卻被丟掉了。原因很小: 鎖死日四價相同 → high == low → tx_close_position 除以零 → 特徵 NaN → build_model_frame 把整列丟掉。

影響量化之後只有 −0.0004(涵蓋率 0.8224 → 0.8220),不值得為它動 特徵定義,尤其研究已凍結。但機制留著:一個特徵 NaN 就能安靜地拿掉 最極端的日子。 record_gaps() 每次重算並分類,/record 有表。

回測 3,811 筆,補到三個 horizon(0 驗證折)

/backtest 原本只有 h=1(3,811 筆),因為只有 CLI run_experiment.py 會寫 experiments/oos_predictions.parquet,而它一次只跑 primary_horizon。h=3/h=5 的 walk-forward 在 build_record.py 裡 本來就算了,先前只是把欄位丟掉。改成一併持久化:

h=1  方向命中 0.3705(多數類 0.4316)  進場勝率 0.4855  進場 67.2%
h=3  方向命中 0.3571(多數類 0.3928)  進場勝率 0.4805  進場 70.7%
h=5  方向命中 0.3726(多數類 0.3836)  進場勝率 0.4834  進場 61.7%

三個 horizon 的方向命中率都低於自己的多數類基準,進場勝率都低於 50%。

⚠️ 沒有重跑 run_experiment.py。 設定、資料、隨機種子都沒變, 重跑會得到完全相同的數字,卻讓驗證折從 23 次變 24 次。 要真的重跑:python3 scripts/run_experiment.py。

⚠️ h=1 的 OOS 檔不可被 build_record.py 覆蓋——它是 CLI 的產物, 而 CLI 才會 log_validation_use()。覆蓋會讓「這份回測是第幾次驗證折的 產物」對不上,已有測試把關。

480 → 483 pytest 全綠。

2026-08-25 十三輪 — 命中率與校準改用大樣本

使用者:「我希望命中率與校準的樣本數要多就對了,至少要能夠回測的量, 否則我們不管怎樣回測都不准。」

這個要求可以量化,不是感覺問題。 判準是: ECE 的有限樣本下限(null floor)必須低於上線門檻 0.05。 下限本身高過門檻時,一個完美校準的模型也會被判不及格。

來源(h=1)nECEnull floorexcess判讀
walk-forward OOS3,8110.04260.01190.0307✅
保留集重播2490.07180.04400.0279✅
事前 live60.18930.18930.0000❌ 下限是門檻的 3.8 倍

/performance 加了來源與 horizon 切換,預設改成 walk-forward OOS。 沒有「全部」這個選項——把 3,811 筆與 6 筆平均起來不代表任何東西, 而且會讓「事前只有 6 筆」消失。頁面上多一張「樣本數夠不夠判讀」對照表, 三個來源的 n/ECE/floor/excess 並排。

可靠度圖現在每一箱有 n=1821、1257、355…(先前每箱 1 筆),而且讀得出 東西了:模型說 25% 實際 31%(低估)、說 64% 實際只有 21%(嚴重高估)。 主 KPI 一律改成平衡命中率,原始命中率降為對照並附上「永遠猜多數類」。

🔴 我在同一天第二次踩「實作了 ≠ 接上了」

/performance 不走 _page()——它自己組 body 再呼叫 page()。 我加 picker/adequacy 時用 return _page(...) 去做字串替換, 結果只命中錯誤分支,主體完全沒接上,而頁面照樣 200、照樣有內容。 ast.parse 過、服務重啟成功、curl 回 200,全部都是綠的。

新測試 test_performance_page_actually_renders_the_source_picker 查的不是 狀態碼,是輸出的 HTML 裡真的有那段東西,而且三個來源都要有切到其餘 兩個的連結。

走勢圖標題補上「事前 live」——它一直只畫 live,但切到 wf 時 標題不說清楚會讓人以為那 4 筆是選到的來源。

478 → 480 pytest 全綠。

2026-08-25 十二輪 — 把逐日紀錄補成連續、三個 horizon 都有

使用者:「目前只有 6 筆,樣本太少,那就想辦法抓之前的來用當作回測。」

先找洞。 事前 live 只有 6 筆(h1=4 / h3=1 / h5=1),而先前兩支腳本 各補一塊之後仍有兩個洞:

  • 時間:2025-08-06 → 2026-01-01(保留集前半)完全沒有紀錄
  • horizon:h=3 / h=5 各只有 1 筆,而晉升階梯要求三個各 60 筆

第二個洞比第一個危險:只補 h=1 的話樣本數那一關永遠不會過, 而畫面上會看起來很豐富——「部分成功偽裝成完整成功」的形狀。

scripts/build_record.py 一次補完兩個洞,判準先寫進 config/prereg-2026-08-25b-full-record.toml。產出 data/live/record.csv:12,174 筆,2010-01-18 → 2026-08-21,h=1/3/5。

h來源筆數涵蓋率平衡命中原始多數類ECE excess
1walk-forward3,8110.8280.38170.3710.4320.0308
1保留集重播2490.7390.39310.4260.4100.0259
3walk-forward3,8110.8250.37970.3570.3930.0603
3保留集重播2470.7250.44990.4410.3440.0472
5walk-forward3,8110.8230.39410.3730.3740.1031
5保留集重播2450.7140.35250.2980.4650.1436

校準隨 horizon 惡化得很快:ECE excess 0.031 → 0.060 → 0.103。 h=3 / h=5 離校準閘門(ECE < 0.05)差得很遠。

h=3 保留集那格 0.4499 — 差 0.0001 沒觸發門檻,我照樣查了

事先寫死的異常門檻是 0.45。差 0.0001 就宣布「沒觸發」是在鑽自己訂的 規則的漏洞,所以查了。不是度量假象:模型用了三類、逐類召回率分散 (0.456 / 0.541 / 0.352)、置換檢定 2000 次只有 1 次超過(p≈0.0005)。

但四個理由讓它不能被當成 edge:(1) 6 格裡的最大值,選擇是事後的; (2) 同 horizon 的 walk-forward 有 3,811 天只有 0.3797,差 −0.0701; (3) h=3 的 ECE excess 0.0472,機率根本沒校準; (4) 保留集已用盡,這件事無法再驗證。 依研究凍結:寫下來,不要跑。已登記 h3-holdout-anomaly, 事前預期(「是假象」)對帳為猜錯。

晉升階梯:現在只剩兩關,而那兩關都不是模型能力

補完之後 h=1/3/5 樣本數都遠超過 60 → 三條變 ✅。剩下的阻擋只有 帳戶參數與品質哨兵 20 日。也就是說這個系統變成 「填完帳戶參數就可以進 L2」,而它的 4 條事先判準仍然 0 過 4 不過。 L2 是紙上口數、不碰真錢,風險有限;但這個落差寫在 /record 頁面上。

evaluate_l1_to_l2() 的樣本數改成三個來源分開顯示:

h1 已結算樣本數  4,064(事前 live 4 + walk-forward OOS 3,811 + 保留集重播 249)
h1 區間涵蓋率    82.2%  事前 live 75.0%(n=4);WF 82.8%(n=3,811);重播 73.9%(n=249)

先前只分兩類,把 3,811 筆合法的 walk-forward OOS 說成「分數已看過」 ——高估了污染,而且就是不對。

收掉重複來源

build_oos_record.py/backfill_live_2026.py 與其 CSV、/oos-record/ /replay-2026 兩頁、oos_record()/replay_2026()/backfill_2026() 三個函式全部刪除,由 build_record.py + /record 取代。 同一份紀錄三個來源就是漂移的溫床,而今天已經在「只補 h=1」上踩過一次。 報告(reports/oos-record-*.md、reports/backfill-2026-*.md)保留為歷史。

測試檔改名 tests/test_record.py。其中一條原本斷言 assert "pending.append" in src——換一種寫法就紅,但不變量根本沒變。 已改成斷言不變量本身(紀錄最後一天不得晚於最後一個有答案的日子, 且 h 越長結束得越早)。

頁面:/record。476 pytest 全綠。

2026-08-25 十一輪 — 研究段逐日 OOS 紀錄(3,811 天)

使用者:「所以我們的預測紀錄也要從 2005 年開始做回測」。

2005 拉不到,而且不是設定問題。 研究段 2005-01-04 → 2025-08-05 就是訓練資料;在上面訓練再在上面預測是樣本內。唯一誠實的來源是 walk-forward,而它需要 initial_train_days = 1250 的初始訓練窗—— 那 1,250 天沒有更早的資料可以訓練。最早的一天是 2010-01-18。

成本:OOS_LOOK,不佔驗證折。逐日 OOS 預測本來就是 ExperimentResult.predictions,每次實驗都算,只是從沒存下來; scripts/build_oos_record.py 呼叫的是 library 函式而非 CLI 腳本 (後者才 log_validation_use())。已登記於 reports/research_ledger.jsonl——順帶把 src/tx/research.py 接上了第一個呼叫點(它先前有測試但沒有任何 caller)。

全段(3,811 天,2010-01-18 → 2025-08-05)
平衡命中率0.3817 vs 無資訊基準 0.3333(差 +0.0484)
原始命中率0.3705 —— 輸給「永遠猜多數類」的 0.4316
區間涵蓋率0.828(GARCH),平均寬度 0.0255
ECE0.0426,null floor 0.0119 → excess 0.0308
逐年異常無。最高是 2024 的 0.446,未觸發事先寫死的 0.45 門檻

三個交叉檢核全部對得上(涵蓋率 0.828 vs 0.8279、平衡 0.3817 vs 0.3831、excess 0.0308 vs 0.0375)——這代表逐日紀錄與既有結論是同一件事 的不同切面,不是新的獨立證據。對不上才要查。

研究登記簿對帳:事前猜「逐年都在 0.34–0.42」,實際 2010 是 0.325、 2024 是 0.446,兩端都超出 → prior_was_correct = False。 方向猜對(無異常年份、與五折同量級),但逐年波動被我低估。

時間軸有缺口,頁面上明白畫出來:本頁到 2025-08-05, /replay-2026 從 2026-01-02 開始,中間 2025-08-06 → 2026-01-01 (保留集前半)沒有紀錄。那是使用者指定「從今年 1 月」的結果,不是漏做。

頁面:/oos-record。473 → 476 pytest 全綠。

2026-08-25 十輪 — 2026 保留集重播(153 天逐日紀錄)

使用者指示「台指期的紀錄跟回測都先從今年 1 月開始抓」,並選擇把結果 當作晉升階梯的證據。

先講清楚這 153 天是什麼:2026-01 之後的 154 個交易日全部落在 保留集裡(2025-08-13 → 2026-08-24)。該區間的總量分數已於 08-21 (GARCH 涵蓋 0.72–0.75)與 08-25(方向 1 過 / 2 不過)看過, 所以這不是第三次獨立驗證,是那段期間的逐日明細。 判準與這一節的說明在跑之前寫進 config/prereg-2026-08-25-backfill-2026.toml。

方法:研究段(2005-01-04 → 2025-08-05,5,061 天)訓練一次, 視窗內不重新擬合;GARCH 參數同樣在研究段擬合,條件變異數的遞迴 只吃該日之前的已實現報酬。結算報酬取自 y_ret_1d(ret_roll 複利,鐵律 8)。 輸出獨立到 data/live/backfill_2026.csv,不寫進 live 記錄—— live 的價值全部來自「它是事前的」,混進去就再也分不出來。

全段
區間涵蓋率0.706(目標帶 0.70–0.90,勉強在帶內)
涵蓋率最低的月份2026-02 = 0.500(12 天)
平衡命中率0.3720 vs 無資訊基準 0.3333(差 +0.0387)
逐類召回率跌 0.143 / 震 0.742 / 漲 0.231
ECE0.0737,153 天 null floor 0.0643 → 真實誤差約 0.0094

這一輪最重要的發現是一個度量錯誤,而且是我自己犯的。 第一版報告算的是原始命中率 0.4314,卻拿去比平衡基準 0.3333, 報成「差 +0.098」。但:

  • 「永遠猜震盪」的原始命中率也正好是 0.4314——原始口徑下模型 沒有勝過一個不看任何資料的規則
  • 2026-08 原始命中率 0.867,平衡命中率正好 0.3333。那個月 87% 的 日子是震盪、模型 100% 猜震盪——猜對是因為它永遠猜同一個答案

CLAUDE.md 早就寫著「不與永遠猜多數類比」,我還是踩了它的鏡像版本。 定義現在只存在 tx.metrics.balanced_hit_rate 一份,腳本與網頁共用, 並有測試驗「常數預測器的平衡命中率必須正好是 1/3」。

晉升階梯:依使用者決定,重播樣本計入 L1→L2 的樣本數,但 evaluate_l1_to_l2() 接了 replay= 之後兩個來源分開顯示:

h1 已結算樣本數   157(事前 live 4 + 保留集重播 153)   ✅
h1 區間涵蓋率     70.7%   事前 live 75.0%(n=4);重播 70.6%(n=153)

ETA 仍然只算事前 live 樣本(h1 尚缺 56 筆,估 2026-12-14)—— 把重播算進去 ETA 會變成 0 天,等於宣布「已經跑滿了」,那不是事實。

頁面:/replay-2026。467 → 473 pytest 全綠。 (先前 commit 訊息寫「453 → 472」,那是沿用文件裡的舊數字、沒有實測;實測值如上。)

四十五輪:執行 c-grid + 改善驗證履歷(2026-08-25)

pytest:390 passed(+31)。使用者要求:跑 c-grid-boundary, 並把「回測過提升的紀錄」留下來。

一、c-grid-boundary 執行完畢——驗證折消耗 0 次

scripts/diagnose_c_grid.py,報告在 reports/c-grid-curve-2026-08-25.md。事後核對 reports/validation_use_log.md 沒有新增任何一筆,豁免成立。

結果與我在 prereg 寫下的預期一致(結果 2):

C=0.0001  −1.09732  ←最佳     C=0.1   −1.21748
C=0.001   −1.09948               C=1     −1.46796
C=0.01    −1.12830               C=10    −1.82536

延伸兩個數量級後最佳仍貼在新下界,平均曲線單調 → 依決策規則 2 不延伸網格,也不再往下試。

二、但資訊量最大的數字不在預期裡

class_weight="balanced" 下係數趨近 0 的漸近值,正是 ln(1/3) = −1.098612。而最佳點是 −1.09732:

超額 +0.00129 nats
逐折 +0.0034 / +0.0008 / +0.0001 / +0.0009 / +0.0039   ← 五折全為正

58 欄核心路徑在最佳正則化下總共只換到 0.0013 nats(2026-09-04 更正:原寫 117 欄,診斷報告第 5 行是 58 欄)——曲線的最佳點 幾乎正好落在「每天都說 1/3 / 1/3 / 1/3」那條線上。但它在五折全部 為正,與平衡準確率 t=7.09 是同一個結論:判別能力存在、方向一致、 量級接近沒有。 這是「統計顯著 ≠ 經濟顯著」在概似空間的版本, 不是新的獨立證據。

⚠️ 誠實記一處與平均曲線不符:逐折最佳是 [0.001, 0.0001×4], 4/5 貼邊而非 5/5。不因為平均曲線好講就寫成「全部貼邊」。 不過 0.0001 與 0.001 只差千分之幾 nats、到 C=1 差 0.37——曲線在 那一段是平的,平段裡的 argmax 是雜訊,不是最佳點。

三、讓「不消耗驗證折」變成結構性保證

prereg 的豁免完全建立在「診斷碰不到測試段」上。靠自律不夠:

  • inner_cv_curve() 從 _select_c() 抽出來,兩邊共用同一份評分。 重寫一份會漂移,漂移之後曲線就不再是關於 _select_c 的證據。
  • build_model_frame() 從 run_experiment 抽出來,診斷不自己重寫 合併與切分。
  • tests/test_c_grid_diagnostic.py 用 AST 檢查腳本從未取用 .test、未 import 績效模組、未呼叫 run_experiment。 哪天有人順手加一行看測試段,測試會先擋下來。

四、改善驗證履歷(使用者選定:獨立的 append-only 帳本)

experiments/verification_log.jsonl。登記簿做不到這件事—— 它記的是當前狀態,verified_result 一個字串,同一個提案驗證第二次 就把第一次蓋掉。payoff-asymmetry 馬上會撞到:現在只能用保守下界 測,日內資料夠了之後還要再測一次。

更關鍵的是:執行次數是多重比較的分母。 只記「採用了什麼」而不記 「試了幾次」,等於把 p-hacking 的證據自己刪掉——留下的紀錄會系統性 偏樂觀。所以頁面上採用率的分母刻意寫成執行次數而非提案數。

判定分四種,其中兩組刻意分開:

failed vs refuted試了效果不夠 vs 成因根本錯了
failed vs inconclusive為 payoff-asymmetry 而存在——保守下界沒過不可記成 failed

兩條入帳規則寫在 _validate(),繞過 append() 手寫也會在讀取時被擋: 沒有 prereg 不得判 passed、寫了 predicted 就必須填 prediction_correct(不對帳的預期只是裝飾)。 沒有 update/delete 是設計,有測試釘住。

五、履歷自己也有「實作了 ≠ 接上了」的暴露面

附加是手動動作,忘了寫不會報錯,而漏記的履歷看起來跟完整的一模一樣。 所以反向查:登記簿已判定的項目,履歷裡必須找得到,否則頁面亮警告。

回填三筆後的現況:

執行 3 次 / 3 個提案   採用率 0%   已花驗證折 1 次   預期命中 1/2

回填時發現 cost-assumption 在登記簿標 rejected、在履歷該標 refuted——同一件事兩種標籤。依登記簿自己的定義(否定的是「成本是 瓶頸」這個成因,不是效果不夠)更正為 refuted。

六、順手修掉一個渲染缺陷

登記簿與履歷是資料檔,散文本來就用 markdown 寫,而頁面直接 esc()——/backtest 一頁數出 24 處字面 **。而那些星號標的 正好是每段最該被看見的那一句,退化成雜訊等於把重點藏起來。 新增 ui.md_inline():跳脫在前、轉換在後,只支援粗體與行內程式碼 兩種(支援越多,資料檔就越接近模板,越容易變成注入口),有 XSS 測試。


四十四輪:三張 prereg——寫的過程否決了其中一個提案(2026-08-25)

pytest:359 passed(不變,本輪只寫規格不改程式)。 使用者要求:把 high-vol-entry/payoff-asymmetry/c-grid-boundary 三個提案的 prereg 寫出來。登記簿三項 proposed → frozen, 待寫 prereg 歸零。

一、最重要的一件事:寫 prereg 的過程本身否決了一個提案

high-vol-entry 原本的提案是「高波動期不進場」。動手寫判準時我才 看清楚它的來歷:

我把資料切成三組,然後挑出表現最差的那一組說「別碰它」。 三組裡本來就一定有一組最差。差距只有約 4.5pp,而高波動組 786 筆 進場的抽樣標準誤約 1.8pp——約 2.5 個標準誤,還沒算看了三組的 多重比較。

更根本的是「三分位」這個切法本身是任意的。改成四分位、五分位, 或換一個波動定義,最差的那一組就會變。任何從觀測結果反推出來的 門檻都帶著這個病。

這就是 prereg 制度真正的價值:它不只是把判準寫死,它逼你把提案的 來歷寫出來,而一個說不出先驗理由的提案在寫的當下就會露餡。 如果直接去實作,這個門檻會被當成「發現」而不是「窺探」。

改用的替代方案沒有可調門檻:

exposure = direction_signal × clip(σ_target / σ20, 0, 1)

先驗依據與這份資料的觀測結果無關——標籤的震盪帶本來就是 k × sigma20 × √h,門檻隨波動放大是標籤定義的結構性後果; 且波動調整曝險是投資組合管理的標準做法,早於本專案存在。 σ_target 取研究期 ret_roll 的全期標準差,由訓練資料決定,不是自由參數。

關鍵判準(🔴 最重要的一條):對照組不是「原策略」,而是 「原策略乘上一個常數,使平均曝險與波動調整版相同」。 縮小曝險必然降低波動,Sharpe 因此變好是算術結果不是擇時能力。 贏不過等曝險常數版就只是縮量,不採用。門檻 +0.10 Sharpe。

二、payoff-asymmetry:把「測不出來」寫成「無效」會永久誤殺

停損/停利回測有一個無法迴避的問題:同一天內先碰到哪一個? 日 OHLC 只有 H 與 L,看不出順序。而本專案的 1 分 K 只有 64 檔 (約 30 天滾動視窗),期交所 tick 過期即永久消失,無法回補。

所以本檔採保守下界設計,而不是假裝能測:沿用 src/tx/stops.py 既有的「同一根 bar 內雙觸發一律當停損先發生」,跑出來的是下界, 真實表現只會更好或相等。

判讀因此刻意不對稱,而這個不對稱必須事先寫死:

結果可以下的結論
下界過了判準成立(真實只會更好)→ 採用
下界沒過不得判定「無效」,只能記「保守假設下不成立,待日內資料累積後重測」,狀態退回 proposed

停損點取自模型自己的 q10/q90,不引入 N 倍 ATR 這類可調比例—— 那會開出一個搜尋空間,而我們沒有驗證折可以揮霍。 所需賺賠比 1.13 是由勝率反算的必要條件:(1−0.4707)/0.4707 = 1.1245, 算出來的,不是挑的。

三、c-grid-boundary:查證後發現它根本不用等解凍

這一張與另外兩張不同,status = "executable_without_unfreeze"、 validation_folds_consumed = 0。

理由是去查了 models.FoldPipeline._select_c():C 的挑選是巢狀的, 在訓練段內部用 TimeSeriesSplit(n_splits=3) 做,完全不碰該折的 測試段。所以「CV 想要多強的正則化」這個問題,可以只看訓練段的內折 分數曲線回答——不看任何樣本外績效,不構成偷看驗證折。

⚠️ 但這條豁免有嚴格邊界,已寫死在檔案裡:

可以做:報告內折 log-likelihood 對 C 的曲線
不可做:把新的 C 套進去跑 OOS,看 Sharpe / 勝率有沒有變好

第二件事需要獨立 prereg + 一次驗證折。混在一起做,就是用「診斷」 當幌子偷看績效。

檔案裡也寫下了我的預期:曲線單調、最佳仍貼在最小值 0.0001 (理由:曝險僅 0.095、每折準確率低於基礎率、成本歸零仍輸買進持有 0.63 Sharpe,三個獨立證據都指向沒有訊號)。 寫下來是為了結果出來時能檢驗我的判斷力,而不是事後說「我早就知道」 ——本輪之前已經有一次我預測區間會轉綠而實際沒有的紀錄。

四、雙向對照與登記簿狀態

順手補了 bearish-tilt.toml 缺的 registry_id(另外三張都有, 只有它是 -,check_consistency() 抓不到這種單向斷鏈)。四張都通過 prereg ↔ 登記簿雙向對照。

frozen 4 / rejected 1 / refuted 1     待寫 prereg:0
排隊中的驗證折成本:4 → 3 次(c-grid-boundary 不消耗)

五、下一步

prereg-2026-08-25-c-grid-boundary.toml 現在就可以執行, 不消耗驗證折、不需解凍。另外三張等解凍(每 horizon 累積 60 筆 live 樣本,以目前速度估計 2026-12 前後)。


四十三輪:把「看到結果就想改」變成有紀律的閉環(2026-08-25)

pytest:359 passed(+12)。使用者要求:回測結果都要有結論、 有如何提升、有進步流程,改完再回測,全部放在同一頁。

一、先講清楚這件事的兩面

「回測 → 看到問題 → 想辦法改 → 再回測」是系統進步的唯一途徑。 但它同時是過擬合最快的路——看了成績再決定改什麼,等於用測試集 選模型。本專案的 prereg 制度管的是「單次實驗」,管不到跨次的迭代。

所以不是禁止這條路(那等於禁止進步),而是讓它留下痕跡、且不可跳關。

二、進步流程(六關,資料驅動不是寫死的)

① 診斷 → ② 結論 → ③ 提案 → 🔒④ 凍結判準 → ⑤ 執行檢定 → ⑥ 再回測驗證

③ → ⑤ 不可跳關,中間的④就是 prereg。頁面上每一關都標出目前 停在該關的提案數,現況:提案 3、凍結 1、已判定 2。

同時顯示排隊中的驗證折成本 4 次(已用 21 次)——驗證折是有限資源, 不把成本標出來就會被無限排隊。

三、改善提案登記簿

experiments/improvement_registry.toml,六項,每項都要能回答 「從哪個證據來、判準凍結了沒、最後怎麼判的」:

id狀態重點
bearish-tilt判準已凍結偏空過度預測 2.1 倍,最大單一虧損來源
high-vol-entry已提案⚠️ 提案本身有資料窺探風險,已註明
payoff-asymmetry已提案卡在缺日內歷史資料(tick 只有 30 天)
c-grid-boundary已提案預期效益低,不應排在 bearish-tilt 之前
cost-assumption已否決成本不是瓶頸,此路已排除
stationarity假說推翻成因錯了,不是效果不夠

「已否決」與「假說推翻」刻意分開——前者是試了效果不夠(可能值得 換個做法再試),後者是連成因都想錯(該把整條線停掉)。合併成一個狀態 會讓人分不出下一步該做什麼。

四、登記簿自己也要被檢查

check_consistency():宣稱「已凍結」就必須真的有 prereg 檔案存在、 終態必須寫結果、未完成必須說卡在哪。

理由是本專案已經抓到四次的同型缺陷——一份說「已凍結」卻找不到 prereg 的登記,會讓人以為紀律在跑而實際沒有。有問題時頁面會出現 黃色警告卡,不會靜默。

五、誠實標註提案本身的風險

high-vol-entry 這一項我在 improvement 欄位直接寫了: 「⚠️ 這等於用回測看到的分組結果去選規則,是典型的資料窺探——必須先 在 prereg 裡寫死用哪個波動門檻、為什麼是那個數字,且門檻要有先驗理由」。

提案階段就把風險寫進去,比等到寫 prereg 才想起來便宜。


四十二輪:回測結果頁 + 偏空傾斜的 prereg(2026-08-25)

pytest:347 passed(+9)。使用者要求在網站加「回測結果」欄位, 用現有紀錄回測、顯示勝率與原因。

一、資料來源與防呆

live 只有 6 筆,算不出勝率。改用 walk-forward OOS (2010-01 → 2025-08,3,811 筆),並先把它落地存檔——原本跑完就丟, 只活在 run_experiment 的記憶體裡。

刻意存在 experiments/ 而不是 data/prod/:路徑本身就是一道防呆, 避免有人把回測當實績。頁面最上方黃色橫幅明示,並連到 /performance 的 live 紀錄。

二、兩種勝率分開報

指標值為什麼要分開
方向命中率(含中性)37.1%永遠說中性的模型在震盪盤就能拿高分
進場勝率(只算會下單的日子)48.6%判斷可交易性只能看這個

只看前者會把退化預測器誤判成好模型——那正是本專案踩過的坑, 現在有測試 test_always_flat_scores_high_hit_rate_but_never_trades 釘住。

三、四個原因拆解,第二個是關鍵

① 波動狀態:高波動組命中率最高(40.2%)但那不是能力——震盪帶是 ±sigma20/2,高波動期被撐寬,實際震盪日從 38% 升到 49%,命中率是 機械地跟上去的。同組的進場勝率反而最低(45.9%)。

② 預測方向 🔴 最重要:

        預測佔比   實際佔比    進場勝率   訊號平均報酬
偏空     52.40%    25.16%     46.47%    −0.000120   ← 虧損來源
中性     32.83%    42.85%       —          —
偏多     14.77%    31.99%     55.95%    +0.001228   ← 有效但次數少

③ 盈虧結構:獲利因子 0.9685(<1)、賺賠比 1.089、期望值 −1.3bp。 賺的時候確實賺比較多,但勝率不足 50%,相乘後仍為負。

④ 分年度:10/16 年為正,但 2020–2023 連續四年為負且幅度最大—— 那四年正是台股大多頭,與「過度預測偏空」互相印證。

四、把假說凍結起來,而不是急著修

config/prereg-2026-08-25-bearish-tilt.toml。寫的過程中發現我原本的 假說不完整,這件事本身值得記下來:

我原先直覺認定成因是 class_weight="balanced"。但 balanced 的效果是把 三類推向約略均等(33/33/33),而實測是 52/33/15——偏空遠超過 1/3、偏多遠低於 1/3。除了 balanced 之外還有別的東西在推向偏空。

所以 prereg 寫成兩個競爭假說:

  • H1:balanced 造成少數類過度預測
  • H2:特徵與標籤的關係本身帶偏空傾向,或模型在對抗 TX 的長期 上行漂移(ret_roll 年化 15.18%)

並且階段一只看分布、不看績效——先分辨成因,再談矯正。 直接關掉 balanced 然後看績效,在 H2 為真時會得到「有改善」的假象 (交易變少、虧損自然變少),卻誤以為找到成因。

其他刻意的設計:

  • 門檻 0.39 / 0.45 取自 52.40% 與 25.16% 的中點,是先驗值不是調出來的
  • 經濟判定必須在未污染期做——同一組折已被看過 21 次,重跑的正面 數字嚴重偏樂觀。walk-forward 只作機制旁證
  • 三道防呆:退化(單類 ≥90%)、進場比例崩到 <10%、 累積報酬也要改善(少交易會讓平均變好而總額變差)
  • 決策規則把「H1 成立但矯正無效」事先寫成一個合法結果, 以免屆時被當成失敗而想再試別的

四十一輪:新增成本敏感度回測——退掉一整個假說(2026-08-25)

pytest:338 passed(+9)。使用者要求「多做一個回測結果」。 選了成本敏感度掃描,因為它補的是目前最大的空白,而且凍結期內可做。

一、為什麼是這個回測

config/costs.toml 有 9 項標著未查證,整份報告都掛 assumed-cost—— 但從來沒有人量過那個不確定性到底重不重要。

breakeven_bp 只回答單一系統的損益兩平點。這張表把它推廣成曲線, 而且四個系統一起看:成本上升時買進持有(幾乎不換手)與方向訊號 (每天調曝險)受傷程度完全不同,只看單一系統會漏掉那個交互作用。

凍結期內可做:不換特徵、不換模型、不重新擬合,連 OOS 預測都是 既有的那一份——只是把同一組預測放進不同的成本假設裡重算損益。

二、結果

Sharpe(net),口徑 next_open / garch / initial_train_days=1250:

倍率round_trip買進持有方向訊號區間覆蓋層方向×覆蓋層
×00 bp0.77990.14920.77530.2750
×1(假設值)3.09 bp0.77960.07050.76730.1858
×26.18 bp0.7793−0.00810.75940.0965
×1030.92 bp0.7767−0.63700.6956−0.6162

三、這張表退掉了一整個假說

方向訊號在整個掃描範圍內都沒有贏過買進持有——連成本歸零時都還輸 0.6307 的 Sharpe。

也就是說:9 項未查證的成本參數,不是目前沒有 edge 的原因。 這比「在某個倍率翻盤」更有價值——它是個否定結果,直接排除了 「也許我們的成本假設太悲觀」這條路,不用再花時間去查證成本參數 來期待翻盤。(成本參數仍然該查證,但那是為了 L2+ 的口數與風險預算, 不是為了讓 edge 出現。)

順帶兩個讀表要點:

  • 買進持有幾乎不受成本影響(×10 只掉 0.0032)——它是這張表的錨點, 因為它的換手接近零。
  • 區間覆蓋層在零成本下 0.7753 vs 買進持有 0.7799,幾乎打平但 始終沒超過,且隨成本衰減比買進持有快。

四、實作

  • CostModel.scaled(factor):三項成本(手續費/期交稅/滑價)一起 乘同一個倍率。不去猜哪一項估錯,而是問「整體要錯幾倍」。 縮放不會讓 unverified 消失——label 仍是 assumed-cost, 有測試釘住(否則整個 assumed-cost 標註制度就破功了)。
  • cost_sensitivity() / cost_flip_point():flips_at=None 是有意義的 回傳值,代表「從來沒贏過」。
  • 結果落地成 reports/cost-sensitivity-<date>.md,帶日期與完整口徑—— 這個結論會被反覆引用,不能只活在終端機輸出裡。
  • 九條測試,含單調性不變式(成本越高、有換手的策略只會更差, 反過來就是引擎算錯了)。

四十輪:報告反映「這次執行」而不是「系統紀錄」(2026-08-25)

pytest:329 passed(+3)。「故障偽裝成空狀態」第四次現形, 這次的形式是「報告與權威紀錄互相矛盾」。

一、實際發生的事

1. 早上 cron 出了預測,daily-2026-08-25.md 正常 2. 我用 --skip-predict 重跑一次產報告 3. 同一個檔案被改寫成「本日沒有產生預測」,並自動 commit 進 git

而 predictions.csv 裡好端端躺著 3 筆。預測資料沒丟(CSV 是 append-only + 去重),但人看到的那一份說謊了——而它才是被 commit 起來當成當日存證的東西。

二、根因

write_report 的預測區段吃的是 pred 變數——這次執行產生了什麼。 --skip-predict、品質哨兵擋下、predict 拋例外,三種情況 pred 都是 None,報告就宣告「沒有預測」。

但那三種情況說的都是「這次沒有重新產生」,不是「系統沒有預測」。 兩者差很多。

三、修法:報告是系統狀態的視圖,不是單次執行的日誌

新增 _predictions_on_record()——pred 是 None 時,退回讀 predictions.csv 的最新一天,並在報告頂端標明:

> ℹ️ **本次執行沒有重新產生預測**(🔴 資料品質哨兵未通過…),
> 以下是 data/live/predictions.csv 裡最新的一筆。
> 報告反映的是**系統紀錄**,不是單次執行的結果。

兩個訊號並存——為什麼沒重新產生(含品質哨兵的完整失敗清單) 以及紀錄裡最後一筆是什麼。退回紀錄不等於粉飾。

只有在紀錄裡也真的沒有時,才會出現「本日沒有產生預測」。

四、三次真實情境都驗過

情境報告內容
正常執行預測區段,沒有退回通知
--skip-predict紀錄中的預測 + 「操作者指定」的說明
品質哨兵擋下(注入 sigma20=-1)紀錄中的預測 + 🔴 完整失敗清單
紀錄裡也沒有「本日沒有產生預測」

(品質哨兵那次用真實壞資料跑,驗完已還原,哨兵重新全綠。)

五、這個模式已經第四次

accuracy() 把損毀顯示成空 → 題材獵人「停更兩個月」→ 報告沒有預測 卻不說原因 → 報告與紀錄矛盾。共同點都是:真相是「壞了」或 「沒重跑」,但呈現出來是「本來就沒有」。


三十九輪:多日標籤恆等式 + 從 twii_forecast 借一條檢查(2026-08-25)

pytest:326 passed(+8)。

一、補完標籤恆等式(3d/5d)

哨兵原本只驗 y_ret_1d ≡ ret_roll[T+1]。3/5 日走的是 forward_return(ret, h) 的另一條程式路徑(rolling + 連乘 + shift), 1 日對不代表它們對——而 h3/h5 正是 ECE 最差的兩個 horizon, 標籤若錯位,那個「差」會被誤讀成模型問題。

實測三個 horizon 目前都正確(誤差 1e-16 級),所以這是防護不是修 bug。 四種變異全部抓到:平移、錯位一天(最陰險——值域、分布、統計量 全部正常)、把連乘誤寫成相加。

二、翻 twii_forecast 找到一條我們沒有的檢查

src/twii/quality/checks.py 有 check_no_future_dates,tx_forecast 沒有。

期貨這邊比現貨更容易踩到:夜盤的 date 是隔天——今晚 15:00 開始的盤標的是明天。而期交所盤中就會發布部分檔案(tick 檔實測過: 盤中抓到的是殘缺的),所以傍晚重建有機會把一個還沒結束的交易日 寫進序列。cron 正好排在 18:30,在 15:00 之後。

為什麼非補不可——step_freshness 算落後天數是:

stale_bdays = max(len(pd.bdate_range(last_day, date.today())) - 1, 0)

last_day 在未來時 bdate_range 是空的 → 回報「落後 0 個工作日」, 看起來完全健康。而下游會拿那一列去出預測、去結算,等於用一個 還在跑的交易日的殘缺價格。

補了兩處: 1. check_series 新增未來日期檢查(哨兵擋下 → 不出預測) 2. step_freshness 改成未來日期回報負數並印 🔴,不再壓成 0

驗算:末端 08-27 → stale_bdays = −2;末端 = 今天 → 0;末端 08-20 → 3。

三、twii_forecast 還有什麼可以用(尚未取用,先記著)

項目內容tx 的狀態
providers/fred.py/macro.py總經資料源(FRED)沒有;解凍後才可考慮當特徵
providers/ndc.py/industry.py國發會景氣燈號、產業別沒有;同上
quality/checks.py 的 check_calendar_containment交易日曆包含關係沒有,但 tx 的日曆自成一格,價值待評估
align.py跨市場對齊規則tx 已有自己的 _lag1 規則
data/prod/model_panel.parquet3,680 × 869 欄跨市場面板已在唯讀消費(cross_market 特徵群)

⚠️ 前四項全部屬於新增特徵,是模型研究,凍結中不可動。 記在這裡是為了解凍時不用重找。


三十八輪:報告標題寫「每日預測」,卻可以沒有預測(2026-08-25)

pytest:318 passed(+5)。「故障偽裝成空狀態」第三次現形。

一、發現

write_report() 的預測區段是:

if pred and pred.get("horizons"):
    ...
# ← 沒有 else

沒有預測時整段直接消失,而標題仍然是「台指期每日預測」。 讀的人看到一份有結算、有戰績、格式正常的報告,只是少了預測—— 不會有人意識到出事了。

發現的方式很偶然:跨過午夜後我用 --skip-predict 跑了一次, 產生的 daily-2026-08-25.md 就是這個樣子。

二、最嚴重的觸發條件不是 --skip-predict

三種情況都會走到這條路徑:

觸發嚴重性
操作者指定 --skip-predict低(是自己要的)
predict 步驟拋例外中
品質哨兵擋下(壞資料)高

第三種是設計上刻意的行為(「用壞資料出的預測比沒有預測更糟」), 但它的通知方式是一片空白。壞資料擋下預測是最該大聲說的事。

三、修法:沒有預測時,那件事本身就是報告的頭條

新增區段,並把三種原因分別接上(品質哨兵先寫、CLI 分支只在還沒被寫時 才寫,靠順序區分「壞資料被擋」與「操作者要求跳過」):

## ⚠️ 本日沒有產生預測

**原因**:🔴 **資料品質哨兵未通過**(2 項檢查失敗)——用壞資料出的
預測比沒有預測更糟,所以擋下。失敗項目:targets: 5570 列 sigma20 ≤ 0…

> 這一節存在本身就是訊號。標題寫「每日預測」卻沒有預測時,
> **不可以只是把區段拿掉**——那會讓故障看起來像正常的空狀態。

用真實壞資料端到端驗證:把 sigma20 注入 −1,品質哨兵(昨天才補上 的那條)確實擋下,報告確實把原因印在最前面。驗證後資料已還原, 哨兵重新全綠。

連原因都拿不到時印「原因不明」——空白不是選項。

四、順帶修掉我自己寫的脆弱測試

test_rewrite_writes_marker_for_the_website_banner 昨晚是綠的,今天早上 紅了——因為它依賴真實時鐘:step_history_audit 用 since=date.today() 只看今天的改寫,而測試裡的 log 日期寫死 2026-08-24,跨過午夜就變成 「昨天」。

測「今天的行為」的測試,必須自己決定今天是哪一天。 加了 frozen_today fixture 把日期凍住,五條相關測試都改用它。


三十七輪:/rerun 的身份檢查可以被偽造(2026-08-24)

pytest:313 passed(+5)。

一、發現

_can_rerun() 原本第一條就是:

if request.headers.get("tailscale-user-login"):
    return True

Tailscale-User-Login 是請求標頭,任何人都能自己帶。 實測:

curl -X POST -H "Tailscale-User-Login: [email protected]" \
     -H "X-Forwarded-For: 1.2.3.4" .../rerun/start
→ 303(放行)

只有在「Tailscale 保證覆寫或剝除客戶端自帶的同名 header」時才安全, 而那是無法從程式內驗證的外部假設。

二、實際暴露面:目前是零(2026-08-24 的快照,今天已不成立)

🔴 2026-09-18 更正:以下的「暴露面為零」不再是事實。Funnel 現在指向的就是本站——你正在讀的這一頁就是從公開網際網路送出去的。底下那段推論(「不是正在被利用的洞,攻擊者得先在這台機器上或 tailnet 內」)是當時的判斷,不要拿它當今天的前提;這一節自己預告的「指過來的那一刻」已經發生了。

查證後確認:

Funnel → https://…ts.net → 127.0.0.1:(內部埠) → CVCA 平台
tx_forecast 在 127.0.0.1:(內部埠),只綁本機

公開的是 CVCA,不是 tx_forecast。 所以這不是正在被利用的洞, 攻擊者得先在這台機器上或 tailnet 內。

但它是個活的陷阱:先前的規劃就是要把這個站對外(Funnel 已經開著, 只是指向別的埠)。指過來的那一刻,它就變成公網可觸發 「每按一次燒 3–5 分鐘 CPU + 重建資料」的入口。

三、處置:安全的預設值勝過方便的預設值

改成預設不信任身份標頭,要用得明示開啟:

(身份信任開關,預設關閉)

驗證:

情境結果
偽造 Tailscale-User-Login(預設)403 ✅
本機直連303 ✅(沒有把自己鎖死)
經代理但無身份403 ✅
明示開啟 + 有身份303 ✅
明示開啟 + 無身份403 ✅

五條測試釘住。這個開關的錯誤方向是「公開世界可以叫我的機器跑東西」, 所以預設值選安全那一邊。

CLAUDE.md 加了一條踩坑,並註明把 Funnel 指到 8010 之前先重讀 _can_rerun。


三十六輪:「準確率輸給猜震盪」這個說法不公平(2026-08-24)

pytest:308 passed。查超參數邊界時順帶發現:我自己一路重複的 一個判讀是錯的比法。

一、比錯了

模型用 class_weight="balanced" 訓練——它被要求最佳化的是平衡 準確率。而 accuracy_prior(「永遠猜多數類」)最佳化的是原始 準確率。拿兩者直接比,模型必輸:它本來就被要求多猜少數類 (跌 25.2%/漲 32.0%,而震盪 42.9%)。

那個比法說明不了任何事情,而它出現在 CLAUDE.md、status.md、 以及我這幾輪的每一次回報裡。

二、公平的比較

平衡準確率的無資訊基準是 1/3(三類均等猜):

fold01234平均
平衡準確率0.36670.39010.36580.39900.39390.3831

五折全部高於 1/3。 以折為單位的保守檢定(把每折當一個獨立觀測, 自由度只有 4):t = 7.09,遠超 t₀.₀₂₅,₄ = 2.78。

也就是說:存在微弱但統計上真實的判別能力。

三、但這不改變結論

統計顯著 ≠ 經濟顯著。

direction  Sharpe vs 買進持有  −0.7091            ❌
cost       breakeven 0.66bp(需 6.19bp)          ❌

訊號撐不過成本。系統沒有可交易的 edge——這一點沒有變, 0 過 / 4 不過也沒有變。

而且驗證折已用 14 次,任何正面判讀一律偏樂觀,這條也適用於 上面那個 t = 7.09。

四、為什麼要特地更正

「輸給猜震盪」聽起來像「模型完全沒用」,而實際狀況是「有一點訊號, 但不足以賺錢」。兩者對後續決策的意涵不同:前者指向「換一整套 做法」,後者指向「訊號在,要找的是更低成本的表達方式或更強的特徵」。

用錯的比法會把研究方向帶偏,所以即使結論(不過)沒變,說法也要改對。 已同步更新 CLAUDE.md,並在 FoldReport 加 balanced_accuracy 欄, 逐折表從此三個數字一起報。


三十五輪:超參數被釘在網格邊界(2026-08-24)

pytest:308 passed(+6)。

一、五折全部選同一個邊界值

fold 0  chosen_c 0.0100
fold 1  chosen_c 0.0100
fold 2  chosen_c 0.0100
fold 3  chosen_c 0.0100
fold 4  chosen_c 0.0100

網格是 [0.01, 0.1, 1.0],0.01 是最小值——在 sklearn 裡 C 越小正則化越強。每一折都選最強的那一端。

二、為什麼這件事不該靜默通過

CV 每折都撞到同一面牆,代表它想要的比網格能給的更極端: 真正的最佳值在網格外,而我們不知道它在哪。

這時候把 chosen_c = 0.01 當成「選出來的最佳超參數」報出去是誤導 ——那不是最佳值,是撞到牆。原本的輸出只印數字,沒有任何提示。

三、與其他證據完全一致

這不是孤立現象,它和已知的每一件事都對得上:

  • 方向系統的絕對曝險只有 0.095(買進持有的 9.5%)
  • 準確率 0.32–0.41,每一折都低於「永遠猜多數類」的基礎率
  • 模型幾乎不表態,p_flat 長期偏高

CV 的意思很清楚:「這些特徵沒有訊號,請把係數壓到最小。」 它壓到網格允許的極限,還想再壓。

四、處置:只加診斷,不動網格

run_experiment.py 現在會在超參數釘死在網格任一端時大聲警示, 並說明「最佳值在網格外」。兩條測試釘住這個警示與「網格不可只有一個值」。

沒有換網格——那是模型研究,凍結中。而且換了也只會讓模型更接近 常數預測,不會產生 edge。這個發現的價值不在於「調參」, 在於它是又一個獨立證據指向同一個結論:目前的特徵集沒有訊號。


三十四輪:故障偽裝成空狀態 + GARCH 多步把關(2026-08-24)

pytest:302 passed(+7)。

一、🔴 結算檔損毀時,網站會說「還沒有資料」

web.data.accuracy() 原本:

except Exception:  # noqa: BLE001 — 網站對缺檔要優雅
    return {"n": 0, "note": "尚無結算資料"}

「對缺檔優雅」把損毀也一起吞了。檔案壞掉時網站顯示「還沒有資料」 ——那是謊話:資料存在,只是壞了。而「還沒有資料」看起來完全正常, 不會有人去查。

這與題材獵人「停更兩個月」是同一種病:把故障顯示成空狀態。 那次也是看起來像沒資料,實際是 outcome_log.csv 舊格式讓欄位錯位。

改成三態,首頁與 /performance 都接上顯示:

狀況顯示
檔案不存在尚無結算資料(真的空)
只有表頭的合法空檔尚無結算資料(真的空)
檔案損毀⚠️ 明確報錯,帶例外型別與檔名,並註明「這不是還沒有資料」

實測:把 outcomes.csv 寫成亂碼,/performance 正確顯示 「⚠️ 結算檔案讀取失敗(KeyError: 'resolve_date')——這不是「還沒有 資料」,是 data/live/outcomes.csv 有問題」。驗證後資料已還原(6 筆完好)。

順帶:outcomes.csv 的兩個 read_csv 補上 keep_default_na=False, 與 predictions.csv 的讀法一致——後者曾因此在讀寫循環中被悄悄改寫。

二、GARCH 多步前瞻補上 look-ahead 把關

forecast_next(h=1 的 live 路徑)早就有變異測試釘住, 但 forecast_quantiles(horizon>1) 沒有——而 h3/h5 走的正是那條, 且 GARCH 現在是被 interval 判準檢驗的線上模型。

補三條:竄改測試段後段的報酬,前段的分位數必須逐值不變(h=1/3/5); 外加變異驗證——竄改前段時後段必須改變,否則代表比較的切片 根本沒被碰到,「全綠」是假的。

結果:多步路徑乾淨,無洩漏。

三、其他掃描

  • 22 個網站路由全部 200。
  • 全專案只有 5 處寬鬆的 except Exception,每一處都有 noqa 與理由; 唯一一個把故障吞成正常狀態的就是上面那個,已修。

三十三輪:對品質哨兵本身做變異測試(2026-08-24)

pytest:295 passed(+6)。哨兵是「今天要不要出預測」的閘門。 它自己有沒有效,不能靠它說了算——拿真實資料製造 11 種壞法,看它抓不抓得到。

一、11 種變異,10 種抓到,1 種漏掉

抓到的:重複日期、high&lt;low、收盤≤0、收盤跑出 [low,high]、報酬爆表、 合約回頭、y_ret 與 ret_roll 不一致、y_high&lt;y_low、夜盤有旗標無價、 available_at 掉時區。

漏掉的:sigma20 ≤ 0。

二、為什麼這一個漏掉會很嚴重

threshold_h = k × sigma20 × √h——sigma20 是動態震盪帶的唯一來源。 它塌成 0 或變負,震盪帶就消失,每一天都會被判成漲或跌, 「震盪」這一類從標籤裡整個蒸發。而值域檢查、恆等式檢查全部照過。

原本的哨兵只檢 threshold_*,沒檢 sigma20。兩者通常會一起壞 (threshold 由 sigma20 導出),但那是「通常」不是保證——兩欄各自 存進 parquet,只要有一邊被單獨改寫(重建中斷、手動修檔、schema 遷移), 不一致就沒有人會發現。

三、補檢查時,變異測試又抓到我自己寫得不夠緊

第一版只加了「比值是不是常數」:

threshold_h / (sigma20 × √h) 應該恆等於 k

看起來很嚴謹,但把 sigma20 整欄 ×1.5 依然是常數(只是換了個 k), 檢查照樣漏。改成三條一起:

1. sigma20 > 0 2. 各 horizon 反推的 k 彼此相同 → 抓「單一 threshold 欄被改寫」 3. sigma20 可以從 ret_roll 重算 → 抓「整欄等比例縮放」

第 3 條是最強的,因為它把 sigma20 錨回原始資料,而不是錨回另一個 同樣可能被改壞的衍生欄。

修正後 4 種 sigma20 相關的壞法全部抓到(含只改一列)。

四、這一輪的方法論

閘門的有效性要用變異測試證明,不能靠讀程式碼相信它。 兩次都是同一個模式:讀起來很合理的檢查,一做變異就發現有洞。 六條新測試把這些變異釘住,以後任何人放鬆哨兵都會立刻紅。


三十二輪:十條鐵律逐條稽核(2026-08-24)

pytest:289 passed(+6)。上一輪抓到鐵律 8 被違反,而且是從實作 當天就違反、275 個測試一路全綠。既然證實「鐵律有寫但沒測試把關」, 就逐條掃過去。

一、稽核結果

鐵律狀態依據
1 Point-in-Time✅ 本輪補上測試截斷不變性,3 個切點 + 變異驗證
2 raw 不可覆蓋✅write_parquet 守衛 + 歷史指紋稽核(廿四輪)
3 資料/切分/特徵/Target 分開✅targets 只有 date/sigma20/y_*/threshold_*,無違規欄
4 不得虛構資料✅本輪拒絕回填漏掉的樣本,即為實踐
5 date 不轉 UTC✅四個 prod parquet 全部 tz-naive
6 look-ahead 有測試✅本輪把特徵層的缺口補上
7 建表有 metadata✅file_hash 已補齊 11 個寫入點(廿三輪)
8 績效只能來自 ret_roll🔴 被違反,已修見三十一輪
9 不得輸出分數口✅lots_from_risk_budget 回 int + math.floor
10 抓不到價保留 pending✅既有測試涵蓋,本輪再加一條(y_ret 缺值也保留)

十條裡只有第 8 條真的被違反。 其餘九條實作與文件一致。

二、特徵層原本沒有任何測試檔

117 欄、餵給模型的東西,卻沒有 test_features.py。既有測試裡 test_models 測的是 scaler/imputer 只看訓練段、test_targets_splits 測的是切分不洩漏——都不是「特徵本身在 D 日有沒有用到 D 日之後的 資料」。

補上 tests/test_features_pit.py,用截斷不變性:

完整序列算一次特徵 → 取 D 之前的列
截斷到 D 再算一次   → 取 D 之前的列
兩者必須逐值相同

不必逐欄推導公式,新增特徵自動被涵蓋——事後想到才檢查的清單 永遠會漏,結構性的不變式不會。三個切點都測,另加變異測試種一個 shift(-1),確認檢查本身有效(否則「全綠」可能只是比較邏輯壞了)。

結果:特徵層乾淨,無洩漏。

三、兩種洩漏,兩種檢查,缺一不可

截斷不變性有它抓不到的東西——同列的時序問題:

  • 美股 D 日收盤在台股 D 日收盤之後,用 US[D] 預測台股 D 是洩漏, 但它沒有用到任何未來的列,截斷測試完全看不出來。 → 查證 16 欄跨市場特徵全部正確 _lag1,並補測試鎖住。
  • 夜盤相反:date=D 涵蓋 D−1 15:00 → D 05:00,刻意不 lag。 前提是 available_at 真的早於開盤。→ 實測 2,258 天全部 05:00 可用, 台股 08:45 開盤,餘裕 3h45m,0 天違規,補測試鎖住這個前提。

四、其餘檢查

  • 7 筆待結算預測全部合法等待(各差 1–5 個交易日),無卡死。
  • 6 筆已結算的標籤門檻逐筆核對正確(h1 ±1.46%/h3 ±2.53%/h5 ±3.26%, 皆隨 sigma20 動態調整)。

三十一輪:結算報酬違反鐵律 8(2026-08-24)

pytest:283 passed(+8)。使用者要求自主研究、發現嚴重問題直接修。 查結算正確性時抓到一個已經在污染晉升閘門的缺陷。

一、缺陷

live.resolve_predictions():

px_actual = _actual_price(series, target)   # px_raw_close
ret = px_actual / px_ref - 1.0              # 🔴 跨合約相減

CLAUDE.md 鐵律 8 白紙黑字:「標籤與績效只能來自 ret_roll, 不得用 px_raw 跨合約相減」。這裡違反了。

pred_date 與 target 之間跨轉倉日時,兩個價格屬於不同合約,比值裡含 假跳空(實測 271 個轉倉日平均差 0.55%、最大 3.67%)。

二、為什麼嚴重:它污染的是晉升閘門

interval_hit 是這樣判的:q10 <= ret <= q90。而 q10/q90 是在 ret_roll 空間預測的(模型學的是 y_ret_hd,GARCH 擬合的是 ret_roll)。拿含假跳空的價格比值去比 ret_roll 空間的分位數, 單位不一致——涵蓋率因此可能整個判反。

而 h1 區間涵蓋率正是 L1→L2 晉升門檻的四條之一。

三、實際影響:6 筆已結算裡已有 2 筆受影響

2026-08-17-h3: ret -0.022228 → -0.020384  (差 +0.001844)
2026-08-17-h5: ret -0.024538 → -0.022698  (差 +0.001840)

這兩筆的 interval_hit 剛好沒翻轉(差 18bp 不足以跨過邊界), 但那是運氣——最大轉倉差 3.67%,遠大於 h1 的門檻寬度。 h5 跨轉倉的機率約是 h1 的五倍,樣本累積後必然出事。

四、處置

1. 改用 targets 的 y_ret_{h}d(ret_roll 複利,與模型訓練目標 同一個定義)。y_ret 缺值時保留 pending,不退回價格比值湊數字。 2. 修正 2 筆歷史記錄。這不是竄改:原值違反鐵律、可由 targets 決定性重算、舊值完整保留在 git 歷史。留著已知錯誤的數字餵給 money gate,比修正它更糟。 3. 迴歸測試把跳空做成 −5%,退回價格比值會立刻紅 (test_settlement_return_comes_from_ret_roll_not_price_ratio)。 另一條測 y_ret 缺值時必須保留 pending。 4. px_actual 保留在記錄裡供稽核——它是真實結算價,只是不再用來算報酬。

五、這一輪的教訓

鐵律寫在 CLAUDE.md 上,但沒有測試把關的鐵律只是一句話。 這條鐵律從專案第一天就寫著,程式碼卻從實作當天就違反它,而且 101 → 275 個測試一路全綠。已補上測試,並把這個案例寫進 CLAUDE.md 的踩坑清單。


三十輪:兩張 prereg 都套用了,而我的預測錯了(2026-08-24)

pytest:275 passed(+9)。使用者指示「兩個都處理」,兩張 prereg 都標為 approved_by_user 並實作。結果與我事前告訴使用者的不一樣, 這一節的重點就是把那個錯誤講清楚。

一、我說「interval 會由紅轉綠」——沒有

提案時我明確告訴使用者:「同意套用 → interval 這關會由紅轉綠」。 實測:

measures_model    garch
mean_coverage     0.8279              ← 確實落進 [0.76, 0.84] ✅
sigma_from_edge   +11.10              ← 距門檻 11 個標準誤,不再是雜訊
narrower_than_baseline_all_folds   False   ← 卡在這裡 ❌
mean_width        0.0255  vs  baseline 0.0293

判準是 AND:涵蓋率要達標,而且寬度要在每一折都窄於無條件 基準。GARCH 涵蓋率過了、平均寬度也比基準窄,但不是每一折都窄, 所以整體仍然不過。

我當初只看涵蓋率那一項就斷言會轉綠,沒檢查同一個判準的第二個條件。 這是判讀疏漏。更要緊的是:我拿一個沒驗證過的預測去換取使用者同意—— 「改了會過」這種話本身就該先被驗證,不該當成既定事實來說。

二、切分改動:機械效果如預期,真實品質卻退步

prereg 事先寫死要同時報兩件事,實測:

改動前改動後
ECE 有限樣本下限(機械效果)0.02770.0235 ✅ 如預期下降
excess_over_null(真實校準)0.02990.0375 ❌ 變差
mean_ece0.05770.0611

prereg 事先寫的判讀規則是「只有 (a) 改善而 (b) 沒有 → 誠實寫『閘門 變鬆,模型沒變好』」。實測比那還糟一級——(b) 明確退步。這正是 prereg 另一半事先警告過的代價:第一折訓練段從 2,530 天砍到 1,250 天。

⚠️ 但前後不是同一組測試集,不可直接相減。 固定暖機期讓 OOS 往前 延伸到 2010–2015,那段以前從來沒被測過。「ECE 變差」可能是模型真的 變差,也可能是新納入那段本來就更難——用現有證據分不出來, 不得挑對自己有利的解釋。

三、其餘判準(同樣不可直接比較)

direction  sharpe_diff  -0.9821 → -0.7091   但 consistent_folds 2/5 → 1/5
cost       breakeven    -4.85bp → +0.66bp   仍未達 2 倍門檻 6.19bp

結論:0 過 / 4 不過 / 1 無法檢定——與改動前完全相同。 兩個改動加起來,沒有讓任何一關由紅轉綠。

四、這一輪真正的收穫

不是數字變好(沒有變好),是兩個結構性缺陷被修掉:

1. [backtest].interval_model 成為單一事實來源,daily_run 與 interval 判準讀同一個鍵——「線上跑 A、判準檢 B」在結構上不可能再發生, 並有測試(test_interval_model_is_single_source_of_truth)釘住。 2. 切分不再用「前半」這個相對量,資料再長,多出來的全部進 OOS。

以及一個意外發現:新切分下分位數迴歸的涵蓋率也從 0.7594 升到 0.8011。原本那個「不過」有多少來自模型、多少來自那組特定的折, 現在看起來更可疑——這反過來支持一件事:單一組折上的點比較, 本來就不該拿來下這麼重的判定。


二十九輪:樣本數——43% 的交易日根本沒有預測(2026-08-24)

pytest:266 passed(+7)。ECE 的下限、涵蓋率的信賴區間、L2 的 60 筆門檻——這三件事的分母都是樣本數。查下去發現一個更基本的問題。

一、閉環開始後的 7 個交易日,只有 4 天有預測

交易日      : 08-14 08-17 08-18 08-19 08-20 08-21 08-24
有預測      :  ✅    ✅    ❌    ❌    ✅    ✅    ❌
捕捉率 4/7 = 57%

成因:daily_run 預測的日期是「序列裡的最後一天」,不是「今天」。 上游資料落後時,它會替一個已經出過預測的舊日期重跑 (append_prediction 以 date+horizon 去重,後寫的取代先寫的), 而當天這個交易日就永遠沒有預測。

log 裡有直接證據——2026-08-20 那次排程:

序列最後交易日 2026-08-17,落後 3 個工作日
步驟 2/5:產生今日預測
  2026-08-17-h1  ...        ← 產出的是 08-17,不是 08-20

鏈路跑成功、log 全綠、沒有任何錯誤訊息,樣本卻少一筆。 與這個月 抓到的其他缺陷同型:不會有人發現,除非有人去數。

二、不能回填,只能誠實計數

為 08-18 補一筆「用當時資料算出來的」預測,在 CSV 裡會與真正事前 產生的記錄完全無法區分,而寫的當下結果早就已知。那是鐵律 4 直接禁止的事,也正好是 live 記錄唯一的價值來源(事前、不可竄改)。

所以漏掉的就是漏掉了。 能做的是讓它變成看得見、會出聲的數字:

  • promotion.sample_capture_rate()——有資料的交易日裡實際產出預測的比例
  • daily_run 每天印出捕捉率,低於 90% 加 ⚠️,並列出漏掉且無法回補的日期
  • /promotion 頁一張橫幅,紅字顯示捕捉率與漏掉的日子

三、修正我自己上一輪公布的 ETA

第二十二輪做樣本累積 ETA 時,假設「穩態約 1 筆/交易日」——等於預設 100% 捕捉。實測 57% 之後,estimate_l1_to_l2_eta() 現在把還差的筆數 除以實測捕捉率:

二十二輪(假設 100%)本輪(實測折算)
h1 達 60 筆2026-11-112027-01-07

差了將近兩個月。原本那個日期偏樂觀,這裡更正。

四、當場把今天的樣本救回來

發現 2026-08-24 也在漏掉名單裡——因為我整場都用 --skip-predict。 今天資料齊(落後 0 個工作日)、明天的結果還沒發生,補跑是正當的 事前預測(跟 18:30 的 cron 會做的事完全一樣),不是回填。跑完:

捕捉率 57% → 71%(5/7)
h1 ETA     2027-01-07 → 2026-12-11

08-18/08-19 那兩天仍然永久計為漏掉,不做任何補救。

五、順帶記錄:walk-forward 也丟掉一半樣本,但那是設計

查的過程順便確認了 OOS 只有 2,531 筆的原因——splits.py:

start = n // 2      # 前半當初始訓練,後半均分成 n_folds 段測試

研究期 5,061 天,前半 2,530 天固定當初始訓練,只有後半進 OOS。 這是刻意的設計(保證第一折有十年訓練資料),不是缺陷。改成 「固定暖機期」可以讓 OOS 增加約 60%、每折 506→812 筆,ECE 下限 從 0.0277 降到 ~0.022。

但我沒有改:那會讓所有判準的數字變動,而且方向是讓閘門更好過, 又是在已知 ECE 卡在 0.0577 的情況下提出的——與上一輪的 interval 判準同一個問題。要動要另外寫 prereg 並經使用者同意。


二十八輪:涵蓋率 0.7594——閘門檢的不是線上那個模型(2026-08-24)

pytest:259 passed(+6)。查 interval 判準為什麼差 0.0006 不過, 查出兩件事,第二件比第一件嚴重得多。

一、0.0006 的差是雜訊,不是「差一點點」

涵蓋率是二項比例,本身有抽樣誤差。補上 Wilson 95% CI 與「距門檻 幾個標準誤」之後:

mean_coverage     0.7594
allowed_range     [0.76, 0.84]
coverage_95ci     [0.7423, 0.7756]     ← 涵蓋門檻 0.76
sigma_from_edge   -0.0735              ← 只差 0.07 個標準誤

用點比較決定要不要放行真錢,等於擲硬幣。 ROADMAP 3.6 對 Sharpe 提過同一件事(「A 0.87 > B 0.86 在雜訊裡毫無意義」),涵蓋率是同一個病, 只是沒有人把它連起來。

⚠️ 但要分清楚兩件事:與門檻下緣 0.76 的差是雜訊(0.07σ), 與目標 0.80 的差不是(CI 上界 0.7756 < 0.80)。區間確實系統性偏窄, 這個結論不變。

二、更嚴重的:判準量的是已經退役的模型

interval 判準吃的是 coverage_80,而它來自:

cov, width = interval_coverage(y_ret, out["q10"], out["q90"])   # 分位數迴歸

但 daily_run 自 2026-08-20(prereg-2026-08-20b)起,線上區間 已改用 GARCH(1,1)——predictions.csv 的 model_version 從 linear-v1 變成 linear-v1+garch-iv,白紙黑字。

這道閘門檢的不是線上跑的那個模型。 而 garch_coverage 其實 每折都有算,只是沒有被任何判準用到:

garch_coverage_not_gated   0.8088    ← 線上模型,離 0.80 只差 0.0088

也就是說:閘門用雜訊等級的差距,擋下一個已經退役的模型; 而真正在跑的那個,涵蓋率幾乎正中目標卻不被檢驗。

這與二十六輪的 execution 是同一類缺陷——閘門量的東西跟實際跑的 東西不一致,而且都不會有任何錯誤訊息。

三、我沒有自己把這道閘門改綠

修法很明顯(讓判準跟著 daily_run 實際使用的區間模型走),但效果 就是把一道紅燈變綠燈,而且是在我已經知道 GARCH = 0.8088 會過的情況下 提出的。形式上這就是移動球門柱,不該由我單方面決定。

處置:

  • 判準原封不動,本輪仍照原定義判「不過」
  • 落差全部攤在判準輸出裡(measures_model / deployed_model / garch_coverage_not_gated / note),不讓它繼續隱形
  • 寫 config/prereg-2026-08-24-interval-criterion-target.toml, 開頭就揭露「本檔寫定時已經看過結果」,把支持與反對的理由 都列出來,標記 decision_status = "pending_user_approval", 未經使用者明示同意不得套用

四、順手補進 prereg 的紀律

新判準若採用,強制要求:涵蓋率一律附 Wilson CI 與「距門檻幾個標準誤」; 所有區間模型的涵蓋率都要報,即使只有一個進入判定。 這次落差能隱形三個月,就是因為只報了進入判定的那一個。


二十七輪:ECE 卡在 0.05——門檻的一半是估計量偏誤(2026-08-24)

pytest:253 passed(+7)。ECE 0.0577 是唯一擋住 L2 的技術性條件 (其餘是帳戶參數等使用者),查它為什麼過不了。

一、先確認 isotonic 校準沒有「做了沒接上」

models.py 有完整的 isotonic 實作,config [model] 卻沒有 calibration 鍵 → mp.get("calibration", "none") 取到 none。看起來像第五個 「做了沒接上」,但查了 prereg 才發現不是:

config/prereg-2026-08-20-calibration-garch.toml 的檢定結果寫得很清楚—— isotonic 讓 h=1 的 ECE 從 0.0506 惡化到 0.0884(fold 2 從 0.0678 爆到 0.2056),決策規則照辦「daily_run 維持 calibration=none」。 沒接上正是遵守凍結判準的結果,不是缺陷。

(診斷也已寫在該檔:校準集是訓練段末端 20%(~530 筆),時序 regime 漂移下 isotonic 記住的是校準窗口的局部雜訊,到測試段就失效。)

⚠️ 上一輪剛抓到四個「做了沒接上」,很容易養成「看到沒接就是 bug」的 反射。這次差一點誤判——先讀懂為什麼沒接,再決定要不要動。

二、真正的問題:ECE 是往上偏的估計量,而沒有人報過下限

ECE 對每個 bin 取 |實際 − 預測| 再加權平均。取絕對值之後,抽樣 雜訊不會抵銷,只會累積——所以完美校準的預測器在有限樣本下也量得到 ECE > 0,樣本越少、bin 越多,偏得越大。

實測本專案的實際情境(每折 OOS 506 筆、機率集中在窄區間):

每折樣本機率範圍完美校準的 ECE 中位數超過 0.05 的比例
2500.15–0.350.034720.2%
5060.15–0.35~0.0253.5%
10000.15–0.350.01760.1%
25000.15–0.350.01120.0%

新增 metrics.ece_null_floor():拿模型自己輸出的機率當真值、 由 y ~ Bernoulli(p) 生成結果(真實校準誤差恆為 0),重複模擬 得到虛無分布。接進 _score_fold 與判準輸出,每次跑都自動報。

用真實資料跑出來(驗證折第 13 次):

mean_ece            0.0577
max_allowed         0.0500
null_floor_median   0.0277   ← 完美校準也會量到這麼多
excess_over_null    0.0299   ← 扣掉偏誤後的「真實」校準誤差

門檻 0.05 有 55% 在還沒有任何真實誤差時就被吃掉了,實際能容納的 真實誤差只有 0.0223,而模型有 0.0299。

三、結論:閘門判得對,但數字一直無法判讀

模型確實有真實的校準不良(0.0299,超過虛無分布的 90 百分位), 閘門擋它是對的。但在今天之前,0.0577 這個數字沒有任何人有辦法 判斷是「校準真的差」還是「樣本太少」——兩者的處置完全相反: 前者要改模型,後者只要等資料。

四、一個必須先寫下來的陷阱

樣本變多 → 下限自動下降 → 這一關會自己變得比較好過,模型完全沒改。 每折 506 筆時下限 0.0277;若日後研究段長到每折 1000 筆,下限降到 ~0.018,門檻的實際容量幾乎翻倍。

所以:日後若校準閘門由紅轉綠,必須先確認 excess_over_null 也跟著 降。只看 mean_ece 轉綠就放行,等於把「樣本變多」誤讀成「模型變好」, 而這正是會讓真錢上場的那道閘門。已寫進判準輸出與網站文案。

網站 /performance 的校準卡同步改寫,並處理了小樣本的極端情況: 目前 live 只有 6 筆已結算,下限中位數 0.1893 已經高過門檻本身—— 文案直接說「這個樣本數下,一個毫無瑕疵的模型也會被判不及格」, 而不是印出「門檻的 379% 被吃掉」這種讀不懂的數字。

五、判準本身沒有動

max_ece = 0.05 原封不動。這一輪只加診斷,不改凍結的門檻—— 看了結果再放寬門檻就是移動球門柱。下限是用來判讀門檻的, 不是用來繞過它的。


二十六輪:回測用的成交價一直是「成交不到的價格」(2026-08-24)

pytest:246 passed(+4)。使用者要求找出台指期最大的問題並修好。 系統性檢查後,最大的問題不是模型,是評估基礎:

execution="next_open"(T+1 開盤成交)在 2026-08-21 任務 #40 就實作 並寫了 5 條測試,但三個 production 呼叫點全部沿用預設的 "close", 只有測試在用 next_open。

也就是說,截至今天為止每一個對外的 Sharpe、breakeven、四系統比較 數字,都是用「預測產生時已經不存在的價格」算出來的。ROADMAP 3.1 自己就寫著這是「樂觀偏誤直接灌進每一個回測數字」的來源,而修法早就 寫好了,只是沒有接上——這是這個月抓到的第四個「做了沒接上」 (前三個:log_validation_use()、品質哨兵沒歷史、daily_run_health 寫錯位置)。

一、接線本身

config/experiment.toml 新增 [backtest].execution = "next_open", 穿進 four_system_table() 與 _score_fold()。接的時候立刻炸了:

BacktestError: series 缺欄位:['px_ratio_close', 'px_ratio_open', 'px_raw_open']

這個錯誤是好消息——引擎明確拒絕在缺開盤價時假裝自己在用誠實模式, 而不是安靜退回 close(迴歸測試 test_next_open_needs_open_columns_and_says_so 把這個行為釘住)。真正的原因是 OOS 預測表從來沒帶開盤價欄位, 這也解釋了當初為什麼沒接上。改成一律帶著,不因當下設定條件性挑選。

二、量化偏誤——結果與 ROADMAP 的預期相反,如實記錄

同一份資料、同一組折,只換成交假設(驗證折第 10、12 次; 第 11 次是 next_open 缺欄位當場拋錯的那一跑,沒有看到任何數字, 但計數器設計上「跑之前就記」所以照樣佔一次——保守是對的, 如實留著不修):

判準close(樂觀)next_open(誠實)差
direction mean_sharpe_diff−0.9943−0.9821+0.012
cost mean_breakeven_bp−5.7560−4.8536+0.90
calibration mean_ece0.05770.05770(成交假設不影響機率)
interval coverage0.75940.75940(同上)

誠實模式的數字反而略好,不是略差。 ROADMAP 預期「樂觀偏誤灌進 每個數字」,實測方向相反,幅度也很小。

原因查得出來:方向系統的部位是 P(漲)−P(跌),而模型幾乎不表態—— 用 live 記錄實測,絕對曝險平均只有 0.095,約買進持有的 9.5%。 換手小、曝險小,成交價假設的影響自然小;而買進持有(曝險恆為 1.0) 幾乎沒有換手,兩者相減後淨影響落在雜訊等級。

這不代表這個修不必要:現在我們知道偏誤的量級(約 0.01 Sharpe/ 0.9bp),而不是猜。等模型真的有信心、部位放大、換手上升時,同一個 偏誤會等比例放大——那時再發現就來不及了。

三、結論沒有改變

兩種模式都是 0 過 / 4 不過 / 1 無法檢定。方向訊號輸給買進持有 (Sharpe 差 −0.98)、ECE 0.0577 過不了 0.05 的閘門、區間涵蓋率 0.759 低於 [0.76, 0.84]、損益平衡成本是負的。系統依然沒有 edge,而且 現在這個結論是建立在誠實的成交假設上。

四、驗證折動用 #10–#12 的說明

這兩次動用是引擎修正的偏誤量化,不是模型搜尋。判斷依據:改動 只可能讓自己的數字變差或不變(實際上略好是意外),而選擇偏誤來自 「挑對自己有利的設定」——一個往不利方向改的變更不構成那種偏誤。 但仍然照規則計數,不因為「這次不算」就跳過留痕。


二十五輪:稽核接進 daily_run,每天自動查(2026-08-24)

pytest:242 passed(+10)。二十四輪的稽核只有在有人打開網頁時才跑, 這一輪接成每日鏈路的步驟 0.6,cron 每天自動查一次。

一、刻意不擋鏈路

step_history_audit() 不加進 failures、不設 skip_predict。理由寫在 程式碼裡:歷史改寫不一定是 bug(修 roll calendar、改特徵定義、上游補 舊資料都會合理改寫歷史),自動擋掉會讓每次正當重構都卡住,那種閘門 最後一定會被關掉。與其養一個註定被關掉的閘門,不如留一個一定會被 看到的紀錄:console 輸出 + logs/HISTORY_REWRITE 標記 + 網站橫幅 + ingestion_log 逐日留痕。

留痕寫在 commit 步驟之前——二十一輪的教訓(寫在之後會晾到明天才 入庫),這次一開始就放對位置,並在程式碼註解裡標明原因。

二、測試生命週期時抓到一個真的設計缺陷

寫「標記檔要能自己消失」那條測試時想到一個問題:ingestion log 是 append-only,一次改寫會永遠留在裡面。全量掃描的話,每天都會重新報 同一起,logs/HISTORY_REWRITE 永遠清不掉——一個解除不了的紅燈很快 就會被無視,那比沒有紅燈更糟。而這正是我自己在上一輪的程式碼註解 裡寫過的話(「殘留的紅燈比沒有紅燈更糟」),差點自己踩進去。

修法:detect_history_rewrites(entries, since=...)。每日檢查傳 since=今天,問的是「今天這次重建改寫了歷史嗎」,不是「歷史上 曾經被改寫過嗎」。後者留給 /health 稽核頁與事後追查——那裡要看 全量,因為它的用途是翻舊帳,不是告警。

兩條迴歸測試把這個區別釘住:test_since_filters_out_old_rewrites (昨天的改寫今天不報)與 test_since_still_catches_a_rewrite_that_happened_today (今天的照樣抓)。

三、順手修掉的小問題

  • history_audit 會污染自己的涵蓋率報表:稽核每天寫一筆留痕, 它沒有 history_hash,不排除的話會被算成「無歷史指紋」,分母越灌越大, 受保護率隨著每天跑一次而越看越差——但實際上什麼都沒退步。已加進 NON_HISTORICAL 並補測試。
  • 橫幅文字重複:標記檔第一行原本寫「N 起歷史指紋變動:」,橫幅 又有「歷史指紋變動」,連起來變成「歷史指紋變動:2026-08-24 — 1 起 歷史指紋變動:應該凍結的過去...」。改成標記檔只放日期、筆數與涉及 的資料集,由橫幅提供語意。

四、驗證方式

告警路徑用直接建立標記檔驗證,沒有往真實 ingestion_log 塞假記錄 ——那會違反鐵律 4(不得虛構資料),而且假資料會永遠留在 git 歷史裡。 標記檔是暫時性訊號不是資料,測完即刪。單元測試則用合成 log 完整覆蓋 偵測與標記的生滅。

實跑 daily_run.py 確認步驟 0.6 出現在鏈路中、留痕與當日 commit 同批 入庫、乾淨時不留標記。


二十四輪:歷史改寫稽核——指紋終於有人讀了(2026-08-24)

pytest:232 passed(+20)。二十三輪把 file_hash 補齊到 11 個 寫入點,但沒有任何人讀它——正是這個月已經抓到三次的同型缺陷 (機制寫好了、沒有接上)。這一輪補完那個缺口。

一、frame_fingerprint 答不了營運上真正要問的那題

整體指紋每天 append 都會變,所以它只能證明「兩次重建逐值相同」, 無法區分:

今天雜湊變了,是因為多了今天這一列(正常), 還是因為 2015 年的某個值被改掉了(嚴重,違反鐵律 2)?

新增 history_fingerprint():切掉近期豁免窗,只雜湊理應凍結的 歷史段。正常 append 不動它,改寫過去才會動它。

二、我自己的第一版是錯的,被自己寫的測試抓出來

第一版用「最近 5 個交易日」當豁免窗。 test_appending_a_row_does_not_change_history_hash 立刻失敗——因為 切點跟著資料末端滑動:每 append 一列,前一個豁免窗裡最舊的那列 就掉進凍結段,雜湊照樣會變,完全達不到目的。

改成錨在月底:切點 = 「最新日期往回 2 個月」那個月的最後一天。 當月內每天 append 都不移動切點,雜湊因此穩定;切點每月只前進一次, 而且回傳值帶著切點本身("2026-06-30:<sha256>"),比對方據此區分 「切點前進了」(預期內)與「同一個切點、雜湊卻不同」(真的被 改寫)。後者是無歧義的證據。

這一題如果沒寫那條測試,會安靜地錯下去——雜湊每天都在變,看起來 「有在運作」,實際上永遠報不出真正的改寫,反而製造每日假警報。

三、稽核層 src/tx/provenance.py

  • detect_history_rewrites():只比較相鄰兩筆、切點相同的記錄。 None(不適用)、舊格式(無切點前綴)、切點前進——三種情況都跳過, 不當比對基準。
  • coverage():涵蓋率與結果一起報。「0 起改寫」在涵蓋率 0% 時 毫無意義,那只代表沒有人在看。區分「受保護(≥2 筆可比對)」/ 「只有 1 筆(還無從比較)」/「無歷史指紋」三態。
  • NON_HISTORICAL:當月月檔每天被上游改寫、tick 滾動視窗舊檔會被 期交所下架、健康狀態每天不同——這些納入稽核只會製造噪音,把真警報 淹掉,明確排除並在程式碼裡寫明每一個的理由。
  • 只報告,不阻擋:歷史改寫不一定是 bug(修 roll calendar、改特徵 定義、上游補資料都會合理改寫歷史)。自動擋掉會讓每次正當重構都卡住, 那種閘門最後一定會被關掉。

四、實跑驗證 + 找到一個真的缺口

連續重建兩次完整鏈路,稽核結果 0 起改寫——重建對歷史是確定性的, 這是正確答案。但涵蓋率報表暴露一件事:tx_contracts 是所有表的 源頭,卻是唯一沒被保護的(它的 file_hash 記的是 raw 目錄指紋, 沒有記自己輸出的歷史段)。補上之後 6/8 受保護。

剩下 2 個(twse_spot_direct、tx_bars_1min)確認是正確的 None, 不是遺漏:前者只有當月 16 列、後者是約 30 天的滾動視窗,都還沒有 任何資料落在 2 個月前的凍結段。已逐一驗證,不是猜的。

網站 /health 平台區新增一列,涵蓋率與結果一起顯示: 「6/8 個資料集受保護,未偵測到歷史被改寫」。


二十三輪:file_hash 補齊到全部 11 個資料寫入點(2026-08-24)

pytest:212 passed(沿用二十二輪的測試,本輪不新增測試,只是把 已驗證過的兩支雜湊函式接到剩下的呼叫點)。使用者說「先幫我 commit 繼續延伸」——commit 完二十二輪之後,把上一輪特意先驗證單一範例 (tx_contracts)的 file_hash 補齊到其餘 10 個 record_ingestion() 呼叫點。

兩種雜湊策略,依資料來源性質分派

不是無腦全部套用同一個函式:

  • 讀取本地目錄的(build_contracts.py 讀 raw 月檔目錄、 fetch_taifex_direct.py/fetch_taifex_ticks.py 寫入的 raw 目錄) → directory_fingerprint(),驗證「目錄裡的檔案有沒有被默默換掉」。
  • 建表/直抓輸出一個 DataFrame 的(build_series.py、 build_targets.py、build_features.py、build_bars.py、 fetch_twse_spot.py)→ frame_fingerprint(),驗證「這次重建出來 的結果跟上次逐值相同嗎」——這正是 frame_fingerprint 當初寫的 目的(docstring:「用於比對重建結果是否逐值相同」),寫完之後 一直沒有真正的呼叫點,這輪終於補上。

garch_params/quality_check/daily_run_health 三個 dataset 刻意留空——它們記的是計算出來的指標或健康狀態,不對應任何檔案, 沒有東西可雜湊,null 在那裡是正確狀態,不是遺漏。

端到端驗證

跑一次完整 daily_run.py --skip-predict(含全部四支重建腳本+三支 直抓 fallback+tick 封存+1 分 K 聚合),逐一核對 ingestion_log.jsonl 裡每個 dataset 最新一筆的 file_hash:11 個資料寫入點全部是真的 64 字元 sha256,3 個非資料類 dataset 維持 null。順便額外收穫: 這次重建把 2026-08-24 當天的 6 筆待結算預測結算了 2 筆 (方向命中 83.3%,樣本數還是太小不構成結論)。


二十二輪:ETA 推算 + 系統性掃描「寫了沒接上」的缺陷(2026-08-24)

pytest:212 passed(+13)。使用者說「繼續延伸下去,然後就繼續看能 不能研究可以迭代的方式」。做兩件事:把樣本累積從靜態數字變成可看見 進度的推算,以及系統性掃一次這個月已經抓到三次的缺陷模式(機制寫了 但沒真的接上),看還有沒有漏網的。

一、樣本累積 ETA——不外推,用設計節奏推算

/promotion 頁原本只顯示「h1: 3/60」這種靜態比數,看不出多久會達標。 新增 estimate_l1_to_l2_eta():刻意不用「已結算天數 ÷ 已結算筆數」 外推速率——樣本只有個位數時經驗速率雜訊極大,外推出的日期是假精確。 改用機制本身的設計節奏(daily_run 穩態下每個 horizon 每交易日約 結算 1 筆)當基準,並在文案裡明講這個假設,以及它跟品質哨兵/系統 紅燈連續天數要一起看。目前推算:h1 約 2026-11-11、h3 約 2026-11-13、 h5 約 2026-11-16 達到 60 筆門檻。

二、系統性掃描:定義了但完全沒被引用的函式/類別

寫一段腳本掃 src/tx/*.py 裡所有 def/class,對照 scripts/、 web/、tests/、其他 src/tx/ 檔案裡有沒有真的被引用。抓到 6 個:

名稱判定處置
file_sha256 / frame_fingerprint真缺陷:record_ingestion() 的 file_hash 欄位存在,11 處呼叫全部沒帶值,ingestion_log.jsonl 裡這欄永遠 null已修(見下)
log_holdout_touchgrep 找不到任何呼叫處,scripts/ 裡沒有——但這支函式的設計語意是「保留集只能動用一次」(見 CLAUDE.md 十條鐵律),2026-08-21 已經動用過一次(reports/holdout_touch_log.md 有一筆 timestamp 與檔案 mtime 吻合的紀錄)。合理推測是當時透過互動式 Python(不是永久腳本)呼叫過這支函式,用完就不該再有呼叫處——這正是「用一次」語意底下的正確狀態,不是忘記接。但我沒有更早的 shell 紀錄可以百分之百確認那筆記錄真的是這支函式寫的,不是手動編輯 md 檔案,誠實記下這個不確定性無需處置,但信心不是 100%
system_overlay「四系統比較」邏輯改用別的路徑實作,這個 helper 是早期設計的遺留記錄,不動——刪除需要更確定不會有人依賴,留給之後
tx_lots_to_mtx舊設計(TX×4 換算 MTX),compute_lots() 現在改成 TX/MTX 各自獨立算風險預算(文件裡明講原因:MTX 粒度細,常出現「TX 0 口但 MTX 2 口」)。這支函式是被取代的舊路徑,不是忘記接記錄,不動
load_bars_window停損模擬器的資料載入 helper,目前沒有腳本呼叫它(測試用合成資料,不走這條路徑)記錄,不動——可能是給未來的回測腳本用的

唯一真的修了的是 file_hash。 其餘幾個要嘛是掃描腳本的誤判,要嘛 是「設計換了路徑,舊 helper 留著」——不是同一種病,不該一律當成 bug 清掉,那樣反而可能刪掉還有人在用、或未來要用的東西。

三、修 file_hash:新增 directory_fingerprint()

src/tx/storage.py 新增 directory_fingerprint(),對一個目錄底下所有 符合 pattern 的檔案取組合雜湊(複用 file_sha256,不是另一套獨立 邏輯)。接進 scripts/build_contracts.py 的 record_ingestion() 呼叫——這是文件裡明講過風險最高的資料集(CLAUDE.md:「當月月檔在 上游每天被改寫」)。8 項迴歸測試涵蓋:目錄不存在、內容變了雜湊要變、 新增檔案要變、排序不影響結果、pattern 過濾。

實跑 build_contracts.py --no-archive 驗證 file_hash 真的從 null 變成 64 字元的 sha256——但這樣只重建了 staging 層,prod 層 還沒跟著同步。為了不留下不一致的中間狀態,接著跑一次完整的 daily_run.py --skip-predict(含正式的 archive 模式)把整條鏈路 重新對齊,ingestion_log.jsonl 裡 tx_contracts 那筆現在也有正確 的 file_hash。

其餘 10 處 record_ingestion() 呼叫還是沒有 file_hash——先接 風險最高的這一個作為驗證過的範例,其餘留給之後的迭代,不在這輪 一次全接完(那會擴大這輪的變更範圍,而且不同資料集的雜湊策略可能 不一樣,例如 tick 封存是滾動視窗,語意跟月檔不同)。

四、這一輪最該記住的

不是每個「定義了但沒被引用」的函式都是同一種病。這個月已經抓到三次 「寫了保護機制,但沒真的接上」(log_validation_use()、品質哨兵沒 歷史、daily_run_health 寫在 commit 之後),容易養成「看到未引用的 函式就當成 bug」的反射——但這次掃出來的 6 個裡,有 3 個是設計換了 路徑後的正常遺留,硬修反而可能是自己在製造問題。先讀懂為什麼 沒被呼叫,再決定要不要修。


十九輪:「退化預測器」是誤診——假說被推翻(2026-08-24)

依 config/prereg-2026-08-24-stationarity.toml(第 9 次動用驗證折) 執行完畢。結論與當初的假說相反,如實記錄。

一、先重現 live,再下判斷

十八輪把網站上「10/10 全中性」判成退化預測器,並假設病因是非平穩 特徵。這一輪第一件事是精確重現 live(複製 daily_run.step_predict 的訓練方式:全歷史、class_weight="balanced"):

複製結果 h1: p_flat = 0.6223
live 記錄 h1: p_flat = 0.622262      ← 四位小數完全吻合

重現成立,才有資格談病因。(註:第一版背景測試給出 p_flat≈0.34, 與 live 對不上,那個數字是錯的,已作廢。對不上 live 的模擬不能拿來 下結論——這一條值得寫進紀律。)

二、逐日重擬合 60 天:沒有退化

scripts/probe_live_degeneracy.py(新增)對最近 60 天逐日用「當日 之前的全歷史」重新擬合,完全比照 live:

基線 58 欄精簡 45 欄
類別分布跌 12 / 震 20 / 漲 28跌 16 / 震 24 / 漲 20
最多類佔比46.7%(判準 A 門檻 90%)40.0%
p_flat 範圍0.077 – 0.821(平均 0.341)0.064 – 0.756(平均 0.353)
已實現準確率28/59 = 47.5%25/59 = 42.4%

模型會漲會跌會震,p_flat 從 0.08 到 0.82 都出現過,而且 47.5% 高於 「永遠猜震盪」的 42.8% 基準。這不是退化預測器。

而且精簡版在 live 情境下更差(42.4% vs 47.5%,剛好掉回基礎率)。 被移除的水準類特徵在長跨度訓練下顯然仍攜帶資訊,不是純粹的雜訊源。

三、那 10/10 全中性是怎麼回事——模型是對的

最近 10 天確實全部預測中性。但實際結果也是:

近 20 個交易日實際類別:  跌 3 / 震 15 / 漲 2   → 震盪佔 75%
全樣本震盪佔比:                                    42.8%

病因不在模型,在波動度。震盪帶是動態的(threshold = sigma20/2):

sigma20        近 20 日中位數 0.0292  vs 全樣本 0.0097   (3.0 倍)
threshold_1d   近 20 日中位數 0.0146  vs 全樣本 0.0049   (3.0 倍)
已實現波動(年化) 近 20 日 40.7%  近 60 日 38.9%  全樣本 21.1%
                 → 近 20 日波動位於歷史 95 百分位

也就是說:現在波動在歷史高檔,震盪帶被撐寬到平常的 3 倍(±1.46%), 於是多數日子落在帶內。模型連續說「中性」不是壞掉,是在說 「這一天的變動會待在(目前很寬的)中性帶裡」——而它連對 8/9 天。

⚠️ 同樣的推理套用到十八輪的「區間比歷史寬 39%」(任務 #50): 拿條件區間去比無條件全樣本寬度,在波動 95 百分位的當下, 本來就該寬。那條診斷的基準選擇有同樣的瑕疵,待解凍後重新設計。

四、對三條凍結判準的誠實裁決

判準門檻基線 58 欄精簡 45 欄結果
A 退化解除單一類別 < 90%43.6%(WF) / 46.7%(live)47.3%(WF)✅ 但空過
B 不得劣化accuracy 掉 < 2pp0.40320.3893(−1.39pp)✅ 過
C 校準ECE 增加 < 0.010.05780.0471(改善)✅ 過

判準 A 是空過的:兩組都沒退化,因為根本沒有退化可解除。 prereg 寫死的決策規則說「A+B+C 全過 → 採用精簡特徵集」,但那條規則 建立在「存在退化」的前提上,而前提被推翻了。

決定:不採用,production 維持 58 欄。 理由:假說既被推翻,支持 改動的就只剩 ECE 從 0.0578 降到 0.0471 這一個數字——而它會讓 h1 的 校準閘門(max_ece = 0.05)以 0.0029 的差距翻過去,把系統從 record_only 切成真的發部位建議。在一個已經動用 9 次驗證折的資料上, 0.003 的邊際不是訊號。不讓一個空過的判準去開真金白銀的閘門。

(h3/h5 的 ECE 是 0.099/0.154,無論如何都過不了閘門,不受影響。)

五、仍然成立、但需要自己的 prereg 的事

prereg 裡列的兩個結構性缺陷是真的,只是它們造成的傷害不是退化:

  • tx_ma20 訓練段 [1798, 6038] vs 測試段 [8238, 46407],值域重疊 0%
  • tx_ma5 ~ tx_ma10 相關係數 1.000,58 欄中 23 對 > 0.95

這兩件事該修,但要等解凍後用針對共線性與值域漂移的判準重新登記, 不能借「修退化」的名義夾帶。已寫進 ROADMAP。

六、題材獵人停更兩個月的根因(任務 #55)

也是誤診連環。逐層剝開:

1. 先以為「停更兩個月」→ 實際是 outcome_log.csv 舊格式列缺 pred_id,整排右移,pred_date.max() 回傳 '8046'(一個股票代號) 2. 修正後:預測其實 8/19、8/20 已恢復,真正的空窗是 6/03 → 8/19 3. 空窗根因:crontab 有一條指向 ~/Desktop/題材獵人專案/ 的殭屍排程,那個目錄根本不存在(Windows 時期遺留),每天 16:00 失敗。而真正的 Thematic stocks/scripts/daily_bot.py 從來沒有掛上任何排程——8/19、8/20 那兩次是我們手動觸發的

處置:移除殭屍 cron,改掛 40 15 * * 1-5(台股 13:30 收盤後,等 yfinance 收盤價穩定), 加 flock 防重入與 timeout 1800。實跑驗證全鏈路完成, 並完成 3 筆到期預測的真實結算(#74 −4.0% 失敗 / #75 +3.8% 小贏 /

#76 −7.3% 失敗)。

七、這一輪最該記住的

「模型一直輸出同一個答案」不等於模型壞了。 先問標籤的分布是不是 也變了。這次如果直接照假說改特徵集,會把一個運作正常的模型改掉, 還會順手打開真金白銀的閘門——而理由是一個空過的判準。


二十輪:晉升階梯自動追蹤(2026-08-24)

pytest:189 passed(+13)。使用者要求「做一個可以迭代的功能」, 選定方向:把 config/prereg-2026-08-21-promotion-ladder.toml 凍結的 L1→L4 門檻,對照 live 累積資料自動算出現況——閉環每多跑一天, 這頁就自動跟著動,不需要另外維護一份「目前進度」的狀態。

新增 src/tx/promotion.py

  • evaluate_l1_to_l2/l2_to_l3/l3_to_l4():純函式,吃 outcomes/帳戶 參數/品質哨兵連續天數,吐出每條件的「門檻 vs 現況 vs 是否符合」。 met 有三態:True/False/None(樣本不足時是「還不知道」, 不是「不符合」——例如 0 筆已結算時涵蓋率不該顯示紅叉,那會讓人 誤以為模型被檢定過而且沒通過)。
  • L2→L3、L3→L4 在尚未達到前一階段時,誠實回報「尚未適用」而不是 編造數字。
  • 晉升判定是 AND:13 項迴歸測試裡特別測了「11 項符合、1 項不符合」 時整體仍是未就緒——不是「大多數過就算過」。

發現一個真實缺陷:護欄寫好了但沒接上

config/prereg-2026-08-21-promotion-ladder.toml 裡的 「品質哨兵連續 20 日無 FAIL」這條門檻原本無法被檢定—— logs/LAST_RUN_FAILED 只記「最後一次」,沒有歷史,連續天數無從算起。 補上 daily_run.py 步驟 0.5 之後把每日品質檢查結果寫進 data/ingestion_log.jsonl(dataset="quality_check"),streak 從 今天(2026-08-24)開始逐日累積,不回溯造假。

這是這個月第二次遇到「機制寫好了、沒有呼叫點」(第一次是 log_validation_use(),見上一輪)。這類缺陷不會有任何錯誤訊息, 只會讓人以為紀律在運作——以後新增判準/計數器類的東西,要順手搜 一次呼叫點確認真的被用到。

網站新增 /promotion

三個台階各一張卡,L1→L2 展開、L2→L3/L3→L4 摺疊(<details>,尚未 適用)。今天的真實現況:h1/h3/h5 已結算樣本 3/1/0 筆(門檻 60)、 h1 涵蓋率 66.7%(n 太少不穩定,已標註)、帳戶參數未填、品質哨兵 streak 0 日——全部誠實列出未通過,頁面不建議晉升,只攤開事實。 降級規則另闢一張卡:自動觸發,不需人工判斷,並在文案裡直接寫明 「人在虧損時最會替系統找藉口」。


二十一輪:把降級規則從靜態文案接成真數字(2026-08-24)

pytest:199 passed(+10)。使用者說「繼續延伸下去,就是要做什麼都可以」, 選定方向:上一輪 /promotion 頁的「降級規則」卡當時只是描述性文字, 沒有真的算數字——跟「品質哨兵連續 20 日」原本沒接上是同一種問題 (寫了規則,沒有資料可查)。這輪把它做成真的。

新增 evaluate_demotion() 與 compute_red_streak()

  • 降級是 OR 邏輯(任一 horizon 滾動 30 筆涵蓋率 < 65%,或系統紅燈 連續 3 日,任一觸發即降級),跟晉升的 AND 邏輯刻意相反,兩者都用 三態 triggered: bool|None 避免「樣本不足」被讀成「安全」。
  • compute_red_streak() 與 compute_quality_streak() 共用同一段 JSONL 解析邏輯(新增 _streak_from_log() 私有函式收斂重複), 兩者方向相反:一個數連續好天數,一個數連續壞天數。
  • daily_run.py 新增 dataset="daily_run_health" 逐日留痕。

過程中連續抓到兩個自己剛寫的小缺陷

1. h5 從畫面上消失:evaluate_demotion() 一開始用 outcomes["horizon_days"].unique() 決定要顯示哪些 horizon——但 h5 目前 0 筆已結算,unique() 裡完全不存在,整列消失,會讓人誤讀 成「這條規則不存在」而不是「還沒有資料」。改成固定看 (1,3,5), 跟 evaluate_l1_to_l2 一致。新增迴歸測試 test_demotion_shows_horizon_with_zero_outcomes。

2. 健康記錄寫在 commit 之後,會晚一天才被存進版控:第一版把 daily_run_health 的 record_ingestion() 放在 step_commit_records()(git add + commit)之後——今天這筆 要等明天下次 commit 才會被 git add data/ingestion_log.jsonl 掃到, 等於每天都有一個空窗。品質哨兵那筆當初就寫對了(在 commit 前), 這次沒對齊。移到 commit 之前,並在程式裡寫清楚為什麼故意選在 這個位置(明確排除 commit 步驟自身的成敗,只看預測鏈路乾不乾淨)。 這是本月第三次遇到「新機制寫完但沒真的接上/接對位置」的模式 (第一次 log_validation_use()、第二次品質哨兵沒歷史、這次是 歷史寫的位置不對)——用實跑 daily_run.py 兩次驗證 diff 才抓到, 單元測試不會告訴你 commit 的時間點對不對。

端到端驗證

實跑 daily_run.py --no-refresh --skip-predict 兩次:第一次確認 quality_check/daily_run_health 都寫進當天 commit(diff 只有 2 行新增,不多不少);網站重啟後 /promotion 頁面即時反映 「品質哨兵連續天數 1 日」「系統紅燈連續天數 0 日」,降級卡的三個 horizon(含 0 筆的 h5)都正確顯示「不適用」而非空白或誤判。


十八輪:投資顧問視角審查——三個主題的共同病灶(2026-08-24)

pytest:172 passed(+15)。使用者要求以專業投資顧問的眼光評分 三個主題。查證後的結論:三個系統都停在統計指標(命中率、涵蓋率), 沒有一個回答「照做會賺錢嗎」——而三者一旦用投資指標檢視,結果 都比表面難看。

🔴 台指期:退化預測器(最嚴重,且網站正在誤導)

10/10 的 live 預測都是「中性」,p_flat 恆 > 0.56(基礎率僅 42.8%)——模型從未表態。網站當時顯示「方向命中率 75%」,那個數字 反映的是震盪日的基礎發生率,不是預測能力。這是假 alpha 展示。

修法:src/tx/diagnostics.py 新增 check_degeneracy(),單一類別 佔比 ≥90% 即判定退化;總覽頁與 /performance 皆就地紅字警示。

🔴 台指期:區間比「什麼都不做」還寬 39–58%

Horizon目前區間寬歷史無條件 80%比值
1 日3.77%2.71%1.39
3 日7.75%4.89%1.58
5 日9.66%6.31%1.53

GARCH 涵蓋率 0.81 很漂亮,但區間寬度超過歷史無條件分位數 = 用更華麗的方式說「我不知道」。高波動期 GARCH 放寬區間是正確 行為,但也意味現階段區間不具決策價值——這件事以前沒有任何地方 講出來。新增 interval_usefulness() 常駐呈現。

🔴 三主題的經濟意義(全部負期望值)

主題指標結果
台指期照訊號做的平均損益0.000%(進場率 0%——不虧錢是因為什麼都不做)
題材獵人5 日平均報酬(扣 0.6% 成本)−1.05%,勝率 45%
美股 Accumulate平均 alpha vs SPY−1.33%(n=214)
美股 Avoid平均 alpha vs SPY+0.69%(n=144)✅

美股那組是本輪最有價值的發現:它在「避開弱勢股」有能力,在 「挑選強勢股」是負貢獻。這解釋了為什麼裸命中率 43.7% 看不出問題 ——命中率可以靠多猜安全牌拉高,期望值不行。

新增:technical skill score

裸命中率會被基礎率灌水。skill_score() = (命中率 − 基礎率) / (1 − 基礎率),≤0 表示不如直接猜多數類。目前樣本個位數尚無統計 意義,列出來是為了隨閉環累積。

三個主題各加「這些數字該怎麼讀」指引

把「prob_boom 是反向指標」「conviction 是反向指標」「續抱不是中性 結果」「這套系統的強項其實在哪」寫成明確指引——診斷表列出病灶 還不夠,要告訴讀者現在該怎麼使用這個系統。


十七輪:網站專業度補強 + 一個資料完整性 bug(2026-08-24)

pytest:157 passed(+1 回歸測試)。以「外部專業訪客第一次進站」 的視角補四個缺口,全部唯讀展示不碰凍結。

🔴 意外收穫:日內頁揭露 tick 檔被凍在殘缺狀態

做日內展示頁時發現 2026-08-21 的 bars 只有夜盤 829 根、日盤 0 根 ——追根因:tick zip 當天盤中抓到的是殘缺檔(只含前一晚夜盤, 當日日盤還沒收),而 fetch_taifex_ticks 的「檔案存在就跳過」 會讓那個殘缺檔永遠殘缺。與當月月檔凍結是同型錯誤,第三次踩到。

修法三件:①最近 3 天強制重抓(--recheck-days)②較大者勝 (tick 檔當日只會長大,來源比現有小就不覆寫)③build_bars 在來源 zip 比 bars 新時重做。實測 08-21 從 0.8MB → 2.0MB、日盤 0 → 301 根, 交叉驗證通過。回歸測試 test_every_bars_day_has_day_session 釘住。

四個新頁面

  • /methodology 方法論——把這個專案最獨特的東西(三條序列的 分工、四道防自欺機器、晉升階梯、誠實結論、已知限制)寫成新訪客 看得懂的一頁。專業度的核心不是圖表多漂亮,是能說清楚自己為什麼 可信、以及哪裡還不可信。
  • /health 系統健康——三板塊 + 平台一頁看完(7 正常/1 異常/ 1 待處理),含 tick 資產累積面板。
  • /intraday 日內 1 分 K——日盤與夜盤分開繪製(時間軸不 連續,畫成一條線會誤導),30 天可選。
  • /api/*.json——predictions/outcomes/health 三個唯讀端點, 讓資料可被程式取用。

基礎專業度

中文 404/500 頁(500 頁明說「網站是唯讀層,錯誤不影響資料與閉環」); 每頁溯源頁尾(git commit、第幾次提交、資料建置時間)——研究站 的最低要求是讀者能回答「我看的是哪一版、資料多新」。 存活探測改 /healthz,/health 讓給人看的頁面。


⛔ 研究凍結宣言(2026-08-21 起生效)

驗證折已使用 8 次,遠超「3–5 次後判讀偏樂觀」的門檻。自即日起 凍結所有以驗證折為依據的模型研究:不再嘗試新特徵組合、新模型、 新校準——每多看一次驗證折,選擇偏誤多累積一層,正面結果的可信度 已經接近耗盡。

裁判換人:閉環的 live 記錄。 每天 3 筆預測、真實到期結算,約 三個月後每個 horizon 有 60+ 筆無偏樣本——那是重啟研究的時機與依據。 在那之前,唯一允許的模型變更是修 bug(行為不變的重構)與已凍結判準 的執行。

解凍條件(擇一):live 樣本每 horizon ≥ 60 筆;或有全新的、未被 本專案任何實驗碰過的資料期間可當測試集。


十四輪:網站三大板塊定調(2026-08-21,使用者定調)

側欄重組為使用者指定的三大方向+系統區,品牌改「量化研究站」:

1. 台指期預測(總覽/命中率與校準/預測紀錄/行情與序列/研究紀錄) 2. 台股題材獵人(新增:每日報告/預測與命中率)——唯讀讀 ~/Desktop/Thematic stocks(路徑含空格,pathlib 處理) 3. 美股研究(每日報告/計分卡)

題材獵人新增兩頁:

  • /theme-report:全球題材掃描與動能爆發報告渲染、最新題材熱度 訊號表、歷史清單
  • /theme-performance:命中率總表(照它 tracker 的分類:勝=勝利+達標, 67 筆結算)、prob_boom 可靠度表(它已標 critical 的 corr=−0.273 病灶可視化——與美股 conviction 表、台指期 ECE 閘門 是同一面鏡子,三個板塊如今用同一套誠實度語言)、最近 15 筆結算

小修:app.py 缺 pandas import 造成 /theme-performance 500,已補。 三個板塊各有自己的資料新鮮度橫幅。pytest:140 passed 不變。


十三輪:樣本考古——40 筆變 342 筆,診斷翻案(2026-08-21)

使用者指示「研究為什麼樣本數低、想辦法找樣本」。研究結論: 美股側的樣本一直都在,少的是結算——scorecard.md 的表格只渲染 40 筆,但 agent.db 躺著 383 筆方向性裁決 + SPY 完整價格。把結算邏輯 (照它 scorecard 原文的規則:Accumulate 7d ±1%、Avoid 21d ±2%, 對 SPY 的 alpha)搬到 db 上自行重算全量:342 筆,與官方已列 40 筆 交叉驗證 25/33 一致(76%,差異多半來自進場日定義,已標注在頁面)。

全量樣本讓 40 筆看不見的真相現形

信心n勝率
5675%
622552%
711024%

信心 7 的勝率不到信心 6 的一半——信心分數是反向指標,最有信心的 pick 是最差的 pick。診斷分解:

  • 病灶在 Accumulate(n=214,勝率 36%,平均 α −1.33%)—— 篩選器是「當日漲 ≥5% 才入選」的追高型,7 日 horizon 內均值回歸 吃掉 alpha。Avoid 其實是賺的(54%,+0.79%)。
  • 逐月惡化:5 月 53% → 6 月 41% → 7 月 32%(regime 相關)。

紀律注記(防止這個發現變成過擬合)

以上是 in-sample 觀察。「跳過或反轉 conviction≥7 的 Accumulate」 是可測試假說,不是現在就改規則的依據——那正是該系統 R4 抓過 自己一次的教科書錯誤。正確路徑:依它自己的 TODO 時程,9/3 樣本 成熟後用 forward 資料驗證,通過才改。診斷全文常駐 /us-scorecard, 每天自動更新。

tx_forecast 側的樣本策略(研究凍結不變)

live 4 筆是時間問題不是設計問題:每天 +3 筆自然累積,約三個月 達每 horizon 60 筆的解凍門檻。不回填歷史「as-if」預測——用今天 的模型對過去日期出預測是回測混充 live,會污染閉環的無偏性,那是 這個專案存在的意義,不賣。大樣本的背景證據已存在且已呈現: walk-forward OOS 2,528 天(GARCH 涵蓋率 0.808–0.810 的來源)與 保留集 250 天終檢,兩者都在 status 與網站可查。


十二輪:品質哨兵 + 趨勢圖 + 異地備份 + conviction 鏡子(2026-08-21)

pytest:140 passed(+13)。研究凍結不變——本輪全是維運與誠實度, 不碰驗證折。

資料品質哨兵 ✅(daily 鏈路步驟 0.5)

write guard 擋不住「語意上壞掉的資料」(high<low、重複日期、標籤 錯位——值域全過,語意全錯)。新增 src/tx/quality.py:OHLC 一致性、 日期唯一遞增、日曆洞、|ret|>11%(假跳空偵測)、前月合約不回頭、 標籤恆等式 y_ret_1d[T] ≡ ret_roll[T+1](建表 shift 錯位的執行期 把關)、夜盤孤兒列與時區。FAIL 就不出預測——壞資料出的預測比 沒有預測更糟;結算與報告照常。13 支 mutation 測試逐條證明有負載; 真實 prod 資料全綠且每天在鏈路裡跑一次。

滾動趨勢圖(/performance)✅

滾動 20 筆的方向命中率+區間涵蓋率曲線。現在樣本少(4 筆)圖是 說明文字;它的價值在隨閉環累積自動長出來——研究已凍結,這條線 就是決定解凍的判決書(涵蓋率目標 0.80 直接畫在圖上)。

git bundle 異地備份 ✅

每日 commit 後自動 git bundle 到 ~/.local/state/tx_forecast/ (repo 目錄外)——資料夾整個被誤刪也能 git clone bundle 還原全部 歷史含閉環記錄。已實測還原:clone 出來 data/live 與 git log 完整。

美股 conviction 可靠度表(/us-scorecard)✅ —— 照出真問題

用它自己 scorecard 已結算的列,按信心分箱對實際勝率(唯讀分析):

信心樣本勝率
5917%
6157%
71629%

信心 6 的勝率比信心 5 還低——沒有單調性,conviction 分數目前 沒有攜帶可用資訊(題材獵人 prob_boom 同病,tx_forecast 用 ECE 閘門 擋掉的正是這個)。樣本尚少(40 筆),依它自己 TODO 的紀律等 9/3 樣本成熟再下結論;表已常駐網站,每天自動更新。


十一輪:美股研究區 + ai-stock-agent 排程恢復(2026-08-21)

網站新增「美股研究」分組(唯讀展示另一套系統)

~/Desktop/ai-stock-agent(symlink → 帶日期實名資料夾)是使用者的 美股每日研究桌:Hermes 驅動、多分析師辯論制、7 日對 SPY 結算的 計分卡(命中率 43.7% 誠實記錄)、paper trading 帳、agent.db 完整歷史。 2026-08-20 從 Mac 搬遷至本機。

網站加兩頁(/us-report、/us-scorecard):中文日報渲染 + 歷史清單、 裁決統計(976 筆)、權益曲線(uPlot,45 天快照)、計分卡渲染。 唯讀——本站不寫入該專案任何檔案;美股區有自己的資料日期橫幅 (報告落後 >2 工作日就標注)。

ai-stock-agent 排程已在本機恢復

依它自己的 restore.sh/MIGRATION.md 步驟執行:

1. 72 支腳本 Mac 路徑((本機路徑))→ 本機路徑,部署至 ~/.hermes/scripts/(改前 tar 備份於 /tmp) 2. HERMES_API_CALL_STALE_TIMEOUT=900 補進 (金鑰檔) (它的 MIGRATION 明載:缺這行深度思考報告會在 ~150s 被掐死, Mac 上 8/12–19 的九天中斷就是這個) 3. 官方 finance/stocks skill 安裝完成 4. 5 個 cron job 合併(非覆蓋)進 Hermes——現有 3 個台股 job 原封不動;jobs.json 改前備份。Strategy Learning Loop 因 vibe-trading venv 未搬遷,裝上但暫停並註明原因 5. (金鑰環境變數) 原本就在(與台股 job 共用 Z.AI plan),無缺 secret

ai-stock 的 job 都有 pin 模型(glm-5.2/zai),明早 8:00 首跑。

⚠️ 附帶發現(回報,不代處理——範圍界線)

台股「台指 Regime 盤前簡報」job(942f012fdb98,taiex-factor-agent, Hermes 領域)已連續 4 次被安全機制跳過:全域模型設定從 qwen3.8-max 漂移到 qwen3.8:27b,該 job 未 pin 模型,Hermes 為 防意外花費而 skip。修法(使用者決定用哪個模型後執行): hermes cron edit 942f012fdb98 --provider <p> --model <m>。 盤後完整版 job 可能同病,值得一併檢查。


十輪:TWSE 現貨直抓 + 清單收尾(2026-08-21)

pytest:127 passed(+7)。改進清單只剩等使用者輸入的項目。

TWSE 現貨直抓 ✅ —— 每日 9% 缺特徵警告消失

探針四項全綠(端點知識複用姊妹專案 loaders/twse.py):舊月/本月 /未來月(優雅回空)都正確;與上游 taiex_price.csv 重疊 26 日 逐值零不一致;直抓當場補上上游缺的 3 天(08-18/19/20)。

src/tx/spot.py + scripts/fetch_twse_spot.py:抓當月(月初補上月), raw 逐月封存(較長者勝),staging/twse_spot.parquet。 build_features.load_spot() 改為上游為底、直抓只補缺不覆寫, 重疊日不一致(>0.01 點)大聲印出。接進 daily_run 直抓段。

效果:現貨補到 08-20 → basis 特徵完整 → 每日預測的 「缺 5/58 特徵,中位數填補」警告完全消失。兩個上游依賴 (期貨 raw、現貨 CSV)現在都有自己的直抓 fallback。

測試小插曲:fixture 的民國年寫錯(114=2025,今年是 115)被自己的 測試抓到——程式本來就對,探針實抓資料一直解析正確。

uPlot 本地化 ✅

uPlot 1.6.32(MIT,unpkg 下載,51KB js + 2KB css)放進 web/static/, FastAPI 掛 StaticFiles,ui.py 改指本地——訪客瀏覽器不再打 unpkg, 離線也能畫圖。頁面已無任何外部資源引用。

失敗標記橫幅 ✅

daily_run 失敗時寫 logs/LAST_RUN_FAILED(含步驟摘要),成功清除; 網站讀到標記就出紅色橫幅——cron 在背景失敗時,打開網站就會看到, 不用翻 log。實測寫入/清除兩個方向都正確。推播管道(LINE/Discord) 仍待使用者指定,橫幅是不依賴任何外部服務的底線防護。


九輪:維運加固 + 保留集終檢(2026-08-21)

git 版控上線,閉環記錄每日自動 commit ✅

專案進 git(58 檔案初始 commit);data/live/ 與 ingestion_log.jsonl 納入版控(全系統唯一不可重建的資產——raw 掛了可直抓重建,「模型 在 X 日說過什麼」遺失就永遠遺失)。daily_run 新增步驟 5:每日自動 commit 閉環記錄與報告——git 歷史同時是備份與防竄改證明。實測 daily loop 2026-08-21 commit 成功。

匿名資訊洩漏修補 ✅

/rerun 頁的 log 尾段與 /rerun/status.json 的 log 路徑含完整檔案 路徑(使用者名稱、目錄結構),先前對 Funnel 匿名流量可見。修正: 匿名只看得到狀態與退出碼,log 內容與路徑只給具名身份。實測驗證。

GARCH 參數留痕 + 擬合一次化 ✅

先前每個 horizon 各擬合一次同樣的序列(三倍浪費);改為每日擬合一次, ω/α/β 記進 ingestion_log.jsonl——日後「區間為什麼突然變寬」有逐日 參數軌跡可查。當前 α=0.095、β=0.890(α+β=0.985,高持續性)。

保留集終檢:GARCH 區間的無偏成績 ✅(一次性,已記錄動用)

最後 250 個交易日(2025-08-11 → 2026-08-20)從未被任何實驗碰過; 對已部署的 GARCH 區間做一次最終評估是保留集的正當用途。動用 記錄於 reports/holdout_touch_log.md(先寫再跑,跑失敗也算動過; 重跑須 --force 附理由)。

horizon保留集涵蓋率驗證折涵蓋率
1 日0.7520.810
3 日0.7220.808
5 日0.7200.807

判讀(誠實):保留集正好是市場最動盪的一年,真實尾部比歷史厚, 區間在這段偏窄約 5–9 個百分點。這是比驗證折更冷靜的無偏數字。 依紀律不觸發任何調整——看了保留集分數再改模型,無偏性就沒了。 後續由 live 記錄繼續驗證;若 live 涵蓋率持續 <0.75,那是「事實在 說話」,屆時再依凍結宣言的解凍條件處理。



八輪:GARCH 接管每日區間(2026-08-20,使用者核准「開始」)

pytest:120 passed(+3)。

prereg-2026-08-20b 檢定:h=3/5 全過,三個 horizon 換 GARCH

判準先凍結(多步 GARCH 未經折內驗證,不能只憑 h=1 的結果就上)。 多步實作:變異數逐步收斂回無條件值後加總,z_q 取訓練段 h 日 標準化殘差的經驗分位數。檢定(驗證折第 7/8 次,正面判讀註明偏樂觀; 緩解因素:4 參數基準模型、贏幅大、h=1 已獨立驗證同方向):

QR 涵蓋GARCH 涵蓋QR pinballGARCH pinball
h=30.73890.80810.00510.0049
h=50.73650.80700.00690.0064

daily_run 已切換

區間來源改 GARCH(1,1)(全歷史擬合、forecast_next 出下一步), 記錄標 model_version = "linear-v1+garch-iv";GARCH 失敗退回分位數 迴歸(不帶後綴,可區分)。方向機率不動,仍受校準閘門管制。

實跑:三筆 2026-08-20-h{1,3,5} 全部 garch-iv,區間 ±2.4%/±4.3%/ ±5.7%(隨 √h 正確放寬、正確巢狀、跨參考價兩側——QR 版曾出現 h5 區間整段在參考價上方的怪異輸出,GARCH 自然修正)。

測試抓到一個真 look-ahead(修正後結論不變)

test_forecast_next_dummy_value_is_irrelevant 發現尾端 dummy 會微幅 改變分位數——追根因:σ² 遞推的初始值用整段樣本變異數,測試段 資訊經初始值滲入訓練段 z_q(影響隨 β^t 衰減但真實存在)。改用模型 隱含的無條件變異數 ω/(1−α−β),只依賴參數不依賴資料。修正後重跑 檢定,判準結論不變。不會失敗的測試等於沒有測試——這支 mutation 測試第一次跑就抓到設計缺陷,是這套紀律的實際回報。



七輪:改進項 2–5(2026-08-20)

使用者從改進清單選了 2–5 四項執行。pytest:117 passed(+12)。

2. target_date + 延遲預測標注 ✅

predictions.csv 的 target_date 欄開始填值(工作日近似,結算仍以 實際交易日曆為準);報告標題從「明日觀點」改為「次一交易日觀點 (預測對象 ≈ 日期)」,預測對象已是過去時整節改標「⚠️ 延遲預測」 並加說明;網站卡片同步加「未來/延遲預測」膠囊。

3. 期交所直抓 fallback ✅ —— 當天就證明了價值

探針(probe_taifex_direct.py,端點知識複用姊妹專案)四項全綠: 舊新日期都通、schema 與現有 parser 完全一致、重疊 138 列 逐值相同、直抓拿到當天資料(當時上游落後 2 天)。

fetch_taifex_direct.py 接進 daily_run 重建鏈開頭(失敗不中止, 退回上游 raw)。配套把 archive 規則改成「較長者勝」:當月檔 單調成長,來源比 canonical 短即視為較舊不覆寫——否則上游落後時 每天會把直抓的新資料蓋回舊狀態。

連鎖修復:資料到今天後預測仍停在 08-17——basis 特徵依賴上游 現貨收盤價(停在 08-17),「全欄非缺值」過濾把最新三天丟掉。修法: 預測列改取最新交易日,缺值交給管線既有的訓練段中位數填補,缺值率 印出、超過 25% 拒絕出預測(中位數填補會把預測拉向樣本中心, 那種預測沒有意義)。實測:預測日 08-17 → 08-20(當天),缺 5/58 特徵(9%)。遺留待辦:現貨收盤價(TWSE)直抓 fallback。

4+5. prereg 檢定:isotonic ❌ 不上、分位數迴歸 ❌ 未勝過 GARCH

判準凍結於 config/prereg-2026-08-20-calibration-garch.toml (跑之前寫定),單次實驗同時檢定兩項(驗證折第 6 次使用—— 兩項均為負面結果,不受偏樂觀問題影響)。

A. isotonic 校準——比不校準更差。 h=1 五折平均 ECE 0.0506 → 0.0884,fold 2 惡化最劇(0.068 → 0.206)。診斷:校準集是訓練段 末端 20%,時序 regime 漂移下 isotonic 記住校準窗口的局部雜訊。 daily_run 維持 calibration = "none"。「離閘門只差 0.0006」的路 走不通,如實記錄。

B. GARCH(1,1) 讓分位數迴歸現形。 pinball 打平(0.0028 vs 0.0028),涵蓋率 GARCH 0.810 vs QR 0.764——4 個參數贏過 58 個 特徵。依判準:區間模型(分位數迴歸)判定未勝過 GARCH 基準,任何 報告不得宣稱其有增量價值。

附帶發現(不據此行動):GARCH 涵蓋率品質暗示 daily 的區間改用 GARCH 會更誠實——屬新決策,需另行 prereg 與使用者同意。



五輪:網站(2026-08-20)

照 cvca/vc-platform 的模式建 web/:FastAPI + 純 Python 產 HTML (無 JS 框架)、側欄殼 + 卡片設計系統、全繁體中文。綁 127.0.0.1:(內部埠), 無登入(使用者決定);圖表用 uPlot(CDN 載入,離線時優雅降級)。

架構原則

  • 純展示層:web/data.py 只讀 data/prod/data/live/reports, 沒有任何寫入路徑;mtime 快取。網站掛了不影響閉環,也污染不了資料。
  • 唯一的寫入操作是「重跑今日鏈路」按鈕(使用者決定要)—— web/runner.py 觸發的就是 cron 跑的同一支 daily_run.py, lock 檔防並發、輸出落 logs/web-rerun-*.log、殭屍 lock 自動回收。
  • CLI 的閘門一個不少:assumed-cost 是每頁常駐黃色橫幅;資料過期 紅色橫幅;record_only 的預測卡直接標「僅記錄(校準未過)」; 已知限制固定在參數頁與側欄 footer。

頁面(七頁)

總覽(三 horizon 預測卡 + 新鮮度 + 60 日戰績 + 近 120 日走勢)、 命中率與校準(分 horizon/分方向/可靠度圖 Brier+ECE)、預測紀錄 (predictions ⨝ outcomes + 每日報告瀏覽)、行情與序列(uPlot 全歷史, px_raw vs px_ratio 雙線,鎖死日虛線標記)、研究紀錄(status.md 渲染)、 資料狀態(prod 清單 + ingestion log)、參數狀態(9 項未查證清單 + 帳戶參數 + 已知限制)、重跑鏈路。

部署

deploy/tx-forecast-web.service(systemd user unit,照抄 cvca 範本, 不需 root)——使用者已安裝並啟用,enabled、開機自啟、 Restart=always。

公開(Funnel):使用者決定「任何人有連結就能看,匿名唯讀」。 _can_rerun() 防護已實作並驗證四種情境(匿名 POST 403/tailnet 具名放行/匿名頁面唯讀/本機直連可按)。tailscale 1.38.4 的 serve 設定需要 root,最後兩行由使用者執行: sudo tailscale …(指令不上公網) sudo tailscale …(指令不上公網) https://morris-z790-aorus-elite-ax.tail9df02e.ts.net:8443 (443 已被 cvca platform 佔用)。

過程中修掉的兩個真實 bug

1. "n/a" 哨兵被 pandas 讀寫循環洗成空字串 🔴(src/tx/live.py, 不是 web 的 bug):append_prediction 每次 read_csv 都把 "n/a" 吞成 NaN、to_csv 寫回空字串——資料檔在每次追加時被悄悄改寫, web 端顯示 nan 才被發現。已全面加 keep_default_na=False、修回 被洗掉的 6 個欄位值、回歸測試釘住(三次讀寫循環後 "n/a" 必須不變)。 2. 重跑狀態永遠卡在 running(web/runner.py):bash wrapper 結束後 變 zombie,os.kill(pid, 0) 對 zombie 會成功。完成判準改為優先看 .rc 檔(wrapper 跑完必寫),並保留 Popen 物件以 poll() 收屍。 完整循環實測:running → done(rc=0) → idle,lock 正確釋放。

pytest:102 passed(+1:n/a 哨兵回歸測試)。



六輪:上線收尾(2026-08-20)

網站已公開 ✅

使用者跑完 sudo tailscale …(指令不上公網) https://morris-z790-aorus-elite-ax.tail9df02e.ts.net:8443 (443 是 cvca platform)。匿名唯讀防護已實測四情境;systemd user service 由使用者安裝,Restart=always`。

成本參數查證:9 → 3 項未查證

2026-08-20 以官方來源逐字比對,升級為 verified:

參數值來源
TX 乘數/tick200 元/點/1 點期交所契約規格 /cht/2/tX
MTX 乘數/tick50 元/點/1 點/cht/2/mTX
期交稅率十萬分之2,買賣雙方各課財政部稅務入口網+期交稅條例;官方範例 200×10000×2/1e5=40 元/口/單邊
保證金TX 原始 701,000/維持 538,000;MTX 175,250/134,500(精確 1:4)期交所保證金一覽表 /cht/5/indexMarging,隨市況調整,此為查證當日公告值

仍為 assumed/pending 的 3 項——各有明確理由:fee(券商折數只有 使用者知道)、slippage(無 bid/ask,永遠無法查證)、account (等使用者)。assumed-cost 標籤照舊保留,網站橫幅自動從 9 改 3。

TX @ 45,888 一買一賣總成本 867 元 ≈ 0.94 bp(其中期交稅 183.6×2、手續費 50×2、滑價 200×2)。

cron 已掛(並修掉一個使用者手邊的重複)

crontab 裡發現 tx_forecast 的 18:30 行重複兩次(同時兩個 daily_run 並發寫 parquet 有損壞風險)。已去重並照 cvca ops 的模式 加固:flock -n 防重疊、timeout 1800 防卡死。

至此「完整閉環」全部到位:cron 每日 18:30 自動跑(重建→三 horizon 預測→結算→報告)、網站公開展示、匿名唯讀。


四輪:四系統比較 + 多 horizon 閉環(2026-08-20)

計畫表(PLAN.md 階段 6)尚未執行的兩塊補完。

四系統比較 — 覆蓋層「方向對、幅度不夠」

同一組折、同一份 OOS 預測、同一個成本模型(core 58 欄設定, run_id 記於 validation_use_log,驗證折使用累計 5 次,已達 「之後任何未定案判讀須註明分數偏樂觀」的門檻):

系統Sharpe(net)ExposureMaxDDBreakeven bp
1 買進持有0.86141.00−23.7%3,113
2 方向訊號−0.11480.09−4.4%−5.9
3 區間覆蓋層0.87080.91−20.7%163.8
4 方向×覆蓋層−0.01630.08−3.0%−2.2

判讀(誠實):

  • 系統 2/4(含方向訊號)皆為負——與判準檢定「方向無增量價值」 的結論一致,四系統表只是再次確認。
  • 系統 3(區間覆蓋層,不含方向模型)平均 Sharpe 0.871 略高於買進 持有 0.861,5 折有 3 折更好(fold 2/3/4),MaxDD −23.7% → −20.7%, 平均曝險 0.91——Sharpe 對部位規模不變,所以這個改善不是縮量假象。 但幅度(+0.009)在雜訊範圍內,與 regime_lab 的 mixture 輪同型: 「方向對、幅度不夠」。不據此宣稱覆蓋層有價值,也不微調門檻重跑 ——要追,先寫死新判準(且此後判讀須帶偏樂觀警語)。
  • 覆蓋層門檻取該折訓練段預測寬度的 0.75 分位(config overlay_threshold_quantile),不寫死常數——固定門檻在不同折等於 不同規則。mutation 測試證明門檻恆不觸發時系統 3 退化為買進持有。

每日閉環擴充至 1/3/5 日

使用者要的四種產出裡「多日 horizon」先前只進了 targets 沒進閉環。 現在 daily_run.py 每天出三筆預測(各 horizon 自己的 walk-forward ECE 與校準閘門——1 日校準好不代表 5 日也好,一個閘門 放行全部等於沒有閘門),結算按各自的到期日,命中率報告新增 「分 horizon 表現」表。

實跑:2026-08-17-h1/h3/h5 三筆全部 record_only(三個 horizon 的 ECE 都超標),區間隨 horizon 正確放寬(1 日 ±1.2% → 5 日 ±3.5%)。

pytest:101 passed(+4:覆蓋層 3、多日結算 1)。



三輪:讓閉環真的能每天跑(2026-08-20)

這一輪修掉兩個會讓每日閉環靜默失效的缺陷,並把鏈路接完整。 兩個缺陷都是檢視每日運轉路徑時發現的,不是測試抓到的——事後都補了 回歸測試釘住。

缺陷 1:主序列永遠少最後一個交易日 🔴

症狀:staging 日盤到 08-17,主序列停在 08-14。每日預測會永遠 用倒數第二天的資料,而且沒有任何錯誤訊息。

根因:未到期合約的「觀測最後交易日」== 資料末端,被當成真的 到期,所有還在交易的合約的 last_front_day 全部擠在資料末端前一天 → 最後一天 uncovered → 被丟棄。

修法:build_roll_calendar() 對「觀測最後日 == 資料末端且排程 到期日在其後」的合約,改用 expiry.scheduled_last_trading_day() (第三個星期三)預測到期日,交易日軸用工作日往後延伸。期交所行事曆 事前公告,不構成 look-ahead。歷史 5,564 列逐位元不變(rebuild 後 逐列比對確認),序列 5,564 → 5,565,08-17 進來、202608 到期日正確 預測為 08-19。已知限制:春節類連假順延窗口在資料末端可能提早一兩天 轉倉——歷史收齊後回到觀測值,且 ret_roll 用合約自身報酬,轉倉時點 差一天不會製造假跳空(offset 0 vs 1 逐位元相同已證明)。

回歸測試:test_last_data_day_is_not_dropped_when_front_is_still_alive (修復前 len==9,修復後 ==10)+ test_real_series_reaches_the_last_data_day(主序列末日 == staging 末日)。

缺陷 2:當月月檔被封存機制凍結 🔴

症狀:上游當月月檔每天長大,但 staging 永遠停在第一次封存的內容。

根因:archive_raw() 遇到內容不同時把新版存成 202608.<hash>.csv 保護舊檔,但 parser 只讀純數字 stem——新資料 每天進來、每天被存到沒人讀的檔名。

修法(src/tx/archive.py,自 build_contracts.py 抽出): 「只增不改」的真正目的是不遺失資料,不是檔名不可變。改成 canonical 檔名永遠持有最新內容(parser 讀它),被取代前舊版先存成 <stem>.<舊內容sha8>.csv——逐版保留,point-in-time 可回溯。上游檔案 縮水時照樣封存與更新(舊版已保住)但記入 shrink_warnings 大聲 印出(上游縮水通常代表上游出事,taiex_price 5,564→32 列的事故就是 這一類)。7 支測試涵蓋全部路徑。

daily_run 接上重建鏈

  • 步驟 0:--no-refresh 以外預設重建,四支 build 腳本 subprocess 嚴格順序執行,中途失敗即中止(改用上一次資料,報告頂部標注)。
  • 新鮮度警告:序列落後今天 >3 個工作日 → console 與報告都警告, 並指向上游 Hermes cron(本專案不代跑對方的抓取,範圍界線)。
  • 口數隨插即用:costs.compute_lots()——[account] 一填口數就 出現,不用改程式。TX 與 MTX 各自用自己的乘數獨立算(100 萬 × 2% × 停損 150 點 → TX 0 口、MTX 2 口,粒度差正是「TX 建模/MTX 執行」的 意義)。保證金已查證就逐口下修到上限內;仍 unverified 時照給口數但 note 標「保證金約束未驗證」。未設定 → n/a,不是 0。
  • 停損點數與 build_prediction 共用 stop_points_from_quantiles() (不利側分位),避免兩處各算一份然後漂移。

閉環的第一筆真實結算 ✅

重建後資料到 08-17,昨天記錄的 2026-08-14-h1 預測拿 08-17 真實 收盤結算:方向命中(預測中性,實際 +0.10% 落在震盪帶)、區間命中。 outcomes.csv 有了第一筆,每日報告的「近期戰績」與可靠度表開始有 內容。這不構成任何績效結論(n=1),但證明 記錄 → 到期 → 真實價格 結算 → 命中率累積 整條路是通的。

pytest:97 passed(+13:series 末端 2、archive 7、compute_lots 4)。


階段狀態摘要
0 專案骨架與探針✅venv(py3.14)、probe_taifex_contracts.py 八節探測全綠
1 逐合約資料層 + 三條序列✅tx_series.parquet 5,564 × 32,2004-01-02 → 2026-08-14
2 夜盤資料層✅tx_night.parquet,2,252 天(2017-05-16 起)
3 Target✅tx_targets.parquet 5,564 × 15
4 特徵層✅tx_features.parquet 5,564 × 118(117 特徵欄,7 群)
5 成本模型✅ 骨架完成9 項參數全部 assumed/unverified,閘門已上線
6 模型與回測✅ 線性基線已跑完三條特徵路徑全部未通過判準,誠實負面結果,見下方
7 閉環(題材獵人模式)✅ 機制完成daily_run.py 四步鏈路跑通;校準閘門實際擋下了目前的模型

pytest:84 passed(test_series.py 13、test_backtest.py 21、 test_targets_splits.py 25、test_live.py 17、test_models.py 14)。 含 12 支 mutation 測試。


二輪:特徵層、模型基線、閉環(2026-08-20)

特徵層 — 117 欄,7 群,三種樣本量取捨

群欄數來源全欄非缺值起點
calendar4本專案2004-01-02
technical39本專案(算在 px_ratio 上)2005-01-04
open_interest8本專案(全市場加總,轉倉免疫)2004-01-05
contract_structure7本專案(基差、期限結構)2004-02-06
night_session14本專案2017-05-16
chips29上游 tier2(唯讀)2017-08-14
cross_market16上游 model_panel(唯讀,只取 _lag1)2011-08-01

上游 tier2 明確排除 8 欄(被上游那條「近月拼接、未回補調整」序列 污染):oi_change/oi_change_pct(轉倉日 OI 跳動是拼接產物)、 is_settlement_day/days_to/since_settlement(與本專案轉倉慣例不一致)、 basis/basis_pct/basis_5d_ma(本專案用自己的前月定義重算)。 替代品:tx_total_oi_change(全市場加總,轉倉免疫)、tx_basis_*、 tx_term_spread(期限結構,上游沒有的特徵——它需要逐合約資料)。

過程中的收穫:select_cross_market() 的「缺欄位就拋錯」防呆第一次跑 就攔下上游命名不一致(指數帶 idx_ 前綴、個股 TSM 不帶)——若寫成 「有就取、沒有就跳過」,16 欄會靜默變 12 欄而沒有任何警告,正是這個 專案家族踩過三次的那類錯。

線性基線 — 三條路徑全部未通過,誠實負面結果

模型:正則化 multinomial logistic(方向)+ 線性分位數迴歸(區間), 折內擬合、TimeSeriesSplit 選 C、5 折 purged walk-forward(purge 5、 embargo 10)、保留集 250 天未動。判準事先寫死在 config/experiment.toml。

路徑特徵樣本方向校準 ECE區間涵蓋成本結論
core582005 起 ~5,050❌ 1/5 折❌ 0.051❌ 0.764*❌ −5.2bp0/4 過
+cross_market742011 起❌ 1/5 折❌ 0.087❌ 0.696❌ −6.2bp0/4 過
+night+chips1172017 起 ~2,190❌ 2/5 折❌ 0.062❌ 0.729✅ 10.9bp1/4 過

\* core 的涵蓋率 0.764 落在允許區間 [0.76, 0.84] 邊緣,但「區間比 無條件基準窄」不是每折成立,依判準整項不過。

最能說明問題的數字:三條路徑的方向準確率(0.40–0.41)全部輸給 「永遠猜訓練段多數類(震盪)」的基準(0.43)。 這條基準是第一輪 實驗後補上的——只跟 1/3 隨機比會把 0.40 誤讀成「還不錯」。

分位數迴歸相對無條件基準的 pinball loss 差距在 ±3% 以內——條件資訊 (目前這組特徵)對區間預測幾乎沒有增量。

依 preregistration 規則:不微調欄位清單重跑。 驗證折使用次數 已累計 4 次(core 兩次 + 兩條對照),記錄在 reports/validation_use_log.md。

值得注意的正面訊號(不構成結論,留給下一輪):117 欄路徑是唯一 損益平衡成本轉正的(10.9bp ≈ 假設成本的 6 倍),且它的後兩折 (2023-12 之後)模型 Sharpe 高於買進持有。樣本太短(各 193 天), 可能只是運氣;若要追,需要先寫死新的判準。

閉環 — 機制完成,校準閘門實際發揮作用

scripts/daily_run.py 四步鏈路(比照題材獵人 daily_bot.py, 任一步失敗記下並繼續):資料新鮮度 → 產生預測 → 結算到期 → 命中率 報告。實跑輸出 reports/daily-2026-08-20.md。

第一筆真實預測的狀態是 record_only——模型 ECE 0.0509 ≥ 0.05, 校準閘門擋下部位建議,只記錄預測。這正是設計目標:題材獵人的 prob_boom 在 corr = −0.273 的狀態下持續給建議,本專案的同型病 在上線第一天就被閘門攔住。

三個病的防護全部有測試:

  • 抓不到價/NaN/0 → 保留 pending(test_live.py 4 支,含 mutation:寫 0 會產生 −100% 假結算)
  • ECE 超標或 NaN → record_only,不給部位建議(4 支)
  • schema 漂移 → 表頭不符直接拋錯(2 支)
  • 口數未設定寫 "n/a" 不寫 0——「沒算」與「建議空手」是兩件事

折內擬合的實證

test_models.py::test_fingerprint_is_invariant_to_test_set:把測試段 特徵乘 1000,訓練段擬合狀態(scaler 統計量、係數、截距、選中的 C、 分位數係數)的 sha256 指紋逐位元不變;mutation 版本把測試段餵進 fit,指紋必須改變。


一、開工前探針的三項事實(推翻現有文件兩項)

1.1 逐合約原始資料存在 ✅

taiex-factor-agent/data/raw/taifex_fut_tx/:272 個 Big5 月檔 (200401 → 202608),月份序列0 缺口,解析 0 失敗。每個月檔含該月 所有到期月份,不是只有近月。

項目列數
原始81,925
排除:價差對單式契約38,985(47.6%)
排除:週契約0
排除:其他商品0
保留:單式月契約42,940
其中 日盤 / 夜盤29,804 / 13,136

1.2 「夜盤資料完全不存在」是錯的 ✅

三個現有 repo 的文件都這樣寫。正確說法是loader 丟棄了 盤後 列。

夜盤起點實測 2017-05-16(201704 有 0 列、201705 有 54 列), 對上台灣期貨夜盤 2017-05-15 上線——上線當晚被標記成次一營業日。

日期語意經實測判定(不是假設):

假設夜盤開盤 vs 對應日盤收盤 中位數差
A:date=D 涵蓋 D−1 傍晚 → D 清晨0.0653% ✅
B:date=D 涵蓋 D 傍晚 → D+1 清晨0.6047%(9 倍)

推論:夜盤在 D 日 05:00 就已結束並公開,早於日盤 08:45 開盤, 所以夜盤特徵對 D 日的預測當期可用,不需要延遲。 assert_night_is_available_before_open() 把這件事釘在行為上。

1.3 結算價 0 哨兵 ✅

212 列,第一次出現 2008-12-17——與 twii_forecast 踩坑清單記載的 「2008-12-17 起 271 個裡 212 個」完全一致,交叉驗證通過。


二、施工中發現並修掉的五個真實缺陷

2.1 pandas 把期交所 CSV 整排右移一格,不報錯 🔴

資料列尾端多一個空欄位(20 欄對 19 個表頭),pandas 預設把第一欄當成 index。欄名完全正常,但 到期月份(週別) 裝的是開盤價。

預設讀法      → 到期月份 = ['5900', '5921', '5911']   ← 那是開盤價
index_col=False → 到期月份 = ['200401', '200402', '200403'] ✅

探針第一次跑就 272 檔全部失敗,因為 classify_contract() 對 '5900' 拋 無法分類的到期月份。若當初寫得寬鬆一點(不認得就 跳過),這個錯會安靜地產出一份看起來合理的資料。

2.2 is_settlement_day 恆為 False

roll_offset=1 時前月合約永遠在自己的最後交易日之前就被換掉, 所以「前月合約今天到期」永遠不成立。一個恆為 False 的特徵欄不會報錯, 只會什麼都不貢獻。已拆成兩欄:is_expiry_day(市場事件,與轉倉規則 無關)與 front_expires_today(字面意義,保留但註明限制)。

2.3 漲跌停偵測寫死 ±10% 會漏抓一半

台股漲跌幅限制歷史上變過三次(±7% → 2008-10 跌幅縮到 −3.5% → 2015-06 起 ±10%)。改用結構性偵測。第一版用「當天所有合約四價相同」, 漏掉 2004-03-22——那天 5 個合約有 4 個鎖死,遠月 200406 的最高價比開盤 高 12 點。改成「前月四價相同 + 過半合約四價相同」後四天全中, 且 370 個純遠月流動性日全部正確排除。

日期事件ret_roll成交量
2004-03-22319 槍擊案後首日−6.998%(當時 ±7% 限制)4,625
2008-10-16金融海嘯(跌幅限 −3.5%)−3.527%3,373
2025-04-07關稅衝擊−10.031%2,736
2025-04-10反彈漲停+9.870%2,382

一般日子成交量中位數 87,444 口,鎖死日 3,054 口——掛單成交不了 是可量化的事實,不是推測。

2.4 assert_no_target_window_leak 用錯日曆 🔴

第一版用 fold.train ∪ fold.test 當日曆,跳過了被 purge 掉的日子。 索引壓縮後 pos + horizon 憑空跨過空窗,把正確的切分判成洩漏。 測試立刻抓到。已改成吃完整交易日曆,並補 mutation 測試釘住這個錯法。

2.5 roll_offset 是否真的生效(查證,非缺陷)

roll_offset=0 與 1 的 ret_roll 逐位元相同,一度懷疑參數沒生效。 實測 offset 0/1/2/5/10:前月合約分別在 0/271/542/1,355/2,710 天 不同(線性增加,參數確實生效);但 ret 只有 offset ≥ 2 才不同。

這是建構上的必然,不是 bug:ret_roll 一律用合約自身報酬,而 0 與 1 兩種規則挑到的合約在該日都有交易,所以結果與轉倉時點無關。 這是穩健性的正面結果,已寫成回歸測試。


三、假跳空的實測規模

「跨合約相減會產生假跳空」不是宣稱,是數字:

指標值
非轉倉日 naive vs ret_roll 最大差0.000e+00
轉倉日平均差0.5528%
轉倉日最大差3.6740%(2020-03-18)
轉倉日差 > 0.5%90 / 271 天
累積:ret_roll+2,164.3%(年化 15.18%)
累積:跨合約相減+657.0%(年化 9.60%)

271 個轉倉日全部落在市場結算日(is_roll_day 與 is_expiry_day 100% 共線)——與 regime_lab 已知限制第 5 項記載的現象一致。差別在於 本專案的 ret_roll 已經消除假跳空,所以共線性只是標籤重疊,不再注入 假訊號。


四、一個必須反覆強調的比較基準問題

ret_roll 的買進持有年化 15.18%,加權指數(價格指數)同期 9.60%。

差額約 5.6%/年,大致等於台股現金股利殖利率——持續轉倉的期貨部位吃到了 台指期長期逆價差的轉倉收益,行為接近「含息報酬」。

regime_lab 的買進持有 gross Sharpe 1.106 是在 ^TWII 價格指數上算的, 不可與本專案的數字直接比較。 本專案的買進持有基準難打很多。

煙霧測試(全樣本,分數不可引用)的買進持有 Sharpe 為 0.777、 MaxDD −55.53%。


五、目前待辦

等使用者提供

  • 帳戶權益基準金額與單筆風險上限%(config/costs.toml [account],目前 0 且 source = "pending_user_input")。 lots_from_risk_budget() 遇到 0 會明確拋錯,不會猜。 方向模型與區間模型不依賴這一節,可以先做。

需要查證

  • 成本參數 9 項全部 assumed/unverified:契約乘數、tick、期交稅率、 手續費、滑價、原始保證金。查到之後把 source 改成 verified 並附來源,不要偷偷改。

下一步施工

  • 階段 4 特徵層:接 twii_forecast/data/prod/model_panel.parquet (唯讀)+ TX 專屬特徵(基差、未平倉、三大法人、大額交易人、PCR) + 夜盤特徵
  • 階段 6 模型:方向分類器、分位數迴歸、部位規則
  • 階段 7 閉環:predictions.csv → outcomes.csv → 命中率與校準表

已知限制(每份報告都要複述)

見 PLAN.md §七,新增一項:

11. 漲跌停鎖死日(實測 4 天)無法成交,回測強制沿用前一日部位; 若策略在那幾天想大幅換手,其真實可行性未經驗證

抽查:agent 學習迴路 ↔ 匯出面板(2026-09-02)

  • 迴路是活的:agent_memory.jsonl 美股席 303 列(至 09-02),引用 short_interest ×215、us_metrics_panel ×56、us_breadth_daily ×20、 us_signals_panel ×12;seat_scores.jsonl 美股席 44 列。
  • 動作帳本抽查撞見假攻擊:agent_actions.jsonl 131 筆裡 129 筆是 by="t" 的 rm -rf 拒絕紀錄——來源是 test_unknown_actions_are_refused 用 ROOT 而非 tmp_path,每跑一次 pytest 落一筆。測試已改 tmp_path 並加「拒絕也要留痕」斷言;129 筆既有污染未清(帳本只增不改, 清不清留給擁有者決定,可用 by=="t" 精準識別)。目前無任何讀取端。
  • 美股席至今沒有走過動作管道(正式動作全部由 tx_chief 提出)—— 範圍申報在 build 腳本裡用 ranged() 寫死,不經 LLM 動作,符合設計。

補全回測與籌碼層:FinMind 回補 + 四個順路挖到的(2026-09-22)

使用者:「有關於回測的東西都必須要補全,還有像是我的籌碼觀察之類的 也是一併要補全」。我先提了保留意見——當時沒有任何一個回測吃這些 籌碼層(features.py/backtest.py/experiment.py 對 broker/tdcc/ holders/revenue/mops 的 grep 命中是 0,唯一讀者是 web/data.py 與 web/app.py 的 /my-chips 檢查表)。使用者重申,照做。

回測:record.csv 落後 19 個工作日

record.csv   24,369 列 / 停在 2026-08-24  →  24,483 列 / 到 2026-09-18

build_record.py --force(保留集動用記一筆,理由寫進帳)。那是當時 唯一的紅燈測試(test_walkforward_full_is_not_systematically_truncated)。

🔴 三個 horizon 因此分岔,而那是設計不是 bug

holdout_days = 250 是相對資料末端的滑動視窗,所以資料一延伸, walk-forward 段跟著變長(末端 2025-08-06 → 2025-09-02,多 19 天)。 而 h=1 的檔刻意由 CLI run_experiment.py 擁有(會 log_validation_use()),build_record 不准覆蓋它——覆蓋會讓 「這份回測是第幾次驗證折的產物」對不上。

於是 h=3/h=5 = 3,831 而 h=1 仍是 3,812。我沒有替使用者燒掉那次 驗證折,把選項與代價交回去;使用者自己跑了 run_experiment.py (第 37 次),三條同步到 3,831。結論不變:0 過 / 4 不過 / 1 無法檢定。

⚠️ test_backtest_has_all_three_horizons 原本斷言「三個列數必須相同」 ——那個不變量只在「三個同時產生」時成立。沒有為了讓它變綠就放寬, 改成守真正該守的:不相同可以,但必須有不同的 vintage 解釋它 (反向驗證過:把 h=1 的 generated_on 改成同一天 → 紅)。

🔴 「整份文件唯一的正面數字」是一個雜訊

CLAUDE.md 記著 breakeven +0.66bp,並稱它是「整份文件唯一的正面數字」。 資料延伸 19 天後重算是 −0.01bp。而逐折一看就知道兩個都是雜訊:

逐折  +15.62  +9.95  −0.57  −18.84  −6.21
平均 −0.01bp 標準差 13.57 標準誤 ±6.07 → 95% 約 [−11.9, +11.9]

+0.66 與 −0.01 落在同一個雜訊區間。 門檻是 6.16bp,差一個量級。 又一次「只報平均不報逐折」。CLAUDE.md 已更正。

籌碼層:39 個請求換到四層歷史

層補前補後驗收(對既有真值逐列比)
集保2 期552 期(2016-01 起)級 1–15+17 416/416 相同
月營收1 個月129 個月(2015-12 起)千元值 13/13 相同
日 K249 根2,613 根(2016-01 起)收盤 3,224/3,224 相同
分點11 天180 天(2,340 請求)前十大逐列相同(名稱寫法不同)

🔴 另立一顆 finmind.db,既有四本帳一個位元組都不動——四層各有 一個真的會漂的差異:集保級 16(差異數調整)正負號相反、日 K 成交量 FinMind 系統性偏高(2330 中位 +14%,多半是盤後定價與鉅額)、 分點名稱兩邊只有一半同名(Yahoo港商麥格理/FinMind港麥格理,而 Yahoo 沒有分點代號,那張對照表程式推不出來)、母體 810 vs 前 30。

所以判準是整段換不拼接(_deeper() 只寫一處:要明顯比較深才換, 相同深度留在已驗證的既有來源)。每一個讀取端都回 source,卡片上印 實際來源與深度——換來源不可以是無聲的。

⚠️ 快取鍵要含兩顆 DB 的 mtime。只盯 broker_daily.db 的話,回補 進 finmind.db 的資料永遠不會被看到——「實作了 ≠ 接上了」的快取版。

⚠️ 月營收的 yoy_pct 在回補那一份是自算的(來源沒給),這違反 「成長率用來源給的」那條有測試釘住的紀律。所以逐檔對了官方公告值: 13/13 檔的 YoY 與 MoM 差 0.0000pp。旗標 yoy_source="derived" 留著。

順路挖到的四個

一、一根不存在的 K 棒在翻轉判定。 tw_bars 的 2026-07-10 全 13 檔 量 0、四價相同,而 tx_series(本站權威日曆)與 FinMind 各自獨立 都說那天不是交易日。它把 52 週低點拉低:2441 從 50.30% 掉到 43.67%、 6727 從 60.47% 掉到 45.93%——兩檔都跨過使用者自己訂的 50% 門檻, 畫面顯示的是相反那一面。判準 is_non_trading_bar() 只寫一份,抓取端 與計算端共用(量 0 且四價相同——漲跌停鎖死日四價相同但有量, 不可誤殺)。13 列已清,有備份。

二、/my-chips 有六檔一點就 500。 mops.summarize() 用 truthiness 判營收(if last.get("rev")),rev == 0.0 被當成「沒有資料」回 None, 顯示層 f"{...:,.1f}" 當場 TypeError。中的正是零營收生技股 (6919/6838/7827/6186/6785/6945)——這張卡最該提醒人的那一類。 🔴 同族 bug 第三次(值班條 age_minutes、早上的 _side)。 or 0 一樣是錯的修法:營收 0 印「0.0 億」是事實,印「—」才是謊。

⚠️ 早上剛補的「抽 60 檔掃例外」沒抓到——7 檔在 2,000 檔裡隨機抽多半 抽不到。隨機抽樣只是把「我測的剛好沒事」的機率調低,沒有消滅它。 新增 _boundary_tickers():去資料裡把邊界值挑出來(rev≤0、 yoy 為 NULL、volume=0、集保 0 人 0 股),不靠運氣。

三、備份只蓋到 6 顆 DB 裡的 2 顆。 tdcc_holders/tw_bars/ tw_mops 從來沒有被備份過——PATTERNS 只收 jsonl/csv/json,.db 全靠逐顆寫死的特例。改成迴圈(DB_LEDGERS),實跑驗過六顆都進 tar。

四、公網上兩句單位寫錯。 broker_daily 存的是張,頁面印「股」。 算術決定性:1569 券商第一名 2,282 當張 = 全日量 15.8%(合理), 當股 = 0.0158%(荒謬)。兩處都改。

🔴 我自己的探針說了一次謊

earliest() 只印 len(rows)、把 note 丟掉,於是 TaiwanStockTradingDailyReport 的 HTTP 400(那個 dataset 只吃單日) 被印成「2015:0 2018:0 … 2026:0」——看起來像「FinMind 沒有這段歷史」, 而它其實從 2021-07 就有。差一點據此下了完全相反的結論。 又一次「故障偽裝成空狀態」,這次是探針自己犯的。已修成錯誤必印。

同一支的 docstring 還寫錯兩句(「量比是前 20 日均量」實際 VOL_MA = 5; 「收盤 455/455 相同」是在一個沒有除權息的窗量的,結構上量不到差異)。 文件寫錯事實比不寫更糟——它會被下一個 agent 當事實引用。 已補量 跨 5 個除息日的整段:本地 vs 未還原 0 天不同、vs 還原 243 天不同。

待辦與待裁定

  • 分點回補跑到 79%(11/13 檔、126 萬列),約再 20 分鐘。
  • 🔴 15:57 有一次 rebuild_broker_from_archive.py 不是本 session 下的 (沒有測試碰它、cron 也不會跑它)。資料獨立對過封存 113/113 組零差異, 是對的;但寫入來源不明——「多 session 併行」那個形狀。
  • 既有的 Yahoo/TDCC/MOPS 抓取鏈全部保留,它們是對帳的真值。

分點補完之後的對帳(2026-09-22 晚)

180 天 × 13 檔補完(2,340 個請求)。與 Yahoo 真值同名同日的重疊 2,385 列:

完全相同   2,349(98.5%)
差 ≤1 張      2      股→張無條件捨去的零股殘差
差 >1 張     34

那 34 筆前兩類是口徑不是錯:14 筆 Δ買 == Δ賣(配對成交,鉅額交易 在分點價位表外另行申報;佐證是 FinMind 全分點合計一律低於全日量, 實測 91%–99.8%,從不超過)、15 筆只差一邊(單向鉅額或自營)。

🔴 剩 5 筆是身分問題,不是口徑,而且全部是 元大豐原/元大高雄:

元大豐原  9838 / 989W   507 列同一天就有兩個代號
元大彰化  981G / 989J   492 列
元大高雄  981m / 989F   不同日各自出現

有些分點共用一個顯示名而其實是不同的分公司代號。 Yahoo 只給名字, 在構造上分不出來。⚠️ 另有 52 個名字跨日對到多個代號(元富 5923→9B23、台新 8156→9B13)——那是券商合併換碼,同一個實體分點, 按名字聚合才對(拆開會讓序列在換碼那天斷成兩截)。

兩種長得一模一樣,程式分不出來,所以不猜:branch_id 記下所有 貢獻的代號(逗號串),讓合併在資料裡看得見而不是無聲。

「不要每次都重跑」:實測 2,340 → 0 個請求

使用者:「新跑出來的資料你都要幫我放在資料夾裡面,不然每次都要重新再跑 很麻煩」。查下去發現缺口是真的——fm_done 只有 broker 與 holders, revenue 與 price 根本沒查進度表,每次都把 2016 年起整段重拉。

補完後用完全一樣的指令再跑一次:0.5 秒、**0 個請求**
  分點   跳過 2,340   集保 13 檔全跳過   月營收 13 檔全跳過   日K 13 檔全跳過

🔴 第一版用「45 天沒更新就重抓」判月營收 → 9/22 看著已經最新的 2026-08 判該重抓(從 8/1 算起 52 天),每次白打 13 個請求,正好是使用者在抱怨的事。 月頻不可以用日數:改成算「今天該有到哪個月」(每月 10 日前公告上個月)。

另外 scripts/rebuild_finmind_from_archive.py:DB 沒了也不用重抓。 實測 35 秒從本地封存重建 170 萬列、逐層吻合、零網路請求 (原本抓它要 2,379 個請求、約 3 小時)。AST 測試守它不可以 import 任何連線用的東西。

⚠️ 一度想因此把 finmind.db 踢出每日備份,量完之後顧慮不成立: 226 MB、gzip 壓到 23.4%、14 顆滾動共 0.7 GB,而磁碟剩 554 GB。留著, 量測寫進註解免得下次再憑印象爭論一次。

🔴 同一條測試第三次被自己的註解咬到

在備份腳本加了一段解釋「data/raw/finmind/ 才是帳」的註解, test_backup_script_produces_a_readable_archive 當場紅——它 grep 原始碼 找 data/raw,命中的是註解。而它原本的解法是寫死一個字串例外, 那是會複製的病(下一段解釋文字進來又要再加一條)。

同族第三次(看門狗被自己的 docstring 誤中、(r["d10"] or 0) 被自己的 註解誤中),所以助手升到 tests/conftest.py::executable_source()—— 剝掉註解與 docstring。⚠️ ast.unparse() 單獨不夠:docstring 是真的 Expr(Constant(str)) 節點,會被保留。

主動式 ETF「最近買了什麼」(2026-09-23)

使用者:「多設一個功能可以每天查主動式ETF最近買的標的」。

主動式 ETF 依規定每日揭露持股,所以「經理人今天買了什麼」當天就看得到 ——台股少數不必等季報、不必猜的主動部位資訊(對照:13F 要等一季,而本專案 2026-09-03 查過 www.sec.gov 從這台機器整台被擋)。

src/tx/active_etf.py        儲存/分類/彙總/覆蓋率
scripts/fetch_active_etf.py 補所有還沒收齊的交易日(cron 50 8 * * 1-6)
/active-etf                 全市場「誰在買」;/my-chips 檢查表多一列個股視角

回補完成:323/323 個交易日(2025-06-02 → 2026-09-23),一天 2 個請求 (持股+異動都不必給 data_id,一次拿全市場)。

🔴 「沒有買賣」與「沒有揭露」在這一層特別容易混成一種

清單上          40 檔
穩定揭露         34 檔(85%)
最近一個交易日    常常只有 20/40 —— 揭露是**分批**進來的
從未揭露          6 檔(3 檔剛上市、3 檔是來源就沒有)

所以 latest_complete() 的判準是覆蓋率不是日期(≥75% 才算齊, 偏嚴是刻意的——晚一天顯示,好過把半個樣本講成全貌),而且缺口那 6 檔 逐檔列出名字。實測當天頭條取 09-21(34/40)而不是 09-22(20/40)。

🔴 TXN US(德州儀器)被分類成台指期貨

第一版把 TX 的判斷排在外國股前面,而且用 cid.startswith("TX")。 這正是本專案記過的「用簡稱做子字串比對」(「主力成本線」命中力成、 「南亞科」命中南亞),只是換成英文代號。修法兩件:外國股先判 (有空白+兩碼市場)、TX 改 fullmatch 綁死長度。

同一個分類還擋掉一個單位錯誤:第一版「新進場」前六名有五個是美債 CUSIP (USU2286QAA86 買 669、US527298CQ43 買 500…),而那些數字與 「聯電 19,783 張」放在同一張表上看起來完全正常。海外與台指衍生品 現在只報檔數、不進台股那張表。

🔴 覆蓋率的分母用了「今天的 40 檔」,於是 2026-03 印出「18/40(45%)」

那句話暗示 22 檔沒揭露,而它們多半還沒上市——主動式 ETF 2025 年才 開始發行。分母改成「那一天已上市的」(fm_aetf_info.listed_from, 一檔一個請求查首個交易日,只查一次)。修完之後同一段歷史讀起來是 2026-04 21/28(75%)、2026-08 34/37(92%)、2026-09 34/40(85%) ——母體在長,那是事實。⚠️ 不知道上市日的算進分母(寧可低估覆蓋率)。

🔴 加欄位到 SCHEMA ≠ 帳上那顆 DB 會有它

CREATE TABLE IF NOT EXISTS 對已經存在的表什麼都不做,所以抓取腳本 當場 no such column: listed_from,而那顆 DB 裡有 255 天收好的資料。 補 MIGRATIONS 表 + connect() 自動升級。唯讀那側升級不了,所以拋 SchemaOutOfDate 由顯示端接成空狀態——讓 OperationalError 逃出去 就是公開頁面整頁 500(/my-chips 為同一類原因倒過兩次)。

兩個守門測試在 /active-etf 上抓到我自己寫的老毛病

test_every_css_class_used_in_html_actually_exists   我發明了 class='tight'
                                                    ——全站唯一一處,CSS 裡沒有
test_no_raw_markdown_leaks_into_visible_text        三處 **粗體** 直接塞進 HTML

第二條是記過五次的那一個。兩條都是既有的測試抓的,不是我看出來的。

驗收

16 條測試(3 條新的逐一反向驗證過:拿掉 migration → 紅、拿掉 schema 檢查 → 紅、分母退回「今天的全部」→ 紅),全套 1,192 passed / 2 skipped。 重啟 tx-forecast-web 後在 127.0.0.1:(內部埠) 實測 /active-etf 200、 新字串都在線上那份、class='tight' 0 處——本機 render 過不算數。

主動式 ETF 接進題材真實性那張卡(2026-09-23 第二輪)

使用者:「那個題材真實性的卡片也接上主動式 ETF 那一列」。

直接把 1a 那一列複製過去是錯的——同一組數字在同一頁上會被當成 兩份證據(本專案記過:輿情席與廣度席量了同一批欄位,而兩邊都進了 同一個證據池)。1a 問「有沒有人在買這一檔」,3b 要問的是 這個故事有沒有人用錢跟:同題材的其他個股,誰在加誰在減。

🔴 五個獨立探針,三個都說「不要出比率」

原本想印「同題材 N 檔裡 M 檔被買」。量下去:

全市場被主動式 ETF 持有      278/1977 = 14.1%
題材圖那 155 檔本身          60–65%          ← 真正的對照組
同題材同儕的「被買比例」       中位 0.44,87/149 擠在 0.30–0.50
「題材」買到的               +4.3pp
把最大的題材隨機切成 36 個     離散度 0.189 → 0.296,中位數滑向底率 0.338
二項虛無檢定                 那點離散度就是抽樣雜訊

切得越細,那個比率越像有鑑別力,而它什麼都沒帶。 加上一個比率 讀起來就是分數,而這張卡片明文不合成分數。所以資料層刻意不回傳 題材級彙總,只回逐檔的名字與張數。有測試守它不會長回來。

實際效果(6213 聯茂):故事是「CCL 缺貨爆量漲停」、官方有「取得機器 設備 5 億」,而同題材的同儕近 20 日多半在被減碼 (2409 −2,243、4979 −1,656、2345 −1,094 張)——那個背離正是這一格 的價值,而一個「62% 被買」的比率會把它蓋掉。

🔴 修掉三個既有缺陷(其中兩個是接線時才暴露的)

一、賣光的部位被印成「目前持有」。 for_stock() 用每檔自己的 MAX(date),於是 3714(2025-06-30 之後再沒出現)畫面上寫著 「目前 1 檔持有」——15 個月前的部位用現在式印出來。612 個成分裡 120 檔中招,而「每檔自己的最後一天」共有 179 個不同的日期。 改成三態:這一天有人持有/曾經有但賣光了(說出最後一天)/從來沒有。

二、異動視窗沒有天花板。 修好持股之後,同一列變成持股講 09-21、 買賣講 09-22(那天只有 12/40 檔回報)。而且會翻號:2308 含半滿日 是 +387 張,截到收齊日是 −69 張。

三、告誡只在空狀態那一支渲染——有東西可報的時候 「看不出動機/沒有量過/沒有 prereg」一句都不說。 grep -c 主動式 CLAUDE.md 當時是 0,那是結構上的原因: 一條只活在 docstring 裡的告誡,下一次接線不會被執行。 已補一節。

🔴 TXN 的第三種形狀

早上才修掉 startswith("TX")(TXN US 被判成台指期),改用 fullmatch。 下午發現那還不夠:00989A 揭露美股時不帶市場後綴,帳上真的有 TXN / TEXAS INSTRUMENTS INC / asset_type=stock。分得開的只有 asset_type——真正的台指衍生品一律是 futures/option/other。

🔴 反向驗證抓到我自己寫了兩條恆綠的測試

六個 sabotage 第一輪只紅了四個:

"_theme_aetf_block(" in app        命中它自己的 **def**,拿掉呼叫點照樣綠
整張卡 grep「看不出動機」             3b 那一段寫著同一句話,1a 整段刪掉照樣綠

改成比對呼叫的樣子(_theme_aetf_block(th.get()與切到 1a 那一節 再查。修完六個全紅。一條恆綠的測試比沒有測試更糟。

驗收

邊界掃描從 4 類擴到 8 類、37 檔(含「早就賣光」「只被一檔持有」 「題材圖連結最多/最少」「不在圖上」,以及 PATH US、 債券附買回-110央債甲3 這種不是台股代號的成分)。 全套 1,199 passed / 2 skipped,重啟後 /active-etf 線上 200。

對抗式覆核:13 條發現,12 條確認、1 條被推翻

改完之後派了四個視角覆核自己的 diff(正確性/空狀態/畫面實際說了什麼/ 測試是不是真的守得住),每條再派反駁者實跑。12 條確認,而其中兩條是 我為了修別的東西當天做出來的:

🔴 round(x) + 0.0        為了躲「-0 張」而補的,結果把 −0.3 張印成 **+0**
                         ——號憑空翻了,而註腳寫著「正是買、負是賣」
🔴 收緊 TX 的分類          `fm_aetf_change` **沒有 asset_type 欄**,於是
                         /active-etf 的 KPI 當場 unknown 44、台指衍生品 1

第二條是「改了判準就要回頭看每一個呼叫點」的實例:判準收緊是對的, 而它的另一個呼叫點少給一個參數,症狀只是 KPI 上多了一個數字。 修好之後 unknown 44 → 3、海外 153 → 189。

其他確認的:last_held 沒釘在基準日之內(晚一天揭露的會被說成「賣光了」)、 同儕只印淨額而不印買賣家數(2308 台達電印成「-73」,而那 20 日裡 21 檔 ETF 動過它,11 買 10 賣)、單成員題材被 continue 掉而「還有幾個沒列」 不數它(同一個條件兩種渲染,其中一種是沉默)、資料層的 note 寫了 markdown 而顯示端走 esc()。

被推翻的那一條是「3b 把 39 檔講成『完全沒有資料』」——我在覆核跑到 一半時就修掉了(拆成「曾經進過但今天沒動」與「從來沒進過這些基金」), 反駁者實跑之後確認現況已經正確。

🔴 一個空狀態的修法製造了另一個空狀態

第一版對「一天都沒收齊」的處理是整個回 ok=False。那修掉了 「沒有人持有」這句假話,卻把還知道的東西一起丟掉——「這 20 天誰買誰賣」 與持股無關,仍然答得出來。而且它當場打紅一條既有測試。 改成 holders_known 旗標:持股那一格說「看不了」,流量照常顯示。

反向驗證又抓到三條恆綠的測試

"_theme_aetf_block(" in app          命中它自己的 **def**
整張卡 grep「看不出動機」               3b 有同一句話,1a 整段刪掉照樣綠
只斷言三種狀態加起來等於成員數           把兩種合併之後總和仍然對

第三條特別值得記:加總守恆抓不到「把兩種混成一種」,因為合併不改變 總和。改成拿帳去對(n_never 必須正好是全期沒進過持股表的那些)。

順帶修掉一條既有的恆綠測試:test_missing_data_is_not_rendered_as_zero 斷言 '"—" if x is None' in body,而那一行被抽成全站共用的 _lots() 之後測試就紅了——行為完全正確,測試卻在比對某一行長什麼樣。 改成驗行為。

🔴 覆核的第四個視角專看「這些測試守不守得住」——抓到 9 條

而其中大部分是我當天剛寫的。把它們逐一反向驗證(改壞 production → 測試必須紅)之後:

比率那條           只看 dict、從來沒看過印出來的東西
                  → 把「62%」直接寫進 HTML,測試照樣綠
持股基準日那條      斷言 `e["day"] in html`,而 `e["day"]` 與
                  `e["window"][-1]` 正常情況是同一天(視窗上限就是基準日)
                  → 整段「持股基準日 …」拿掉,測試照樣綠
同儕不含自己        **完全沒有測試**。而且拿掉之後還會讓另一條測試
                  安靜地變成 skip(單成員題材消失了)
三個 SchemaOutOfDate 的 **接住**  完全沒有測試——有測試的是 `raise` 那一半,
                  而撐住公開頁面的是 `except` 那一半
`_aetf_gaps()`     存在的唯一理由就是不要寫死,而**沒有東西擋它被寫死回去**
                  → 改成 `return (40, 6)`,全套照樣綠
邊界清單           題材圖那一段包在 `except Exception: pass` 裡
                  → 上游改個欄位名,8 類邊界安靜剩 5 類,而測試印成功
抓取端接線          4 條斷言裡 3 條命中腳本**自己的 def 與 argparse 字面值**
                  (`trading_days` 是它自己定義的、`recheck` 是參數名)
兩條 no-op         `assert src is not None`(`__code__` 永遠不是 None)、
                  `assert len(ever) > 0`

全部改掉:比率改看印出來的字(不含 %、除了覆蓋率之外不含任何 N/M)、日期改斷言標籤(持股基準日 {day})、同儕不含自己連 n_members 一起拿題材圖對帳、舊 schema 的 DB 造一顆真的(先用 FM.connect() 建齊所有表再換掉 fm_aetf_info,不然測到的是 「少了別張表」)、_aetf_gaps() 用兩份不同的帳驗它真的在算、 邊界清單讀不到就開一個 🔴 類別讓測試紅、接線改比對呼叫的樣子。

⚠️ 過程中發現「同儕不含自己」其實有兩個執行點(peers 集合與 mem),而 flow_for 只拿到不含自己的集合,所以只破壞其中一個 看不出來——但 n_members 會多算一。冗餘的防線會讓反向驗證說謊, 所以要釘的是畫面上那個數字,不是某一行程式碼。

驗收

全套 1,209 passed / 2 skipped。新測試逐一反向驗證過 (破壞 → 紅、還原 → 綠),其中三條第一次沒紅、改寫之後才真的守得住。 重啟後 /active-etf 線上 200、KPI 正確、可見文字零 markdown。

側欄換成頂部導覽(2026-09-23 第三輪)

使用者給 https://toalpha.tw 當參考:「把界面做成像他這樣,登入後的 使用者界面乾淨簡潔一點」。兩個決定先問過:全站都換、 維持淺色只改結構(深色要重配所有圖表、燈號與 25 隻精靈的底, 工作量不成比例)。

量到的

                  改前              改後
桌機導覽          224×2,073px      橫列 47px
手機導覽          1,693px 在標題前   108px 在標題前
手機 h1 距頂       —                124px
/us-report 手機    橫向溢出(記過)   0
導覽連結          39 條全部攤平      6 群組 × 下拉(預設全收)
內文字級          15px             14px

/us-report 是舊稽核量到側欄被頂到 1,041px 的那一頁,現在 375px 橫向溢出 0。26 頁全數 200、6 個群組、0 個預設展開、零側欄殘留。

🔴 兩次側欄事故是同一個決定的下游

2026-08-26「滾不到」與 2026-09-04「32 條排在標題之前」,都是在補 「把 39 條同時擺出來」。改成群組下拉之後那兩個補丁(order:1/2、 .navjump、#nav 跳連結)整組拔掉——留著就是守一個做不到的事。

但那個形狀只是換了容器:最長的群組(題材 12 條)在矮視窗裡照樣 超出面板。所以那兩條測試搬家不是刪除,改守 .ngm{max-height + overflow-y:auto + dvh}。另外新增一條守結構的: 橫列上只能有群組數那麼多顆按鈕、一個都不可以預設展開。

門檻自己量,不借

逐寬量 .topwrap 佔幾列:1,024/900/820px 一列,760px 起兩列。 所以「下拉改就地展開」取 820px,不沿用主區格線那個 980 ——借來的話 820–980 的視窗會白白失去桌機下拉。

我在新測試裡犯了本專案記過的那個錯

assert "order:2" not in css ——命中 border:2px。 「用簡稱做子字串比對」第四次,這次在測試裡。改成帶邊界的 regex。

驗收

全套 1,226 passed / 2 skipped(新增 2 條、改寫 5 條)。 重啟後 26 頁逐頁實測:200、6 群組、0 預設展開、零殘留; 375px 下 /us-report 與 /my-chips 橫向溢出皆為 0。

主動式 ETF:toalpha 式熱圖+逐檔點開+選日期(2026-09-23 第五輪)

使用者:「我主動式ETF希望可以做的跟他一樣」(toalpha 投信持股權重) +「這個幫我做成用點的再顯示 還有我需要其他天的日期」。

  • 持股權重熱圖(個股 × 月,13 個月):等權平均,不是參考站的金額加權 ——市值欄 14/34 檔整欄是 0,金額加權的話 2025-06→10 的分母只有一檔基金。 逐月母體印在表頭表尾、排序用視窗內最大值、絕對色尺、<0.01 不印 0.00。
  • 逐檔 ETF:收合成一列(標題寫買幾檔賣幾檔+種類),點開看明細。 單位修正:台股張、海外股股、債券面額、期貨口(原本全部 ÷1000 叫張)。
  • 選日期:頂端「← 前一天|選單|後一天 →」,323 個交易日可選。 亂打的日期紅色提醒(不悄悄換天)、未收齊的日子黃色提醒。
  • 順修全站 .kpi b → .kpi>b(副標粗體被放大,13 頁中招)。

設計審查(背景工作流)只跑完一個視角,另外四個撞到額度;完成的那個 獨立抓到市值分母只剩一檔基金——與我自己量到的一致。

驗收:全套 1,238 passed / 1 skipped;新增 12 條測試,逐一反向驗證 (11 條第一輪就紅,第 11 條是我的破壞字串沒對到,重做後紅)。 線上 /active-etf 四種日期參數皆 200、<script> 不回顯、零個 0.00 格。

主動式 ETF 補上原始封存(2026-09-23 第六輪)

使用者問「FinMind 的資料都存在哪」,盤點時發現主動式 ETF 那一層沒有原件 ——違反使用者 09-22 交代的「新跑出來的資料都要放在資料夾裡面」。 使用者:「兩件都做」。

1. 每日抓取先封存再入庫:data/raw/finmind/active_etf/ (holding/change/info/listed),空回應也存。 2. 回補 323 天原件:--rearchive,687 個請求(清單 1+上市日 40+ 323 天 × 2),只封存不入庫、逐日對帳。唯一不一致 2026-09-22 (帳上 1,040 列=當時 20/40;重抓 2,314 列=後來補齊),隨即跑一次 每日抓取補上,也是新封存路徑的第一次正式實跑。

重建腳本:加 active_etf 層(四張表+上市日);所有層改成只讀最新版 (FM.canonical,原本 rglob 會把舊版一起讀進來——全帳沒有舊版所以沒爆過); --dry-run 從只比列數改成逐列比主鍵與數值。檔頭「finmind.db 刻意不進 每日備份」是錯的,更正。

驗收:

dry-run 逐列對帳   持股 341,930/異動 34,472:只在原件 0、只在帳上 0、數值不同 0
複本實跑重建       四張表(含上市日)與正式庫逐列相同,零網路請求
測試              全套 1,244 passed;新增 7 條,9 個破壞逐一驗紅

FinMind 歷史往前補到來源的最早日(2026-09-23 第七輪)

使用者:「歷史資料的部份呢」。逐層探「來源最早給到哪」vs「帳上有到哪」:

層          帳上原本       來源最早(探針)        這一輪
分點        2025-12-23    2021-06-28             背景回補中(~14,000 請求、約 11 小時)
集保        2016-01-08    2010-01-29             ✅ 13 請求,+16,768 列(2015 前是月頻)
月營收      2015-12       2002-01                ✅ 13 請求,+1,880 列
個股日K     2016-01-04    1992-01-04(2303)     ✅ 13 請求,+41,712 列
主動式 ETF  2025-06-02    2025-05-02             ✅ 51 請求,+20 天
台指期分K   2011-01-03    2011-01-03             已是最早,不用補

補之前先修了四個會讓補進來的資料出事的問題(每條都有測試、逐一反向驗紅):

1. 原件檔名寫死成 --since(backfill_finmind.py):增量跑只抓兩週, 卻存成 20160101.json.gz → 完整歷史被改名成舊版、canonical 變成那兩週, 而重建只讀 canonical =整段歷史在重建時安靜消失。增量還沒排進 cron, 所以沒發生過。改用實際起點命名;重建改成同一檔多段原件依抓取時刻 合併(重疊是刻意的:增量往回退 14 天)。 2. 集保/月營收先正規化才封存:舊年份格式不同就是燒了請求、丟了原件。 3. 覆蓋率分母只看上市日:基金上市前就揭露(00980A 05-05 上市、05-02 已揭露), 會印「1/0」與 200%。分母加上「那天有揭露的」。 4. 日期選單只列有異動的日子:2025-05 有 13 天沒人買賣,選到會印「不在帳上」 ——而它們在帳上。改成收過的日子 ∪ 有異動的日子。

另外:分點卡片原本每次讀一檔全部歷史(補完 ≈ 90 萬列),改成只讀最長視窗 180 天(結果逐日相同)。「主動式 ETF 2025-06 起才有」「分點 2021-07 起」兩句 註解都是錯的,已更正。

驗收:全套測試全綠;線上 /active-etf?day=2025-05-21 顯示「2/2 檔有揭露」+ 「這一天沒有任何一檔 ETF 回報異動」,不再說「不在帳上」。

⚠️ 分點補完 finmind.db 會到 GB 級。backup_ledgers.py 的註解寫 「長到 GB 級再重新量一次」——跑完要量,不要憑印象決定。

FinMind 原件的獨立查核與三個漏洞(2026-09-23 第八輪)

使用者:「務必確保有存到本機上 local 哪個地方給我看」。

新增 scripts/finmind_inventory.py(唯讀)→ reports/finmind-storage.md: 逐主鍵比「帳上每一列都找得到原件」。寫它的時候測試抓到它自己兩個 bug (讀不到帳就崩;先列原件才讀帳 → 回補中報出假缺口 2409 2021-10-19)。

8 個唯讀 agent 獨立查核:六層帳上每一列都有原件(分點另抽 60 天整列比值 0 不同; 集保、月營收、日K、主動式 ETF 全量比值 0 不同)。批評者抓到三件「帳 vs 原件」 看不見的,逐條處理:

處置
分 K 週六/假日的夜盤尾巴 496 天從沒被請求(探針:週六 09-19 回 311 根)日曆改成交易日 ∪ 尾巴日(kbar_calendar),當天補抓;夜盤對應規則在週一是錯的,CLAUDE.md 更正(修正後 2025 起 321 場逐點吻合)
09-22 分 K 晚盤被截斷(772 列、晚盤 0 根,重抓 1,380/607)卻記成完成--recheck N 最近 N 天即使收過也重抓;盤點拿 tx_night 當真值逐場驗
分點 9 天有成交卻回空(重問仍空)來源缺,盤點逐日列出,不被 ✅ 蓋掉

一併修:主動式 ETF 每抓到一份就先存(原本第二個請求失敗會丟第一份);集保 fm_done.n_rows 改記實際級數;backfill_finmind.py 原本沒有排程——加 cron 20:30 增量+21:15 盤點(schedule.toml 宣告,對帳測試全綠)。 備份實測:693 MB 的 finmind.db 壓縮 27 秒,推算補完 1.8 GB 約 70 秒(上限 300)。

每條新防線都反向驗紅。🔴 反向驗證途中踩到:只對調兩行、檔案大小不變、又在 同一秒內改動時,Python 沿用舊 .pyc——破壞沒生效(測試照綠)、還原反而紅。 之後一律 PYTHONDONTWRITEBYTECODE=1。

⚠️ 原件只有一份、就在這顆硬碟上(備份也在同一顆,且不收 data/raw/)。 防硬碟故障要另放一份到別的裝置——使用者 2026-09-24 裁定:先不用,本機就好。

第八輪續:對修正本身的審查(20 agent,16 條存活,全部處理)

重抓變少不換 canonical(archive_status)、原子寫入、每層同一套錯誤處理+ 執行紀錄必寫、排程專用 finmind_daily_run.json、空尾巴只有補班週六後的週日算合法、 日曆落後不把平日當尾巴;盤點改看原件內容、上界改今天、分點對日K 成交日、 分 K 逐交易日驗日盤、讀不到真值 ⚪ exit 1;首頁橫幅讀內容。細節見 reports/finmind-storage-audit-2026-09-23.md 第二輪。全套測試綠; 乾淨環境實跑 cron 指令 42 請求 0 錯誤。

第八輪收尾(2026-09-24)

分點往前補跑完:fm_broker 8,506,362 列、13 檔、2021-06-28 → 2026-09-23, 「有成交卻還沒問過」0 天(途中舊程式碼撞 504 崩過一次——正是審查抓到的那條, 換新程式碼從斷點接著跑)。盤點全綠。

來源缺 39 天逐一重問一次,39 天仍空、0 錯誤——確認是 FinMind 沒有, 不是我們漏存。形狀:2021-06-28/29 全是上櫃股(來源的上櫃分點晚兩天開始)、 2023-01-11 13 檔全缺(整天沒有)、其餘零星。

備份補完後實測:finmind.db 1,351 MB → gzip 48 s、298 MB;14 顆約 4.2 GB。

券商推薦=會員首頁、站名改「股票觀測站」(2026-09-24)

使用者:「把會員登入進來的畫面做成像 toalpha.tw/brokers」→ 釐清後:內容照做 (券商評等與目標價)、另做新的會員首頁、站名「股票觀測站」、維持淺色。

新增 src/tx/broker_calls.py、scripts/fetch_broker_calls.py(cron 21:45 每天、 schedule.toml 宣告)、/brokers(要登入;登入後跳這裡)、broker_calls.db (登記進資料字典)。來源:FinMind 全市場新聞(1 請求/天)+站上新聞; 內文原件存 data/raw/broker_news/、新聞原件 data/raw/finmind/news/、 收盤價原件 data/raw/twse_close/。

試跑 09-23:37 篇 → 38 筆,目標價/評等/前次目標價對得上原文; 參考站同日的光寶科 370、智邦 3,688、聯強 115、南亞科小摩 710、陽明群益 65、 世芯 6,784 都有。缺的幾筆(台化玉山、聯電 185、景碩高盛)FinMind 新聞裡沒有 → 併入站上自己的新聞當第二來源(09-23 候選 37 → 51)。

新增測試 tests/test_broker_calls.py(26 條),8 條防線逐一反向驗紅 (整數比對、編造目標價留空、股票/券商不在原文丟整筆、佔位理由、具名券商不併、 彙整去重、跨文章合併、模型掛掉不寫帳)。全套測試綠。

回補:最近 30 天先、再往前到 2026-03-23(週圖 26 週),背景跑。 ⚠️ 與參考站不同:覆蓋率較低、目標價未還原除權息——頁面上照實說。

券商推薦:審查與修正(同日)

17 agent:逐欄對原文抽驗 48 筆(37 對、7 部分錯)+程式審查 11 條(全部沒被推翻)。 修:目標價歸屬改問「前面最近提到的是哪一檔」(限這篇在講的股票、認簡稱、列表留空)、 美元/情境價/與現價差 4 倍以上留空、日期用文章發布日、主鍵改 (文章, 序號)、 讀取合併以 (日, 股, 券商) 為組、逾時也整輪停手、每篇處理完才記收完、 關注排行先按家數、未收完不說「沒有調升」、停手原因上橫幅、公網橫幅不印 pid/埠號。 重驗(不叫模型)1,120 篇 → 1,272 筆、有目標價 951(原 991,擋下的多為配錯或列表)。 測試 45 條,新防線逐一反向驗紅(有四條第一次沒紅:破壞點選錯或有備援防線,重做後紅)。

券商推薦:修正後重驗(同日)

新抽 48 筆逐欄對原文:35 對、11 部分錯、1 錯;上一輪的配錯一筆都沒再出現。 新抓到:FinMind 新聞時間是 UTC(4/48 日期早一天、在發布之前)→ 換算台北日期 (識別鍵不動)、發布時間帶時區換算;區間一端、FactSet 共識。測試 51 條, 新防線逐一反向驗紅(區間那條第一次沒紅:另一道規則先擋住,補測另一端後紅)。 重驗:1,536 篇 → 1,698 筆(有目標價 1,263),142 篇日期改成台北日期。 第二段回補(舊程式碼)跑完後,背景接力:新程式碼補掃整段 → 全部重驗 (logs/broker_calls_followup.log)。

會員區分頁列加上「我的籌碼觀察」(同日)

使用者:「登入帳號後應該要有個我的籌碼觀察在裡面,就不用特意往右上角去點」。 會員首頁與籌碼觀察頁共用一條分頁列:總覽|依個股|逐筆最新|依券商|我的籌碼觀察; 籌碼觀察頁標成目前這一頁、點「總覽」回會員首頁。測試一條(反向驗紅)。

看盤軟體改版(2026-09-24)

使用者:「把股票觀測站全部做成可視化、研究的東西放進系統、當一般人用的看盤軟體」 → 裁定:看盤軟體的排法、盤後資料就好。

  • 選單改 9 組(大盤|個股|自選股|籌碼|券商推薦|題材|美股|系統|我的帳號), 研究頁全部收進「系統」;單一連結的群組直接是按鈕。
  • 新頁:/ 大盤(指數 K 線、漲跌家數、類股熱圖、三大法人、排行)、/stock 個股 (日/週/月 K、均線、量、法人、融資、券商推薦、營收)、/chips 籌碼排行; 自選股加迷你走勢。舊首頁搬到 /tx。
  • 資料:market.db(全市場日 K/法人/融資/大盤,FinMind 一天一請求=全市場), 2016 起回補中;cron 20:15。⚠️ 10/22 到期後要換官方端點(未做)。
  • 圖表層 web/charts.py,座標逐點測試;測試全綠,新防線逐一反向驗紅。
  • 與題材獵人、美股研究兩個 session 協調分工(各自新檔、只用 charts.py)。
status.md:4,013 列 · 20 天前(09-24) · 未宣告排程
版本 5f92ccf(第 163 次提交,2026-10-14) | 資料建置於 2026-10-14 10:30 UTC | 行情與研究結果供查看;會員可管理觀察清單並要求更新個股分析