Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 回測。