跳轉到

配方集:照抄就能跑的完整腳本

所屬章節:入門 每一份都是實跑驗證過的完整腳本,貼上換參數就能執行 績效數字為實測值,資料日 2026-09-11

這頁在解決什麼

寫一支最簡單的單檔回測,資訊散在六頁裡:

backtest.md          → daily_bars() 範本、為什麼不用 get_stock_ohlc
backtest-strategy.md → 標準骨架、on_init 放參數、暖身期
backtest-order.md    → Condition.Global 七個參數
backtest-report.md   → unit/capital/tax 陷阱
backtest-tools.md    → self.Ind 每根都要呼叫
data-history.md      → 日頻 1000 筆上限

這頁把它們拼好。 要改需求時再回去查對應那頁。


共用前置

import sys
sys.path.insert(0, r'<snowyowl_project 路徑>')    # 用原始碼樹時才需要;pip 裝的不用
import pandas as pd
import snowyowl as so

api = so.login(type='google')        # 或 so.login('user@x.com', 'pwd')
bt = api.bt

取日 K 的標準函式

日 K 一律從 api.MSMP 取,不要用 bt.Tools.get_stock_ohlc—— 後者是 1 分 K resample,跟積木的日頻 sig 對不上,還多一次大量下載。

def daily_bars(api, symbol, adj=True):
    """回傳可直接餵 feed_data 的 df。adj=True 用還原價。"""
    k = api.MSMP.日_K
    p = 'adj_' if adj else ''
    cols = {'開盤價': f'{p}Open', '最高價': f'{p}High', '最低價': f'{p}Low',
            '收盤價': f'{p}Close', '成交量': f'{p}Volume' if adj else 'Volume'}
    df = pd.DataFrame({zh: getattr(k, en)[symbol] for zh, en in cols.items()})
    df.index.name = 'DateTime'
    return df.dropna().reset_index()      # ← dropna 必要,未上市期間是 NaN

要 adjclose 就整組都用 adj_*(含 adj_Volume),不要混用還原價與未還原量。

成本速查

標的 config
台股個股(以股計) fee=0.001425, tax_long=0, tax_short=0.003, unit=1
台股個股(以張計) 同上但 unit=1000,qty 要換算成張數
台股 ETF(0050/0056…) fee=0.001425, tax_long=0, tax_short=0.001, unit=1
台指期 fee=500, tax_long=0.00002, tax_short=0.00002, unit=200, margin_long=0.13
美股 fee=0, tax_long=0, tax_short=0, unit=1

台股只填 tax_short,tax_long 留 0——兩邊都填會把證交稅收兩次。


配方一:單檔技術指標回測(每次固定金額)

對應 sample.md 案例 1。0050 的 MA5/MA10 多頭排列買、空頭排列賣,每次 10 萬。

SYM, AMOUNT = '0050', 100_000
df = daily_bars(api, SYM, adj=True)

class MaCross(bt.Core):
    def on_init(self):                      # 只放參數與狀態,下單會 RuntimeError
        self.n_fast, self.n_slow = 5, 10
        self.amount = AMOUNT
        self.close_list = []

    def on_bar(self, row):
        self.close_list.append(row['Close'])         # row 的 key 一律英文
        fast = self.Ind.rolling_mean(self.close_list, self.n_fast)
        slow = self.Ind.rolling_mean(self.close_list, self.n_slow)
        self.char['ma5'], self.char['ma10'] = fast[-1], slow[-1]    # 進 char 才上圖
        if len(self.close_list) < self.n_slow:
            return                                   # 暖身期不下單

        if fast[-1] > slow[-1] and self.global_qty == 0:
            qty = self.amount / row['Close']         # ★ 股數,必須搭 unit=1
            self.Condition.Global(True, qty, 0, self.PriceType.market_ioc, None, 'bk')
        elif fast[-1] < slow[-1] and self.global_qty > 0:
            self.Condition.Global(True, -self.global_qty, 0,
                                  self.PriceType.market_ioc, None, 'bp')

core = MaCross()
core.feed_data(df, ['DateTime', '開盤價', '最高價', '最低價', '收盤價', '成交量'])

cfg = bt.config(name='MA5_10_0050', symbol=SYM,
                fee=0.001425, tax_long=0, tax_short=0.001, unit=1)   # ETF 稅率
bench = df.set_index('DateTime')['收盤價']
rp = bt.generate_report({cfg: core}, capital=1_000_000, benchmark=bench)

