目录本文共 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,交易所规则可能调整,请以官方最新说明为准。
本文仅供学习参考,不构成投资建议。交易所的费率、活动与功能可能随时调整,请以官方页面为准。文中部分链接为推广链接,详见推广披露。