簡繁
ESC
↑↓ 選擇↵ 開啟站內全文搜尋
註冊幣安 ↗

量化回測入門:為什麼回測賺錢、實盤卻虧錢?

回測賺錢而實盤虧錢,通常是因為回測漏算了手續費和滑點、不小心用了未來資料、引數過度擬合曆史、只挑了活下來的幣。本文用 CCXT 拉取幣安 BTC/USDT 真實日線,寫一個極簡均線回測並實測執行,逐一演示這些偏差對結果的影響,以及如何做樣本外檢驗。

識途編輯部更新 閱讀 約 21 分鐘來源 4 個官方資料
目錄本文共 8 節
  1. 01量化回測是什麼?能回答什麼、不能回答什麼?
  2. 02第一步:怎麼用 CCXT 拉取真實 K 線資料?
  3. 03第二步:怎麼寫一個最簡單的均線回測?
  4. 04手續費和滑點會吃掉多少回測收益?
  5. 05什麼是未來函式?為什麼它會讓回測虛高?
  6. 06什麼是過擬合?樣本外檢驗怎麼做?
  7. 07什麼是倖存者偏差?為什麼回測要算上已下架的幣?
  8. 08一份回測結果至少要檢查哪些專案?

回測賺錢、實盤虧錢,最常見的原因有四個:回測沒算夠手續費和滑點,不小心用了當時還看不到的未來資料,引數被調得恰好貼合曆史,以及只拿今天還活著的幣做測試。把這幾個偏差逐一排除後,回測結果通常會大幅縮水,而這才更接近實盤的樣子。下面用 CCXT 拉取幣安 BTC/USDT 的真實日線,寫一個極簡的均線回測並實際執行,把每種偏差的影響用數字展示出來。

量化回測是什麼?能回答什麼、不能回答什麼?

回測是把一套事先寫清楚的買賣規則放到歷史行情裡逐根 K 線模擬執行,統計假如當時這樣做,結果會怎樣。它能幫你回答:

  • 這套規則在過去的牛市、熊市、震盪市裡分別是什麼表現?
  • 最壞的時候從高點回撤了多少?你能不能扛住?
  • 交易頻率有多高?手續費會吃掉多少?

它不能回答的是:這套規則未來會不會賺錢。歷史只出現過一次,未來的行情不會照著重演。回測更大的價值在於排除:一套規則如果連在歷史上、算足成本之後都表現很差,就沒必要拿真錢去試。

第一步:怎麼用 CCXT 拉取真實 K 線資料?

先安裝依賴:pip install ccxt pandas。拉取公開行情不需要 API Key。CCXT 的入門用法見 CCXT Python 入門。

import time
import ccxt
import pandas as pd

exchange = ccxt.binance()                      # 公開行情,不需要 API Key
symbol, timeframe = "BTC/USDT", "1d"
since = exchange.parse8601("2021-01-01T00:00:00Z")
end = exchange.parse8601("2026-10-01T00:00:00Z")

rows = []
while since < end:
    batch = exchange.fetch_ohlcv(symbol, timeframe, since=since, limit=1000)
    if not batch:
        break
    rows += batch
    since = batch[-1][0] + 1                    # 從最後一根 K 線之後繼續拉
    time.sleep(exchange.rateLimit / 1000)       # 主動放慢,避免觸發頻率限制

df = pd.DataFrame(rows, columns=["ts", "open", "high", "low", "close", "volume"])
df = df.drop_duplicates("ts")
df = df[df["ts"] < end]
df["date"] = pd.to_datetime(df["ts"], unit="ms").dt.date
df.to_csv("btc_usdt_1d.csv", index=False)
print(len(df), "根日線", df["date"].iloc[0], "→", df["date"].iloc[-1])
print(df[["date", "open", "high", "low", "close"]].tail(3).to_string(index=False))

執行時的真實輸出:

2099 根日線 2021-01-01 → 2026-09-30
      date     open     high      low    close
2026-09-28 84472.01 84999.00 82563.00 83500.01
2026-09-29 83500.01 84563.99 82775.94 83663.66
2026-09-30 83663.66 85649.95 82956.11 83623.60