驗證段(每次回測都跑)

COL = 'Strategy'                    # ★ 單策略時欄名就是這個,不是 config.name
td = rp.trade_detail
gross, cost = (td['pnl'] + td['cost']).sum(), td['cost'].sum()
invest = rp.report.loc['Invest', COL]

print(f'毛損益 {gross:,.0f} / 成本 {cost:,.0f}(佔 Invest {cost/invest*100:.1f}%)')
print(f'期間 {rp.report.loc["StartDate",COL]} ~ {rp.report.loc["EndDate",COL]}')
print(f'曝險率 {(rp.daily["position"].abs()>0).mean()*100:.1f}%')
print(f'策略 {rp.report.loc["TotalReturns",COL]*100:.2f}% '
      f'vs benchmark {rp.report.loc["TotalReturns","benchmark"]*100:.2f}%')

rp.dashboard(path='dashboard.html', view=None)      # ★ 腳本裡要給 path

實測結果

期間 2022-08-15 ~ 2026-09-11(4.08 年,日頻 1000 筆上限)
                     策略        0050 B&H
TotalReturns      +81.04%       +292.84%
CAGR               16.31%         41.71%
MaxDrawdown       -14.23%        -27.48%
SharpeRatio          1.38           1.56
AnnualisedVol      11.38%         24.28%

毛損益 104,213 / 成本 20,644(佔 Invest 20.0%)
53 筆交易、勝率 48%、盈虧比 2.56、曝險率 61.3%

(2026-09 的輸出;2026-10 起 rp.report 的 MaxDrawdown 改名 MddRatio,另有 MddAmount 最大回撤金額。)

怎麼讀:總報酬輸很多,但 MDD 與波動都只有一半。 曝險率 61.3% 是關鍵——38.7% 的時間空手,而標的同期漲 3 倍。 這是結構問題,不是參數問題(實測掃 95 組 MA 參數,贏過 buy&hold 的是 0 組)。


配方二:積木訊號當進出場

積木選出的 sig 是日頻矩陣,與 MSMP 日 K 天然對齊,不需 reindex/ffill。

SYM = '2330'
con = api.Block.con_tw('今日收盤價<>D日(周月)均線', period=20, table='日_K')
r = api.Block.screener_tw([con])

df = daily_bars(api, SYM, adj=True)
df['sig'] = df['DateTime'].map(r['sig'][SYM]).fillna(False)

