
run_all_backtests.py 跑完,terminal 輸出 CAGR / Sharpe / MDD 三個數字,然後就結束了。每次看到這個我都有點不舒服——不是數字不好,而是我知道底下還藏著大量資訊沒被用到。FinLab 的 sim() 回傳的 report 和 position DataFrame 裡有每一天每一檔股票的倉位狀態,但原本的程式只從裡面取出三個彙總指標就把它丟掉了。
這篇記的是我怎麼把這些被丟掉的資訊拿回來、存進 SQLite、再整理成 Obsidian 可以開的 Markdown 月報,以及途中踩到的一個型別 cast bug。
FinPilot 既有的 run_all_backtests.py 跑完只算 CAGR / Sharpe / MDD 三個數字。我想要更多:每個策略歷年的 Top N 贏家、命中股票清單、和 0050 大盤的 alpha 比較——而且整理成 Obsidian 可開的 Markdown 月報,可以在筆記庫裡標籤、跳轉、查找。
目標是三件事同時達到:
position DataFrame 解析出來存進 DB整個作業在一個工作天內完成,包含 debug 時間。
既有的 backtest 輸出是「策略 × 績效指標」的二維表,格式大概是這樣:
strategy_id | cagr | sharpe | mdd
s01 | +22.1% | 1.42 | -31.2%
s05 | +18.4% | 1.11 | -28.5%
我真正想要的是「策略 × 個股 × 時間」的三維資料。對每個策略,我想知道:
後面兩點是日常操作決策的直接輸入。「當前命中」告訴我今天如果跑這個策略,我現在應該持有哪些部位;「vs 0050 alpha」告訴我這個策略值不值得用真錢驗證,還是大盤隨便買就已經贏它了。
為什麼要進 Obsidian?我的研究筆記都在 Obsidian,個股觀察、財報摘要、法說重點全在裡面。如果 backtest 月報也在 Obsidian,我可以用 [[]] wiki link 把策略報告和個股觀察串起來。例如 s11 命中 2330,我直接點 [[2330 台積電]] 就能跳到我的個股筆記頁。這種串連在 CSV 或 terminal 輸出裡是做不到的。
我沒有重寫任何策略。FinPilot 的 16 個策略檔(agents/strategies/s*.py)原本就用 FinLab API,直接重用——我只是接在 sim() 後面解析 report.position。
新增 3 張表進 history.db:
strategy_trades
-- strategy_id TEXT, symbol TEXT
-- entry_date DATE, exit_date DATE, return_pct REAL
strategy_summary
-- strategy_id TEXT, annualized_return REAL
-- sharpe REAL, mdd REAL, win_rate REAL
-- vs_0050_alpha_annual REAL
strategy_current_hits
-- strategy_id TEXT, snapshot_date DATE
-- symbol TEXT, name TEXT
Pipeline 流程走七步:
1. 對每個策略檔 exec() 跑出 report + position DataFrame2. 從 position 解析 entry/exit transition,寫 strategy_trades3. position.iloc[-1] 取當前命中,寫 strategy_current_hits4. FinLab price:還原收盤價 抓 0050 buy & hold NAV 曲線5. 對齊兩條 NAV 的時間軸,算 strat_cagr - bench_cagr = alpha_annual6. 寫 strategy_summary7. 從三張表 JOIN 後整理成 Markdown 月報
第 2 步的 entry/exit 解析邏輯:對 position DataFrame 的每一欄(每一個 symbol),找出從 0 變 1 的時間點為 entry、從 1 變 0 的時間點為 exit,計算這段區間內股票的實際報酬率,寫一筆 trade 紀錄。這樣每一筆交易都有完整的 entry / exit / return,後面要算 Top winners 直接 ORDER BY return_pct DESC LIMIT 10 就好。
不是隨便 dump 文字。要在 Obsidian 開起來舒服,幾個關鍵設計:
---
title: FinPilot Backtest Report
date: 2026-05-12
strategies_total: 19
strategies_passed: 16
benchmark: 0050
tags: [backtest, finpilot, monthly]
---
# Backtest Report 2026-05-12
> [!info] 本次跑了 19 個策略,16 個成功,3 個略過
## 策略排行(按 vs 0050 alpha)
| 策略 | 年化 | vs 0050 | 勝率 | MDD |
|----------|---------|--------------|--------|---------|
| [[#s111]] | +49.4% | **+25.5%** | 27.2% | -28.8% |
> [!success] 表現最好:[[#s111]] — 年化超越 0050 +25.5%
## 各策略詳細
### s111 - 均量>100張+營業利益成長率>0%
> [!success] 優於 0050
#### 歷史前 10 名贏家
1. **2330** +312% (8 次交易)
#### 當前命中(2026-05-11)
2451 創見, 2031 新光鋼, ...
有四個關鍵元素:
YAML frontmatter:Obsidian 認得,會放到右側 Properties panel,tags 欄位讓 Tag panel 可以過濾這份報告,date 欄位讓 Dataview plugin 可以按日期 query。
Callout 三色:> [!info] 是中性說明、> [!success] 是策略跑贏 0050、> [!warning] 是跑輸或 MDD 過大。Obsidian 渲染出來會有顏色對應,讓人掃一眼就知道狀況,不用逐行讀數字。
[[#s111]] 內部跳轉:[[# 加上 section heading 是 Obsidian 的 in-document anchor link。點下去直接跳到該策略的詳細 section,在一份很長的報告裡這個很實用。
#backtest #finpilot 標籤:Obsidian Tag panel 的索引邏輯,把這份月報和其他有同樣標籤的筆記串在一起。
這份報告開在 Obsidian 和普通 Markdown viewer 都能看,但 Obsidian 體驗最佳——callout 有顏色、frontmatter 有 panel、wiki link 可以點。
Pipeline 第一次跑完,我打開報告:13/16 成功。失敗的是 s11、s13、s14,錯誤訊息:
FAILED: cannot convert float NaN to integer
traceback 指向 build_trade_log() 裡的這段:
for symbol in position.columns:
held = position[symbol].astype(int) # <-- 在這裡 raise
我查了一下根因:FinLab 部分策略在 lookback 期之前會回傳 NaN。例如 60 日動能策略前 60 天的 position 是 NaN——因為動能指標需要 60 天的歷史才算得出來,在那之前策略根本不知道要不要持有這檔股票,所以回傳 NaN。這是合理的行為,但我的 .astype(int) 直接 cast 就噴錯了。
修法一行:
held = position[symbol].fillna(False).astype(int)
NaN 填 False,再 cast 成 int 就是 0,語意正確——lookback 期不持有。
修完之後我加了一個 regression test,檢查當 position 的前幾列包含 NaN 時 build_trade_log() 要能正常執行、不能 raise,然後重跑 pipeline。
3 個策略從 FAILED 變 OK:
三個都是高成長 + 基本面篩選的策略,差點被一個型別 cast 埋掉。fillna(False) 一行的差距是 3 個策略能不能用。
寫 unit test 的時候多想一個 edge case——lookback 期的 NaN——就能省下 backtest 重跑一次的 12 分鐘。
報告寫完,我發現「策略命中清單」只有 symbol 和 name,看不出哪些是真便宜、哪些是基期低的假象。同一個月 s11 命中 2330 和 2031,光看名字沒辦法判斷哪個更值得認真研究。
strategy_monitor 已經有算 PEG 的邏輯,用 5~100 PE / 5~200% op_growth 過濾,我直接把結果 JOIN 進命中表:
| symbol | name | 策略數 | PEG | 涵蓋策略 |
|--------|--------|--------|------|--------------------|
| 2031 | 新光鋼 | 3 | 0.10 | s102, s118, s13 |
| 2414 | 精技 | 3 | 0.26 | s118, s14, s62 |
| 8210 | 勤誠 | 3 | 0.46 | s01, s05, s10 |
PEG 欄位顯示 — 代表那檔股票的 op_growth 超出篩選範圍(基期低、數字爆表),不代表好或壞,只是 PEG 在這個情況下沒有意義。看到有數值的就是「PE 合理 + 成長健康」雙條件達標,和多個策略都有重疊——這種股票才值得認真看。
從「策略命中 15 檔」縮成「PE + 成長雙達標的 3 檔」,研究的時間成本差很多,這個過濾邏輯幾乎是免費的。
重用勝於重寫。這個 pipeline 完全沒有重寫任何策略邏輯,只是把輸出解析拿出來用。如果我從頭自己實作動能策略或價值策略,第二天就會發現邊際 case 是地獄——lookback 期的 NaN 只是其中一個,還有除權息調整、停牌日補值、新上市個股的冷啟動問題。站在 FinLab sim() 上面,這些細節都不用我自己處理。
報告格式比資料本身更重要。同樣的 16 個策略 stats,dump 成 CSV 沒人想看;變成 Obsidian 月報(YAML frontmatter + callout 三色 + wiki link 跳轉),我自己會在筆記庫裡點開來研究、和個股觀察筆記串在一起。資料相同,但可用性差了一個量級。
小 bug 殺大功能。fillna(False) 一行,差距是 3 個策略能不能用、1,000+ 筆 trade 紀錄存不存在、報告裡有沒有 s11 / s13 / s14 的 section。寫 regression test 雖然要多花 10 分鐘,但那 10 分鐘省下了重跑 backtest 的 12 分鐘,也防止之後有人動到 build_trade_log() 又踩回去。
這篇講把 sim() 結果拼成 Obsidian 月報、順便修一個型別 cast bug。下一篇 #4 講每天 21:00 推 Telegram 的「持倉日報」——對每個持股組 Yahoo 新聞 + 月營收 + 法人流向 + Claude AI 評語。
本系列文章源自我部署的 FinPilot 專案。原專案由 hu0937 開源並維護,是一個整合 Telegram Bot + Claude AI + APScheduler 的台股量化分析平台。本次的歷程是「部署 + 擴充」,所有原始的 Bot 指令、策略 daemon、sim() 回測架構都來自原作者的設計。
原專案:https://github.com/hu0937/FinPilot
感謝原作者把這麼完整的 base 開源出來,讓我可以站在這個地基上加東西。