跳轉到

案例集:小白怎麼問回測

所屬章節:入門 三個由淺入深的回測案例,每個都附「白話提問 → LLM 產出 → 必須追問的問題」 程式碼中的參數合法值以 get_table_tw() 與各參考頁為準;績效數字引自各頁實測

這頁不教 API,教怎麼問。API 細節在 backtest.md 起的五頁。

三個案例共同的一句話:每次回測結束都要問「成本佔多少」和「回測期間實際是哪一段」。 文件裡那個 -11.31% 的 MA20 例子,毛損益其實是 +22,000——策略本身沒錯, 錯在日頻訊號進出太頻繁,51 筆交易吃掉 32 萬成本。新手最容易誤判的就是這裡。

案例 難點
一、月營收選股回測 unit 配錯差 1000 倍
二、台指期 TXF 回測 期貨四個專屬規格
三、組合回測 + 參數尋優 組合視角與過度配適

案例一:月營收選股回測

提問

我想找月營收年增率成長超過 20% 的上市股票,挑最多 10 檔, 每檔投入 10 萬元做回測。手續費和證交稅要算對。

四個要素:條件(月營收年增率 >20%)、股池(上市)、檔數上限(10 檔)、 每檔金額(10 萬)。其中「每檔 10 萬」是關鍵——框架不能直接填金額, 必須換算成股數,而且 unit 配錯會差 1000 倍。

LLM 應該產出

import snowyowl as so
import pandas as pd

api = so.login('user@x.com', 'pwd')

# ── 1) 選股:月營收年增率 > 20%,股池限上市 ──────────────────
con = api.Block.con_tw('月營收年增率>±P%', operator='>', percent=20)
#   percent 合法值:20/30/40/50/-20/-30/-40/-50
r = api.Block.screener_tw([con], pool=['tse'])       # tse = 上市 1125 檔

print(f"資料日期 {r['date'].date()},選出 {r['dataframe'].shape[0]} 檔")
picks = r['dataframe']['stock_id'].head(10).tolist()  # ⚠️ head(10) 不是排行
print(picks)

# ── 2) 日 K 從 MSMP 取,與 sig 天然對齊 ─────────────────────
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()

