案例集:小白怎麼問回測
所屬章節:入門 三個由淺入深的回測案例,每個都附「白話提問 → 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 股。
堅持用張制就得自己換算並取整:
但實測這樣寫單筆進場中位數是 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 |
選股 + 回測到此為止 |
相關頁
- 開始使用 SnowyOwl:三條路的分流
- 回測 api.bt 總覽:積木 sig 餵進 Core 的完整路徑
- 商品規格與績效報告:
unit/capital/tax_*的完整說明 - 錯誤訊息速查:13 項「不報錯但結果是錯的」
- Block 已知落差:積木名稱可能與實際邏輯不符