iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
Software Development

量化交易入門:從 K 線到可組合的交易策略引擎系列 第 21

Day 20:回測賺 300%、實單卻賠錢?把滑價與手續費加回去看真相

  • 分享至 

  • xImage
  •  

理想回測的三個假設全都是假的

昨天那張表是理想回測,而理想回測假設了三件事:

  1. 能用收盤價成交。 實際上買要用賣價、賣要用買價,中間差一個價差。
  2. 不用付錢。 實際上每一筆都要付手續費。
  3. 想買多少有多少。 實際上單大到一定程度就會吃穿第一檔,剩下的量要往更差的價格成交。

今天逐一把它們加回去。但重點不是「加上成本之後績效會變差」——那件事不用算也知道。重點是那些數字從哪裡來,以及策略對它們有多敏感

昨天用了 0.1% 的手續費與 0.05% 的滑價。前者查得到(Binance 現貨的一般 taker 費率),後者是拍腦袋給的。今天要把後者換成一個有根據的數字,而結果會很意外。

交易概念補課:成本的三個來源

手續費(fee) 是交易所收的,公告在費率表上。它分兩種:

  • taker fee:下市價單、立刻吃掉別人的掛單。Binance 現貨的一般用戶是 0.1%。
  • maker fee:掛限價單等別人來成交。費率比較低,高階 VIP 甚至是負的(交易所倒過來付錢)。

本系列一律用 taker 費率,理由是策略的訊號是「這一根收盤價成交」,而要保證成交只能下市價單。掛單雖然便宜,但有可能不成交——一個「掛不到就不進場」的策略跟回測算的完全是兩件事,而那個差別在回測裡看不出來(回測會假設每一次都掛到了)。

滑價(slippage) 是「以為會用這個價格成交,實際上用了另一個」,來源有三個:

  • 價差:買賣價中間的距離。這是最小的一份,而且算得出來
  • 市場衝擊:單太大,吃穿第一檔之後往更差的價格成交。
  • 延遲:從決定下單到交易所收到之間,價格已經動了。

第一個今天用 Day 09 錄下來的掛單簿算。第二個要看單有多大,而在我們這個規模下等一下會看到答案是「可以忽略」。第三個要實單才量得到,離線資料裡不存在——那件事要等 Day 23 接上 testnet 之後才有機會。

那個 0.05% 錯得離譜

Day 09 錄了 118 個小時的掛單簿深度摘要,3,352 筆取樣。半個價差就是市價單一定要付的那一份(買用賣價、賣用買價,而中間價在兩者正中間)。

# quantbot/domain/services/slippage_estimation_service.py
class SlippageEstimationService:
    """從 Day 09 錄下來的掛單簿估一個有根據的滑價,取代拍腦袋給的 0.05%。

    市價單付的滑價由兩部分組成:

    1. **半個價差。** 買要用賣價、賣要用買價,而中間價在兩者正中間,所以一趟就是
       半個價差。這部分算得出來,而且它是下界——不管單多小都要付。
    2. **走簿子的成本。** 單大到吃穿第一檔之後,剩下的量要往更差的價格成交。
       這部分**算不出來**,因為 Day 09 錄下來的是前 5/10/20 檔的加總,
       沒有逐檔價格。所以這裡只能回答「單有沒有小到吃不完前五檔」。

    第二點是 Day 09 那句「先想清楚要算什麼特徵,再決定存什麼」的代價現形。
    要能估價格衝擊就得重新錄一次,而不是重跑一次計算。

    這是 domain service:吃已經在手上的 DepthSeries,回一份報告,沒有任何 I/O。
    """
uv run python -m quantbot.entrypoints.cost_analysis_command \
    --strategy trend_ema_rsi --timeframe 1h \
    --start 2025-01-01 --end 2026-08-01 \
    --depth-start 2026-08-01 --depth-end 2026-08-15
掛單簿滑價估計(3,352 筆取樣,涵蓋 118.0 小時)
  半價差 中位數 0.00078 bp、平均 0.00078 bp、95 百分位 0.00078 bp
  前五檔買方名目 中位數 229,406,這次要下的單 10,000(吃不完)
  建議的滑價假設 0.00078 bp(= 0.0000078%)
  注意:這只涵蓋滑價的價差那一部分。延遲造成的滑價要實單才量得到,而市場衝擊在
  這個單量下可以忽略(見上面那一行)。