# ── 3) 策略:每檔固定投入 10 萬元 ───────────────────────────
class RevStrategy(api.bt.Core):
    def on_init(self):
        self.amount = 100_000            # 每檔投入金額

    def on_bar(self, row):
        if row['sig'] and self.global_qty == 0:
            qty = self.amount / row['Close']       # ★ 換算成「股數」,不是張數
            self.Condition.Global(True, qty, 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')

# ── 4) 每檔開一個 core,最後一起進報表 ──────────────────────
core_dic = {}
for s in picks:
    bars = daily_bars(api, s)
    bars['sig'] = bars['DateTime'].map(r['sig'][s]).fillna(False)

    core = RevStrategy()
    core.feed_data(bars, ['DateTime','開盤價','最高價','最低價','收盤價','成交量'],
                   columns=['sig'])                # sig 一定要宣告在 columns
    # ★ unit=1 以「股」計,qty 才是股數,名目金額才會剛好 10 萬
    cfg = api.bt.config(name=s, symbol=s,
                        fee=0.001425, tax_long=0, tax_short=0.003, unit=1)
    core_dic[cfg] = core

rp = api.bt.generate_report(core_dic)
print(rp.report.to_string())

最大的坑:unit 配錯差 1000 倍

「每檔 10 萬」在框架裡有兩種寫法,只有一種對:

寫法 單筆進場金額(實測中位數)
qty = 100000/price + unit=1(以股計) 100,077 ← 正確,就是 10 萬
qty = 100000/price + unit=1000(以張計) 100,076,656 ← 差 1000 倍

原因:unit=1000 時框架把 qty 當成張數,100000/2375 = 42.1 被當成 42 張 = 42,100 股。

堅持用張制就得自己換算並取整:

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

但實測這樣寫單筆進場中位數是 653,430——台股一張最少幾十萬, 10 萬的目標在張制下買不到一張,max(1, ...) 只好進位成一張。 要精準控制 10 萬就必須 unit=1。

必須追問的五句

追問 答案
每檔 10 萬要怎麼設? qty = 100000/row['Close'] 搭 unit=1。直接填金額或用 unit=1000 都會錯
head(10) 是營收成長最快的 10 檔嗎? 不是。 errors.md 13 項靜默錯誤之一——dataframe 沒排序,head(10) 只是前 10 列(照代號排)。要挑最強的得對 cond1_* 欄位排序
capital 要設多少? 設了也沒用。 qty >= 1 走固定口數路徑,capital 完全不參與計算。實測設 10 萬 / 100 萬 / 1 億,報表數字完全相同
報表看哪一欄? 固定金額下單要逐檔各自看。Strategy 欄是共用資金池視角,只在百分比下單時才有意義
扣成本前是賺是賠? 永遠要問。見本頁開頭

變體:如果要的是「10 檔共用資金池」

「每檔 10 萬 × 10 檔」另一種解讀是總資金 100 萬平均分配,寫法完全不同:

# 共用資金池:qty < 1 走百分比路徑,capital 才會生效
class RevStrategy(api.bt.Core):
    def on_bar(self, row):
        if row['sig'] and self.global_qty == 0:
            self.Condition.Global(True, 1/10, 0,       # 佔資金池 1/10
                                  self.PriceType.market_ioc, None, 'bk')

cfg = api.bt.config(name=s, symbol=s, fee=0.001425,
                    tax_long=0, tax_short=0.003, unit=1000)   # 1000 或 1 都行
rp = api.bt.generate_report(core_dic, capital=1_000_000)      # capital 這時才生效
print(rp.report['Strategy'])                                   # 看這一欄
固定 10 萬 共用資金池 1/10
qty >= 1,實際股數 < 1,佔資金池比例
unit 必須 1 1000 或 1 都行
capital 不生效 生效,是資金約束
看哪一欄 逐檔各自看 rp.report['Strategy']
適合 看單一規則的期望值 資金有限、10 檔輪動

1/10 不是隨便取的——capital 只累加已實現損益、不扣未平倉佔用, 所以 qty × 同時持有檔數 > 1 就是隱性槓桿。取 1/檔數 剛好是安全上限。

提問模板

用「月營收年增率 > 20%」選上市股票(pool=['tse']),最多 10 檔,
每檔固定投入 10 萬元回測。成本用台股個股規格。
跑完告訴我:
① 資料日期和總共選出幾檔
② unit 設多少、為什麼(我要確認名目金額真的是 10 萬)
③ head(10) 是不是排行,如果不是要怎麼挑最強的
④ 扣成本前的毛損益,成本佔投入資金幾 %

案例二:台指期 TXF 回測

提問

我想回測台指期。用 60 分 K,突破前 20 根高點就做多,停損 1%、停利 2%。 手續費和最小跳動記得用期貨的規格。

期貨跟股票差在四個地方,提問時要交代:週期、規則、風控、期貨規格。

LLM 應該產出

import numpy as np
import snowyowl as so

api = so.login('user@x.com', 'pwd')
bt = api.bt

# 1) 期貨資料在 MSMP._fut_TXF(不在 api.Data 的 80 張表裡)
#    2,570,820 列 1 分 K、2017-05 起、DateTime 零重複 = 單一近月連續序列
df1 = api.MSMP._fut_TXF        # DateTime, Open, High, Low, Close, TotalVolume, settlement

class TxfBreakout(bt.Core):
    def on_init(self):
        self.n = 20
        self.stl, self.pft = 1.0, 2.0
        self.high_list = []
        self.tick_map = [(np.inf, 1)]      # ★ 期貨最小跳動 = 1 點,不設會套用股票規格

    def on_bar(self, row):
        prev_high = max(self.high_list[-self.n:]) if len(self.high_list) >= self.n else None
        self.high_list.append(row['High'])
        if prev_high is None:
            return                          # 暖身期不下單

        self.Condition.Global(row['Close'] > prev_high and self.global_qty == 0,
                              1, 0, self.PriceType.market_ioc, None, 'bk')

        # ★ 停損停利每根都要重掛,不能包在 if 裡
        self.Condition.set_STL('bk', self.stl)
        self.Condition.set_PFT('bk', self.pft)

core = TxfBreakout()

# 2) 用 set_tick_period 聚合成 60 分(期貨預設日盤 08:45-13:45 + 夜盤 15:00-05:00)
core.set_tick_period(freq=60, need_settlement=True,
                     args=['DateTime','Open','High','Low','Close','TotalVolume'])
core.feed_tick_df(df1)

# 3) 台指期規格:fee 是「每口固定金額」不是比例
cfg = bt.config(name='txf_breakout', symbol='TXF',
                fee=500, tax_long=0.00002, tax_short=0.00002, unit=200)
rp = bt.generate_report({cfg: core})
print(rp.report.to_string())

期貨四個一定會踩的坑

追問 答案
期貨資料在哪? api.MSMP._fut_TXF,底線開頭的私有屬性,不在 api.Data 的 80 張表。SnowyOwl 沒有選擇權、海外期貨。見 data-history.md
tick_map 要設嗎? 要。台指期 self.tick_map = [(np.inf, 1)]。不設會套用股票的 [(10,0.01),(50,0.05),...],成交價全錯
fee=500 為什麼不是小數? bt.config 的 fee 依大小自動切換:< 1 為費率、>= 1 為每口固定金額。台指期 fee=500, unit=200
跳空時停損在哪成交? set_STL 掛的是 stop_rod,跳空以開盤價成交。backtest-order.md 有 12 個成交價案例實測表

還有一個容易忽略的:feed_data 不會觸發 on_bar2。 要多週期訊號(例如 60 分進場 + 日線過濾)必須走 set_tick_period(freq=[60,'D']) + feed_tick_df。

判斷結算日的標準寫法:row['DateTime'] == row['Settlement']。

提問模板

用 MSMP._fut_TXF 回測台指期,⟨週期⟩ K 棒,規則是 ⟨白話⟩,停損 X% 停利 Y%。
期貨規格要對:tick_map=[(inf,1)]、fee=500、unit=200。
跑完告訴我:
① 跳空時停損在哪個價成交
② 有沒有穿越結算日的問題
③ 扣成本前的毛損益,成本佔多少

案例三:組合回測 + 參數尋優

提問

我想選「月營收年增率排行前 100 名 + 市值大於 50 億」的股票,排除 ETF, 然後挑其中 5 檔做組合回測,資金 1000 萬平均分配。 跑完幫我看兩件事:一是成本佔多少,二是把均線參數拿去尋優, 但我要知道尋優是不是過度配適。

進階在三個地方:多積木 AND、組合共用資金池、尋優 + 過度配適的警覺。

LLM 應該產出

import sys, snowyowl as so
api = so.login('user@x.com', 'pwd')

# 1) 多積木 AND 選股(參數合法值一定要先查 get_table_tw)
b1 = api.Block.con_tw('月營收年增率排行前N名', top_n=100)   # 合法值 50/100/150/200/500/1000
b2 = api.Block.con_tw('市值>M億', value=50)                # 合法值 10/20/30/50/100
r = api.Block.screener_tw([b1, b2], pool=['all', '~etf'])
print(r['date'], r['dataframe'].shape)                     # ★ 一定要報資料日期

picks = r['dataframe']['stock_id'].head(5).tolist()        # ⚠️ head(5) 不是排行

# 2) 組合回測:MCP 的 BT.run 幫你處理 weight 與百分比下單
sys.path.insert(0, 'mcp')
from snowyowl_mcp import backtest as BT

con = api.Block.con_tw('今日收盤價<>D日(周月)均線', period=20, table='日_K')
rp = BT.run(api, [con], picks, capital=10_000_000)
print(rp.snowyowl_weight)                                  # 未指定時自動 = 1/檔數 = 0.2
print(rp.report.to_string())                               # ★ 只有 Strategy 欄是組合

尋優:

def opt(period=20):
    con = api.Block.con_tw('今日收盤價<>D日(周月)均線', period=period, table='日_K')
    return BT.run(api, [con], picks, capital=10_000_000)

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

組合回測最反直覺的一點

rp.report 裡除了 Strategy 之外的每一欄,都是「該檔獨佔全部資金」的單獨視角, 逐檔加總不等於 Strategy。實測:逐檔 Invest 加總 12,086,944 vs Strategy 的 6,313,784。

必須追問的四句

追問 答案
head(5) 是不是最強的 5 檔? 不是。 errors.md 13 項靜默錯誤之一。dataframe 沒有排序,head(5) 只是前 5 列。要排行得自己對某個 cond<N>_* 欄位排序
capital 有真的生效嗎? 用 qty=1 固定口數時走向量化路徑,capital 完全不參與計算。要生效必須用百分比下單(qty < 1)。BT.run 預設就是這樣
會不會有隱性槓桿? 會。capital 只累加已實現損益,未平倉部位不扣可用資金。N 檔同時進場時 weight × 同時持有檔數 > 1 就是槓桿。weight=1/檔數 是為了避開這個
尋優出來的參數可信嗎? 評分是 CalmarRatio(CAGR / abs(MddRatio)),完全不看交易次數——可能挑出只交易 3 次剛好都對的參數。backtest-tools.md 直接寫「尋優本身就是過度配適的來源」,建議至少切成前後兩段分別驗

提問模板

用 ⟨積木條件 A⟩ 且 ⟨積木條件 B⟩ 選股,排除 ETF,挑 N 檔做組合回測,資金 X 元。
跑完:
① 只看 Strategy 欄
② 確認 capital 有真的生效
③ 成本佔比
④ 尋優後給我 TotalTrades,我要判斷是不是過度配適

小白最常犯的三個提問錯誤

這樣問 為什麼不好 改成
「幫我找會漲的股票」 沒有可對應的積木,LLM 只能猜一個名字像的 講可觀測的條件:營收、法人買超、突破均線
「幫我回測近十年」 日頻硬上限 1000 筆(約 4.1 年),十年做不到 「近三年」,或明說「可以降到週/月頻」
「順便幫我下單」 SnowyOwl 沒有下單功能,api 上不存在 Order 選股 + 回測到此為止

相關頁