回測:商品規格與績效報告
所屬章節:回測
tax_long/tax_short的語意與capital的生效條件皆以實測驗證(2026-08-21)
config 決定帳務怎麼算,generate_report 把跑完的策略彙整成報表。
bt.config — 商品規格
bt.config(name='strategy', symbol='symbol',
tax_long=0, tax_short=0, daytrade_discount=1,
fee=0, unit=1, margin_long=1, margin_short=1,
lot_size='inf,0,floor,1')
只影響報表、不影響回測本身——同一個跑完的 core 換一份 config 重新 generate_report 就好,不用重跑。設錯不會報錯,但整份報表失真。
| 欄位 | 說明 | 預設 |
|---|---|---|
name |
策略規則名稱,多策略回測時的分組依據,也是 rp.report 的欄名 |
'strategy' |
symbol |
交易標的代號 | 'symbol' |
tax_long |
進場那一腳的稅率,見下方說明 | 0 |
tax_short |
出場那一腳的稅率 | 0 |
daytrade_discount |
當沖稅率折扣:進出場同一天時稅率乘上此值(1=無折扣、0.5=減半) |
1 |
fee |
手續費。>= 1 為每口固定金額;< 1 為成交金額費率 |
0 |
unit |
合約乘數,決定「名目金額 = qty × unit × price」。預設 1(以股計)——qty 就是股數。想用「張」思考才設 1000,那時 qty 是張數。台指期 200 |
1 |
margin_long |
多單保證金。>= 10 為每口固定金額;< 10 為名目金額比例(成數) |
1 |
margin_short |
空單保證金,同上 | 1 |
lot_size |
百分比下單(add_qty < 1)時的口數限制,格式 '最大口數,最小口數,取整方式,幾口一取',取整只接受 floor / ceil。固定口數下單不受影響 |
'inf,0,floor,1' |
fee與margin會依數值大小自動切換計算模式。fee=500是每口 500 元,fee=0.001425是 0.1425% 費率——寫錯一個小數點,成本會差幾百倍。
tax_long / tax_short 的名字會誤導
看 generate_report/pnl.py 的 _make_tradeX 成本公式:
tax_long 乘的是進場價、tax_short 乘的是出場價,與多空方向無關。
台股證交稅只在賣出收,所以台股只要填 tax_short=0.003,tax_long 保持預設的 0 就好。
(兩邊都填會把稅收兩次、成本虛增一倍。)
| 標的 | 設法 |
|---|---|
| 台股個股(以張計) | fee=0.001425, tax_short=0.003, unit=1000,qty 填張數 |
| 台股個股(以股計) | fee=0.001425, tax_short=0.003(unit 用預設 1),qty 填股數 |
| 台股 ETF | 同上但 tax_short=0.001 |
| 台股個股(含當沖減半) | 再加 daytrade_discount=0.5 |
| 台指期 | fee=500, tax_long=0.00002, tax_short=0.00002, unit=200, margin_long=0.13, margin_short=0.13 |
| 美股 | fee=0, tax_long=0, tax_short=0, unit=1 |
# 台股個股
cfg = bt.config(symbol='2330', fee=0.001425,
tax_long=0, tax_short=0.003, daytrade_discount=0.5, unit=1000)
# 台指期(固定手續費/口,保證金為名目金額成數)
cfg = bt.config(symbol='TXF', tax_long=0.00002, tax_short=0.00002,
fee=500, unit=200, margin_long=0.13, margin_short=0.13)
# 百分比下單,換算口數無條件捨去、無上限
cfg = bt.config(name='pct_TXF', symbol='TXF', tax_short=0.00002, fee=20, unit=200,
margin_long=0.13, margin_short=0.13, lot_size='inf,0,floor,1')
bt.generate_report
| 參數 | 說明 | 預設 |
|---|---|---|
core_dic |
{config: core} 字典。core 須已跑完整段資料。可放多組一起比較 |
必填 |
margin_call |
是否把未實現的浮動虧損也算進資金佔用(影響 rp.daily 的 invest、rp.report 的 Invest / MaxPosition)。True 較保守,模擬保證金追繳 |
False |
capital |
起始資金。只在百分比下單時才生效,見下 | 1000000 |
benchmark |
基準指數的每日收盤價(dict 或 pd.Series,index 為日期)。提供後 rp.report 多一欄做買進持有比較 |
None |
capital 只在百分比下單時生效
_make_tradeX(generate_report/pnl.py)在「所有交易都是固定口數」時會走向量化路徑,capital 完全不參與計算。
實測:同一組固定 1 口的策略,capital 設 10 萬 / 100 萬 / 1 億,
Invest、NetProfit、MaxPosition、TotalTrades、TotalReturns、
AvgCashUtilizationRate 完全相同。那時的 TotalReturns 其實是 NetProfit / Invest。
要讓資金池真的生效,add_qty 要填 0~1 的比例:
此時:
capital只累加已實現損益,不會扣掉未平倉部位佔用的資金。 所以 N 檔同時進場時每檔都拿「同一份 capital」的比例去算口數,add_qty × 同時持有檔數若 > 1 就是隱性槓桿。取1/檔數最安全。
兩種部位大小的寫法
Condition.Global 的 add_qty 有兩種語意,由「是否 < 1」決定,
而單位由 config.unit 決定——兩者搭錯會差 1000 倍。
A. 換股/輪動:qty = 1/n
n 檔輪流持有、共用一份資金:
qty < 1 走百分比路徑,capital 真的生效,rp.report['Strategy'] 是共用資金池的組合視角。
實測 3 檔、capital=10,000,000、qty=1/3(unit=1000):
capital 只扣已實現損益、不扣未平倉佔用,所以
qty × 同時持有檔數 > 1 就是隱性槓桿——取 1/n 剛好是安全上限。
B. 各檔訊號獨立:qty = 固定金額 / price,搭 unit=1
每檔各進各的、互不排擠,每筆固定名目金額。必須以「股」計(unit=1):
cfg = bt.config(symbol=s, fee=0.001425, tax_long=0, tax_short=0.003, unit=1)
def on_bar(self, row):
if row['sig'] and self.global_qty == 0:
qty = 100000 / row['Close'] # 每筆 10 萬元
self.Condition.Global(True, qty, 0, self.PriceType.market_ioc, None, 'bk')
qty 這時 ≥ 1,走固定口數路徑——capital 不參與計算(見上一節)。
unit 配錯的實測差距
同一條 qty = 100000/price 公式,只改 unit:
| 寫法 | 單筆進場金額(中位) |
|---|---|
unit=1(以股計) |
100,077 ← 正確,就是 10 萬 |
unit=1000(以張計) |
100,076,656 ← 差 1000 倍 |
因為 unit=1000 時 qty 被當成張數:100000/2375 = 42.1 張 = 42,100 股。
以張計的正確寫法要自己換算並取整:
實測這樣寫單筆進場中位 653,430——因為台股一張最少就要幾十萬,
10 萬元的目標在張制下根本買不到一張,max(1, ...) 只好進位到一張。
要精準控制名目金額就用 unit=1。
兩者的差別
A. 換股 1/n |
B. 獨立 金額/price |
|
|---|---|---|
qty |
< 1,佔資金池比例 | ≥ 1,實際股數 |
unit |
1000 或 1 都行 | 必須 1 |
capital |
生效,是資金約束 | 不生效(固定口數走向量化路徑) |
| 適合 | n 檔輪動、資金有限 | 各訊號獨立、看單一規則的期望值 |
| 看哪一欄 | rp.report['Strategy'] |
逐檔欄位各自看 |
部位被夾成 0 時的誤導訊息
lot_size 預設 'inf,0,floor,1',capital × add_qty 買不起一單位時 floor 會把口數變 0。
全部被夾掉時每日損益全為 0,make_daily 會拋:
實務上最常見的原因不是版本,而是資金不夠買一個單位(台股一張 1000 股)。
提高 capital 或減少檔數即可。
產出內容
| 屬性 | 說明 |
|---|---|
rp.report |
績效摘要(KPI)。多策略時每個策略各佔一欄,另有一欄 Strategy 是合併視角 |
rp.trade_main |
委託/成交明細(合併視角),每筆下單都在,不論成交與否 |
rp.trade_detail |
進出場配對明細,計算損益的核心資料 |
rp.position |
每日持倉部位 |
rp.daily |
每日績效彙總(權益曲線) |
rp.daily_close |
每日收盤價 |
rp.core[name] |
單一策略獨立的 StrategyResult(不受其他策略影響),name 對應 config.name |
rp.dashboard() |
產出互動式績效儀表板(HTML),見下方 |
rp.save(path=None) |
存成一個 xlsx,見下方 |
rp.set_benchmark(data) |
事後設定/更新基準指數(同一天多筆取最後一筆) |
沒有
rp.trade。 逐筆交易在rp.trade_detail。
單策略時欄名是 Strategy,不是 config.name
config.name 只在多策略時才成為欄名。core_dic 只放一個 core 時,
整份 rp.report 只有 Strategy 一欄(給了 benchmark 就多一欄 benchmark),
config.name 那一欄根本不存在:
input
cfg = bt.config(name='MA5_10_0050', symbol='0050', ...)
rp = bt.generate_report({cfg: core}, benchmark=bench)
print(list(rp.report.columns))
rp.report.loc['Invest', 'MA5_10_0050']
output
安全寫法:
rp.dashboard()
| 參數 | 值 | 行為 |
|---|---|---|
path |
None(預設) |
自動命名 YYYYMMDD_dashboard_N.html 存進 Path.cwd(),N 從 1 遞增,不覆蓋既有檔 |
'out.html' |
存到指定路徑 | |
view |
'notebook'(預設) |
Jupyter 內嵌顯示。非 notebook 環境只會印出 <IPython.core.display.HTML object> |
'web' |
存檔後用瀏覽器開啟 | |
None |
只存檔,不顯示也不開啟 | |
page_size |
20 |
表格每頁筆數 |
unit / unit_label |
1e4 / '萬' |
金額圖的單位,兩者要一致(1, '元' 就是元) |
在終端或腳本裡跑,要明確給 path,否則檔案會散落在當前目錄:
view='notebook'且path=None且確實在 notebook 裡時,不會存檔,只內嵌顯示。 其餘組合一律存檔。
四個分頁:
| 分頁 | 內容 |
|---|---|
| 總覽 | 上方 10 張卡片(總報酬率、平均年報酬、年化報酬、投資金額、淨利潤、勝率、Calmar、Sharpe、Sortino、年化波動率);近期報酬與交易次數、資金使用率、資料明細(含 MddRatio/MddAmount/MddDuration);累計報酬、月報酬熱圖(% 與金額)、損益與回撤(金額)圖、最大 5 次回撤(天數/金額)表 |
| 績效分析 | 個股績效表(交易次數、勝率、total_qty、報酬率、損益)、每日摘要 |
| 交易明細 | 委託(最新委託/全部委託)、成交(最新庫存/全部成交) |
| 個股分析 | 選股票 → K 線(進出場標記,並畫出策略的 self.char/self.char1 指標)、進出場規則績效、個股委託、個股成交 |
rp.report 的每一列都會出現在總覽(卡片或表格),新增的列自動補在「資料明細」最後。
股票池很大時檔案會很大:個股分析把每檔每根 K 棒都存進 HTML。實測美股 6,490 檔、317 萬根約 116 MB、產生約 17 秒。
rp.save() — 存成 xlsx
| sheet | 內容 |
|---|---|
report |
rp.report |
daily |
rp.daily(index 名 date) |
trade_main |
rp.trade_main |
trade_detail |
rp.trade_detail |
symbol_stats |
個股績效(同 dashboard 績效分析頁) |
rule_stats |
進出場規則績效(股票代號、進場規則、出場規則、次數、占比、total_pnl、average_pnl) |
- 不給
path時自動命名YYYYMMDD_report_N.xlsx存在目前資料夾,N從 1 找第一個沒用過的,不覆蓋舊檔;副檔名一律改成.xlsx。 - 不存
daily_close:股票池的「天數 × 檔數」寬表動輒幾十萬格,xlsx 寫入會佔掉九成時間。要的話直接拿rp.daily_close。
曝險率要自己算
28 項 KPI 裡沒有曝險率,但它是判斷「輸給 buy&hold 是參數問題還是結構問題」的關鍵:
實測 0050 MA5/MA10:曝險率 61.3%——有 38.7% 的時間空手。 標的同期漲 3 倍時,空手的每一天都是純機會成本,這是結構性的,掃參數救不了。
benchmark 的報酬率從策略 StartDate 起算
_make_benchmark_report(benchmark, index_list, start_date) 刻意以策略的 StartDate
(第一次進場日)對齊,讓兩邊比較同一段期間。所以跟自己算的 buy&hold 會對不上:
rp.report.loc['TotalReturns', 'benchmark'] # 2.9284 ← 從 StartDate(2022-08-15) 起
bench.iloc[-1] / bench.iloc[0] - 1 # 3.0703 ← 從資料第一天(2022-08-01) 起
兩個都對,區間不同。 暖身期越長(MA240 之類)差距越大。 引用「buy&hold 報酬」時要講清楚是哪一個。
多策略時 rp.report 的 Strategy 欄是「把所有 name 的交易按時序合併、
共用一份 capital 重算」的結果;其餘各欄是「該策略獨佔全部資金」的單獨視角。
所以逐檔加總不等於 Strategy。
benchmark = df.set_index('DateTime')['收盤價'].resample('D').last().dropna()
rp = bt.generate_report({cfg1: core1, cfg2: core2},
margin_call=True, capital=10_000_000, benchmark=benchmark)
print(rp.report) # 兩個策略 + benchmark 並列
print(rp.core['pct_TXF'].report) # 只看單一策略
時間欄位是字串
rp.trade_main的order_dt/dt、rp.trade_detail的entry_time/exit_time/current_time都已經strftime成字串(顯示用), 不是datetime64。要做.dt.month、算持有時間,得先pd.to_datetime()。 空值仍是NaN,isna()可以正常篩選未平倉列。 (dtype 在 pandas 2 顯示object、pandas 3 顯示str,值都是 Python 字串。)
rp.trade_main(委託/成交明細)
| 欄位 | 說明 |
|---|---|
name |
策略名稱(對應 config.name) |
symbol |
交易標的代號 |
rule |
觸發此筆委託的訊號名稱(Condition 的 rule) |
type |
委託類型,例如 market_ioc、limit_rod |
order_dt |
委託時間 |
order_qty |
委託數量(正=買、負=賣) |
order_price |
委託價格(市價單為 0) |
dt |
實際成交時間 |
qty |
實際成交數量 |
price |
實際成交價格 |
after_qty |
成交後的持倉數量 |
uid |
唯一委託識別碼 |
math_uid |
配對用識別碼(對應進出場配對) |
rp.trade_detail(進出場配對)
| 欄位 | 說明 |
|---|---|
name |
策略名稱 |
symbol |
交易標的代號 |
direction |
long(多)或 short(空) |
type |
平倉(已結清)或 未平倉(仍持有) |
entry_rule / entry_time / entry_price |
進場的規則、時間、價格 |
exit_rule / exit_time / exit_price |
出場的規則、時間、價格(未平倉為 NaN) |
current_time / current_price |
未平倉時的最新時間與市價(已平倉為 NaN) |
qty |
成交口數(張數),取絕對值 |
unit |
每口合約乘數 |
cost |
總交易成本(手續費 + 交易稅) |
entry_amount |
進場時實際佔用的資金(含保證金,負值) |
exit_amount |
出場後回收金額(pnl + entry_amount;未平倉為 NaN,改看 predict) |
predict |
未平倉部位以當前市價估算的出場金額 |
pnl |
本筆損益(已扣除 cost) |
return |
本筆報酬率(pnl / abs(entry_amount)) |
margin |
本筆保證金佔用 |
想看「扣成本前的毛損益」用
(pnl + cost).sum()。日頻頻繁進出的策略常常毛損益接近零、 全部虧損來自成本——先看這個再看報酬率。
rp.daily(每日績效)
以交易日為單位追蹤資產變化。MDD 與波動度都基於 value_pct。
| 欄位 | 說明 |
|---|---|
cashflow |
當日現金流(進出場產生的淨現金變化) |
entry_cost |
當日持倉的進場成本(負值,代表佔用資金) |
market_value |
當日持倉的浮動市值 |
profit_loss |
累計損益(已實現 + 未實現) |
unrealized |
未實現損益(entry_cost + market_value) |
realized |
已實現損益(profit_loss - unrealized) |
position |
當日最大資金佔用(以保證金/進場金額估算) |
invest |
累計最大投入資本(報酬率的分母,取歷史最大值)。是否計入未實現虧損由 margin_call 決定 |
dd |
當日回撤金額 = profit_loss - profit_loss.cummax()(創新高那天是 0)。rp.report 的 MddAmount 就是它的最小值 |
PNL/COST |
累計損益 / 累計進場成本 |
return |
當日報酬(return_cumsum 的差分) |
return_pct |
當日報酬率:(1 + return_cumsum) / (1 + prev_return_cumsum) - 1 |
value_pct |
當日資產變動率:profit_loss.diff() / (前日 profit_loss + invest),風險指標都用這個 |
rp.report(KPI 摘要,28 項)
| 欄位 | 說明 |
|---|---|
StartDate |
第一筆進場日期 |
EndDate |
回測結束日期 |
BacktestDate |
執行回測當下的日期 |
TotalReturns |
累計總報酬(daily['return'].sum()) |
CAGR |
年化報酬率(複利) |
AAR |
年化報酬率(單利:平均每日報酬 × 252) |
MddRatio |
最大回撤比率(基於 value_pct,負數)。2026-10 前叫 MaxDrawdown |
MddAmount |
最大回撤金額 = daily['dd'].min()(負數,元)。可能比任何單月虧損都大(月中跌深、月底反彈時月損益看不出來) |
MddDuration |
最長回撤持續天數 |
CalmarRatio |
CAGR / abs(MddRatio)。bt.Tools.optimize 的評分 |
SharpeRatio |
夏普比率(基於 value_pct,年化) |
SortinoRatio |
索提諾比率(只計下行波動) |
AnnualisedVolatility |
年化波動率(value_pct 標準差 × √252) |
WinRate |
勝率(已平倉交易中 pnl > 0 的比例) |
ProfitLossRatio |
盈虧比(獲利總額 / 虧損總額) |
MaxPosition |
期間最大資金佔用(峰值 position) |
Invest |
期間最大投入資本(峰值 invest,受 margin_call 影響) |
AvgCashUtilizationRate |
平均資金使用率 |
TotalTrades |
總交易筆數(含未平倉) |
ProfitTimes / LossTimes |
獲利/虧損筆數(已平倉) |
NetProfit |
淨利 = round(daily['profit_loss'].iloc[-1]) |
TradeCost |
總交易成本(所有手續費+稅) |
AvgProfitPerTrade |
NetProfit / TotalTrades |
WeeklyReturn |
近 5 根(約 1 週)報酬 |
MonthlyReturn |
近 20 根(約 1 月)報酬 |
QuarterlyReturn |
近 60 根(約 1 季)報酬 |
AnnualReturn |
近 240 根(約 1 年)報酬 |
NetProfit取自每日曲線末值並四捨五入,期末若有未平倉部位會與rp.trade_detail['pnl'].sum()有小差異(未平倉部位在trade_detail預扣進出場雙邊成本, 在daily只計單邊)。全部平倉時兩者一致。
設定 benchmark 會多一欄;WinRate / TotalTrades 等交易統計對 benchmark 不適用,值為空。