幾個要點:

  • fetch_ohlcv 每根 K 線返回 6 個值:開盤時間(毫秒時間戳)、開、高、低、收、成交量。
  • 幣安現貨 K 線介面單次最多返回 1000 根,所以要用 since 分頁循環。歐易 K 線介面單次上限更小,循環寫法相同。
  • 幣安日線預設按 UTC 0 點切分。對比不同來源的資料時,要先確認時區一致。
  • 截止時間設為過去的某一天,避免把還沒走完的當天 K 線也算進來。最後一根未收盤的 K 線價格還在變,混進回測會帶來錯誤。

第二步:怎麼寫一個最簡單的均線回測?

規則只有一句:短期均線在長期均線上方時持有 BTC,否則持有 USDT。程式碼裡刻意把成本和成交時機寫成引數,方便後面演示它們的影響。

import pandas as pd

df = pd.read_csv("btc_usdt_1d.csv", parse_dates=["date"])

def backtest(df, fast, slow, cost=0.0015, lookahead=False, start=None, end=None):
    """極簡均線回測(教學用):快線在慢線上方就持有,否則空倉。
    cost = 單邊手續費 0.1% + 滑點 0.05%,每次買入或賣出各扣一次。
    start/end 只擷取統計區間,均線仍用完整資料計算,避免區間開頭沒有均線。"""
    d = df.copy()
    d["fast"] = d["close"].rolling(fast).mean()
    d["slow"] = d["close"].rolling(slow).mean()
    d["signal"] = (d["fast"] > d["slow"]).astype(int)
    if lookahead:
        # 錯誤示範:用當天收盤價算出的訊號,去吃當天的漲跌(未來函式)
        d["pos"] = d["signal"]
        d["ret"] = d["close"].pct_change()
    else:
        # 正確做法:今天收盤出訊號,明天開盤才成交;收益按“開盤到下一個開盤”計算
        d["pos"] = d["signal"].shift(1)
        d["ret"] = d["open"].shift(-1) / d["open"] - 1
    d = d.dropna(subset=["slow", "ret"])
    if start:
        d = d[d["date"] >= start]
    if end:
        d = d[d["date"] < end]
    trades = d["pos"].diff().abs().fillna(d["pos"])   # 倉位變化一次 = 成交一次
    strat = d["pos"] * d["ret"] - trades * cost
    equity = (1 + strat).cumprod()
    years = len(d) / 365
    return {
        "總收益": float(round((equity.iloc[-1] - 1) * 100, 1)),
        "年化": float(round((equity.iloc[-1] ** (1 / years) - 1) * 100, 1)),
        "最大回撤": float(round((equity / equity.cummax() - 1).min() * 100, 1)),
        "成交次數": int(trades.sum()),
    }

def buy_and_hold(df, start=None, end=None):
    d = df
    if start:
        d = d[d["date"] >= start]
    if end:
        d = d[d["date"] < end]
    equity = d["close"] / d["close"].iloc[0]
    return {"總收益": float(round((equity.iloc[-1] - 1) * 100, 1)),
            "最大回撤": float(round((equity / equity.cummax() - 1).min() * 100, 1))}

if __name__ == "__main__":
    s = "2021-07-01"   # 留出前 180 天給均線“預熱”
    print("統計區間", s, "→", df["date"].iloc[-1].date(), "(單位:%)")
    print("一直持有         ", buy_and_hold(df, start=s))
    print("MA20/60 不計成本 ", backtest(df, 20, 60, cost=0, start=s))
    print("MA20/60 計入成本 ", backtest(df, 20, 60, start=s))
    print("MA20/60 未來函式 ", backtest(df, 20, 60, lookahead=True, start=s))
    print("MA5/20  不計成本 ", backtest(df, 5, 20, cost=0, start=s))
    print("MA5/20  計入成本 ", backtest(df, 5, 20, start=s))

把它儲存為 backtest.py,執行時的真實輸出:

