回測:事件模型與策略撰寫
所屬章節:回測 事件順序、
row欄位、on_init下單的錯誤、完全平倉的行為皆以合成資料實測驗證(2026-08-21)
理解執行順序是寫策略的前提——它決定了送出的委託何時成交。
標準骨架
import numpy as np
import snowyowl as so
api = so.login('user@x.com', 'pwd')
bt = api.bt
df = bt.Tools.get_stock_ohlc('2330', freq=60, adj=False)
class MyStrategy(bt.Core):
def on_init(self):
# 1) 參數:要被 optimize 尋優的都放這裡,命名成 self.xxx
self.n_fast, self.n_slow = 5, 20
self.stl, self.pft = 1.0, 2.0
# 2) 狀態容器
self.close_list = []
# 3) 期貨要改最小跳動單位(預設是股票規格)
# self.tick_map = [(np.inf, 1)]
def my_indicators(self, row):
self.close_list.append(row['Close'])
fast = self.Ind.rolling_mean(self.close_list, self.n_fast)
slow = self.Ind.rolling_mean(self.close_list, self.n_slow)
self.char['fast'] = fast[-1] # 存進 char 才會畫在 show_monitor 與 dashboard 的 K 線
self.char['slow'] = slow[-1]
if len(self.close_list) < self.n_slow:
return False, False # 暖身期不下單
return fast[-1] > slow[-1], fast[-1] < slow[-1]
def on_bar(self, row):
long_sig, short_sig = self.my_indicators(row)
self.Condition.Global(long_sig and self.global_qty == 0,
1, 0, self.PriceType.market_ioc, None, 'bk')
self.Condition.Global(short_sig and self.global_qty > 0,
-self.global_qty, 0, self.PriceType.market_ioc, None, 'bp')
# 風控:每根都要重掛,不要包在 if 裡
self.Condition.set_STL('bk', self.stl)
self.Condition.set_PFT('bk', self.pft)
core = MyStrategy()
core.feed_data(df)
cfg = bt.config(symbol='2330', fee=0.001425, tax_long=0, tax_short=0.003, unit=1000)
rp = bt.generate_report({cfg: core})
print(rp.report)
事件方法
class MyStrategy(bt.Core):
def on_init(self):
"""策略建立時呼叫一次。只初始化參數與狀態,不能下單。"""
def on_bar(self, row):
"""主商品 base 週期(freq[0])K 棒收盤時觸發。主要下單邏輯寫這裡。
row 是當根收盤 K 棒的 dict,等同 self.row_list[-1]。"""
def on_bar2(self, row):
"""主商品第 2 週期(freq[1])收盤時觸發,純訊號、不觸發撮合。
通常算高週期指標存成 self.xxx,下單邏輯仍寫在 on_bar。
有第 3 個週期就是 on_bar3,依此類推。"""
def on_intrabar(self, row):
"""K 棒收盤前、盤中價格變動時觸發(每筆 tick 一次)。
row 是正在形成中、尚未收盤的那根 bar。"""
def on_position(self, P):
"""倉位變動時觸發:開倉、加倉、部分平倉。
完全平倉「不會」觸發。常見用法:以 match_uid=P.uid 為每筆持倉掛專屬出場單。"""
def on_other(self, row):
"""對標商品 base 週期收盤時觸發,純訊號。多週期依序觸發 on_other2 / on_other3。"""
on_bar2 / on_bar3 / on_other* 只有走 set_tick_period + feed_tick_df 才會觸發,
feed_data 不經過週期聚合系統。詳見 資料與週期。
一根 bar 內的執行順序
引擎處理每根 bar 的固定順序:
1. 更新未實現損益 (重算 global_unrealized_point、每筆持倉的 max_earn / max_loss)
2. 撮合既有掛單 (用「這一根」的 OHLC 判斷上一根掛出去的單有沒有成交)
└─ 有成交 → 觸發 on_position
3. on_bar(row) (你的下單邏輯;這裡送出的委託要等下一根才會被撮合)
4. 依 Condition.Global 的呼叫順序排定委託優先序
5. on_intrabar(row)
實測(bar 0 下市價買單,三根日線):
on_bar(index=0)
→ on_intrabar(index=0)
→ on_position(rule=bk, index=1) ← 在 index 1 的開盤價成交
→ on_bar(index=1)
→ on_intrabar(index=1)
→ on_bar(index=2)
→ on_intrabar(index=2)
由此得到三條設計策略時一定要知道的規則:
| 規則 | 意義 |
|---|---|
在 on_bar 下的單,最快在「下一根」成交 |
沒有「當根收盤價成交」這回事。market_ioc 以下一根開盤價成交 |
on_position 在 on_bar 之前觸發 |
成交發生在撮合階段(步驟 2),所以同一根裡 on_position 先跑、on_bar 後跑 |
| 停損停利要每根重掛 | set_STL 等是「依當前持倉重算出場價、更新掛單」,不呼叫就不會更新 |
多週期時同一筆資料的觸發順序:先所有子週期訊號(on_bar2 / on_bar3…),
最後才是 base 週期(走帳務的 on_bar)。對標商品(on_other)在同一時點先於主商品。
row 的完整欄位
實測 feed_data 的 row key(含一個自訂欄位 mysig):
['DateTime', 'Open', 'High', 'Low', 'Close', 'Volume', 'mysig',
'time', 'ts', 'char', 'char1', 'index']
| key | 說明 |
|---|---|
DateTime |
這根 K 棒的收盤時間(pd.Timestamp) |
Open / High / Low / Close / Volume |
開高低收量。注意 key 是英文,即使 feed_data 傳的是中文欄名 |
index |
bar 序號,從 0 開始,等同在 row_list 中的位置 |
ts |
DateTime 的 epoch 秒(float),內部彙總用 |
time |
14 碼字串 'YYYYMMDDHHMMSS' |
char / char1 |
主圖/副圖指標暫存 dict,等同 self.char / self.char1 |
Settlement |
該筆所屬的結算日。feed_tick_df 且 need_settlement=True 時有;feed_data 要靠 columns=['Settlement'] 帶入 |
| 你自訂的欄位 | 由 columns=[...] 指定帶入(例如外部算好的訊號欄) |
feed_data(df, args=[...中文欄名...])只是告訴引擎哪一欄是哪一個角色,row裡的 key 一律是英文Open/High/Low/Close/Volume。 額外欄位(columns=[...])則保留原本的欄名。
feed_tick_df 的 key 順序略有不同:
['time','Open','High','Low','Close','Volume', <columns>, 'DateTime','Settlement','index','ts','char','char1']
判斷是否為結算日的標準寫法:
row['DateTime'] == row['Settlement']
資料結構
Position(on_position 的 P、core.positions 的 value)
| 欄位 | 說明 |
|---|---|
index |
進場的 bar 索引 |
entry_time |
進場時間 |
entry_qty |
原始進場數量 |
entry_price |
進場價格 |
max_earn |
這筆持倉期間的最高獲利(點數) |
max_loss |
這筆持倉期間的最大虧損(點數) |
rule |
造成進場的規則名稱 |
unrealized_pnl |
當前未實現損益 |
current_qty |
當前剩餘數量(部分平倉後小於 entry_qty) |
uid |
這筆持倉的唯一識別碼,出場單用 match_uid=P.uid 綁定 |
Order(core.orders 的 value)
index、rule、datetime、add_qty、trade_price、trade_type、uid、match_uid。
core.orders 的 key 是 (rule, match_uid)。
狀態屬性
策略內可直接讀。損益以點數計、未含稅費;要真實金額請看 generate_report 的報表。
| 屬性 | 說明 |
|---|---|
self.global_qty |
全域總持倉。正=多、負=空、0=空手 |
self.global_realized_point |
全域已實現損益(點數,不含稅費) |
self.global_unrealized_point |
全域未實現損益(隨行情浮動) |
self.positions |
持倉字典,key = uid,value = Position |
self.orders |
未成交/未取消的委託,key = (rule, match_uid) |
self.row_list |
所有已收盤 K 棒的 dict 列表;row_list[-1] 即當根 |
self.row |
當前 bar 的 dict(永遠等於 row_list[-1]) |
self.stats |
依規則名累計的統計,見下 |
self.char / self.char1 |
主圖/副圖指標暫存 dict。寫進去的數值會畫在 show_monitor() 與 rp.dashboard() 個股分析的 K 線上(char 疊在主圖、char1 畫在副圖;名稱含 hist 的畫柱狀) |
self.tick_map |
最小跳動單位表 [(價格上限, tick), ...]。預設是股票規格 [(10,0.01),(50,0.05),(100,0.1),(500,0.5),(1000,1.0),(inf,5.0)];台指期要在 on_init 設 [(np.inf, 1)] |
self.PriceType |
委託類型常數 |
self.Condition |
條件式下單入口 |
self.Ind |
技術指標工具箱(類別屬性,所有策略共用同一份) |
core.log |
委託生命週期 DataFrame,type 欄是 new / update / cancel / deal |
self.stats
self.stats['規則名'] 會自動建立一個統計盒:
| 欄位 | 說明 |
|---|---|
open_count |
進場次數 |
open_qty |
累計進場口數 |
close_count |
平倉次數 |
current_qty |
目前剩餘口數 |
realized_pnl |
該規則累計已實現損益(點數) |
continue_wins |
連勝/連敗:正數=連勝、負數=連敗,勝敗切換時歸 ±1 |
records |
{出場規則名: [次數, 口數, pnl]} |
常態用法是在策略內部讀 self.stats,依戰績調整下一筆的口數:
class WinStreak(bt.Core):
def on_init(self):
self.base_qty = 1
def on_bar(self, row):
wins = self.stats.get('bk', {}).get('continue_wins', 0)
qty = self.base_qty * 2 if wins >= 2 else self.base_qty # 連勝 2 次就加倍
if row['sig'] and self.global_qty == 0:
self.Condition.Global(True, qty, 0, self.PriceType.market_ioc, None, 'bk')
實測(2330、MA20 訊號、1000 個交易日):52 次進場中有 13 次因連勝被放大到 2 張,
rp.trade_detail 的 qty 分布是 {1: 38, 2: 13},對得起來。
continue_wins 的正負轉換也符合定義——連勝到 3 之後輸一次直接變 -1:
core.stats與self.stats是同一個物件(實測core.stats is self.stats為True), 跑完之後從外面讀core.stats拿到的是同一份累計結果。
從零寫一支策略的檢查清單
- 選餵資料方式:單一週期 →
feed_data(df);需要on_bar2多週期訊號、 或要用小 K 模擬盤中讓停損觸發順序更準 →set_tick_period+feed_tick_df。 on_init放參數:想尋優的都存成self.xxx,optimize才能從外面覆寫。- 期貨記得設
tick_map:self.tick_map = [(np.inf, 1)],否則套用股票的跳動單位。 - 暖身期回傳假訊號:指標資料不足時(
len(close_list) < window)不要下單。 - 每個
Global給獨立的rule名:同名會被視為「更新同一張單」。 避開內建的STL/PFT/DS/TGT。 - 進場條件要含部位檢查:
self.global_qty == 0(只進一次)或< 1(可反手), 否則每根都會重下。 - 風控每根重掛:四個
set_*方法必須無條件呼叫,不要包在if裡。 - 設對
config:unit、fee、tax_short、margin。設錯不會報錯但整份報表失真, 見 績效報告。 - 檢查報表:
rp.report看 KPI、rp.trade_detail逐筆對答案、rp.dashboard()看圖。 - 尋優:把策略包成一個回傳
generate_report(...)的函式丟給bt.Tools.optimize。
會出錯的事(實測)
在 on_init 裡下單 → RuntimeError
class S(bt.Core):
def on_init(self):
self.Condition.Global(True, 1, 0, self.PriceType.market_ioc, None, 'x')
RuntimeError: 還沒有任何 K 棒就下單了 (self.row 是空的)。
Condition.Global 只能在 on_bar / on_intrabar / on_position 裡呼叫,
on_init 只用來初始化參數。
完全平倉不觸發 on_position
實測:index=0 買 2 口 → index=2 賣 1 口(部分平倉)→ index=4 賣掉剩下的。
on_position 只觸發 2 次(開倉、部分平倉),最後那次完全平倉沒有觸發:
要在部位結清後做事,改在 on_bar 讀 self.stats[rule](close_count / realized_pnl /
continue_wins)或事後看 rp.trade_detail。
其他陷阱
| 陷阱 | 說明 |
|---|---|
期待 market_ioc 以當根收盤價成交 |
不會。在 on_bar 下的單是下一根開盤價 |
在 on_bar 讀剛成交新倉的 global_unrealized_point |
該筆的浮動損益要下一根才會計入 |
對 rp.trade_detail 的時間欄直接用 .dt |
那是字串不是 datetime64,要先 pd.to_datetime |
K 棒的 DateTime 是收盤時間 |
自備資料時 DateTime 要用 bar 的結束時間,不是開始時間。用 feed_tick_df 聚合時,標記 09:00 的 K 棒在 period=[['0900','1330']] 下會被歸進 10:00 那個桶 |
| 餵入交易時段外的資料 | 落在 period 之外的 tick 會被忽略並印警告,不進任何 bar |
| 分段餵入時區間重疊 | 重疊那段被時序守門整段丟棄並印警告 |
| 多筆持倉互相干擾 | 出場單沒帶 match_uid=P.uid 會依序沖銷。要獨立出場請在 on_position 用 P.uid 綁 |
| 重複下單 vs 更新委託 | 同一組 (rule, match_uid) 重複呼叫 Global 是更新,不是再下一張 |
feed_data 不觸發多週期 |
需要 on_bar2 / on_other* 一定要改用 set_tick_period + feed_tick_df |
拿 self.row 存自己的東西 |
self.row 是保留屬性,框架每根 K 棒都會把它換成當根 bar,存進去的東西會默默消失。自訂狀態請另取名(例如 self.my_feat)。同理不要覆寫 row_list、char、positions、orders、global_qty、stats、Condition、PriceType、Ind |
有內建停損停利或反手時,自己再寫 -self.global_qty 平倉 |
這張單下一根才撮合;持倉若已被 STL/反手先平掉,它照樣成交,反向開出一筆新倉(trade_detail.entry_rule 會出現你的出場規則名)。改成逐筆 match_uid=P.uid 綁定,持倉不在了框架會自動撤單,見 backtest-order.md |
期待 NetProfit 等於 pnl 加總 |
NetProfit 取自 daily 曲線末值並四捨五入;期末有未平倉部位時兩者會差一點 |
lot_size 只對百分比下單有效 |
add_qty >= 1(固定口數)不受 lot_size 影響 |
| 浮點殘量 | 百分比下單沖銷持倉會有 ~1e-17 殘量,框架用 _QTY_min = 1e-9 容差處理。不要自己用 == 0 比較口數 |
| 即時 tick 重複計算 | callback 要過濾 data.total_volume == 0 的快照,避免重複餵入 |
框架印出的訊息對照
| 訊息 | 意義 |
|---|---|
[設定on_bar] :freq= 60 |
set_tick_period 設定成功,base 週期 60 分 |
[加載] feed_tick / feed_tick_df / feed_other |
對應的餵資料方法已掛上 |
[警告] 主商品第2周期 D 但沒定義 on_bar2, 略過 |
設了多週期但沒實作對應的 on_barN |
[警告] 對標第1周期 60 但沒定義 on_other, 略過 |
設了 set_other_period 但沒實作 on_other |
[警告] tick ... 落在 period 交易時段之外,已忽略 |
資料時間不在 period 宣告的時段內 |
[警告] 主商品資料時間 ... 早於前一筆 ...,已忽略 |
時序守門擋掉倒退的資料 |
[警告] ...: DateTime 沒有依時間遞增,已自動排序後才餵入 |
df 沒排序,框架代為排序;回頭查資料來源 |
【'開始下載':XXX ,freq=1】 / 【下載完成】 |
bt.Tools.get_*_ohlc 正在下載(同商品只下載一次) |