跳轉到

回測 api.bt(backtest_plus)

所屬章節:回測 以 2026-08-20 實際執行結果為準

Overview

api.bt 是 backtest_plus,在登入時綁定(main.py 的 self.bt = _bt(self))。

api.bt.Core()                # 策略類別
api.bt.Tools                 # 工具集
api.bt.config(...)           # 商品/成本設定
api.bt.generate_report(...)  # 產生績效報告

回測的完整說明分五頁:

頁 內容
本頁 api.bt 的綁定方式、用積木訊號回測(單檔與組合)
事件模型與策略撰寫 標準骨架、一根 bar 內的執行順序、row 完整欄位、Position / Order、狀態屬性與 stats、檢查清單、會出錯的事
委託、成交價與風控 Condition.Global、五種委託型別、成交價規則實測表、四個停損停利方法
資料與週期 feed_data / feed_tick_df / feed_tick、freq 聚合、bt.Tools 取歷史資料、時序管控
商品規格與績效報告 bt.config 的成本設定(含 tax_long/tax_short 的陷阱)、generate_report、四張報表的欄位定義
技術指標與參數尋優 self.Ind 25 個增量指標、bt.Tools.optimize

這頁只記 api.bt 的綁定方式,以及積木訊號怎麼餵進回測。

Manual Sample Input/Output

Example 1:公開成員

input

[n for n in dir(api.bt) if not n.startswith('_')]

output(回測會用到的四個)

['Core', 'Tools', 'config', 'generate_report']

input

import inspect
print(inspect.signature(api.bt.config))
print(inspect.signature(api.bt.generate_report))

output

(name: str = 'strategy', symbol: str = 'symbol', tax_long: float = 0, tax_short: float = 0, daytrade_discount: float = 1, fee: float = 0, unit: int = 1, margin_long: float = 1, margin_short: float = 1, lot_size: str = 'inf,0,floor,1') -> None
(core_dic: dict, margin_call: bool = False, capital=1000000, benchmark=None)

Example 2:api.bt 的型態

input

type(api.bt).__name__

output

SecureMain

backtest_plus 這個套件用了一個技巧:它在 __init__.py 裡把模組物件換成一個 繼承 SecureMain 的 CallableModule 實例並塞回 sys.modules, 所以 backtest_plus(api) 這種「呼叫模組」的寫法可行,同時 .core、.generate_report、.other 等子模組屬性照樣存取得到(2026-10 起套件分成 core/ 回測引擎、generate_report/ 帳務與報表、other/ 工具三塊;舊的 utils 已拆掉)。api.bt 就是 backtest_plus(api) 的結果。

這代表:如果你在 SnowyOwl 之外自己 import backtest_plus,拿到的不是普通模組, 而是那個 callable 代理物件。


Example 3:積木 sig 餵進 Core(單檔)

日 K 也從 MSMP 取,不要用 bt.Tools.get_stock_ohlc。 sig 是日頻矩陣(來自 MSMP 日_K),而 get_stock_ohlc 是下載約 4 年 1 分 K 再 resample。 頻率對不上,還多一次大量下載。兩邊都取 MSMP 日頻時日期 index 天然完全對齊, 不需要 reindex/ffill。

input

import pandas as pd

def daily_bars(api, symbol):
    k = api.MSMP.日_K
    zh2en = {'開盤價':'Open','最高價':'High','最低價':'Low','收盤價':'Close','成交量':'Volume'}
    df = pd.DataFrame({zh: getattr(k, en)[symbol] for zh, en in zh2en.items()})
    df.index.name = 'DateTime'
    return df.reset_index()

SYM = '2330'
r = api.Block.screener_tw([api.Block.con_tw('今日收盤價<>D日(周月)均線',
                                           period=20, table='日_K')])
bars = daily_bars(api, SYM)
bars['sig'] = bars['DateTime'].map(r['sig'][SYM]).fillna(False)

class BlockStrategy(api.bt.Core):
    def on_bar(self, row):
        if row['sig'] and self.global_qty == 0:
            self.Condition.Global(True, 1, 0, self.PriceType.market_ioc, None, 'bk')
        elif not row['sig'] and self.global_qty > 0:
            self.Condition.Global(True, -self.global_qty, 0,
                                  self.PriceType.market_ioc, None, 'bp')

