Lin.Log|Studio
// 01 · obsidian-backtest-monthly-report$ cat obsidian-backtest-monthly-report.md

把回測歷史寫成 Obsidian 月報

2026.05.12技術筆記8 分鐘閱讀
把回測歷史寫成 Obsidian 月報

技術主題: sim() 回測結果輸出成 Obsidian 相容 Markdown 月報
實作時間: 2026/05
工時估計: 約 1 天
紀錄者: _YLin_Lai
參考資料:
- 原 repo: https://github.com/hu0937/FinPilot
- FinLab sim(): https://doc.finlab.tw/
- Obsidian Callouts: https://help.obsidian.md/Editing+and+formatting/Callouts


run_all_backtests.py 跑完,terminal 輸出 CAGR / Sharpe / MDD 三個數字,然後就結束了。每次看到這個我都有點不舒服——不是數字不好,而是我知道底下還藏著大量資訊沒被用到。FinLab 的 sim() 回傳的 reportposition DataFrame 裡有每一天每一檔股票的倉位狀態,但原本的程式只從裡面取出三個彙總指標就把它丟掉了。

這篇記的是我怎麼把這些被丟掉的資訊拿回來、存進 SQLite、再整理成 Obsidian 可以開的 Markdown 月報,以及途中踩到的一個型別 cast bug。

1.主題

FinPilot 既有的 run_all_backtests.py 跑完只算 CAGR / Sharpe / MDD 三個數字。我想要更多:每個策略歷年的 Top N 贏家、命中股票清單、和 0050 大盤的 alpha 比較——而且整理成 Obsidian 可開的 Markdown 月報,可以在筆記庫裡標籤、跳轉、查找。

目標是三件事同時達到:

  • 資料面:把三維的「策略 × 個股 × 時間」資訊從 position DataFrame 解析出來存進 DB
  • 分析面:和 0050 buy & hold 對齊算出年化 alpha,讓每個策略都有一個可比的基準線
  • 呈現面:輸出一份在 Obsidian 開起來舒服的 Markdown 月報,而不是 dump 成 CSV 讓它在目錄裡積灰

整個作業在一個工作天內完成,包含 debug 時間。

2.背景(需求簡介)

既有的 backtest 輸出是「策略 × 績效指標」的二維表,格式大概是這樣:

strategy_id | cagr   | sharpe | mdd
s01         | +22.1% | 1.42   | -31.2%
s05         | +18.4% | 1.11   | -28.5%

我真正想要的是「策略 × 個股 × 時間」的三維資料。對每個策略,我想知道:

  • 歷年哪些股票讓我賺最多(Top winners)
  • 當前命中——最近一期再平衡選了哪些股票
  • 跟 0050 buy & hold 比,是真有 alpha 還是運氣

後面兩點是日常操作決策的直接輸入。「當前命中」告訴我今天如果跑這個策略,我現在應該持有哪些部位;「vs 0050 alpha」告訴我這個策略值不值得用真錢驗證,還是大盤隨便買就已經贏它了。

為什麼要進 Obsidian?我的研究筆記都在 Obsidian,個股觀察、財報摘要、法說重點全在裡面。如果 backtest 月報也在 Obsidian,我可以用 [[]] wiki link 把策略報告和個股觀察串起來。例如 s11 命中 2330,我直接點 [[2330 台積電]] 就能跳到我的個股筆記頁。這種串連在 CSV 或 terminal 輸出裡是做不到的。

3.架構

我沒有重寫任何策略。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 就好。

4.Obsidian 友善 Markdown

不是隨便 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 可以點。

5.NaN bug:3 個策略全失敗

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)

NaNFalse,再 cast 成 int 就是 0,語意正確——lookback 期不持有。

修完之後我加了一個 regression test,檢查當 position 的前幾列包含 NaN 時 build_trade_log() 要能正常執行、不能 raise,然後重跑 pipeline。

3 個策略從 FAILED 變 OK:

  • s11(殖利率>6%+營業利益):CAGR +15.61%,495 trades,當前命中 10 檔
  • s13(營業利益成長率>10%):CAGR +18.7%,1,154 trades,當前命中 18 檔
  • s14(月營收加速>1.05):CAGR +14.2%,1,026 trades,當前命中 12 檔

三個都是高成長 + 基本面篩選的策略,差點被一個型別 cast 埋掉。fillna(False) 一行的差距是 3 個策略能不能用。

寫 unit test 的時候多想一個 edge case——lookback 期的 NaN——就能省下 backtest 重跑一次的 12 分鐘。

6.延伸:PEG 數值加進命中表

報告寫完,我發現「策略命中清單」只有 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 檔」,研究的時間成本差很多,這個過濾邏輯幾乎是免費的。

7.心得

重用勝於重寫。這個 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 開源出來,讓我可以站在這個地基上加東西。

相關紀錄

linlog@studio $ exit

有東西想一起做出來?

[聯繫我]
email dannylaii@linlogstudio.com
hours Mon – Fri · After 7PM
base Taiwan · 遠端優先
Connection to lin.log closed. © 2026 Lin.Log|Studio