拆解台股盤後報告:讓 AI 當分析師,但不讓它說了算

· · Vibe Coding / AI Agent / 架構設計 / 投資工具

台股盤後自動分析報告在作品集裡介紹的是「它能做什麼」:強弱族群、RRG 資金輪動、訊號判讀。這篇要講的是另一面——它是怎麼被做出來的,以及裡面有哪些我覺得值得借鏡的設計。

先講結論:這個專案最厲害的地方不是用了 AI,而是很清楚哪些事該交給 AI、哪些事絕對不能交給 AI。整個 repo 有 40 多支腳本、162 個測試,但每日主流程裡真正需要 AI 的角色只有三個,其餘全部是確定性的程式。

一、三層分工:大模型切題、小模型平行研究、大模型收斂

每天收盤後要處理漲跌幅前後各 100 檔股票。如果把 200 檔丟給一個 AI 叫它「寫一份報告」,結果會又慢又貴又不穩。這個專案把工作拆成三種角色:

  1. Controller(大模型,1 次):把 200 檔股票歸成 20~30 個族群,每個族群寫成一個獨立的 task 檔
  2. Worker(小模型,平行 N 個):一個族群一個子行程,各自上網查近 3 天新聞,寫出族群故事
  3. Finalizer(大模型,1 次):讀取所有結果,寫總結、長線策略與歷史記憶

INTERACTIVE · 模擬示意

三層分工:切題 → 平行研究 → 收斂