core = BlockStrategy()
core.feed_data(bars, ['DateTime','開盤價','最高價','最低價','收盤價','成交量'],
               columns=['sig'])          # 額外欄位一定要宣告,否則 row 裡沒有
cfg = api.bt.config(name=f'block_{SYM}', symbol=SYM, fee=0.001425,
                    tax_long=0, tax_short=0.003, unit=1000)
rp = api.bt.generate_report({cfg: core}, capital=1_000_000)
print(rp.report.to_string())

output

                          Strategy
StartDate               2022-08-29
EndDate                 2026-08-20
BacktestDate            2026-08-21
TotalReturns               -0.1131
CAGR                       -0.0309
AAR                        -0.0296
MaxDrawdown                -0.1955
MddDuration                   1149
CalmarRatio                -0.1580
SharpeRatio                -0.7078
SortinoRatio               -0.9197
AnnualisedVolatility        0.0655
WinRate                     0.3300
ProfitLossRatio             0.3900
Invest                     2682802
NetProfit                  -303378
MaxPosition                2443477
TotalTrades                     51
ProfitTimes                     17
LossTimes                       34
TradeCost                   325378
AvgProfitPerTrade            -5949
AvgCashUtilizationRate      0.0502
WeeklyReturn                 0.00%
MonthlyReturn               -3.39%
QuarterlyReturn             -3.94%
AnnualReturn                -6.26%

上面的輸出是 2026-08-20 版本。2026-10 起 MaxDrawdown 改名 MddRatio,並在它下面新增 MddAmount(最大回撤金額),KPI 共 28 項,定義見 backtest-report.md。

sig 與 bars 的 index 完全對齊(bars['sig'].notna().all() 為 True), 1000 個交易日中訊號成立 52 天。

報表產出

屬性 型態 內容
rp.report DataFrame 28 項績效。Strategy 欄是合併視角;多策略時每個 config.name 再各一欄
rp.trade_detail DataFrame 逐筆交易,21 欄,含 name/symbol/direction/pnl/cost
rp.trade_main DataFrame 委託層級,進出各一列
rp.position DataFrame 部位變化
rp.daily DataFrame 每日權益曲線,13 欄(含 dd 每日回撤金額)
rp.daily_close DataFrame 每日收盤價
rp.dashboard() method 產出互動 HTML 報表(總覽/績效分析/交易明細/個股分析四頁)
rp.save(path=None) method 存成一個 xlsx,六個 sheet(report / daily / trade_main / trade_detail / 個股績效 / 進出場規則績效)
rp.set_benchmark(series) method 設對照基準

沒有 rp.trade——逐筆交易在 rp.trade_detail。


Example 4:組合回測(多檔共用一份資金池)

Core 是單商品設計(沒有多商品餵入的方法), 組合是在報表層組起來的:N 檔就開 N 個 core,generate_report 收一個 {config: core} 的 dict。

但關鍵是下單方式:generate_report/pnl.py 的 _make_tradeX 在「所有交易都是固定口數」時會走 向量化路徑,capital 完全不參與計算。也就是說用 qty=1 跑組合時 capital 是惰性參數,TotalReturns 其實是 NetProfit / Invest, MaxPosition 可以是 capital 的數倍——那不是組合報酬。

要讓 capital 真的生效,必須用百分比下單(qty < 1,代表佔資金池的比例):

raw = capital * qty / margin        # generate_report/pnl.py 的 _make_tradeX_loop
capital = 起始資金 + entry_time 之前已實現的損益

input

import sys; sys.path.insert(0, 'mcp')      # snowyowl/mcp
from snowyowl_mcp import backtest as BT

con = api.Block.con_tw('今日收盤價<>D日(周月)均線', period=20, table='日_K')
rp = BT.run(api, [con], ['2330','2317','2454','1301','2882'], capital=10_000_000)
print(rp.snowyowl_weight)      # 沒指定 weight 時自動 = 1/檔數
print(rp.report.to_string())

output

                          Strategy        2882        2454        2330        2317        1301
