回測 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
output(回測會用到的四個)
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
output
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 成本公式:
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 會拋:
實務上最常見的原因不是版本,而是資金不夠買一張(台股一張 1000 股)。
BT.run 已攔下這個例外改成正確說明。
注意
api.bt需要有效 token(登入時印Token無效就不會掛上)。- 回測用的 dashboard 資產(plotly、CSS、JS)在
snowyowl/backtest_plus/generate_report/dashboard/assets/,DASHBOARD_VERSION = "2.0.0"。