跳轉到

回測:商品規格與績效報告

所屬章節:回測 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 成本公式:

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

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

rp = bt.generate_report(core_dic, margin_call=False, capital=1000000, benchmark=None)
參數 說明 預設
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 的比例:

self.Condition.Global(True, 0.2, 0, self.PriceType.market_ioc, None, 'bk')
#                           ^^^ 投入當下資金池的 20%

此時:

capital 在進場時 = 起始資金 + entry_time 之前已實現的損益
口數 raw = capital × add_qty / margin,再依 lot_size 取整與夾限

capital 只累加已實現損益,不會扣掉未平倉部位佔用的資金。 所以 N 檔同時進場時每檔都拿「同一份 capital」的比例去算口數, add_qty × 同時持有檔數 若 > 1 就是隱性槓桿。取 1/檔數 最安全。

兩種部位大小的寫法

Condition.Global 的 add_qty 有兩種語意,由「是否 < 1」決定, 而單位由 config.unit 決定——兩者搭錯會差 1000 倍。

A. 換股/輪動:qty = 1/n

n 檔輪流持有、共用一份資金:

N = len(SYMS)
self.Condition.Global(True, 1 / N, 0, self.PriceType.market_ioc, None, 'bk')

qty < 1 走百分比路徑,capital 真的生效,rp.report['Strategy'] 是共用資金池的組合視角。

實測 3 檔、capital=10,000,000、qty=1/3(unit=1000):

交易 164 筆 / qty=0 被夾掉 2 筆
單筆進場金額  中位 2,700,843   最大 3,259,638
MaxPosition 8,563,185          ← < capital,沒有超額

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 股。

以張計的正確寫法要自己換算並取整:

qty = max(1, round(100000 / (row['Close'] * 1000)))    # unit=1000

實測這樣寫單筆進場中位 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 會拋:

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

實務上最常見的原因不是版本,而是資金不夠買一個單位(台股一張 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

['Strategy', 'benchmark']

KeyError: 'MA5_10_0050'

安全寫法:

COL = 'Strategy' if len(core_dic) == 1 else cfg.name
invest = rp.report.loc['Invest', COL]

rp.dashboard()

rp.dashboard(path=None, view='notebook', page_size=20, unit=1e4, unit_label='萬')
參數 值 行為
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,否則檔案會散落在當前目錄:

rp.dashboard(path='dashboard.html', view=None)      # 腳本
rp.dashboard()                                      # notebook

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

rp.save()                        # 存成 ./YYYYMMDD_report_N.xlsx,回傳實際路徑
rp.save('my_report.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 是參數問題還是結構問題」的關鍵:

exposure = (rp.daily['position'].abs() > 0).mean()     # 有部位的交易日佔比

實測 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 不適用,值為空。


相關頁