統計區間 2021-07-01 → 2026-09-30 (單位:%)
一直持有          {'總收益': 149.6, '最大回撤': -76.6}
MA20/60 不計成本  {'總收益': 135.9, '年化': 17.7, '最大回撤': -48.1, '成交次數': 41}
MA20/60 計入成本  {'總收益': 121.8, '年化': 16.4, '最大回撤': -48.9, '成交次數': 41}
MA20/60 未來函式  {'總收益': 167.6, '年化': 20.6, '最大回撤': -44.3, '成交次數': 41}
MA5/20  不計成本  {'總收益': 93.5, '年化': 13.4, '最大回撤': -62.3, '成交次數': 113}
MA5/20  計入成本  {'總收益': 63.3, '年化': 9.8, '最大回撤': -63.5, '成交次數': 113}

先看清楚這些數字說明了什麼、沒說明什麼:兩組均線規則在這段區間裡的總收益都低於一直持有,回撤更小是因為一部分時間空倉。這只是一段特定歷史的結果,換一個起點、換一個幣,結論都可能反過來。下面幾節逐一拆解輸出裡的差異。

手續費和滑點會吃掉多少回測收益?

手續費是每次成交按金額收取的費用。截至 2026 年 10 月,幣安和歐易普通使用者現貨單邊費率在 0.1% 左右,具體以帳戶內顯示為準,規則見 交易手續費怎麼算。

滑點是你預期的成交價和實際成交價之間的差距。回測裡假設按開盤價成交,但真實下單時,市價單會吃掉訂單簿上的多檔掛單,行情劇烈波動時成交價會更差;交易對越冷門、單筆金額越大,滑點越明顯。限價單可以控制價格,代價是可能不成交,見 限價單和市價單的區別。

示例裡把兩者合計按每次成交 0.15% 扣除。對照輸出:

  • MA20/60 五年多成交 41 次,計入成本後總收益從 135.9% 降到 121.8%。
  • MA5/20 成交 113 次,同樣的成本讓總收益從 93.5% 降到 63.3%,差了 30 個百分點。

成本的影響和成交次數成正比。很多回測裡看起來很漂亮的高頻規則,一旦把手續費和滑點算進去,收益就所剩無幾甚至轉為虧損。所以回測時成本只能往高估,不能往低估。可以用 手續費計算器 先估一下自己的實際費率。

什麼是未來函式?為什麼它會讓回測虛高?

未來函式是指回測在某個時間點用到了當時還不可能知道的資訊。示例裡 lookahead=True 的寫法就是一個典型錯誤:

  • 均線用的是當天收盤價,收盤那一刻才算得出訊號;
  • 但程式碼讓這個訊號去吃當天從開盤到收盤的漲跌,等於開盤時就已經知道了收盤價。

同樣的 MA20/60 引數,同樣計入成本,正確寫法總收益 121.8%,帶未來函式的寫法變成 167.6%,憑空多出約 46 個百分點。真實交易中這部分收益根本不存在。

常見的未來函式還有:

  • 用當根 K 線的最高價或最低價判斷「是否觸發」,又按更有利的價格成交;
  • 用全部資料計算的均值、標準差去做標準化,等於提前知道了未來的分佈;
  • 使用事後被修正過的資料,例如事後才確定的指數成分或已經調整過的歷史價格。

檢查方法很簡單:對每一筆模擬成交,問自己「在這個時刻,我真的能拿到計算訊號所用的全部資料嗎?」示例裡的正確寫法是用 shift(1) 把訊號推遲一根 K 線,今天收盤出訊號,明天開盤成交。

什麼是過擬合?樣本外檢驗怎麼做?

過擬合是指你不斷調整引數,直到它們恰好貼合這段歷史,記住的是過去的偶然波動,而不是能延續的規律。引數越多、試的組合越多,越容易找到一組「歷史上很好看」的引數。

檢驗辦法是樣本外檢驗:把資料切成兩段,只用前一段(樣本內)挑引數,引數定死後,再在後一段(樣本外)跑一次。下面把 2021 年 7 月到 2024 年底作為樣本內,2025 年以後作為樣本外,測試 19 組均線引數:

from backtest import df, backtest, buy_and_hold

