Share Notes

chundev

View the Project on GitHub latteouka/share-notes

期貨結算日轉倉回測:夜盤日期歸屬陷阱與三種重入方式比較

日期:2026-03-18 環境:台灣期貨市場(個股期貨),自建回測引擎


TL;DR

回測期貨結算日轉倉行為時,夜盤資料的日期歸屬是最容易踩的陷阱 — 台灣期貨夜盤(個股期 17:25 / 台指期 15:00 開盤)在資料源中歸屬到下一個交易日,如果用結算日當天的 dateString 去查夜盤開盤價,拿到的其實是前一天晚上的資料。修正這個 bug 後,原本「夜盤進場最便宜」的結論完全翻轉。三種結算日重入方式(結算日次月 open / 結算日夜盤 / T+1 open)的價格差異不大,沒有哪一種有穩定優勢。


背景

在台灣期貨市場,個股期貨每月第三個星期三結算。如果策略持有的部位被結算日強制平倉(P0 出場),但進場條件仍然成立,有三種方式可以重新建倉:

  1. 結算日開盤轉倉次月 — 結算日當天用次月合約的開盤價進場
  2. 結算日夜盤進場 — 結算日 17:25 夜盤開盤時進場(此時近月已結算,新的近月 = 原次月)
  3. T+1 進場 — 隔天日盤 08:45 開盤進場

回測引擎的目標是比較這三種進場方式的價格差異,找出哪種方式成本最低。


問題 / 錯誤訊息

初版分析腳本中,用以下方式取得結算日夜盤開盤價:

const nightOpen = nightSessionMap.get(exitDate) ?? null;
// exitDate = 結算日(例如 2024-05-15)

結果顯示「夜盤 open 在 4/5 次中是最便宜的進場點」,看起來夜盤進場有明顯優勢。但這個結論是錯的。


除錯過程

初版結果:夜盤看起來最便宜

出場日 次月open 夜盤open(錯) T+1 open
2024-05-15 840 826 857
2024-06-19 961 951 986

→ 夜盤 4/5 最低。結論:夜盤進場最好。

⚠️ 發現異常:夜盤 open 比日盤收盤(出場價)還低?結算日收盤 838,夜盤 826?不太合理。

根本原因:dateString 歸屬日期

台灣期貨夜盤交易時段是當天 15:00 ~ 隔天 05:00。在資料源(FinMind / 期交所)中,夜盤的 dateString 歸屬到下一個交易日

實際時間                    → dateString(資料歸屬日)
2024-05-15 17:25 夜盤開盤   → 2024-05-16
2024-05-14 17:25 夜盤開盤   → 2024-05-15

所以 nightSessionMap.get("2024-05-15") 拿到的是 5/14 晚上的夜盤 open,不是 5/15 結算日當天的夜盤。

正確寫法:

// 結算日當天的夜盤 open → dateString 歸屬到 T+1
const t1Date = uniqueDates[exitDateIdx + 1];
const nightOpen = nightSessionMap.get(t1Date) ?? null;

修正後結果:夜盤不再有優勢

出場日 次月open 夜盤open(正確) T+1 open 最便宜
2024-05-15 840 848 857 ①次月
2024-06-19 961 988 986 ①次月
2025-09-17 1275 1270 1280 ②夜盤
2026-01-21 1745 1760 1775 ①次月
2026-02-23 1945 1925 1930 ②夜盤

修正後:次月 open 最低 3 次、夜盤最低 2 次、T+1 最低 0 次。沒有任何一種方式有穩定優勢。


核心知識:期貨夜盤日期歸屬規則

台灣期交所的夜盤(盤後交易)日期歸屬規則:

商品類型 日盤 夜盤開盤 dateString 歸屬
台指期/選擇權 08:45 ~ 13:45 15:00 下一個交易日
個股期貨(如台積電期) 08:45 ~ 13:45 17:25 下一個交易日

注意:不同商品的夜盤開盤時間不同(台指期 15:00、個股期 17:25),但 dateString 歸屬規則一致 — 都歸屬到下一個交易日。這個規則在期交所官方查詢系統和 FinMind API 中一致。

為什麼這樣設計?

夜盤的交易結算歸屬到下一個交易日的結算。例如週一晚上的夜盤交易,其保證金、損益、未平倉都算在週二的帳上。這是期交所的制度設計,不是資料源的 bug。

在回測引擎中的處理

如果你的回測引擎用 Map<dateString, nightOpen> 存夜盤資料,查詢邏輯要注意:

// ❌ 錯誤:拿到的是「前一天晚上」的夜盤
const nightOpen = nightMap.get(settlementDate);

// ✅ 正確:結算日當天的夜盤歸屬到 T+1
const nightOpen = nightMap.get(nextTradingDay);

這個 bug 之所以難抓,是因為:


結算日轉倉回測的完整結果

回測條件

三種進場方式的價格最低次數(12 次條件滿足)

進場方式 價格最低次數
① 結算日次月 open 5 次
② 結算日夜盤 open 2 次
③ T+1 open 5 次

平均價差(T+1 − 次月 open)= +5.5 點,約 0.3~0.5%。

為什麼夜盤通常不是最便宜?

直覺上「結算日收盤後立刻進場」應該最接近出場價,但實際上:

相比之下,次月合約在結算日當天的日盤 open 是在還未結算時的價格,有時反而比結算後的夜盤便宜。


回測引擎中 P0 vs P4 轉倉的區別

在自建回測引擎中,結算日出場(P0)和持有天數到期(P4)的轉倉邏輯是完全分開的:

exit 檢查 →
  P0(結算日)→ 無條件出場,不可攔截
  P4(持有天數到期)→ 可被 rollover 或 holdOnEntrySignal 攔截

如果要支援「P0 結算日轉倉」,需要在引擎中新增專門的邏輯,不能直接複用 P4 的 rollover。這是因為:

  1. P0 是制度性的 — 合約到期,你沒有「不出場」的選項,只能「出場 + 立刻用次月重入」
  2. P4 是策略性的 — 你可以選擇繼續持有同一張合約
  3. P0 轉倉涉及合約切換 — 需要處理近月→次月的價差(basis),P4 不需要

學到的事


參考資料