Tick 矩陣回測原理說明
核心問題
Tick 資料是時間序列,每一筆都依賴前一筆的「狀態」, 傳統上必須逐行迭代,看似無法向量化。
但 short_v1_backtest_fast.py 卻做到了。為什麼?
傳統 iterrows vs 向量化 — 全貌對比
┌─────────────────────────────────────────────────────────────────────────┐
│ 傳統 iterrows 狀態機 │
│ │
│ tick #1 ──► [Python if/else] ──► state="waiting" ──► 繼續 │
│ tick #2 ──► [Python if/else] ──► state="waiting" ──► 繼續 │
│ tick #3 ──► [Python if/else] ──► state="waiting" ──► 繼續 │
│ ↑ │
│ 每一筆都要進 Python interpreter,無法跳過,5000 筆 = 5000 次 overhead │
│ │
│ tick #N ──► [Python if/else] ──► state="in_pos" ──► entry! │
│ tick #N+1 ─► [Python if/else] ──► check exit ──► 繼續 │
│ tick #N+K ─► [Python if/else] ──► exit! ──► break │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ polars 向量化(本程式做法) │
│ │
│ 全部 5000 筆 tick │
│ ┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐ │
│ │t1│t2│t3│t4│t5│t6│t7│t8│t9│..│..│..│..│..│..│..│..│..│..│tN│ │
│ └──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘ │
│ │ │
│ ▼ 一次 C 層級批次運算(cum_sum / shift / filter) │
│ ┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐ │
│ │✗ │✗ │✗ │✗ │✓ │✓ │✗ │✗ │✗ │..│..│..│..│..│..│..│..│..│..│✗ │ │
│ └──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘ │
│ ↑ │
│ 取 row(0) = 進場點,整批只需 ~0.05ms │
└─────────────────────────────────────────────────────────────────────────┘
時間軸拆解:一天的回測流程
09:00 09:30 11:30 13:03
│ │ │ │
▼ ▼ ▼ ▼
────┬────────────────────┬──────────────────┬─────────────────┬──────
│ │ │ │
│ 觀察期 │ 進場窗口 │ 禁止新進場 │ 強平
│ 統計最高/最低價 │ 等待觸發價 │ │
│ │ │ │
│◄──── before_0930 ──►│◄── entry_window ─►│ │
│ filter │ filter │ │
│ │ │ │
│ price_lowest ────┘ │ │
│ price_highest │ │
│ │ │
│ trigger_price = lowest - 1 tick │ │
│ stop_loss = last_close * 1.065 │ │
│ buy_back = last_close * 0.91 │ │
│ (全部在進場前就算好,不依賴進場狀態) │ │
進場後的出場邏輯(三條件取 OR,第一個觸發者勝):
┌────────────────────────────────────────────────────────────┐
│ after = df[row_idx > entry_idx] ← 切出進場後的子 DataFrame │
│ │
│ close >= stop_loss → 停損出場(軋空) │
│ OR │
│ str_time >= "13:03:00" → 強平出場(時間到) │
│ OR │
│ ask_price <= buy_back → 停利出場(空單獲利) │
│ │
│ exit_rows.row(0) ← 第一個觸發的 tick = 出場點 │
└────────────────────────────────────────────────────────────┘
進場條件向量化圖解
昨收 = 53.0
trigger_price = 52.1 (最低價 52.2 - 1 tick)
amount_cum 門檻 = 6億
tick time close prev_close amount_cum A:累積額 B:觸發價 C:下跌碰 全部?
──── ──────── ───── ────────── ────────── ──────── ──────── ──────── ──────
#1 09:30:01 52.8 52.9 1.2億 ✗ ✗ ✗ ✗
#2 09:30:03 52.6 52.8 2.4億 ✗ ✗ ✗ ✗
#3 09:30:07 52.4 52.6 4.1億 ✗ ✗ ✗ ✗
#4 09:30:15 52.2 52.4 6.8億 ✓ ✗ ✗ ✗
#5 09:30:22 52.1 52.2 8.3億 ✓ ✓ ✓ ✓ ◄── 進場!
#6 09:30:25 52.0 52.1 9.1億 ✓ ✗ ✓ ✗
#7 09:30:31 52.1 52.0 9.9億 ✓ ✓ ✗ ✗ (上漲碰觸,C 條件不符)
polars 對全部欄位同時計算 A & B & C,找到 #5 即停止
→ entry_price = 52.1
出場條件向量化圖解
進場價 = 52.1
buy_back = 53.0 * 0.91 = 48.23 (停利目標)
stop_loss = 53.0 * 1.065 = 56.45 (停損線)
tick time close ask_price 停損(close≥56.45) 強平(≥13:03) 停利(ask≤48.23) 觸發?
──── ──────── ───── ───────── ───────────────── ──────────── ────────────── ──────
#6 10:15:01 52.0 51.9 ✗ ✗ ✗ ✗
#7 10:45:33 51.5 51.3 ✗ ✗ ✗ ✗
#8 11:22:18 50.2 50.0 ✗ ✗ ✗ ✗
#9 12:10:05 49.5 49.3 ✗ ✗ ✗ ✗
#10 13:02:58 48.8 48.2 ✗ ✗ ✓ ✓ ◄── 出場!
#11 13:03:00 48.7 48.1 ✗ ✓ ✓ ✓ (已取 #10)
→ exit_price = 48.8 + 1 tick = 48.9
→ 空單獲利 = 52.1 - 48.9 = 3.2 元/股
向量化成立的四個前提
┌──────────────────────────────────────────────────────────────┐
│ 向量化可行性檢查 │
├──────────────────────────┬──────────────────────────────────┤
│ 前提 │ 本策略 │
├──────────────────────────┼──────────────────────────────────┤
│ 只進出場一次 │ ✓ 單純空單當沖 │
├──────────────────────────┼──────────────────────────────────┤
│ 進場條件不依賴持倉狀態 │ ✓ 只看 close / cum_sum │
├──────────────────────────┼──────────────────────────────────┤
│ 出場條件進場前就已確定 │ ✓ 全部由昨收預算,不會隨時間移動 │
├──────────────────────────┼──────────────────────────────────┤
│ 累積量可預先算完 │ ✓ cum_sum 與進場無關 │
└──────────────────────────┴──────────────────────────────────┘
反例(無法向量化):
✗ 移動停損(stop_loss 會隨最高/最低價更新)
✗ 加碼策略(持倉數量在進場後改變)
✗ 多次進出場(第二次進場依賴第一次的出場結果)
兩層加速架構
主程式(main process)
│
│ args_list = [
│ (2024-01-02, 2330, 583.0),
│ (2024-01-02, 2317, 121.5),
│ (2024-01-02, 2454, 88.2),
│ (2024-01-03, 2330, 579.0),
│ ... 共 ~10,000 個任務
│ ]
│
└── multiprocessing.Pool.map(run_backtest_fast, args_list)
│
├── CPU Core 0
│ ├── (2024-01-02, 2330): 讀 parquet → polars 向量化 → 回傳 dict
│ └── (2024-01-03, 2317): 讀 parquet → polars 向量化 → 回傳 dict
│
├── CPU Core 1
│ ├── (2024-01-02, 2317): 讀 parquet → polars 向量化 → 回傳 dict
│ └── (2024-01-03, 2330): 讀 parquet → polars 向量化 → 回傳 dict
│
├── CPU Core 2
│ └── ...
│
└── CPU Core N
└── ...
第一層加速:multiprocessing(水平擴展,多核並行)
第二層加速:polars 欄位運算(垂直加速,每個 task 內部快 100x)
速度直覺估算
假設:
每檔每天 5,000 筆 tick
10,000 個回測任務(約 2,000 天 × 5 檔/天)
8 核 CPU
┌─────────────────┬─────────────────┬──────────────────────────┐
│ 方法 │ 單任務耗時 │ 總耗時(8核) │
├─────────────────┼─────────────────┼──────────────────────────┤
│ iterrows 單進程 │ ~5 ms │ 10,000 × 5ms = 50 秒 │
├─────────────────┼─────────────────┼──────────────────────────┤
│ iterrows 多進程 │ ~5 ms │ 50秒 ÷ 8核 ≈ 6.3 秒 │
├─────────────────┼─────────────────┼──────────────────────────┤
│ polars 單進程 │ ~0.05 ms │ 10,000 × 0.05ms = 0.5 秒 │
├─────────────────┼─────────────────┼──────────────────────────┤
│ polars 多進程 │ ~0.05 ms │ 0.5秒 ÷ 8核 ≈ 0.06 秒 │
└─────────────────┴─────────────────┴──────────────────────────┘
iterrows 多進程 vs polars 多進程:約 100x 加速
瓶頸已從 CPU 計算移到 I/O(讀 parquet 檔),
這也是為什麼 parquet 優先於 CSV(columnar 格式,讀取更快)。
資料流總覽
┌─────────────────────────────────────────────────────────────────┐
│ 一個任務的完整資料流 │
└─────────────────────────────────────────────────────────────────┘
昨日收盤價
last_close = 53.0
│
├── price_buy_back = 53.0 × 0.91 = 48.23
├── price_too_low = 53.0 × 0.93 = 49.29
├── price_too_high = 53.0 × 1.065 = 56.45
└── 這些都在讀 tick 前就算好
讀取 tick 檔(parquet 優先)
/shioaji_ticks/2330/2024-01-02.parquet
│
▼
pl.DataFrame(5,000 rows)
│
├── cast / fill_null / 解析 str_time
├── cum_sum → amount_cum
└── shift(1) → prev_close
│
▼
before_0930 filter
→ price_lowest_before_0930 = 52.2
→ trigger_price = 52.1
│
├── 前置過濾(trigger 太低 / 漲幅過大)→ 不符合 return None
│
▼
entry_window filter(09:30 ~ 11:30)
→ 找第一個符合 A & B & C 的 tick
→ entry_row(進場點)
│
▼
after filter(entry_idx 之後)
→ 找第一個符合 停損 | 強平 | 停利 的 tick
→ exit_row(出場點)
│
▼
回傳 dict {date, symbol, price_enter, price_exit, ...}
結論
Tick 回測能向量化的前提:
策略邏輯可以被表達為「DataFrame 上的條件過濾 + 取第一筆」, 而不需要跨 tick 維護可變狀態。
short_v1_backtest_fast.py 的空單當沖策略恰好完全符合,
透過 polars 欄位運算(tick 維度) + multiprocessing(股票維度) 兩層加速,
實現矩陣化 tick 回測。