IN_S, IN_E = "2021-07-01", "2025-01-01"     # 樣本內:只用這段挑引數
OUT_S = "2025-01-01"                        # 樣本外:引數定死之後才看

rows = []
for fast in [5, 10, 20, 30]:
    for slow in [30, 50, 100, 150, 200]:
        if fast < slow:
            ins = backtest(df, fast, slow, start=IN_S, end=IN_E)
            out = backtest(df, fast, slow, start=OUT_S)
            rows.append((f"MA{fast}/{slow}", ins["總收益"], out["總收益"]))

by_in = sorted(rows, key=lambda r: r[1], reverse=True)
by_out = sorted(rows, key=lambda r: r[2], reverse=True)
print(f"共測試 {len(rows)} 組引數")
for name, i, o in by_in[:3]:
    print(f"{name:9s} 樣本內 {i:+7.1f}%  樣本外 {o:+6.1f}%  樣本外排名 {by_out.index((name, i, o)) + 1}")
print("樣本內(2021-07~2024)一直持有", buy_and_hold(df, start=IN_S, end=IN_E))
print("樣本外(2025~2026-09)一直持有", buy_and_hold(df, start=OUT_S))

執行時的真實輸出:

共測試 19 組引數
MA5/150   樣本內  +242.6%  樣本外   -1.7%  樣本外排名 8
MA5/100   樣本內  +210.6%  樣本外  +12.4%  樣本外排名 3
MA10/150  樣本內  +180.7%  樣本外   +1.0%  樣本外排名 6
樣本內(2021-07~2024)一直持有 {'總收益': 179.3, '最大回撤': -76.6}
樣本外(2025~2026-09)一直持有 {'總收益': -11.6, '最大回撤': -53.0}

樣本內排第一、收益 242.6% 的 MA5/150,到樣本外變成了 -1.7%,在 19 組裡只排第 8。如果你只看樣本內結果就拿它去實盤,期望和現實會差得很遠。另外注意,樣本外這段 BTC 一直持有是虧損的,市場環境已經和樣本內完全不同。

做樣本外檢驗時要守住幾條規矩:

  • 樣本外只能看一次。 看了樣本外結果再回去改引數,樣本外就變成了樣本內,檢驗失去意義。
  • 引數越少越好。 兩個引數的規則已經能試出 19 種組合,再加上止損、倉位、過濾條件,組合數量會成倍增加,過擬合的機會也隨之增加。
  • 看引數附近是否穩定。 好的引數附近的組合也應該表現接近;如果只有某一個數字特別好、旁邊的都很差,多半是巧合。
  • 可以滾動檢驗。 把時間切成多段,依次用前一段挑引數、後一段檢驗,看結果是否持續穩定。

什麼是倖存者偏差?為什麼回測要算上已下架的幣?

倖存者偏差是指你只用今天還存在、還活躍的幣做回測,那些中途暴跌、下架的幣天然被排除在外,結果就會整體偏樂觀。比如「回測過去三年市值前 20 的幣」,如果用的是今天的前 20 名名單,就等於提前知道了三年後誰會留下來。

交易所的交易對列表本身就在不斷變化。下面這段程式碼統計幣安當前列出的現貨 USDT 交易對狀態:

import ccxt

binance = ccxt.binance()
markets = binance.load_markets()
usdt_spot = [m for m in markets.values() if m["spot"] and m["quote"] == "USDT"]
inactive = [m for m in usdt_spot if not m["active"]]
print("幣安現貨 USDT 交易對", len(usdt_spot), "個,其中當前不可交易", len(inactive), "個")
幣安現貨 USDT 交易對 770 個,其中當前不可交易 254 個

截至 2026 年 10 月實測,這 254 個交易對的狀態都是 BREAK,幣安文件對它的定義是交易對當前不可交易。下架、遷移等情況都會讓交易對停在這種狀態,而很多行情網站和排行榜根本不會顯示它們。做多幣種回測時,選幣規則要用當時就能知道的資訊,比如按每個時間點的歷史成交額篩選,而不是按今天的排名。

一份回測結果至少要檢查哪些專案?

把上面的內容整理成一張清單。看自己或別人的回測時,逐項核對:

檢查項 要問的問題 示例裡的做法
資料 來源、時區、是否包含未收盤 K 線? 幣安日線,UTC,截止到已收盤的日期
成交時機 訊號出現後,下一根 K 線才成交嗎? shift(1),次日開盤成交
成本 手續費和滑點按多少算?每次成交都扣了嗎? 單邊 0.15%,買賣各扣一次
樣本外 引數是否只用樣本內挑選?樣本外表現如何? 2025 年以後只檢驗一次
對比基準 和一直持有、定投相比怎麼樣? 同時列出一直持有的結果
風險 最大回撤多少?最長多久沒創新高? 統計了最大回撤
選幣 有沒有用今天的名單去測過去? 只測單一交易對
實盤差異 限價單能否成交、程式停機、API 報錯怎麼辦? 未模擬,需在測試網驗證

最後一項是回測永遠模擬不完的部分:實盤中程式可能因為網路或時間戳報錯漏掉一次成交,限價單可能掛著不成交,極端行情裡交易所可能暫停交易。這些都要靠在幣安現貨測試網或歐易模擬交易里長期執行來發現,常見報錯的處理方法見 交易所 API 常見報錯怎麼解決。

如果你只是想按固定節奏買入,不一定需要自己寫回測和程式,交易所自帶的定投和網格工具也許就夠用,兩者的取捨見 交易所機器人和自己寫程式怎麼選。更多 API 與量化教程見 識途量化專欄。

常見問題

量化回測結果可信嗎?

只能作為參考。回測說明的是一套規則在過去那段行情裡的表現,前提是成本、成交和資料都模擬得準確。即使這些都做對了,市場環境變化後結果也可能完全不同,回測收益不代表未來收益。

回測裡的未來函式是什麼意思?

未來函式是指回測在某個時間點使用了當時還不可能知道的資料,例如用當天收盤價算出的訊號在當天開盤就買入。這樣的回測會顯著高估收益,實盤根本無法復現。正確做法是訊號出現後,下一根 K 線才成交。

什麼是過擬合?怎麼避免?

過擬合是指引數被反覆調整到恰好貼合曆史資料,記住了過去的噪音而不是規律。避免方法是把資料分成樣本內和樣本外,只用樣本內挑引數,挑完後在樣本外檢驗一次;引數越少、對引數變化越不敏感越好。

回測要算多少手續費和滑點?

手續費按你帳戶的實際費率算,幣安和歐易普通使用者現貨單邊約 0.1% 左右,以帳戶內顯示為準。滑點取決於交易對深度和下單金額,主流幣小額可按 0.05% 左右粗估,冷門幣要高得多。成交越頻繁,成本影響越大。

用 CCXT 拉取 K 線一次最多能拿多少根?

取決於交易所。幣安現貨 K 線介面單次最多返回 1000 根,預設 500 根;歐易 K 線介面單次最多 300 根。要拉更長的歷史,需要用 since 引數分頁循環獲取,並在每次請求之間留出間隔,避免觸發頻率限制。

準備好出發了嗎?

幣安邀請
幣安

全球交易量最大的加密貨幣交易所之一,幣種與流動性領先

邀請碼
註冊幣安 ↗含推廣連結 · 註冊前請確認當地法規 · 幣安評測
歐易邀請
歐易

老牌頭部交易所,App 體驗與 Web3 錢包整合出色

邀請碼
註冊歐易 ↗含推廣連結 · 註冊前請確認當地法規 · 歐易評測

參考來源

  1. Binance Developer Docs:Market Data Endpoints(Kline/Candlestick data)
  2. CCXT Manual:OHLCV Candlestick Charts 與 Markets
  3. OKX API v5 文件:Get candlesticks
  4. 歐易幫助中心:歐易策略交易概況(收益率回測預估的風險提示)

以上資訊核實於 2026-10-22,交易所規則可能調整,請以官方最新說明為準。

本文僅供學習參考,不構成投資建議。交易所的費率、活動與功能可能隨時調整,請以官方頁面為準。文中部分連結為推廣連結,詳見推廣披露。