0.00078 個基點。 昨天假設的 0.05% 是 500 個基點,也就是高估了大約 6,400 倍。

原因很單純:BTC/USDT 現貨的價差就是一個最小跳動單位(0.01 USDT),而價格是十萬鎂的量級,所以相對價差小到可以忽略。中位數、平均、95 百分位三個數字完全一樣,也是同一件事的表現——價差幾乎永遠貼在一個 tick,沒有分布可言。

第二行回答的是市場衝擊:前五檔的買方掛量中位數換算成名目金額是 229,406 USDT,而這次要下的單是 10,000。單連前五檔都吃不完,所以走簿子的成本可以忽略。

要注意這幾個數字的適用範圍。它們來自一段 118 小時的錄製,而那段錄製剛好在 2026 年 8 月初;BTC 這種流動性最好的交易對在那段時間的價差不代表所有交易對、所有時段。換到 SOL/USDT 或者把單放大到一百萬鎂,結論會完全不同。

還有一件事這個估計答不出來:走簿子的成本算不出來,不是因為它不重要,是因為資料沒存。 Day 09 錄的是前 5/10/20 檔的加總,沒有逐檔價格,所以「吃掉三檔的平均成交價」這件事事後永遠算不回來。要能算就得重新錄一次,而不是重跑一次計算。這是 Day 09 那句「先想清楚要算什麼特徵,再決定存什麼」的代價,現在現形了。

沒有掛單簿的期間怎麼辦

掛單簿只有錄的那幾段,而回測期間是 19 個月。所以命令列把兩個時間範圍分開指定

# quantbot/entrypoints/cost_analysis_command.py
def depth_period(arguments: argparse.Namespace, fallback: TimeRange) -> TimeRange:
    """估滑價用哪一段掛單簿。

    預設跟回測期間相同,但那個預設多半會回「沒有資料」——掛單簿只有自己錄的
    那幾段(Day 09),而回測期間是整段歷史。分開指定比偷偷把範圍放大好:
    「這個滑價是用另一段時間的掛單簿估的」是一個必須被看見的假設。
    """

而完全沒有掛單簿的時候,估計會回 None,報告印的是:

掛單簿滑價估計:這段期間沒有錄到掛單簿。
  沿用假設值,而且它是假設——NEVER 因為沒有資料就當成沒有滑價。

「沒有資料」與「沒有滑價」要分得開。前者該做的事是沿用一個保守的假設並且標注它是假設;當成後者處理的話,回測會安靜地變好看。

掃一遍費率

有了滑價的實測值,剩下的變數就只有手續費。而手續費不是一個固定數字——它取決於 VIP 等級與有沒有用 BNB 折抵,從 0.1% 一路到 0.02% 左右。

所以要問的不是「這個策略在 0.1% 下賺不賺」,是「它要多低的費率才成立」。

# quantbot/domain/services/cost_sensitivity_service.py
class CostSensitivityService:
    """同一組訊號,換幾種成本假設各跑一次,找出它在哪一點由賺轉賠。

    它不用重算訊號——部位序列跟成本無關,只有權益曲線有關。所以整個掃描的成本
    是「幾次向量化乘法」,跟訊號那一段比可以忽略。

    它注入 BacktestService 而不是自己算:回測的算式只該有一份,而多一份的下場是
    敏感度分析與主線回測用不同的成本模型,於是兩邊的數字對不起來卻沒人發現。
    Domain service 注入 domain service 沒有問題,兩者都是純計算、都不吃 Protocol。
    """

「部位序列跟成本無關」這句話值得停一下,因為它是這個掃描能便宜到可以隨手跑的原因:換一個費率不會改變任何一個進出場決定,只會改變權益曲線。所以訊號算一次,費率掃八次,總成本幾乎等於算一次。

反過來說,這件事在真實交易裡不成立——手續費會影響「這筆值不值得做」,而一個考慮成本才決定要不要進場的策略,換費率就要重算訊號。那種策略這套架構表達得出來(成本可以是一個特徵),但今天的三個策略都沒有這樣做。

三個策略的敏感度

