MARKET RESEARCH
預測紀律
為什麼會出錯、根因是什麼、怎麼改、改了沒有
已修並驗證19fixed(登記簿 2026-09-01 版,43 天前)
修了一半2partial —— 剩下的寫在改善方法(登記簿 2026-09-01 版,43 天前)
已查證未修0open(登記簿 2026-09-01 版,43 天前)
量出來的事實1measured —— 不是缺陷(登記簿 2026-09-01 版,43 天前)
🕐 這四個計數是上游登記簿那一版的盤點,本站唯讀不重算。登記簿已 43 天沒有更新(2026-09-01)——所以「已查證未修 0」的意思是那一天沒有未修項,不是今天沒有問題。
這一頁的規矩最後更新 2026-09-01
每一項都必須有 原因(不是現象)與 改善方法(不是抱怨)。
只寫「prob_boom 不準」沒有用——那會讓人想去調參數。要寫
「它是 min(催化劑×10, 95),分子只跟題材有關,
所以同題材下每檔股票拿到一模一樣的分數,在建構上就不可能有個股
鑑別力」,才會讓人知道調參數沒用、要換做法。
🔴 缺「原因」或「改善方法」的登記不會被擋下——本站唯讀上游的 登記簿,這種項目照樣完整顯示在下面的個別項目表裡,只是會在下方亮一則 警告點名是哪一項缺什麼。看到的每一項不代表都通過了完整性檢查。
共通病因(這一段比個別項目重要)同一個坑踩很多次,代表問題不在那幾行程式,在寫法的習慣。⚠️ 兩個數字口徑不同,不相等是正常的:「全專案盤點 N 次」是上游寫登記那天(2026-09-01)對整個專案手寫的次數;「本登記簿收錄 M 項」是本站算的——這份登記簿裡掛在這個病因底下的個別項目數。一個病因可以踩很多次而只登記一項,也可以拆成好幾項登記。⚠️ 前者是那一天的快照,之後沒有人重數過
| 病因 | 說明 | 例子 | 根治方法 |
|---|---|---|---|
| 不可重建的資料用就地覆寫 歷史盤點 9 次 本登記簿收錄 1 項 | read → 改 → 寫回同一路徑,沒有 tmp+rename、沒有備份。 外層 cron 的 timeout 隨時可能在寫入中途送 SIGTERM。 | ・ML 特徵 CSV 11,225 列 ・prediction_log ・outcome_log ・tech_indicators.json ・latest.json ・backfill_horizons.py 的 outcome_log 寫回(2026-08-27 新寫的,漏網第 9 處) | scripts/atomic_io.py,9 處全部已改。另加 guard_shrink 擋縮水覆寫。
⚠️ 「8 處全改完」之後,兩天後新寫的程式又犯了一次。
backfill_horizons.py(2026-08-27)用 df.to_csv 就地覆寫 outcome_log,
而 atomic_io 在那時候已經存在了。2026-09-01 補修。
這說明修完既有的 N 處不等於這個坑關上了——它只清掉了存量。
新程式要寫任何不可重建的資料時,得先問一句:這個檔案被砍在寫入中途
還救得回來嗎?救不回來就走 atomic_io,不要自己 to_csv / open(w)。
|
| 故障偽裝成空狀態 歷史盤點 5 次 本登記簿收錄 6 項 | 抓不到資料時回傳空的、或填一個看起來正常的預設值,而不是報錯。 下游看到的是「今天沒有」,而不是「壞了」——而「今天沒有」看起來 完全正常,沒有人會去查。 | ・法人籌碼爬蟲回空 DataFrame ・RSI 填 50 ・結算價寫 0.0 ・主題熱度預設 5.0 ・抓價全滅時靜默改用歷史舊價 | 抓不到就拋錯或明確標記缺漏,絕不用中性預設值頂替。 中性值最危險,因為它落在正常範圍內。 |
| 寫死的清單決定了系統的上限 歷史盤點 3 次 本登記簿收錄 3 項 | 題材、股票、名稱全部是手填清單。清單外的東西在結構上不可能被看見, 而且看不見的時候沒有任何跡象。補清單是治標——補到 30 個,第 31 個 照樣看不見。 | ・26 條 RSS 查詢的關鍵字就是題材名 ・THEME_MAP 12 個 ・yfinance 只抓手填的 23 檔 | 改成從沒有預設立場的資料讓答案浮出來。已實作 theme_discovery.py (廣掃新聞 ∩ 全市場價量 ∩ 共動)與 stock_names_official.py(交易所全表)。 |
| 沒有任何東西會跟它對帳 歷史盤點 3 次 本登記簿收錄 6 項 | 一個數字算出來就直接印,沒有第二條路徑驗算。錯了也不會有人發現—— 期望值公式把虧損加成正的,活了好幾個月。 | ・期望值 +5.22% vs 實現 −0.56% ・prob_boom 校準表用被污染的欄位 ・log 標 ✅ 但 69% 解析失敗 | 關鍵數字旁邊一定要有一個獨立算法的對照值,差太多就出警告。 已在 print_accuracy_report 加「實現平均報酬」與期望值對帳。 |
| 用一個在訊號存在時買不到的價格 歷史盤點 2 次 本登記簿收錄 2 項 | 績效計算取的價格,在報告產出的當下已經不存在了。台股 13:30 收盤、 而所有 bot 都是 15:40 之後才跑——任何「當日收盤」或更早的價格, 訊號出現時都已經買不到。 這不是資料洩漏(資訊上合法,收盤後才用收盤價),是不可實現的假設: 回測數字對,但那筆交易做不出來。它不會拋錯、不會有缺值, 只會讓整套績效安靜地建立在一個不存在的成交上。 | ・run_theme_hunter 的 entry_price = 當日收盤(2026-08-25 修) ・發現層結算用 trade_date 而非進榜日(2026-09-01 修) | 進場價一律取訊號產出之後第一個可成交時點的價格(T+1 開盤或收盤), 抓不到就保留 pending。絕不退回用當日收盤頂替——那正是這個坑的原型。 寫任何新的結算路徑時先問一句:「這個價格,在訊號出現的那一刻買得到嗎?」 兩次都是新管線各自犯一次,所以規則要寫在 pattern 這一層,不是個別修完就算。 |
個別項目(22 項)「原因」要指到檔案:行號或機制,不能只說「怪怪的」
| 項目/狀態 | 現象 | 原因 | 影響 | 改善方法 |
|---|---|---|---|---|
| prob-boom-not-a-probability 修了一半 沒有任何東西會跟它對帳 | prediction_log 最近幾筆的 prob_boom 全都是 95;報告印成「勝率 95%」 | theme_simulator_v3.py:117 是 min(scenario.catalyst_strength * 10, 95)。
分子只跟題材有關,同一題材底下每一檔股票拿到完全相同的分數;
而 catalyst_strength 在 catalyst_monitor.py:770 已被 clamp 在 10.0,
所以熱度 ≥9.5 的題材必然輸出 95。
它在建構上就不可能有個股鑑別力——這不是「校準不好」,
是它根本不是機率,只是催化劑分數換了個單位再撞上天花板。
註解還寫著「× (美股Boost+1)」,但程式碼裡沒有 boost 這一項。
| 使用者看到「暴漲機率 95%」,而實現的勝利+達標率是 6.5%(31 筆) | ① 短期:停止把它標示為「機率」或「勝率」,改稱「催化劑強度(0-95)」。 ② 中期:若要有真正的機率,必須是個股層級的模型且經過校準檢定 (像台指期那邊的 ECE 閘門)。在那之前不應該有任何數字叫「機率」。 ③ 已做:網站的可靠度表已標明「這張表測不出東西」並說明兩個理由。 |
| pred-id-collision 修了一半 | pred_id=46 在 outcome_log 出現兩次,對應不同股票與不同日期 | prediction_tracker.py:62-77 的 get_next_pred_id():舊格式時回傳
len(df)+1,新格式時回傳 max(int_ids)+1。兩套編號器交替使用
必然撞號。
| 主鍵失效,任何 join 出來的分析都可能對錯行 | 改用全域單調序號或 UUID。theme_simulator_v3 已改用 sim-{日期}-{代號}。 |
| discovery-no-edge 量出來的事實 | 發現引擎找得到清單外的題材,但那些題材沒有超額報酬 | 不是缺陷,是量出來的事實。10 個交易日、47 筆發現的歷史回測: 5 日 題材 +3.60% 市場等權 +3.50% 超額 +0.10% t=+0.08 10 日 題材 +3.48% 市場等權 +4.10% 超額 −0.62% t=−0.39 兩個都與 0 無法區分。勝率 70.2% 不代表準——那段期間全市場 5 日平均 漲 3.5%,什麼都在漲。 ⚠️ 這個回測還帶著無法消除的樂觀偏誤:Google News 的 after:/before: 回的是今天的索引與排序,後來證明重要的文章更可能還留著。 所以判讀是不對稱的:不賺錢 → 結論成立;賺錢 → 只能說值得繼續累積。 現在落在第一格。 | 「看得見清單外的題材」與「那些題材會賺錢」是兩件事,目前只有前者成立。 13 個發現的題材裡 10 個是寫死清單看不到的(31/47 筆紀錄)。 | ① 已做:theme_discovery_track.py 每天存全市場收盤,累積乾淨樣本, 三個月後可用不帶偏誤的資料重測。 ② 待做(不可現在做):想試「只取 🆕 全新題材」或「只取共動 ≥3 檔」 之類的子集。但 n=10 天且語料偏樂觀,任何子集分析保證找得到假訊號。 要做必須先寫死判準、等乾淨樣本。這是台指期那邊 prereg 制度的同一條規矩。 |
| loop-variable-leak 已修並驗證 沒有任何東西會跟它對帳 | outcome_log 同一個結算批次內,所有股票的 scenario 與 prob_boom 完全相同 | prediction_tracker.py 寫 outcome_log 時,for r in resolved: 迴圈裡取的是
row['scenario'] 與 row['prob_boom']——row 是上面
for _, row in pending.iterrows() 殘留的最後一筆。
根因是 resolved 這個 dict 本來就沒帶這兩個欄位,所以下游只好去抓
迴圈外的變數。
| 13 個多筆批次裡 12 個的 prob_boom 是常數;分箱表 50 筆中 46 筆(92%) 落在這種批次。依 prob_boom 分箱 = 依批次分箱 = 依時間分箱, 量到的是不同時期的行情差異。上游那個 corr −0.273、以及 v1.3 據此 「廢除 prob_boom 排序」的決策,資料基礎都不可信。 | 在 resolved.append 時就帶好 scenario/prob_boom/name,下游一律用 r。已修。 |
| expectancy-formula 已修並驗證 沒有任何東西會跟它對帳 | 報告印「期望值 +5.22%」,但同一份資料的實現平均報酬是 −0.56% | prediction_tracker.py 的 (1 - win_rate)/100,而 win_rate 已經是百分數
(30.0)。(1 - 30.0)/100 = −0.29,負負得正,虧損被「加」進期望值。
只要 win_rate > 1 就必然高估。
| 使用者最直接看到的「這套策略賺不賺」的數字,報的是反的,差 5.78 個百分點 | 改成兩邊同一個分母:p_win × avg_win − p_loss × avg_loss。 並在旁邊加印實現平均報酬做對帳,差 >3pp 就出警告—— 這個 bug 能活這麼久,就是因為沒有任何東西會跟它對帳。 修正後 −0.52% vs 實現 −0.56%,差 0.04pp。 |
| rsi-default-50 已修並驗證 故障偽裝成空狀態 | 抓不到技術指標的個股,仍出現在推薦名單上並標「✅進場區」 | run_theme_hunter.py 的 rsi = ... if tech.get('RSI_14') is not None else 50。
get_latest_tech() 在下載失敗或資料不足 30 根時直接 continue,該股根本
不在 tech_data 裡 → RSI 填 50。
而 50 正好落在報告的「中線 RSI 40-60 整理正常區」——
「沒資料」因此偽裝成一筆有目標價、有停損、標著 ✅進場區 的具體建議。
| 無法量化(不知道歷史上有幾檔中招),但這是最危險的一種: 它不是變成空的,是變成一筆看起來完全正常的買進建議。 AGENTS.md:41 記著 2026-08-19「結算抓不到價寫 0.0 造成 28 筆假結算」, 結算路徑當時修了,推薦路徑的同型 bug 沒修。 | 缺指標或缺報價一律排除,並在報告列出「今日缺資料 N 檔」。 排除方式是把項目從 r['results'] 裡拿掉,不是在每個消費點各加判斷 ——下游有 5 處消費點,漏掉一處就等於沒修。已修。 |
| csv-column-shift 已修並驗證 故障偽裝成空狀態 | prediction_log.csv 有 70/108 列(65%)的欄位語意整排位移一格 | theme_simulator_v3.py 用手寫字串 append,只給 14 欄,而檔頭是 21 欄 且第一欄是 pred_id。整列往左移:日期跑進 pred_id、代號跑進 pred_date、 股名跑進 stock_id、題材跑進 name。 每一列的欄數看起來都對,所以 pandas 與 csv.reader 都不報錯。 | pred_id 這個閉環主鍵壞了:outcome_log 有 38 個 pred_id 在 prediction_log 裡找不到來源,且 pred_id=46 出現兩次(不同股票、不同日期)。 「全部來自真實到期結算的閉環」這個說法,在 38/70 的紀錄上追不回去。 | 改用 csv.DictWriter + 從檔頭讀回來的 fieldnames,欄位對齊由結構保證。 另修:相對路徑改絕對路徑(這支自己沒 chdir,從別的目錄執行會生一份 新的空 log);status 標 'simulated' 而非留空或 'pending'。已修。 ⚠️ 已寫進去的 70 列仍是舊格式(依使用者指示不動資料)。 tx_forecast 的讀取層有還原邏輯(依「第一欄是不是日期」判斷 schema)。 |
| theme-funnel 已修並驗證 寫死的清單決定了系統的上限 | 題材永遠只有 ABF、AI GPU、記憶體那幾個 | 三層寫死的清單,每層都在縮小: 26 條 RSS 查詢 → 12 個 THEME_MAP → 7 個 SCENARIOS(唯一能產生推薦的) 而且每一條查詢的關鍵字就是題材自己的名字(q=ABF+載板+欣興), 所以它只能查回它已經相信的東西——這是確認機不是發現機。 18/26 條查詢的結果抓進來、算了分數、然後丟掉:CPO、AI 散熱、AI 電源、 化合物半導體、機器人、軍工無人機、重電綠能、生技…全部結構上不可能 變成一筆建議。 再加上 theme_bridge 用子字串比對傳分數,而「記憶體超級循環」與 「記憶體封測」互不包含——12 個題材只有 1 個對得上,其餘 11 個永遠 拿寫死的預設 5.0。頁面上「除了 ABF 是 10.0 其他全是 5.0」不是評分結果, 是預設值。 | 系統結構上看不見清單外的任何題材,而且看不見時沒有任何跡象 | 沒有去補那三張清單——補清單只是把 12 個變成 30 個,第 31 個照樣 看不見,那是治標。 改成新增一條不經過這個漏斗的路徑:theme_discovery.py。 查詢只含市場結構詞(盤後/類股/漲停/買超),題材從 「廣掃新聞詞頻 ∩ 全市場價量異常 ∩ 個股共動」的交集浮出來。 並用 _assert_queries_are_theme_free() 結構性擋住有人往查詢裡加題材名。 實測第一天就撈到「矽晶圓」(現有 12 個 THEME_MAP 完全沒有), 當天 2 檔同向 +8.63%。 |
| duplicate-outcomes 已修並驗證 | 網站顯示的勝率 42.0%,去重後只有 24.2% | outcome_log 70 列裡有 19 列是逐字重複(2026-05-03 那一批,同一筆 結算被寫進去 3–8 次),而且重複集中在贏的那幾筆——24 列重複裡 16 列是勝利/達標,全樣本只有 21/70。 寫入端為什麼會重複尚未查清(可能是同一天重跑多次且沒有去重)。 | 勝率 42.0% → 24.2%;平均 5 日報酬 −0.56% → −2.88% | 已修(2026-08-25),三個層次都做了。
① 查清楚成因。 重複列的 pred_id 各不相同(145–168),其他欄位
逐字相同——也就是說同一筆預測被寫進 prediction_log 3–8 次,
然後每一份都各自結算。那些 pred_id 在 prediction_log 裡找不到來源
(是那 38 個孤兒之一),寫入者不在 repo 裡。
② 寫入端加去重鍵。 用 (pred_date, stock_id),不是 pred_id
——後者本身就會撞號(兩套編號器交替使用,見 pred-id-collision)。
比對前兩邊都正規化成字串:prediction_log 混著舊格式列,同一欄可能被
pandas 讀成 str/int/float,型別不同時 == 恆為 False,去重會靜默失效。
實測 '2382' / 2382 / 2382.0 三種寫法現在都擋得住。
log_prediction() 的既有去重有同一個型別問題,一併修。
擋下時會印「🛡️ 去重擋下 N 筆」——靜靜跳過與靜靜寫入一樣糟,
前者會讓人以為結算數變少是市場沒訊號。
③ 清掉既有的 19 列(使用者 2026-08-25 核准後才動)。
先寫 outcome_log.csv.bak-20260825-dedup,被刪的每一列另存
removed_duplicates_20260825.csv(含刪除理由與時間),git 留可回退的 commit。
刪除前 73 列 勝率 40.4% 平均 −0.79%
刪除後 54 列 勝率 22.9% 平均 −3.05%
19 列全部來自 2026-05-01,是同 5 筆預測的複本;而重複集中在贏的那幾筆
(24 列裡 16 列是勝利/達標,全樣本只有 21/70),所以它是單向把勝率灌高。
讀取層的去重保留著防上游再出問題,現在回報「再去重 0 列」。
|
| institutional-crawler 已修並驗證 故障偽裝成空狀態 | 報告顯示「法人淨買超=0」,讀起來像查到了只是法人賣超 | market_data.py:77 用 股票代號,而證交所 T86 的實際欄位是 證券代號
→ KeyError → 被 except Exception 吞成空 DataFrame → 上層
if df.empty: return 靜默結束 → data/institutional.db 檔案根本不存在
→ 查詢一律回全 0 → check_beta_tech 的 total_buy > 0 永遠是 False。
整條鏈上沒有任何一個環節會報錯。專案其他地方用的都是 證券代號,
只有 market_data.py 用錯。
| Beta 策略永遠進不了場。而且目前 market_data.py 因為 import pandas_ta
(套件沒裝)整支 import 不進來,連帶 router.py 也是——
所以現在是死路徑,沒有實際傷害,但也代表少了一個真正的訊號源。
| 已修(2026-08-25)。實際壞的比原本查到的多:
① 欄位名錯的有三個不是一個:股票代號→證券代號、
外陸資買賣超股數→外陸資買賣超股數(不含外資自營商)、
總買賣超→三大法人買賣超股數。而 row.get("總買賣超", 0) or 0
讓最後一個恆為 0 且看起來像真的查到 0。
② 查詢參數也錯:strType 應為 selectType。
③ pandas_ta 改成函式內延遲載入——它只用在技術指標,
卻寫在模組頂層把整支 market_data.py 一起拖死,法人籌碼是被連坐的。
④ save 前先 init_institutional_db(),並改用 INSERT OR REPLACE
(PRIMARY KEY (ticker,date)),同一天重跑覆寫而非累積。
⑤ 補上櫃:T86 只有上市,而 6488 環球晶、5483 中美晶都在櫃買。
欄序用三條恆等式驗證而不是用猜的——初版索引寫錯時,
912 列裡有 781 列剛好通過、131 列沒過,部分失敗比全部失敗難發現得多。
⑥ 改成 backfill_institutional(7):櫃買 SSL 間歇性壞掉時不無限重試,
而是每次執行掃最近 7 個交易日補洞——暫時性失敗會自動癒合。
(法人資料與價格歷史不同,交易所歷史端點吃日期參數,補得回來。)
|
| entry-price-unobtainable 已修並驗證 用一個在訊號存在時買不到的價格 | 所有績效統計都建立在一個現實中買不到的進場價上 | run_theme_hunter.py 的 entry_price = 當日收盤價,而 bot 是 15:40 才跑, 台股 13:30 就收盤了。這個價格在報告產出時已經不存在。 這不是資料洩漏(資訊上合法,收盤後才用收盤價),是不可實現的假設。 | 整套勝率與期望值都偏樂觀,幅度未量化 | 已修(2026-08-25)。結算改用 fetch_open_price() 抓預測日之後第一個
交易日的開盤價,抓不到就保留 pending——絕不退回用收盤價頂替,
那正是這個 bug 的原型。同時保留舊口徑的報酬做對照,差 >0.5pp 會印出來。
⚠️ 實測結果與預期相反,值得記下來。 14 筆歷史紀錄的滑價
平均 −0.19%、中位數 −0.72%:T+1 開盤通常低於前一日收盤,
所以舊口徑其實略為保守而不是樂觀。
但修正的理由不受影響:那個價格買不到,這是正確性問題不是偏誤問題。
而且單筆差異高達 ±5%(最大 −4.87%),對個別交易的損益影響很大,
只是正負相抵後平均接近零。
(該樣本含重複列,見 duplicate-outcomes,所以有效樣本更少。)
|
| resolve-calendar-days 已修並驗證 | 「5 日報酬」欄位裡混了 1、2、3、4 個交易日的報酬 | prediction_tracker.py 的 date_5d = pred_date + timedelta(days=5) 是
日曆日不是交易日,再用 abs(diff).idxmin() 貼到最近的交易日。
13 個結算批次裡有 4 個 resolve_date 落在週末,導致不同 pred_date 的
預測被結算在同一根 K 棒上。最極端的是 gap=1 天和 gap=2 天的兩批,
卻都寫在 gain_5d 欄位裡。
| 不同持有期的結果被放進同一個勝率分母;報酬幅度不可比 | 已修(2026-08-25)。新增 nth_trading_day_after(),直接數該檔股票自己的
交易日——不必維護交易日曆,而且自動處理停牌(停牌日不會出現在 history 裡,
本來就不該算)。還沒滿 N 個交易日就保留 pending,不用「最近的一天」湊。
實測:2026-05-11 的 5 日結算從 05-16(週六)改成 05-18;
2026-08-18 從 08-23(週日)改成 08-25。
|
| fake-columns 已修並驗證 故障偽裝成空狀態 | outcome_log 的 actual_close_10d / 20d / gain_10d / gain_20d 全部是 0.0 | prediction_tracker.py:350-357 硬編 0.0,從來沒有真的算過。 best_gain 也不是窗口內最佳報酬,就是 gain_5d 複製一份。 諷刺的是 notes 寫著「20天持有」、HOLD_TARGETS 設定 20 天、 目標價按 +12%/20天 訂,但結算只在第 5 個日曆日做一次。 目標價與結算窗口在設計上就對不起來。 | 58 筆已結算的列,10d/20d 欄位 100% 是 0.0,任何用到它們的分析都是錯的 | 已修(2026-08-25)。真的算:用同一個 nth_trading_day_after()
取第 10、20 個交易日的收盤,算出 gain_10d / gain_20d / stop_hit_10d/20d。
未滿期寫 None(CSV 裡是空白)而不是 0.0——「還沒到」與「報酬是 0」
必須分得出來。best_gain 也不再是 gain_5d 複製一份,改成三個持有期裡
最好的那個(有算到的才算)。
|
| stop-loss-optimizer-circular 已修並驗證 沒有任何東西會跟它對帳 | v1.4 宣稱「網格搜尋實證 ATR×2.0 最優(PF 最高 2.44)」 | 三個問題疊在一起:
① 循環論證:stop_loss_optimizer.py:46-47 把 actual_close=0 的列
補成「剛好在停損價出場」,於是虧損 = 停損距離,而停損距離正是
這個網格在優化的變數。而 2026-08-19 修 bug 之前,所有列都是
actual_close=0。「PF 2.44」極可能是這條 imputation 的產物。
② 搜尋空間 325 組、樣本 ~20-31 筆,且 if len(trades) < 3: continue
——3 筆交易的組合就有資格當「最優」。沒有 holdout。
③ 結論寫死在程式裡:第 302-303 行的「結論:寬停損對本系統幾乎無效」
是字串常數,不管資料算出什麼都會印。
而且這支程式現在根本跑不起來——第 20 行 BASE = '{_PC_STR}/...'
漏了 f 前綴,所以 v1.4 的「實證」無法重現。
| 停損參數在 4 個月內 2.0 → 1.5 → 2.0 繞了一圈回原點,每次都以「實證」包裝 | 已修(2026-08-25),而且移除循環論證之後結論完全變了:
舊版宣稱 「網格搜尋實證 ATR×2.0 最優,PF 最高 2.44」
實際重跑後 2x ATR 勝率 21%/均報酬 −3.8%
3x ATR 勝率 21%/均報酬 −4.2%
最佳組合也只有 勝率 18.8%/均報酬 −3.1%
沒有任何一組是賺錢的。 那個 PF 2.44 確實是補值的產物。
修的五處:
① 移除 stop_price imputation(改成排除 actual_close 無效的列並回報筆數)
② 移除兩行寫死的結論字串——它們不管資料算出什麼都會印
③ 修 BASE = '{_PC_STR}/...' 漏掉的 f 前綴(所以這支程式本來根本跑不起來,
v1.4 的「實證」無法重現)
④ 最少交易筆數 3 → 20(3 筆樣本在 175 組裡挑最大值必然找得到好看的數字),
並在輸出頂端標明「樣本內、無 holdout、搜尋 N 組」
⑤ 分母寫死的 /20 改成實際筆數(resolved 已有 34 筆,會印出 >100% 的勝率)
順帶:row.get('rsi', 50) ——RSI 預設 50 在這個專案出現第三次
(推薦路徑、結算路徑、這裡)。50 落在「正常區」,缺資料的紀錄會安靜地
通過每一個 RSI 濾網,等於用一批「不知道 RSI 是多少」的樣本去調 RSI 門檻。
已改成 RSI 缺值一律排除。並在載入時統一轉型,不再逐處撞 TypeError。
|
| llm-parse-failure 已修並驗證 沒有任何東西會跟它對帳 | log 標「✅ 有效:5/16」,且每個主題的 α均、β均都是 0.0 | ⚠️ 2026-08-25 查清楚了,而且根本不是解析問題。
原本的判讀是「LLM 抽取的解析失敗率最高 69%」。查下去發現真相是
根本沒有 API 金鑰,一次模型請求都沒發出過。三個缺陷串成一條:
① catalyst_intel_center.py 有兩份同名的 _load_openrouter_key
(129 行與 160 行),後者覆蓋前者。
② 存活的那一份是用正則去 grep catalyst_monitor.py 的原始碼,
找 (憑證細節不公開) = "字面字串"。但那邊早就改成
(憑證細節不公開) = _load_minimax_key()(函式呼叫)→ 正則永遠不匹配 → None。
③ 就算走到 _load_minimax_key() 也拿不到:(憑證細節不公開) 的 providers
只有 zai、沒有 minimax-oauth;(憑證細節不公開).minimax 只有指紋
((憑證細節不公開))不含 (憑證細節不公開),而它宣告的來源是
env:(憑證細節不公開)——而那個環境變數沒設。
於是掉到最後的後備 return "sk-cp-...lOAo",
那是一個帶刪節號的佔位字串,長度 13,不是金鑰。
於是 call_llm 每次都回 {"error": "No API key found"},
而 .get('alpha_score', 0.0) 把它靜默轉成 0.0。
α/β 全部是 0.0 不是「模型判斷為中性」,是根本沒問過模型。
(憑證細節不公開).minimax 的 request_count 是 0——從來沒用過。
那份 log 從頭到尾沒有一個 ❌,結尾是「✅ bot 全鏈路完成」
——✅ 在這個系統裡不代表成功。
| 題材排名建立在大部分沒抽取成功的 LLM 輸出上 | 程式面已全部修好(2026-08-25),剩下的只有一件事需要你提供。
已修:
① 移除重複的 _load_openrouter_key,改成直接呼叫
catalyst_monitor._load_minimax_key()。
用正則讀另一個模組的原始碼取金鑰,本來就會在對方改寫法時無聲失效。
② _load_minimax_key() 補上讀環境變數 (憑證細節不公開)
(那正是 (憑證細節不公開) 宣告的來源,先前程式碼根本沒讀)。
③ 拿掉後備的佔位字串 "sk-cp-...lOAo",改回 None——
讓上游知道「沒有金鑰」,而不是拿一個假金鑰去撞 401。
④ AsyncOpenAI(...) 從模組層改成延遲建構。沒有金鑰時舊版會
在 import 時就拋 OpenAIError,整支 catalyst_monitor import 不進來
——與 pandas_ta 那次是同一種病:模組層的失敗會傳染。
⑤ 報告與哨兵把「沒有金鑰」與「有金鑰但解析失敗」分開報。
混在一起會讓人去查 prompt,而實際上根本沒發出過請求。
網站 /theme-sources 現在直接寫「根本沒問過模型」。
⑥ 改用 GLM(Z.ai coding plan)取代 MiniMax(2026-08-25,使用者指示)。
MiniMax 沒有可用金鑰((憑證細節不公開).minimax 只有 (憑證細節不公開)
不含 (憑證細節不公開),request_count 是 0——從來沒成功過),
而 GLM 的金鑰本來就已經在 (憑證細節不公開) 的 (憑證細節不公開),
(憑證細節不公開) 也已經記著端點 `(憑證細節不公開)。
所以不必新增任何金鑰,只要讓程式去讀既有的那把。
⑦ 新增 llm_client.py——全專案唯一的金鑰載入與呼叫路徑。
金鑰載入散在三個模組正是這次出事的根源;集中之後「金鑰從哪來」
只有一個答案,而且它會回報「有沒有、長度多少」(不印金鑰本身)。
模型預設 glm-4.5:新聞評分是結構化小任務,不需要推理模型。
glm-4.6 可用但 113 個 completion token 裡有 105 個是 reasoning,
一次要評上百則新聞時成本差很多。max_tokens 提到 1500——
實測 glm-4.6 用 200 會被推理吃光而 content 為空字串。
實測單則新聞:「矽晶圓三年首見漲價潮,環球晶、台勝科迎接循環轉折」
→ alpha 8.3 / beta 8.7 / ticker 6488(環球晶)/ 推理正確。
|
| no-venv 已修並驗證 故障偽裝成空狀態 | market_data.py 現在 import 不進來(No module named 'pandas_ta') | 不是 venv,是 pip install --user 裝在 ~/.local/lib/python3.14/,
與 apt 套件混用,零版本鎖定,專案內沒有 requirements.txt。
路徑綁死小版本:Ubuntu 一旦把 /usr/bin/python3 換成 3.15,
yfinance、pandas、feedparser、curl_cffi 會同時從 sys.path 消失。
而 pandas(pip) 建在 numpy(apt) 的 ABI 上,apt upgrade python3-numpy
可能直接 ABI 破壞 pandas。
| 已經有一支模組壞掉沒人發現;下次系統更新可能整條鏈路同時掛掉 | 已修(2026-08-25)。四件事:
① 建 .venv + requirements.txt,10 個套件全部 pin 版本。
刻意不列 pandas_ta(與 pandas 3.x 不相容)與 streamlit/plotly
(只有互動式儀表板需要,不在 daily_bot 鏈路上),理由寫在檔案裡。
② cron 兩條都改走 .venv/bin/python。
③ daily_bot 找不到 venv 時會出聲再退回系統 python,不靜默降級。
④ 新增 scripts/smoke_imports.py 並排進 daily_bot 第 0 步。
冒煙測試第一次跑就抓到兩個沒人知道的壞模組:
loop_health_dashboard 缺 streamlit(合理,已列為選配)
theme_report_generator from stock_theme_map import STOCK_NAMES
——而 stock_theme_map 裡根本沒有 STOCK_NAMES
已改用交易所權威股名表(1,980 檔),對不到再退回 THEME_MAP。
順帶發現 j4_select_core 在 import 時就執行主流程並寫檔
(寫死的 Windows 路徑),冒煙測試已排除它以免變成一次真的執行——
但那本身是缺陷,尚未修。
|
| no-alerting 已修並驗證 故障偽裝成空狀態 | 系統不知道「今天沒跑成功」,也沒有任何告警路徑 | AGENTS.md:32-34 宣稱有三條 cron(21:00 健康報告、16:05 hard_case_mining、 22:05 prob_boom_calibration)。 ⚠️ 這一條先前記錯了,更正如下。 原本寫「三條全部不存在」, 但 2026-08-25 12:50 實際檢查時,21:00 的 daily_status_report 是存在的 (16:05 與 22:05 確實不存在)。無法確定是先前查錯還是之後才加上, 如實標明而不是默默改掉。 實測 daily_status_report.py 跑得起來,而且它自己就在標 「prob_boom 公式嚴重失準」——所以問題不是「沒有健康報告」, 是沒有人看它,而且它的輸出只進 log 不會通知任何人。 全 scripts/ grep smtp|sendmail|telegram|webhook|notify|alert → 零實際 發送程式碼。系統連 MTA 都沒有(sendmail/mail/postfix 全部 not found), 所以 cron 的失敗郵件也寄不出去。 | 任何失敗都要人主動去翻 log 才會發現 | ① ~~裝上 21:00 的健康報告 cron~~ ——它已經存在(見上方更正)。 真正缺的是通知路徑:它的輸出只附加進 daily_bot.log, 而系統連 MTA 都沒有,沒有人會看到。 ② 紅燈條件用「latest.json 的 date 是否等於今天」「tech_indicators.json 的 date 是否等於最近交易日」,而不是以 24 小時牆鐘判斷。 ③ ⚠️ theme_bridge.py:198-213 的 _load_latest() 在 age ≥24h 時靜默重產, 而重產用的是同一份可能已過期的 catalyst_snapshot——等於把舊資料 洗成新的 generated_at。每個週一讀到週五的檔案必然觸發一次。 |
| non-atomic-writes 已修並驗證 不可重建的資料用就地覆寫 | 全專案 grep os.replace / .tmp / fsync 是零命中 | 所有寫檔都是 read → 改 → to_csv(同一路徑)。而外層 cron 是
timeout 1800,到點送 SIGTERM——如果那一刻正好在寫入中途,
留下的就是截斷檔,而且沒有備份。
受害最重的是 data/ml_data/tw_stocks_2y_features.csv:11,225 列、
兩年的 ML 特徵。交易所的當日端點只給今天,漏一天永久補不回來。
| 8 處不可重建的資料處於「隨時可能被截斷且無法還原」的狀態 | 新增 scripts/atomic_io.py:寫 <name>.tmp → fsync → os.replace()。 POSIX 保證同檔案系統的 rename 是原子的。8 處全改,ML 特徵另加 .bak。 ⚠️ 原子寫入防不了「算錯了但完整地寫回去」——2026-08-19 那次 28 筆假結算就是後者:檔案完好,內容是錯的。所以另加 guard_shrink(), 要寫回的筆數少於現有 90% 就拋錯(上游抓不到資料時最常見的失敗形態)。 ⚠️ 跨檔案一致性它也保證不了。theme_bridge 連寫兩個檔案,中間掛掉會 「快照新、latest 舊」。改成固定先寫每日快照、後寫 latest—— 「latest 較新」永遠成立,反過來不成立。 |
| stock-name-contradictions 已修並驗證 寫死的清單決定了系統的上限 | 9 張手填的「代號→名稱」對照互相矛盾,10 個代號有爭議 | 每個模組各自維護一份手打的名稱表,沒有共同來源。而交易所自己 每天都在公布——這件事不該靠投票或記憶決定。 最明顯的是 2881 在 8 張表裡一致是「富邦金」,而 2882 在 6 張表裡 也是「富邦金」。兩個代號不可能都是富邦金,所以那 6 張一定錯。 | outcome_log 裡「英業達」同時掛在 2356 與 2357 上,而兩者進場價中位數 是 64 與 581(差 9 倍)。價格是照代號抓的所以績效沒錯, 但任何依名稱做的比對、去重、人工複核都會出錯(17 筆,佔已結算 24%)。 連帶問題:題材抽取會把個股名當題材(「聯發科概念股」→「聯發科」)。 | 新增 stock_names_official.py:交易所全市場 1,980 檔,7 天快取,原子寫入。 ① 仲裁:比對後修正 40 處指向不同公司的名稱,散落 10 個檔案。 分類是關鍵——88 筆裡 18 筆只是縮寫(「京元電 vs 京元電子」), 把它們跟「建準 vs 興勤」混報,整份清單會被當雜訊沒人看。 ② 排除清單:題材抽取用它結構性排掉個股名,不必手工加停用詞 (手工清單一定會漏,而且漏掉時沒有跡象)。 只比完全相等,不做子字串——「光學」是題材、「大立光」是個股。 ③ 每日檢查已排進 daily_bot 第 6 步,再有人手打錯名稱會立刻被抓到。 |
| discovery-settlement-duplicates 已修並驗證 沒有任何東西會跟它對帳 | 發現層首批結算即將把 3 個題材寫成 25 筆 | watchlist.jsonl 是每跑一次 append 一列,不是每天一列。上線日
2026-08-25 重跑多次,同一個 (進榜日, 題材) 最多重複 9 筆
(AI×9、記憶體×9、矽晶圓×7、金融×4;54 列實際只有 26 個進榜事件)。
theme_graph.dedup_last() 與 web/data.py::theme_discovery() 早就按
(日期, 題材) 去重了,但結算路徑 theme_discovery_track.py 沒有
——同一個去重規則在三個消費點各寫一次,漏掉第三個。
抓到的時機是首批 5 日結算落地前一天(outcomes 當時仍為空)。
再晚一天就會寫進 append-only、且交易所補不回來的檔案。
| 未實際發生(趕在寫入前修掉)。若發生:同一個發現在成效統計裡被算 9 次, 而發現層本來就只有幾十筆樣本,等於整層的超額報酬與勝率全部失真, 且無法事後分離——outcomes 沒有記「這筆來自哪一列 watchlist」。 | 已修(2026-09-01)。結算前先按 (date, term) 去重,同組取 trade_date
最新那筆(市場資料最新鮮),watchlist 本身不動(append-only)。
兩個數字都留著顯示(沿用 AGENTS.md 既有慣例):
「watchlist 54 列 → 26 個 (進榜日, 題材);28 列是同日重跑」。
只報去重後的,「一天跑了 9 次」這件事就從畫面上消失了。
回歸測試 test_discovery_pipeline.py::test_dedup_takes_latest_trade_date
與 ::test_dedup_keeps_both_numbers_visible 守著這兩件事。
|
| discovery-entry-day-unobtainable 已修並驗證 用一個在訊號存在時買不到的價格 | 發現層的進場價用了訊號發布前就已收盤的價格 | theme_discovery_track.py 取進場日時用 trade_date(市場資料日)而不是
date(進榜日)。2026-08-25 那批的 trade_date 是 08-24——交易所當日
端點還沒更新,發現引擎記下的是前一天的收盤。
照 trade_date 推算,進場日會落在 08-25 收盤;但發現引擎 15:40 才跑完,
台股 13:30 就收盤了。那個價格在訊號存在的時候已經買不到。
這是 entry-price-unobtainable 的第二次,換了一條管線再犯一次:
上次是 run_theme_hunter 用當日收盤,這次是發現層用前一日收盤。
模組開頭自己寫的判準(「進場價用進榜隔一個交易日的收盤」)是對的,
是程式沒跟上文件。
| 未實際發生(同樣趕在首批結算前修掉)。若發生:所有發現層績效建立在 買不到的價格上,而發現層的成效正是晉升門檻 P1~P3 的唯一依據—— 等於用假數字決定哪些題材進推薦。 | 已修(2026-09-01)。進場日一律以進榜日 date 為準,買在其後第一個
交易日的收盤,不足期就 pending。首批 5 日結算因此順延到 2026-09-02。
回歸測試 ::test_entry_day_uses_listing_date_not_market_date 守著它:
餵一筆 date 晚於 trade_date 的紀錄,斷言它不會被結算。
|
| satcom-half-promotion 已修並驗證 寫死的清單決定了系統的上限 | SCENARIOS 裡有一個情境,每天跑、每天被整個剔除,從未出現在推薦裡 | SATCOM 低軌衛星/太空通訊爆發(催化 9.0,過閥值)的 5 檔成分股
(3491/2313/6285/2367/3105)沒有一檔在 rebuild_tech_indicators.py
的 23 檔抓取名單裡。run_theme_hunter 逐檔以「缺技術指標」排除
→ 情境剩 0 檔(需 ≥2)→「整個情境不列入報告」。
晉升一個題材要動 6 個檔案(THEME_MAP、SCENARIOS、技術指標名單、
catalyst_key_map、RSS 查詢、分類樹),當初只做了 SCENARIOS 一步。
沒有任何錯誤訊息——它在程式碼裡看起來已經上線了。
它也從未通過任何驗證:催化 9.0 與先例文字都是手寫的。
| 零產出但每天佔用一次情境迴圈;更嚴重的是它讓「已上線」這件事看起來是真的 | 已移除(2026-09-01)。評估後選擇移除而非補完:補完等於讓一個零驗證的 題材,在晉升門檻剛定好的第一天就從側門插隊。移除的功能損失是零 (它的實際產出本來就是「被剔除」)。 回歸路徑走正門:發現層已抓得到低軌衛星(lift ×1.8~×3.4), 過 PROMOTION_CRITERIA.md 門檻後照 PROMOTION_CHECKLIST.md 完整晉升。 那份 checklist 就是為了不再出現半套晉升而寫的,SATCOM 是它的頭號案例。 |
與 回測結果 的改善提案登記簿是同一套紀律,對象不同:那邊管台指期的研究提案(要 prereg、要算驗證折),這邊管題材獵人的缺陷(要根因、要修法)。兩邊共通的規矩是不准只寫現象。
版本
5f92ccf(第 163 次提交,2026-10-14) | 資料建置於 2026-10-14 10:30 UTC | 行情與研究結果供查看;會員可管理觀察清單並要求更新個股分析