昨天結束時手上有三個能跑的策略,而沒有一個數字回答「這些訊號值不值錢」。曝險 48% 與 1.35% 的兩個策略,連比較的基準都還沒有。
今天做回測。而回測這件事有一半的內容是「它能證明什麼」,所以先把那半講完再寫程式。
**回測(backtest)**是拿歷史資料重跑一次策略,看它當時會賺會賠。
它能證明的事只有一件:這組規則在這段歷史上的表現是這樣。 不能證明的事很多,而最重要的一條是它跟未來的關係——歷史上有效不代表未來有效,因為市場結構會變、有人會做一樣的事、參數會失效。
這句話聽起來像免責聲明,但它有實際的操作意義:回測的價值不在「證明這個策略會賺」,在淘汰。一個在歷史上都撐不過的策略,未來能撐過的機率很低;而一個歷史上表現好的策略,只是「還沒被淘汰」。回測是一道篩子,不是一張保證書。
另外兩個詞今天會用到:
今天三個策略都是整段資料一起跑,也就是全部都在樣本內。這件事的後果 Day 21 會展開。
回測是一個很容易騙自己的工具,而騙法有固定的幾種。
一、未來函數(look-ahead bias)。 用了當下還不知道的資訊。最常見的是「用第 t 根的收盤價算訊號,卻假設能在第 t 根開盤成交」。Day 04 第一次警告過,Day 16 把它做成引擎的行為——而今天要把它的價格算出來。
二、倖存者偏差(survivorship bias)。 只測試「還活著」的標的。這在股票回測裡是大問題(下市的公司不在資料庫裡),在加密貨幣裡更嚴重——測試那幾個活到今天的幣,會系統性地忽略掉幾千個歸零的。本系列只做 BTC/USDT 這一個交易對,所以沒有跨標的的選擇問題,但要知道「BTC 活到今天」這件事本身就是一種選擇。
三、用未來資料調參數。 看完整段資料的結果,回頭把參數調到最好,再宣稱那是回測績效。這件事在單一策略上還算容易察覺,在積木化之後會變得很自然——因為改一個參數只是改一行 YAML。
四、參數挖礦(parameter mining)。 試幾百組參數,挑最漂亮的那一個。它是第三種的自動化版本,而積木庫讓它變得非常容易。
後兩種是 Day 21 的主題,那一天會示範在完全沒有訊號的隨機資料上,搜尋幾百個組合照樣能挖出夏普值很好看的「策略」。今天先處理第一種。
回測有兩種寫法。
**逐根模擬(事件驅動)**是 Backtrader 那一類引擎的做法:一根一根往前走,維護一個狀態機(現在有多少現金、多少部位、有哪些掛單)。它能表達的東西多——限價單、部分成交、多資產的資金分配、當根之內的停損觸發。
向量化是一次算完整段:部位是一條序列、報酬是一條序列,兩者相乘再累積。它表達得比較少(沒有「當根之內」的概念),但快好幾個數量級。
這個系列選向量化,兩個理由:跟前面十八天的資料處理一脈相承(NEVER 用 for loop 遍歷 K 線),以及 Day 21 要跑幾百個組合——那件事在逐根模擬上要跑一個下午。
而選了向量化就得回答一個問題:它算的跟逐根模擬是同一件事嗎? 這個問題等一下用一份逐根的對照實作回答。
整段模擬的核心是這樣:
# quantbot/domain/services/backtest_service.py
close = signals.table["close"].astype("float64")
positions = signals.positions
price_returns = close.pct_change().fillna(0.0)
gross_returns = positions * price_returns
turnover = (positions - positions.shift(1, fill_value=0.0)).abs()
cost_returns = turnover * specification.costs.one_way_rate
# 成本是**乘**上去的,不是減掉的:換倉發生在前一根的收盤,付掉成本之後
# 剩下的錢才去承受這一根的漲跌。寫成 (1 + r) - c 會多算一個 c × r 的二階項,
# 數字上很小,但那樣就跟現成的回測引擎對不起來(見測試)。
net_growth = (1.0 - cost_returns) * (1.0 + gross_returns)
net_returns = net_growth - 1.0
equity = specification.initial_capital * net_growth.cumprod()
gross_equity = specification.initial_capital * (1.0 + gross_returns).cumprod()
它之所以只有這幾行,是因為 Day 16 把部位序列的時間語意釘死了:positions[t] 是「第 t 根整根都持有」,成交發生在第 t-1 根的收盤。所以「這一根的策略報酬」就是部位乘上這一根的價格報酬,兩邊講的是同一段時間,不必再對齊一次。
如果當初沒有把那個語意定下來,這裡就得先花一段程式碼決定「部位到底是這一根還是下一根」,而那個決定會散在每個算報酬的地方。
成本那兩行用換手計價,也就是 |position[t] - position[t-1]|。這個寫法自動處理了兩件事:連續持有的那幾根不必付錢,而進場與出場各付一次。不必去數交易筆數,也不會漏掉「結束時還開著的部位只付了一邊」這種情況。
(1 - c) × (1 + r) 而不是 1 + r - c,這個差別只有 c × r 那麼小(0.1% × 1% = 1e-5),但它有具體的意義:換倉發生在前一根的收盤,付掉成本之後剩下的錢才去承受這一根的漲跌。減法的寫法等於讓全額的資金去賺報酬、再從結果裡扣手續費,那是兩件不同的事。
寫成乘法還有一個好處,等一下對照 VectorBT 時會看到。
權益曲線之外還要有交易明細——Day 22 的勝率與賠率、平均持有時間都從它來。而從部位序列還原交易有兩個地方會安靜地錯。
# quantbot/domain/services/backtest_service.py
@staticmethod
def _trades(
positions: pd.Series,
close: pd.Series,
specification: BacktestSpecification,
) -> pd.DataFrame:
"""把部位序列拆成一筆一筆交易。
兩個容易錯的地方:
- **進場價是前一根的收盤價。** `positions[t]` 為 1 代表在第 t-1 根收盤時
成交,所以拿第 t 根的收盤價當進場價會少算(或多算)一根的漲跌,而那個
誤差剛好是策略最想看到的方向。
- **回測結束時還開著的部位要算進來。** 它的出場價是最後一根的收盤價。
漏掉它會讓最後一筆交易憑空消失,而那一筆常常是最大的一筆。
"""
第一個是未來函數的一個變形,而且方向固定:如果拿當根的收盤價當進場價,一個「因為漲了才進場」的策略會把那一根的漲幅算進獲利,於是每一筆交易都憑空多賺一點。
第二個看起來只是邊界情況,但它在趨勢策略上特別重要——趨勢策略賺錢的方式是「少數幾筆抱很久」,而最後那一筆常常還開著。實測那個趨勢策略最長的一筆抱了 210 根(將近九天)。
這個系列從 Day 04 起有一條規矩:每個自己實作的計算都要有對照組。回測有兩個,而它們回答的問題不一樣。
第一個問題:向量化跟逐根模擬算的是同一件事嗎?
tests/reference/reference_backtest.py 是一份逐根的實作——一個迴圈,維護 capital、previous_position、previous_price 三個變數,讀起來跟算式一模一樣。測試在 500 根隨機走勢上比對兩者的權益曲線,容忍度 rtol=1e-12。
它驗的是自己的實作內部一致:向量化的 cumprod 有沒有把某一根的成本算到錯的位置、shift 有沒有差一格。這種錯誤在向量化的寫法裡看不出來,在迴圈裡一眼就看得出來。
第二個問題:我們對「回測」的定義跟業界主流是同一件事嗎?
這個問題自己的對照組回答不了。一份自己跟自己一致、但語意跟所有人都不同的回測,數字再穩定也沒有意義。所以另一個對照組是 VectorBT——把同一組訊號餵給它,比對總報酬。
對接的時候要處理一個語意差異:我們的部位是「這一根整根都持有」(成交在前一根收盤),VectorBT 的訊號是「這一根收盤成交」。所以把部位的轉折往前挪一根,就是同一件事的兩種說法:
# tests/domain/services/test_backtest_service_against_vectorbt.py
# 我們的部位是「這一根整根都持有」,成交在前一根收盤;VectorBT 的訊號是
# 「這一根收盤成交」。所以把部位的轉折往前挪一根,就是同一件事的兩種說法。
held = signals.positions != 0.0
previously_held = held.shift(1, fill_value=False)
portfolio = vbt.Portfolio.from_signals(
table["close"],
(held & ~previously_held).shift(-1, fill_value=False),
(~held & previously_held).shift(-1, fill_value=False),
init_cash=INITIAL_CAPITAL,
fees=FEE_RATE,
slippage=0.0,
freq="1h",
)
結果不是完全相同,而那個殘差是這個測試最有價值的產出:500 根資料、15 筆交易,相對差 1.5e-5。
差在哪裡:全額買進時手續費從哪裡扣。我們的算法是「拿 (1 − f) 的資金去買」,VectorBT 是「花掉全部資金,其中 1/(1 + f) 買到貨」。兩者差在 f² 的量級,而且每筆交易累積一次——所以誤差會隨交易筆數成長,方向固定(我們扣得比較多,也就是比較保守)。
滑價在這個比對裡設 0,因為兩邊的模型不同:我們扣一個比例,VectorBT 調整成交價。這件事沒有對錯,但混用兩種模型的數字不能互相解讀,所以要在測試裡就把它隔離掉。
測試因此不斷言「完全相同」,它斷言三件事:交易筆數完全一樣、報酬差在 f² 的量級、而且差的方向是保守的那一邊。
uv run python -m quantbot.entrypoints.backtest_command \
--strategy trend_ema_rsi --timeframe 1h \
--start 2025-01-01 --end 2026-08-01 --ideal
--ideal 是理想回測:沒有手續費、沒有滑價。它的三個假設——能用收盤價成交、不用付錢、想買多少有多少——三個都是假的,明天會逐一把它們加回去。今天先看理想的版本,因為它是策略邏輯本身的上限。
BTC/USDT 現貨 1 小時 K 線,2025-01 至 2026-08,資料來自 Day 07 的 TimescaleDB:
| 策略 | 交易筆數 | 曝險 | 理想總報酬 |
|---|---|---|---|
| 趨勢跟隨 | 232 | 48.42% | −26.83% |
| 均值回歸 | 28 | 1.35% | +6.10% |
| 動能爆發 | 190 | 6.67% | +0.89% |
| BuyAndHold | 1 | 99.99% | −33.71% |
先講最重要的一件事:這段期間 BTC 跌了 33.71%,所以這是一個下跌市場的回測。三個策略「贏過大盤」有很大一部分只是因為它們大多數時間在場外——曝險 1.35% 的策略在下跌市場裡當然虧得少。
這就是為什麼曝險那一欄一定要印出來。曝險不同的兩個策略,總報酬率不能直接比。 均值回歸的 +6.10% 是用 1.35% 的時間換到的,而 BuyAndHold 的 −33.71% 是用 100% 的時間換到的。要公平比較需要別的指標,那是 Day 22 的事。
第二件事:這些是理想數字,而理想數字裡最漂亮的那個(均值回歸 +6.10%)只有 28 筆交易。28 筆是一個統計上非常薄的樣本,Day 21 會算它到底薄到什麼程度。
Day 16 把訊號位移做成引擎的行為,理由是「靠每個策略自己記得一定會漏」。現在可以把那句話換成一個數字。
StrategyEngine(signal_delay_bars=0) 就是關掉位移——條件在第 t 根成立,部位在第 t 根就持有,也就是用第 t 根的收盤價算出訊號、又假設能在第 t 根一開始就買到。
uv run python -m quantbot.entrypoints.backtest_command \
--strategy trend_ema_rsi --timeframe 1h \
--start 2025-01-01 --end 2026-08-01 --show-look-ahead
策略 trend_ema_rsi(試驗次數 1)
K 線 13,823 根,交易 232 筆,曝險 48.42%
總報酬 -63.54%(未扣成本 -26.83%)
策略 trend_ema_rsi_look_ahead(試驗次數 1)
K 線 13,823 根,交易 232 筆,曝險 48.42%
總報酬 +95.39%(未扣成本 +292.11%)
關掉訊號位移的差別:-63.54% → +95.39%
同一個策略、同一組條件、同一段資料、同樣 232 筆交易、同樣的曝險。唯一的差別是訊號晚不晚一根,而理想報酬從 −26.83% 變成 +292.11%。
差 319 個百分點。而這個差距不是因為偷看了很多——只偷看了一根 K 線,一個小時。
值得注意的是它為什麼這麼大。交叉訊號發生的那一根,通常也是價格動得最快的那一根(快線要穿過慢線,價格得先走一段)。所以「用當根收盤價成交」等於系統性地拿到每一波行情最好的那個價位,232 次。
這就是 Day 16 為什麼不讓策略作者自己記得 shift。一個漏掉位移的策略不會報錯、不會有警告、圖也畫得出來——它只會變成回測結果最漂亮的那一個,然後被挑出來上線。
signal_delay_bars 這個參數因此只有一個用途:印出上面那個對照。實際回測一律是 1,而 0 的結果 NEVER 當結論看。
還有一個設計決定值得單獨講,因為它從今天適用到系列結束。
# quantbot/domain/dto/backtest_report.py
@dataclass(frozen=True)
class BacktestReportDto:
"""一次回測的結果。
它刻意**只放原始結果**,不放績效指標。夏普、最大回撤、勝率都是從權益曲線與
交易明細算出來的,而算它們的地方是 PerformanceMetricsService(Day 21 與 22)。
分開的理由是這份 DTO 會被存下來、被比較、被畫圖,而每次多一個指標就改一次
形狀的東西沒辦法當共同基準。
trial_count 沒有預設值,所以**建不出一份沒有標注試驗次數的報告**。這條從今天
起適用到系列結束:一個「試了 500 個組合挑出來的最佳結果」與一個「只跑了一次的
結果」,數字看起來一樣但可信度差好幾個數量級。Day 21 會說明差多少。
"""
trial_count 沒有預設值,這是刻意的:一份報告如果不說自己是第幾次嘗試的產物,看報告的人沒有辦法從報酬率反推出那件事。而在一個「改一行 YAML 就是一個新策略」的工具裡,那個數字很快就不是 1。
文字報告把它印在第一行,不是附註。
quantbot/
├── domain/
│ ├── values/
│ │ ├── cost_model.py 今天:手續費與滑價分開,一律 taker
│ │ └── backtest_specification.py 今天:起始資金 + 成本模型
│ ├── dto/backtest_report.py 今天:只放原始結果,不放指標
│ └── services/backtest_service.py 今天:四行算式 + 交易明細
├── application/run_backtest_application.py 今天
├── infrastructure/reporting/
│ └── text_backtest_report_renderer.py 今天:試驗次數印在第一行
├── entrypoints/backtest_command.py 今天
└── tests/
├── reference/reference_backtest.py 今天:逐根模擬的對照組
└── domain/services/
├── test_backtest_service.py 今天
└── test_backtest_service_against_vectorbt.py 今天
VectorBT 只在測試裡出現,所以它進的是 dev 依賴:
uv add --dev vectorbt
它會拉進 numba 與 scipy 那一串,測試時間因此多了約三秒。正式路徑不 import 它——domain/ 依賴任何外部技術都會被 import-linter 擋下來,而回測是 domain 的核心邏輯。
docker compose -f docker/docker-compose.yml up -d
uv sync
uv run python -m quantbot.entrypoints.ingest_pipeline_command
回測用的是收盤價,所以資料缺一段的後果比前幾天嚴重:缺漏會讓 pct_change() 把兩根之間的跳空算成一根的報酬,而那一根剛好在部位裡的話,績效數字就錯了。管線跑完會印一份資料完整性報告,回測之前值得看一眼。
八項全過才算完成:
uv run pytest 全綠。rtol=1e-12 內相同。 這條驗的是向量化沒有把成本算到錯的位置。trial_count=0 要丟例外,而 BacktestReportDto 建不出沒有試驗次數的實例。uv run mypy quantbot 與 uv run lint-imports 全過。特別確認 domain/ 沒有 import vectorbt。第 2 項與第 3 項是今天的重點,而它們守的是不同的東西:一個守實作正確,一個守語意不孤立。
免責聲明:本文為程式與資料工程的技術分享,所有數字皆為歷史資料上的觀察與教學範例,不構成投資建議。這段期間 BTC 下跌 33.71%,所有結果都要放在那個背景下讀。
上面那張表是理想回測。而理想回測的三個假設全都是假的,明天逐一把它們加回去。
先講一個今天已經出現、但還沒解釋的數字:趨勢策略的理想報酬是 −26.83%,扣掉成本之後是 −63.54%。中間那 36 個百分點就是 232 筆交易、總換手 464 倍的代價。
明天要回答三個問題:那個 0.05% 的滑價是哪裡來的(拍腦袋給的,而 Day 09 錄下來的掛單簿可以估出一個有根據的數字)、成本吃掉了毛利的幾成(均值回歸的答案是 135%,也就是毛利全部被吃掉還不夠),以及手續費要多低這個策略才成立——那條敏感度曲線會說明為什麼「交易頻率」是積木組合最容易出錯的地方。
Series.pct_change 的第一個值是 NaN,回測要決定它算 0 還是丟掉 — pandas documentation, Series.pct_change
Portfolio.from_signals 的 fees 與 slippage 語意(fees 按成交金額比例、slippage 調整成交價)— VectorBT documentation, Portfolio