策略 毛報酬 交易 換手 淨報酬(taker 0.1%) 成本佔毛利 由賺轉賠的來回成本率
趨勢跟隨 −26.83% 232 464 倍 −54.00% 毛利為負 不存在
均值回歸 +6.10% 28 56 倍 +0.32% 91.4% 0.2117%
動能爆發 +0.89% 190 380 倍 −31.02% 3684% 0.0048%

三個結論,一個比一個難看。

趨勢跟隨連討論成本的資格都沒有。 它的毛利就是負的(−26.83%),成本只是讓它更糟。這種情況下「成本佔毛利」那個比率沒有意義,所以報告印的是「毛利為負」而不是一個數字——硬算出來的百分比會被誤讀成「成本不高」。

均值回歸活下來了,但只剩 0.32%。 毛利 +6.10%,扣掉成本剩 +0.32%,也就是成本吃掉了毛利的 91.4%。而它由賺轉賠的位置是來回 0.2117%,換算成單邊 taker 費率大約是 0.1058%——比一般用戶的 0.1% 高一點點。

這代表什麼:這個策略在「一般用戶費率」這個點上剛好還在水面上,而任何一個小小的不利變化都會把它推到水下——費率調漲、滑價比實測差一點、或者實單有延遲。一個活在小數點第二位上的策略不算活著。

動能爆發需要零手續費。 毛利 +0.89%,而由賺轉賠在來回 0.0048%——也就是單邊 0.0024%,比任何交易所的最低費率都低一個數量級。成本是毛利的 3,684 倍。

它為什麼這麼慘:190 筆交易,而毛利只有 0.89%。平均每筆交易的毛利是 0.0047%,而來回成本至少 0.04%(VIP 費率)。每一筆交易都在虧,只是回測不看成本的時候看不出來。

交易頻率是積木組合最容易出錯的地方

上面那張表講的其實是一件事:成本不是一個固定的百分比,是「單次成本 × 交易次數」。

策略 交易 每筆毛利 來回成本(0.1%) 每筆淨值
均值回歸 28 +0.218% 0.200% +0.018%
動能爆發 190 +0.0047% 0.200% −0.195%

同樣的成本率,一個活著一個死透,差別只在交易次數把單次的邊際優勢放大或稀釋了幾倍。

這件事對積木庫特別重要,因為組合很容易不小心組出一個交易過於頻繁的策略。Day 18 那個交叉組合就是例子:趨勢的進場配動能的出場,曝險從 48% 掉到 3.46%,而交易筆數幾乎沒變——每一筆抱得更短,成本佔比就更高。改一行 YAML 就會發生這件事,而它在毛報酬上完全看不出來。

所以從今天起,看回測結果時要一起看三個數字:毛報酬、交易筆數、成本佔毛利。第一個是策略邏輯的上限,第三個是它剩下多少。

一個內部一致性檢查

昨天趨勢策略在 0.1% 手續費 + 0.05% 滑價下付出的成本是 4,499.72。今天的表裡,taker 0.15% 那一列(滑價幾乎為零,所以來回 0.3%)付出的成本是 4,500。

同一個數字,兩條不同的路徑算出來——一邊是「0.1% 費 + 0.05% 滑價」,一邊是「0.15% 費 + 0.0000078% 滑價」。它們相等是應該的(成本模型只看 one_way_rate 這個和),而確認它真的相等是這種重構後最值得做的一件事:如果敏感度分析自己寫了一份成本算式,這兩個數字就會不一樣,而不一樣的地方會很難找。

這就是 CostSensitivityService 注入 BacktestService 而不是自己算的理由。

加成本前後的權益曲線

# quantbot/infrastructure/charting/plotly_cost_comparison_renderer.py
class PlotlyCostComparisonRenderer:
    """加成本前後的權益曲線疊在同一張圖上。

    疊圖而不是並排兩張:要看的是兩條線**什麼時候開始分開、分得多快**,而那是
    同一個時間軸上的距離。分成兩張圖就得靠眼睛在兩邊來回對,而兩張圖的 y 軸範圍
    多半不一樣,看起來的斜率會騙人。

    基準策略(BuyAndHold)也畫進來,因為「策略賠錢」與「市場在跌」是兩件事,
    而分辨它們唯一的方法是把基準放在同一張圖上。
    """

