跳轉到

回測:事件模型與策略撰寫

所屬章節:回測 事件順序、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:

2023-01-09  wins=2   qty=2
2023-03-17  wins=3   qty=2
2023-03-21  wins=-1  qty=1      ← 勝敗切換,歸 -1

core.stats 與 self.stats 是同一個物件(實測 core.stats is self.stats 為 True), 跑完之後從外面讀 core.stats 拿到的是同一份累計結果。


從零寫一支策略的檢查清單

  1. 選餵資料方式:單一週期 → feed_data(df);需要 on_bar2 多週期訊號、 或要用小 K 模擬盤中讓停損觸發順序更準 → set_tick_period + feed_tick_df。
  2. on_init 放參數:想尋優的都存成 self.xxx,optimize 才能從外面覆寫。
  3. 期貨記得設 tick_map:self.tick_map = [(np.inf, 1)],否則套用股票的跳動單位。
  4. 暖身期回傳假訊號:指標資料不足時(len(close_list) < window)不要下單。
  5. 每個 Global 給獨立的 rule 名:同名會被視為「更新同一張單」。 避開內建的 STL / PFT / DS / TGT。
  6. 進場條件要含部位檢查:self.global_qty == 0(只進一次)或 < 1(可反手), 否則每根都會重下。
  7. 風控每根重掛:四個 set_* 方法必須無條件呼叫,不要包在 if 裡。
  8. 設對 config:unit、fee、tax_short、margin。設錯不會報錯但整份報表失真, 見 績效報告。
  9. 檢查報表:rp.report 看 KPI、rp.trade_detail 逐筆對答案、rp.dashboard() 看圖。
  10. 尋優:把策略包成一個回傳 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_position 觸發 2 次: [(1, 'bk', 2, 2), (1, 'bk', 1, 2)]
期末 global_qty = 0

要在部位結清後做事,改在 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 正在下載(同商品只下載一次)

相關頁