目錄本文共 8 節
回測賺錢、實盤虧錢,最常見的原因有四個:回測沒算夠手續費和滑點,不小心用了當時還看不到的未來資料,引數被調得恰好貼合曆史,以及只拿今天還活著的幣做測試。把這幾個偏差逐一排除後,回測結果通常會大幅縮水,而這才更接近實盤的樣子。下面用 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 引數分頁循環獲取,並在每次請求之間留出間隔,避免觸發頻率限制。
準備好出發了嗎?
參考來源
- Binance Developer Docs:Market Data Endpoints(Kline/Candlestick data)
- CCXT Manual:OHLCV Candlestick Charts 與 Markets
- OKX API v5 文件:Get candlesticks
- 歐易幫助中心:歐易策略交易概況(收益率回測預估的風險提示)
以上資訊核實於 2026-10-22,交易所規則可能調整,請以官方最新說明為準。
本文僅供學習參考,不構成投資建議。交易所的費率、活動與功能可能隨時調整,請以官方頁面為準。文中部分連結為推廣連結,詳見推廣披露。