1Controller大模型 × 1
2Workers小模型 × 10
3Finalizer大模型 × 1
    研究階段的瓶頸是等 API,不是本機 CPU,所以併發數拉高,總時間幾乎等比例縮短。 拉到 1 再跑一次比較看看。

    分工的重點在於每一層拿到的任務都很窄。Worker 的 prompt 明確規定:

    「給搜尋型 agent 明確的搜尋預算與停止條件」這一條,後來還被寫進了專案的 AGENTS.md 當通用原則。沒有停止條件的 agent 會一直搜下去,時間跟費用都會失控。

    研究階段的併發數預設是 10:族群研究的瓶頸在等 API,幾乎不吃本機 CPU,20~30 個族群大約兩輪就跑完。

    二、能用程式做的,就不要叫 AI 做

    這是整個專案最值得學的一點。

    Finalizer 一開始實測要跑 688 秒,佔全流程 34%。拆開一看,大部分時間都花在「讀 150 個檔案、依規則搬字串、算分數加總」——這些工作根本不需要模型,而每讀一個檔都是一次 API 來回。

    於是多了一支 build-analysis-skeleton.ts,把可以機械化的部分全部先做好:

    Finalizer 現在只要讀 1 個檔,只做真正需要判斷的事:趨勢評分、進場理由、總結、改寫被標記的故事。沒被標記的故事,prompt 直接要求「原樣照抄,不要潤飾」——這句話本身就省下大量時間。

    改完那次的 commit 紀錄是:finalizer 從 957 秒降到 284 秒(每天的族群數不同,耗時本來就會浮動,所以跟上面的 688 秒不是同一次量測)。

    同樣的思路也出現在族群分類上。AI 分類常犯固定的錯,例如把華通(2313)放進一般 PCB,而不是低軌衛星;或把記憶體模組和矽晶圓混成一組。與其每天期待 AI 自己想通,不如在 controller 之後跑一支 refine-group-tasks.ts,用確定性的規則把已知的誤分拆回來。族群名稱也有一份 taxonomy.json(目前 99 個標準名稱與別名),確保「CPO 矽光子」和「CPO 矽光子(分化)」在歷史比對時會被算成同一個族群。

    三、AI 的幻覺,用程式擋

    check-story-facts.ts 是我最喜歡的一支檔案,因為它的註解記錄了一次真實事故。

    台灣上市櫃公司的月營收依規定在次月 10 日前公布,所以 8/28 的盤後報告裡不可能出現「8 月營收」。但某天的報告寫了:

    京元電作為測試代工龍頭,8 月營收衝破 31 億元,年增 31.9%,連七月 39.91 億元年增 36.75% 的佳績後再創新高。

    七月那半句是真的,八月整句是編的,而且自相矛盾(31 億比 39.91 億少,怎麼會是「再創新高」)。

    註解裡說得很直白:prompt 已經寫了「不要硬湊新聞」,模型還是會在找不到資料時填一個看起來合理的數字。這種錯讀起來完全正常——有公司、有數字、有年增率——人工很難每天逐段抓,只有機械規則擋得住。

    // N 月營收最晚在 (N+1) 月 10 日公布;跨年也要對
    export function revenueMonthPublished(month: number, asOf: Date): boolean {
      const y = asOf.getFullYear();
      const m = asOf.getMonth() + 1;
      const year = month <= m ? y : y - 1;
      const deadline = new Date(year + (month === 12 ? 1 : 0), month === 12 ? 0 : month, 10);
      return deadline <= asOf;
    }

    處理方式也很講究:整句刪除,不改寫。改寫等於讓 AI 再編一次;少一句話的故事,遠好過一個假的營收數字。

    下面這個小工具用的是同一套規則。把報告日期從 8/28 往後拖,看八月、九月的營收句子分別在哪一天變成「可以寫」:

    INTERACTIVE · 同一套規則

    拖動報告日期,看哪些句子會被刪掉

      第三句的「8月28日」是日期而不是營收,所以不會被誤刪。規則刻意收窄成「提到營收,而且未公布月份後面緊接金額或增減幅」。

      四、每天自動跑的流程,要能自己爬起來

      這份報告是排程在平日晚上自動跑的,沒人盯著。所以流程韌性被設計得很仔細:

      連 shell 腳本裡都有踩過坑的痕跡:變數後面接全形括號時,bash 會把 $rc) 連同中文位元組一起當成變數名,所以一律寫成 ${rc}。這種註解對下一個接手的人(或 AI)非常有價值。

      五、效能優化:先量測,不要憑感覺

      主流程的每個步驟都包在一個 timed 函式裡,跑完會印出一張「最慢的在最上面」的耗時表,並存成 JSON 供跨日比較。腳本註解寫得很清楚:要優化先看這張表,不要憑感覺猜。

      看了表之後發現幾件事:

      六、懂金融資料的「假訊號」

      寫投資工具最怕的不是程式錯,而是資料看起來對、其實是錯的。這個專案在好幾個地方都預先想到了:

      每一條都寫在該檔案開頭的註解裡,連「為什麼不用另一種做法」都交代了。

      七、讓系統替自己打分數

      AI 每天說某個族群「正在擴散」,那它說得準嗎?

      score-report.ts 每天把前一天的分析結果跟之後的實際股價對照,算出每個族群、每種階段判斷在 T+1、T+5 的平均報酬與勝率,存成 scorecard.json。Finalizer 會讀這份記分板當作自我校準的參考——例如過去被標成「擴散」的族群,五天後平均表現如何。

      另一支 build-stock-picks.ts 則走相反路線:純規則、零 AI。它整合族群輪動、法人籌碼、集保大戶、公司派作價等訊號,而且規定進榜必須至少兩個獨立資料源同時給正訊號——只有法人在買、但大戶在倒或族群在弱化的,一律擋在門外。每天的結果都存成歷史快照,權重之後可以回測調整。

      一個用 AI 寫敘事、一個用規則選股,兩者互相對照。

      八、寫給 AI 看的專案文件

      這個專案本身也是用 Claude Code、Codex 這類 AI 工具開發與維護的,所以文件有一大部分是寫給 AI 看的:

      再加上前面提到的,幾乎每支腳本開頭都有一段「為什麼這樣做、踩過什麼坑」的註解。這讓 AI 在修改時比較不會把一個刻意的設計當成 bug 修掉。

      小結:可以帶走的幾個原則

      如果你也在用 AI 做自動化工具,這個專案有幾條原則很值得借用:

      1. AI 負責判斷與敘事,程式負責其他所有事。配對、計分、排序、格式轉換都不該交給模型
      2. 把任務切窄再平行化,並給每個 agent 明確的預算與停止條件
      3. 幻覺用規則擋,而且擋下來就刪掉,不要叫 AI 再改一次
      4. 無人值守的流程要能斷點續跑,並以實際產出判斷成敗
      5. 先量測再優化,把慢但不相依的步驟移出關鍵路徑
      6. 讓系統記錄自己的準確度,才知道 AI 的判斷到底值不值得信
      7. 註解寫「為什麼」,給下一個接手的人,也給下一個接手的 AI

      報告的網頁版可以在這裡看到,原始碼在 GitHub。對這類 AI 自動化流程有興趣,歡迎透過聯絡頁找我聊。