class BlockStrategy(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(df, ['DateTime', '開盤價', '最高價', '最低價', '收盤價', '成交量'],
               columns=['sig'])        # ★ 額外欄位一定要宣告,否則 row 裡沒有
cfg = bt.config(name=f'block_{SYM}', symbol=SYM,
                fee=0.001425, tax_long=0, tax_short=0.003, unit=1000)
rp = bt.generate_report({cfg: core}, capital=1_000_000)

選股結果不要直接當回測標的 —— 用今天選出的股票回測過去是 look-ahead, 等於事先知道誰會被選上。要嘛用固定清單,要嘛在策略內逐日重算。


配方三:多檔組合(共用資金池)

關鍵:qty 要填 0~1 的比例,capital 才會生效。固定口數走向量化路徑,capital 完全不參與計算。

SYMS = ['2330', '2317', '2454', '1301', '2882']
W = 1 / len(SYMS)                     # ★ 1/檔數 是安全上限,見下方說明

class PctStrategy(bt.Core):
    def on_init(self):
        self.n_fast, self.n_slow = 5, 20
        self.close_list = []

    def on_bar(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)
        if len(self.close_list) < self.n_slow:
            return
        if fast[-1] > slow[-1] and self.global_qty == 0:
            self.Condition.Global(True, W, 0, self.PriceType.market_ioc, None, 'bk')
        elif fast[-1] < slow[-1] and self.global_qty > 0:
            self.Condition.Global(True, -self.global_qty, 0,
                                  self.PriceType.market_ioc, None, 'bp')

core_dic = {}
for s in SYMS:
    c = PctStrategy()
    c.feed_data(daily_bars(api, s), ['DateTime','開盤價','最高價','最低價','收盤價','成交量'])
    core_dic[bt.config(name=s, symbol=s, fee=0.001425,
                       tax_long=0, tax_short=0.003, unit=1000)] = c

rp = bt.generate_report(core_dic, capital=10_000_000)
print(rp.report['Strategy'])          # ★ 只有這一欄是組合,其餘是「該檔獨佔全部資金」

rp.report 逐檔加總 ≠ Strategy。 其餘各欄是單獨視角。

為什麼是 1/檔數:capital 只累加已實現損益、不扣未平倉佔用的資金, 所以 N 檔同時進場時每檔都拿同一份 capital 的比例算口數, weight × 同時持有檔數 > 1 就是隱性槓桿。

資金不夠時的誤導訊息:capital × weight 買不起一張(台股 1000 股)時, floor 把口數夾成 0,全部被夾掉會拋 RuntimeError: make_daily: 每日損益全為 0——訊息說是版本問題, 實際上幾乎都是資金不夠。三種解法:提高 capital、減少檔數, 或改用零股 unit=1(檔數多、資金小時這才是正解,見配方五)。


配方四:期貨雙均線多空反手(台指期 TXF)

對應 sample.md 案例 3。60 分 K、快線上穿做多、下穿做空、多空反手、結算日強制出場。 期貨資料在 api.MSMP._fut_TXF(底線私有屬性),不在 api.Data 的 80 張表裡。

import numpy as np

df1 = api.MSMP._fut_TXF     # DateTime, Open, High, Low, Close, TotalVolume, settlement

class DualMA(bt.Core):
    def on_init(self):
        self.n_fast, self.n_slow = 5, 20
        self.tick_map = [(np.inf, 1)]        # ★ 期貨最小跳動 1 點,不設會套股票規格
        self.C = []

    def on_bar(self, row):
        self.C.append(row['Close'])
        fast = self.Ind.rolling_mean(self.C, self.n_fast)
        slow = self.Ind.rolling_mean(self.C, self.n_slow)
        if len(self.C) <= self.n_slow:
            return                            # 暖身期不下單
        self.char['fast'], self.char['slow'] = fast[-1], slow[-1]

        # ★ 「上穿」要比較前一根,不是單純比大小
        cross_up = fast[-2] <= slow[-2] and fast[-1] > slow[-1]
        cross_dn = fast[-2] >= slow[-2] and fast[-1] < slow[-1]

        # ★ 多空反手:一次補齊到目標部位(±1 口),不必先平再開
        self.Condition.Global(cross_up and self.global_qty < 1,
                              -self.global_qty + 1, 0,
                              self.PriceType.market_ioc, None, 'long')
        self.Condition.Global(cross_dn and self.global_qty > -1,
                              -self.global_qty - 1, 0,
                              self.PriceType.market_ioc, None, 'short')
        # 結算日強制平倉
        self.Condition.Global(row['DateTime'] == row['Settlement'] and self.global_qty != 0,
                              -self.global_qty, row['Close'],
                              self.PriceType.compel_now, False, '結算')

core = DualMA()
core.set_tick_period(freq=60, need_settlement=True,      # 60 分 K,日盤+夜盤
                     args=['DateTime','Open','High','Low','Close','TotalVolume'])
core.feed_tick_df(df1)                        # ★ 期貨走 feed_tick_df,不是 feed_data
cfg = bt.config(name='dualma', symbol='TXF', fee=500,
                tax_long=0.00002, tax_short=0.00002, unit=200)
rp = bt.generate_report({cfg: core})

多空反手的寫法

-self.global_qty + 1 是「一次補齊到 +1 口」:手上是 -1 口時送 +2、空手時送 +1、 已經 +1 口時條件 global_qty < 1 不成立所以不送。不需要先平倉再開倉。

部位檢查要用 < 1 / > -1,不是 == 0——用 == 0 就只能空手時進場,反不了手。

實測結果(2,589,900 列 1 分 K → 60 分 K)

TotalTrades 2508(多 1254 / 空 1254)
多單 pnl  +4,922,079
空單 pnl  -2,591,080      ← 空單是虧的

多空分開看損益,不要只看總和:

td = rp.trade_detail
for d in ('long', 'short'):
    s = td[td['direction'] == d]
    print(f'{d}: {len(s)} 筆, pnl {s["pnl"].sum():,.0f}, 勝率 {(s["pnl"]>0).mean()*100:.1f}%')

這組參數的空單是淨虧損的——總報酬為正純粹靠多單撐著。 只看總績效會誤以為「多空都做有效」。

期貨四個必做:tick_map=[(np.inf,1)]、fee=500(>=1 自動切換成每口固定金額)、 unit=200、結算日判斷用 row['DateTime'] == row['Settlement']。

要多週期(60 分進場 + 日線過濾)一定要走 set_tick_period(freq=[60,'D']) + feed_tick_df, feed_data 不會觸發 on_bar2。


配方五:目標權重輪動(選股 + 月頻換股)

對應 sample.md 案例 2、5。文件到處說「要用目標權重不要固定金額」,這是實作。 小資金做多檔輪動一定要用零股,見下方對照。

TOP_N, CAPITAL = 20, 1_000_000
M = api.MSMP

# ── 1) 股池:排除 ETF / KY / DR ─────────────────────────
bad = set(M.list['etf']) | set(M.list['KY']) | set(M.list['DR'])
close     = pd.DataFrame(M.日_K.adj_Close)
raw_close = pd.DataFrame(M.日_K.Close)      # ★ 未還原價才是真實股價,門檻用它
vol       = pd.DataFrame(M.日_K.Volume)     # 單位是「張」
univ = [c for c in close.columns if c not in bad]
close, raw_close, vol = close[univ], raw_close[univ], vol[univ]
R = close.index

# ── 2) 可交易性遮罩(排名前先套)────────────────────────
turnover = (raw_close * vol * 1000).rolling(20).mean()     # ★ Volume 是張,要 ×1000
tradable = (turnover.rank(axis=1, pct=True) > 0.4) & (raw_close >= 10)

# ── 3) 因子:月營收 YoY,用官方方法轉成公告日 ────────────
feat = pd.DataFrame(M.月_營收.YoY.index_str_to_date()).reindex(columns=univ)
feat = feat.reindex(feat.index.union(R)).ffill().reindex(R)   # union→ffill→reindex

# ── 4) 換股日 = 營收公告日,每次選 top_n ─────────────────
pub = pd.DataFrame(M.月_營收.YoY.index_str_to_date()).index
score = feat.where(tradable)                  # ★ 先遮罩再排名,母體才一致
picks = {d: score.loc[d].dropna().nlargest(TOP_N).index.tolist()
         for d in sorted(set(pub) & set(R)) if score.loc[d].notna().any()}

# ── 5) 攤成「每天該持有誰」的布林表 ──────────────────────
all_syms = sorted({s for v in picks.values() for s in v})
hold = pd.DataFrame(False, index=R, columns=all_syms)
cur = []
for d in R:
    if d in picks:
        cur = picks[d]
    if cur:
        hold.loc[d, [c for c in cur if c in hold.columns]] = True

# ── 6) 每檔一個 core,qty=1/N 走百分比路徑 ───────────────
W = 1 / TOP_N

class Rotate(bt.Core):
    def on_bar(self, row):
        if row['sig'] and self.global_qty == 0:
            self.Condition.Global(True, W, 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')

k = M.日_K
core_dic = {}
for s in all_syms:
    df = pd.DataFrame({'開盤價': k.adj_Open[s], '最高價': k.adj_High[s],
                       '最低價': k.adj_Low[s], '收盤價': k.adj_Close[s],
                       '成交量': k.adj_Volume[s]}).dropna()
    if len(df) < 100:
        continue
    df.index.name = 'DateTime'
    df = df.reset_index()
    df['sig'] = df['DateTime'].map(hold[s]).fillna(False)
    if not df['sig'].any():
        continue
    c = Rotate()
    c.feed_data(df, ['DateTime','開盤價','最高價','最低價','收盤價','成交量'],
                columns=['sig'])
    core_dic[bt.config(name=s, symbol=s, fee=0.001425 * 0.3,       # 手續費 3 折
                       tax_long=0, tax_short=0.003, unit=1)] = c   # ★ unit=1 = 零股

rp = bt.generate_report(core_dic, capital=CAPITAL,
                        benchmark=pd.DataFrame(M.日_K.adj_Close)['0050'].dropna())
print(rp.report['Strategy'])        # ★ 只有這欄是組合

零股 vs 整股:小資金只能做零股

unit=1 是以股計價,也就是零股。這不是變通寫法—— 小資金做多檔輪動在現實中本來就是買零股。實測對照(capital 100 萬、20 檔、W=1/20):

設定 結果
unit=1(零股) 正常跑。單筆進場中位 53,031 元(≈ capital/20)、股數中位 1560 股、最小 120 股
lot_size='inf,0,floor,1000'(整股) 掛掉,全期只成交 2 筆

整股版之所以掛掉:capital × 1/20 = 5 萬,買不起一張台股(動輒幾十萬), floor 把口數夾成 0,然後拋出那個會誤導人的錯誤訊息:

RuntimeError: make_daily: 每日損益全為 0,無法產生報表。
常見原因是 pandas / numpy 版本不相容…

它跟版本無關,就是資金不夠買一個單位。

你要的 設法
零股(1 股一取) unit=1,lot_size 用預設 'inf,0,floor,1'
整股(1000 股一取) unit=1 + lot_size='inf,0,floor,1000',且 capital × W 要買得起一張
整張思考(qty 是張數) unit=1000,qty 自己換算成張數

框架沒有「最低手續費」參數。 fee 只有費率(<1)與每口固定金額(>=1)兩種模式, 無法表達券商零股常見的 20 元低消。小額零股的真實成本會比回測高, 單筆 5 萬 × 0.1425% × 3 折 ≈ 21 元剛好在低消邊緣,再小就會被低消吃掉。

實測結果(60 檔子集、月營收 YoY 單因子)

股池 2874 → 排除 ETF/KY/DR 後 2331
可交易遮罩:平均每日 1211 檔通過
換股 49 次,每次 20 檔,全期出現過 236 檔

TotalReturns   +14.85%     vs  0050 benchmark +312.82%
MaxPosition    638,979     / capital 1,000,000   ← capital 確實生效
毛損益 90,947 / 成本 25,348(佔 Invest 5.7%)
TotalTrades 122

大幅落後 0050,原因見配方六的 IC 分析—— 月營收 YoY 單因子的 IC 幾乎為零。這正是 how-to-ask.md 案例 2 「單因子選股輸給大盤」的完整重現。要改善得做多因子合成(sample.md 案例 5)。

MaxPosition < capital 是確認 capital 有生效的指標。 若遠大於 capital,代表走了固定口數路徑,capital 根本沒參與計算。


配方六:訊號驗證(IC 與分組單調性)

對應 sample.md 案例 4 的第 ④ 階。在花時間跑回測之前先做這個。

兩個一定要知道的陷阱

① 月頻/季頻的 index 是字串,用 index_str_to_date() 轉,不要自己接。

月_營收 的 index 是 '2026-M08'(營收所屬月份),不是公告日。 直接拿去跟日頻對齊就是未來函數——8 月營收要到 9/10 才公告。 MSMP 的矩陣有現成方法:

rev = api.MSMP.月_營收.YoY          # index: '2026-M06', '2026-M07', '2026-M08'
rev_d = rev.index_str_to_date()     # index: 2026-07-13, 2026-08-10, 2026-09-10

它做了三件事,自己接 INDEX['M'] 都做不到:

做了什麼 說明
月頻 → 次月 10 日 月營收法定公告日;12 月跨年處理好;再用 _to_trading_day() 順延到實際交易日(2026-M06 → 07-13 因為 7/10 是週末)
季頻 → 財報公告日 Q1→5/15、Q2→8/14、Q3→11/14、Q4→次年 3/21。2025-Q4 → 2026-03-23
自動遮蔽下市後的資料 依 delist_dates,下市後布林填 False、數值填 NaN。這是防倖存者偏差的關鍵

年頻('2026-Y')同樣支援。一律用它,不要自己拼日期。

② corrwith 會炸,要先 pd.DataFrame() 脫殼。

MSMP 回的是 ProboDataFrame(pd.DataFrame 的子類),它覆寫了 __mul__ / __sub__ 等運算子去做自動 reshape。pandas 的 corrwith 內部會做 ldem * rdem, 一旦兩邊 index 型態不同就會走進 reshape 然後爆掉:

TypeError: '<=' not supported between instances of 'str' and 'Timestamp'

解法是脫殼成純 pandas:

close = pd.DataFrame(api.MSMP.日_K.adj_Close)     # ← 加這層就好

一般的矩陣運算(相加、相除、比大小)要保留 ProboDataFrame, 自動對齊很好用。只有在呼叫 corrwith 這類 pandas 內建統計方法時才需要脫殼。

完整程式碼

import pandas as pd

close = pd.DataFrame(api.MSMP.日_K.adj_Close)     # ★ 脫殼
fwd = close.shift(-20) / close - 1                # 未來 20 日報酬
R = close.index

# 特徵:月營收 YoY,轉成公告日後對齊到日頻
feat = pd.DataFrame(api.MSMP.月_營收.YoY.index_str_to_date())
feat = feat.reindex(feat.index.union(R)).ffill().reindex(R)   # ★ union→ffill→reindex

# ① IC
ic = feat.corrwith(fwd, axis=1).dropna()
print(f'IC n={len(ic)}, mean={ic.mean():.4f}, IR={ic.mean()/ic.std():.3f}')

# ② 分組單調性(5 組)
q = feat.rank(axis=1, pct=True)
for i in range(5):
    mask = (q > i/5) & (q <= (i+1)/5)
    print(f'  Q{i+1}: {fwd.where(mask).stack().mean()*100:+.2f}%')

實測結果(月營收 YoY)

IC n=980, mean=-0.0014          ← 幾乎為零
  Q1: +0.69%
  Q2: +1.07%
  Q3: +1.34%
  Q4: +1.66%
  Q5: +2.14%                    ← 但分組是單調遞增的

怎麼讀:IC 接近零卻分組單調,代表這個因子有方向性但訊號很弱, 橫斷面相關係數被極端值稀釋了(月營收 YoY 有 +1,073,121% 這種除以零的值)。 單獨用它選前 20 檔會選到一堆財報異常的公司——這正是 how-to-ask.md 案例 2 「單因子 YoY 選股輸給 0050」的量化原因。

判讀標準:|IC| > 0.03 算有訊號;分組要單調才可信。 兩個都不過就別跑回測了,先換因子或做極端值處理。


配方七:參數尋優

def opt(n_fast=5, n_slow=10):
    core = MaCross()
    core.n_fast, core.n_slow = n_fast, n_slow      # 覆寫 on_init 設的 self.xxx
    core.feed_data(df, ['DateTime','開盤價','最高價','最低價','收盤價','成交量'])
    return bt.generate_report({cfg: core}, capital=1_000_000)

best = bt.Tools.optimize(opt, {'n_fast': [3, 20, 1], 'n_slow': [10, 60, 5]}, times=50)
rp_best = opt(**best)
print(best, rp_best.report.loc['TotalTrades', 'Strategy'])   # ★ 一定要看交易次數

三個警告:

  1. 每次呼叫都要建新的 core——core 有狀態,重用會累積前一次的部位。
  2. 評分是 CalmarRatio(CAGR / abs(MddRatio)),完全不看交易次數—— 可能挑出只交易 3 次剛好都對的參數。
  3. 尋優本身就是過度配適的來源。 至少把資料切前後兩段分別驗。

掃參數之前先看曝險率——低曝險 + 大多頭標的時,掃參數救不了,見配方一的結論。

前後兩段分別驗(sample 案例 3、4 都要求)

文件到處都說「至少切前後兩段」,做法是這樣:

td = rp.trade_detail
d = pd.to_datetime(td['entry_time'])        # ★ 時間欄是字串,要先轉
mid = d.min() + (d.max() - d.min()) / 2

for label, mask in (('前段', d < mid), ('後段', d >= mid)):
    s = td[mask]
    print(f'{label}: {len(s)} 筆, pnl {s["pnl"].sum():,.0f}, '
          f'勝率 {(s["pnl"]>0).mean()*100:.1f}%')

實測配方四那組雙均線參數:

切點 = 2022-01-13
  前段: 1258 筆, pnl   -457,726, 勝率 34.2%
  後段: 1250 筆, pnl +2,788,724, 勝率 37.6%

兩段差距這麼大就不能只看總績效。 這組參數的獲利幾乎全來自後段, 前段是虧的——代表它吃的是某段特定行情,不是穩定的邊際優勢。

entry_time / exit_time 都是 strftime 過的字串不是 datetime64, 直接用 .dt 會失敗,一定要先 pd.to_datetime()。

更嚴格的做法是只在前段尋優、拿後段驗證(真正的樣本外):

# 只餵前段資料尋優 → 得到 best → 用 best 跑後段,看還成不成立

分段驗證不過的參數,optimize 給的分數再漂亮都不能用。


跑完一定要回答的三件事

不論哪個配方:

問題 怎麼算 為什麼
毛損益與成本佔比 (td['pnl']+td['cost']).sum()、cost/Invest 日頻策略常常毛損益為正、虧損全來自成本。看到負報酬就否定策略是誤判
回測期間實際多長 rp.report.loc['StartDate'/'EndDate', COL] 日頻硬上限 1000 筆(約 4.1 年),使用者以為的「近十年」拿不到
曝險率 (rp.daily['position'].abs()>0).mean() 判斷輸給 buy&hold 是結構問題還是參數問題

相關頁