圖上有三條線:未扣成本、扣成本、以及 BuyAndHold 基準。三條放在一起才看得出兩個不同的問題——策略邏輯有沒有用(未扣成本那條跟基準比),以及它撐不撐得住成本(兩條自己的線之間的距離)。

第二張圖是敏感度曲線:橫軸來回成本率、縱軸總報酬,加上一條零報酬的水平線。曲線跟那條線的交點就是策略還撐得住的成本上限,而對動能爆發來說那個交點幾乎貼在原點上。

今日交付物

quantbot/
├── domain/
│   ├── dto/
│   │   ├── slippage_estimate_report.py    今天
│   │   └── cost_sensitivity_report.py     今天
│   └── services/
│       ├── slippage_estimation_service.py 今天:半價差 + 吃不吃得完前五檔
│       └── cost_sensitivity_service.py    今天:費率掃描 + 由賺轉賠的內插
├── application/analyze_costs_application.py  今天
├── infrastructure/
│   ├── charting/plotly_cost_comparison_renderer.py       今天
│   └── reporting/text_cost_sensitivity_report_renderer.py 今天
├── entrypoints/cost_analysis_command.py   今天
└── tests/domain/services/
    ├── test_slippage_estimation_service.py  今天
    └── test_cost_sensitivity_service.py     今天

先把資料補到最新

滑價估計要用掛單簿,而掛單簿只有自己錄的。想要新的一段就再錄一次(Day 09 的指令):

docker compose -f docker/docker-compose.yml up -d
uv sync
uv run python -m quantbot.entrypoints.ingest_pipeline_command
uv run python -m quantbot.entrypoints.record_microstructure_command --minutes 45

錄完之後把 --depth-start--depth-end 指到那一段,滑價就會換成新的估計。K 線那半照舊,管線會自己判斷缺哪一段。

驗收標準

七項全過才算完成:

  1. uv run pytest 全綠。
  2. 半價差是相對於中間價的比例,有測試釘住(價差 2、中間價 100 → 1%)。用絕對金額的話換一個交易對就不能比。
  3. 建議值取 95 百分位而不是中位數,有測試釘住。價差跟波動同向,而訊號常出現在波動放大的時候。
  4. 涵蓋時數用頭尾相減而不是樣本數除以頻率,有測試釘住。錄製中斷過的話後者會高估。
  5. 費率越高、報酬必須單調不增。這條看起來廢話,但它會抓到「成本符號寫反」這種錯。
  6. 由賺轉賠的位置在兩種情況下要回 None:最低成本就已經賠錢、以及最高成本還在賺。回一個網格端點會被誤讀成「剛好在這裡由賺轉賠」。
  7. 沒有掛單簿的期間要回 None 並印出「沿用假設值」,NEVER 靜靜地把滑價當成 0。

第 6 項是今天最容易寫錯的一條——內插的邊界處理不對,會產生一個看起來很精確的錯誤答案。

免責聲明:本文為程式與資料工程的技術分享,所有數字皆為歷史資料上的觀察與教學範例,不構成投資建議。三個策略在真實成本下都不成立,這是照實記錄的結果。

明天

到今天為止,三個策略在真實成本下的結論是:一個毛利就是負的,一個剩 0.32%,一個需要零手續費。

而接下來最自然的念頭是:參數調一調就好了吧? EMA 換成 20 與 50、RSI 門檻改成 75、偏離門檻改成 −2.5——積木庫讓這件事只是改幾行 YAML,而組合空間有幾百種,全部跑完只要幾分鐘。

明天的主題就是這個念頭,而結論是:「跑 500 個組合、挑回測最漂亮的那一個」在統計上等於自欺。

證明的方式不是說教。明天會把真實報酬的順序打亂——也就是造出一段完全沒有任何訊號的假資料——然後在上面跑同一套組合搜尋,看能「找到」多漂亮的策略。那個結果會說明為什麼 Day 19 那個 trial_count 欄位沒有預設值。

Reference


上一篇
Day 19:策略在腦中很賺,怎麼證明?用向量化歷史回測算出第一份績效數字
系列文
量化交易入門:從 K 線到可組合的交易策略引擎21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言