StartDate               2022-08-08  2022-08-08  2022-08-16  2022-08-29  2022-08-29  2022-10-05
EndDate                 2026-08-20  2026-08-20  2026-08-20  2026-08-20  2026-08-20  2026-08-20
TotalReturns               -0.3831     -0.2057     -0.2593     -0.1108     -0.1673     -0.2984
CAGR                       -0.1169     -0.0576     -0.0748     -0.0302     -0.0467     -0.0908
MaxDrawdown                -0.4224     -0.2494     -0.4092     -0.1527     -0.2665     -0.3905
SharpeRatio                -2.2134     -1.3462     -1.1726     -0.6771     -0.8080     -1.1268
WinRate                     0.2900      0.3200      0.2700      0.2700      0.3300      0.2600
TotalTrades                    275          50          55          51          58          61
Invest                     6313784     2345697     2425945     2177725     2443720     2693857
NetProfit                 -2418864     -482478     -628932     -241332     -408770     -803958
TradeCost                  2467914      568428      439932      408332      639270      677058
MaxPosition                5503431     2013065     1874668     1990833     1978816     1997242
AvgCashUtilizationRate      0.0779      0.0468      0.0386      0.0362      0.0512      0.0547

(27 項只截這幾項,完整同 Example 3。這是 2026-08-20 版本的輸出,現在 `MaxDrawdown` 叫 `MddRatio`,另有 `MddAmount`)

Strategy 欄才是組合——把所有 name 的交易按時序合併、共用一份 capital 重算。 其餘各欄是「該檔獨佔全部資金」的單獨視角,所以逐檔加總不等於 Strategy (實測逐檔 Invest 加總 12,086,944 vs Strategy 的 6,313,784)。

先看成本再看報酬。 這組 MA20 訊號的組合,扣成本前的毛損益是 +49,050, 交易成本 2,467,914(275 筆,佔投入資金 39%)。也就是說 -38.3% 這個數字 幾乎完全是成本,不是策略判斷錯誤——日頻訊號進出太頻繁。 單檔 2330 也一樣:毛損益 +22,000、成本 325,378。


Example 5:三個會咬人的地方

1. tax_long / tax_short 其實是「進場稅」與「出場稅」

generate_report/pnl.py 的 _make_tradeX 成本公式:

cost = qty * unit * ( entry_price*(tax_long+fee) + exit_price*(tax_short+fee) )

tax_long 乘的是進場價、tax_short 乘的是出場價,與多空方向無關。 台股證交稅只在賣出收,所以正確設法是 tax_long=0, tax_short=0.003 ,也就是台股只要填 tax_short,tax_long 保持預設的 0。完整的成本設定對照見 backtest-report.md。

標的 設法
台股個股 fee=0.001425, tax_long=0, tax_short=0.003, unit=1000
台股 ETF 同上但 tax_short=0.001
台指期 fee=500, tax_long=0.00002, tax_short=0.00002, unit=200
美股 fee=0, tax_long=0, tax_short=0, unit=1

2. capital 不扣未平倉部位佔用的資金

capital 只累加已實現損益(Capital[exit_time] += pnl), 當前開著的部位不會從可用資金裡扣掉。所以 N 檔同時進場時, 每一檔都拿「同一份 capital」的 weight 去算口數, weight × 同時持有檔數 若 > 1 就是隱性槓桿。 BT.run 預設 weight = 1/檔數 就是為了避開這件事。

3. 部位被夾成 0 張時,錯誤訊息會誤導

lot_size 預設 'inf,0,floor,1',capital × weight 買不起一張時 floor 會把口數變 0。全部被夾掉時每日損益全為 0, make_daily 會拋:

RuntimeError: make_daily: 每日損益全為 0,無法產生報表。
常見原因是 pandas / numpy 版本不相容導致索引對不起來,...

實務上最常見的原因不是版本,而是資金不夠買一張(台股一張 1000 股)。 BT.run 已攔下這個例外改成正確說明。


注意

  • api.bt 需要有效 token(登入時印 Token無效 就不會掛上)。
  • 回測用的 dashboard 資產(plotly、CSS、JS)在 snowyowl/backtest_plus/generate_report/dashboard/assets/, DASHBOARD_VERSION = "2.0.0"。