
FinPilot 跑起來之後,第一件讓我不滿意的事不是 Bot 設定,而是資料。
原本的 data_fetcher.py 有一段邏輯非常明確:針對觀察清單裡的股票,用 FinMind 的 taiwan_stock_daily() 抓最近 60 天的收盤資料,寫進 quant.db。Bot 查詢夠用,daily increment 也夠用,但我一打開 backtest_engine.py 的設計文件就知道問題在哪——FinLab 的 sim() 要跑 10 年的因子回測,底層要有真實的歷史 OHLCV 對應,60 天根本餵不飽。
策略在 60 天的滾動視窗裡能看到什麼?充其量是最近的動能,連一個完整的景氣循環都沒有。我想驗證的是「月 ROE + 價量背離 + 外資增持」這類複合因子在 10 年跨越多個牛熊循環的表現,沒有歷史資料就是紙上談兵。
所以這個 sub-project 的目標很清楚:建一個獨立的 history.db,裝 10 年的 OHLCV、三大法人、大盤指數、月營收,讓 sim() 有東西可以跑。spec 我寫完覺得很順——後面打了兩次臉。
這篇要記的是建 10 年台股歷史 DB 的設計過程,以及中間兩次資料源 pivot 的始末。目標資料集:
最終落地是一個 9.6 GB 的 SQLite history.db,和原本的 quant.db 完全分離,不動原版程式的任何既有邏輯。這篇就從 schema 設計開始說,到兩次 pivot 結束。
原 data_fetcher.py 用「按股票」模式抓 FinMind:
df = dl.taiwan_stock_daily(stock_id=sym, start_date=start, end_date=today)
對觀察清單的 60 天資料這很合理,但如果我要對全市場 2,600 檔抓 10 年,算一下:2,600 檔 × 5 個 dataset = 13,000 calls。FinMind 免費 register tier 配額是每天 3,000 calls,backfill 要跑 5 天,而且 daily increment 也不可行——每天光維持資料就要消耗掉接近當天所有配額,剩下的 Bot 查詢和回測根本沒得用。
我重新設計方向:用 FinMind 的「按日期」模式——同一個 endpoint 不傳 stock_id,回傳全市場:
df = dl.taiwan_stock_daily(start_date=d, end_date=d)
這樣算:2,500 個交易日 × 5 個 dataset = 12,500 calls backfill,跑完之後 daily increment 只需要 5 calls/day,完全在配額內。這個設計很乾淨,backfill 一次跑完、之後每天維護成本幾乎是零。spec 寫完,計算了一下預估跑完時間約 12 小時,覺得可以接受,準備開跑。
backfill 跑起來,第一個 dataset(price)、第一個 call 就直接 raise:
Exception: FinMind API unexpected response:
Your level is register. Please update your user level.
Detail: https://finmindtrade.com/analysis/#/Sponsor/sponsor
這個錯誤的意思很明確。我的 token 是免費 register tier,實測之後確認:register 不支援「按日期不帶 stock_id」的 bulk endpoint。但是 per-stock 的 dl.taiwan_stock_daily(stock_id=X, start, end) 它支援——因為原 FinPilot 一直用這個就能跑。
我的 spec 整個崩了。整個「按日期批次」設計的前提假設是 bulk endpoint 免費可用,這個假設直接不成立。
當下我有三條路:
1. 升級 FinMind Sponsor,月費約 199 NTD,bulk endpoint 開放2. 回去用 per-stock 模式,接受 backfill 要跑 5 天、daily increment 配額吃緊3. 繞開 FinMind,找其他官方資料源
我選了路線 3。繞開的理由很簡單:FinMind 是第三方封裝,台灣有原始官方資料,TWSE 和 TPEx 都有自己的 open data endpoint,我從來沒認真查過有哪些可以直接用。
爬 TWSE OpenAPI catalog(openapi.twse.com.tw)花了大約 20 分鐘,找到兩個東西:
https://openapi.twse.com.tw/v1/opendata/t187ap05_L:有全市場資料,但只給最新月,沒有歷史分頁,不可用https://www.twse.com.tw/exchangeReport/MI_INDEX?date=YYYYMMDD&type=ALL:bingo實測 MI_INDEX:HTTP 200、response 大約 4.5 MB、JSON 格式、回傳當日全市場所有股票的 OHLCV——而且同一個 response 裡面有大盤加權指數和加權報酬指數 TAIEX_TR,整個塞在同一個 call 裡面。TPEx 也有對應 endpoint:tpex.org.tw 下的 stk_quote_result.php,結構類似。三大法人:TWSE 有 twse.com.tw/fund/T86,TPEx 有 tpex.org.tw 下的 3itrade_hedge_result.php。
四個 endpoint,全部免費、不需要 auth、JSON 直出。速率限制方面,我對 TWSE 和 TPEx 採保守策略:每兩個 request 之間 sleep 0.5 秒,同一個 IP 不要超過每分鐘 15 requests,避免被擋。實測跑了三天的樣本,完全沒有遇到 rate limit 或 IP block。
spec 重寫,改成對這四個 endpoint 按日期逐一 GET,2,500 交易日 × 4 個 endpoint = 10,000 calls,加上 TPEx 的部分大約 10,800 calls,按每分鐘 15 requests 的保守速率,全部跑完約 11.5 小時。daily increment 4 calls/day,完全不受配額限制。
這裡有個意外的 bonus 值得記:原本 spec 裡 TAIEX_TR 加權報酬指數是獨立一個 dataset,FinMind 有這個 endpoint 但 register tier 配額很緊,我本來想說先跳過。改用 MI_INDEX 之後,加權報酬指數就在同一個 call 的 response 裡,完全不需要額外處理。有時候 pivot 反而比原計畫更好。
OHLCV、三大法人、大盤指數這三塊跑完,剩月營收沒抓。
月營收的資料在公開資訊觀測站(MOPS,mops.twse.com.tw),spec 規劃用 Playwright 無頭瀏覽器抓 t21sc03 的 HTML 表格。這個選擇在當時有邏輯:MOPS 幾乎所有頁面都是 JS render,curl 沒辦法直接拿到資料,Playwright 是標準解法。
啟動 Playwright、開 chromium、goto('mops.twse.com.tw/mops/web/t21sc03')——回傳 404。
新版 MOPS 改 UI 了,舊 URL 已經下線。找到新 URL:mops.twse.com.tw/mops/#/web/t05st10_ifrs,開起來是有的,但表單有一個欄位是「公司代號」,設計上強制單筆查詢,沒有辦法選「全市場」。
算一下:3,000 檔 × 120 個月 = 360,000 次 page load,每次 Playwright 約 8 秒,整個跑完要 800 小時。這條路完全不可行。
我在那個當下幾乎要回去建議升級 FinMind,讓 bulk endpoint 直接把月營收搞定。但還有一個東西沒試:MOPS 有沒有「市場別彙總」的報表頁,讓我不用打公司代號?
掃了一遍 MOPS 主選單的 link list,找到 t21sc04_ifrs。表單進去只有「市場別」、「年度」、「月份」三個欄位,沒有公司代號——可以選「全市場、2015年、1月」這樣的查詢。
提交表單之後發現問題:結果不在同一頁顯示,而是 popup 開一個新視窗。Playwright 攔截到 page.expect_popup(),進去看,popup 是一個 dispatcher 頁,說「結果在另一個視窗」,不是真的資料頁。
繼續往下追。從 dispatcher 頁的 HTML 裡找到:
<form ... onsubmit="window.open('/nas/t21/sii/t21sc03_113_1_0.html')"
這個 path 格式很規律:/nas/t21/{market}/t21sc03_{roc_year}_{month}_0.html。把這個路徑的 domain 換成另一個 MOPS 子域:
https://mopsov.twse.com.tw/nas/t21/{market}/t21sc03_{roc_year}_{month}.csv
直接 curl 測試:HTTP 200、UTF-8 with BOM 的 CSV、台積電 113 年 1 月月營收 215,785,127 千元,完整欄位都在。
底層資料根本是靜態 CSV,Playwright 抓的是它的 HTML 包裝。把整個 Playwright 路線拆掉,改成 httpx 直接 GET CSV,market 有兩個值(sii 上市、otc 上櫃),roc_year 從 104 到 114,month 1 到 12,全部組合跑一遍:15 分鐘跑完 10 年 / 124 個月 / 約 22 萬筆月營收記錄,全部寫進 history.db。
Playwright 和 chromium 從 requirements.txt 整個移除,省了 100 MB 的依賴。
這個 sub-project 做下來有幾個感想我覺得比技術細節本身更值得記。
spec 是假設,碰壁是現實。
我這個 session 寫了不少 spec,每次寫完都有點自信過頭。FinMind bulk endpoint 可以免費用——spec 假設的,結果不行。Playwright 是抓 MOPS 的唯一方式——spec 假設的,結果底層是靜態 CSV。但正因為假設有被寫成文字,錯了才容易知道哪裡錯、從哪裡開始修。如果沒有寫 spec,第一個錯誤出現的時候你連「我當初的設計假設是什麼」都說不清楚,就只是一團模糊的挫敗感。
不要先進 Playwright。
這句話我本來不會說,因為 Playwright 在很多場景是正確選擇,特別是真的需要 JS render 的頁面。但這次 MOPS 的案例告訴我,Playwright 應該是最後手段,不是第一手段。Playwright 啟動慢、selector 脆弱(網站改個 class 就壞掉)、JS render 的時序不確定(該等哪個 event 才算載入完成),長期維護成本很高。正確的順序是:先試 curl、試直接 GET JSON、試靜態 CSV 的規律——能直接拿到資料就不要開 headless browser。這次從 Playwright 改回 httpx + CSV,省了完整一天的 selector debug 和 chromium 環境設定問題,而且最終的 fetcher 程式碼從兩百多行降到八十行。
官方資料源永遠比第三方封裝好。
FinMind 是方便的封裝,它把各個源整合成一個 unified API,適合快速打樣或者觀察清單裡少量股票的日常維護。但當你認真要做 production 級的資料 pipeline,官方源幾乎在每個維度都更好:沒有配額限制、沒有訂閱費、沒有第三方的 API 設計決策綁著你,速度也不會比 FinMind 慢。TWSE 的 MI_INDEX 一個 call 給你全市場 OHLCV + 大盤指數,比我用 FinMind 逐股抓還快。TPEx 的對應 endpoint 也有同等的結構,幾乎是對稱設計。這不是 FinMind 不好,是我一開始就沒有認真查官方有什麼可以直接用,就習慣性地抓第三方封裝。下一次碰到類似問題,第一步應該先問「官方有沒有 open data endpoint」,不是直接打開 FinMind 的文件。
這篇記的是兩次 pivot:資料源的假設被打臉(FinMind register tier 不支援 bulk),技術路線的假設被打臉(MOPS 底層是靜態 CSV 不需要 Playwright)。最後兩個問題都被 curl + 官方資料源救起來,而且最終的設計比原本的 spec 更乾淨:沒有月費、沒有 headless browser 依賴、daily increment 一天只需要幾個 HTTP call。
下一篇 #3 要講把這個 9.6 GB 的 history.db 接到 sim() 回測、輸出 Obsidian 月報的過程,以及一個讓 3 個策略全部失敗的 NaN bug,那個 bug 找起來比建這個 DB 還費工。
本系列文章源自我部署的 FinPilot 專案。原專案由 hu0937 開源並維護,是一個整合 Telegram Bot + Claude AI + APScheduler 的台股量化分析平台。本次的歷程是「部署 + 擴充」,所有原始的 Bot 指令、策略 daemon、sim() 回測架構都來自原作者的設計。
原專案:https://github.com/hu0937/FinPilot
感謝原作者把這麼完整的 base 開源出來,讓我可以站在這個地基上加東西。