回测引擎的五个工程细节:3000 根 4 小时 K 线跑出来的成本差
策略逻辑通常只有十行,剩下的全是工程问题。这篇不讲策略,只讲一个能跑的回测引擎里,哪些细节会让结果从「看起来能用」变成「可信」,每一节都附上今天的实测数字。
0. 基准数据
BTCUSDT,日线 1000 根(2024-01-28 至 2026-10-23,42031.06 → 81706.84),4 小时 3000 根(2025-06-10 至 2026-10-23,109205.14 → 81706.84)。这两段长度不同,后面会说为什么这本身就是个坑。
1. 数据层:分页、时区、缺口
币安 /api/v3/klines 单次最多 1000 根,要更多必须按 endTime 往前翻。三个细节:
python
def fetch(symbol, interval, count):
rows, end = [], None
while len(rows) < count:
limit = min(1000, count - len(rows))
params = {"symbol": symbol, "interval": interval, "limit": limit}
if end:
params["endTime"] = end
batch = requests.get(BASE + "/api/v3/klines", params=params, timeout=25).json()
if not isinstance(batch, list) or not batch:
break
rows = batch + rows # 前插,不是 append
end = batch[0][0] - 1
time.sleep(0.25) # 公共接口限频,别打爆
return rows[-count:]
- 前插 :往前翻出来的批次时间更早,必须
batch + rows。写成 append,回测会在一段乱序数据上跑,指标全错但不报错。 - 单位 :币安给毫秒,别用
unit="s",否则整段数据错到 1970 年,pandas 不会提示你。 - 缺口:拿到数据先做一次完整性检查,这条比前两条重要。
python
import pandas as pd
def check_gaps(df, freq):
ts = pd.to_datetime(df["open_time"], unit="ms") if df["open_time"].dtype != "datetime64[ns]" else df["open_time"]
diff = ts.diff().dropna()
expected = diff.mode().iloc[0]
bad = diff[diff != expected]
return {"bars": len(df), "expected_gap": str(expected), "missing": int((bad / expected - 1).sum())}
币安 7×24 交易,4 小时线理论上不该有缺口。一旦出现,说明中间有数据缺失或接口返回不完整,这时候算出来的「最大回撤」可能是某次断档造成的假信号。
2. 成交层:信号与成交必须错开一根
最常见的写法:
python
df["signal"] = (df["close"] > df["ma20"]).astype(int)
df["ret"] = df["signal"] * df["close"].pct_change() # 错
这行代码隐含的假设是:你在收盘瞬间知道收盘价,同时又以收盘价完成成交。实盘做不到。正确写法:
python
df["position"] = df["signal"].shift(1).fillna(0)
再进一步,把成交价定成下一根开盘,并且滑点有方向 :买入往上偏,卖出往下偏。很多人只写 price * (1 - slippage),那样买入反而变便宜了,等于给策略发补贴。
python
entry_px = df["open"].iloc[i] * (1 + SLIP)
exit_px = df["open"].iloc[i] * (1 - SLIP)
3. 成本层:按换手扣,不是按次数扣
python
d["turnover"] = d["position"].diff().abs().fillna(0)
d["net"] = d["position"] * d["mkt"] - d["turnover"] * (FEE + SLIP)
用 diff().abs() 而不是数信号变化次数,是因为中间仓位(0.5 仓)按次数算会漏掉一半换手。
这一节给实测。同一个双均线 10/30,BTC 4 小时,63 笔交易,只改成本假设:
| 单边成本 | 总收益 | 最大回撤 | 夏普 |
|---|---|---|---|
| 0 | -25.12% | -39.04% | -0.67 |
| 0.05% + 0.02% | -31.40% | -43.25% | -0.91 |
| 0.10% + 0.05% | -37.94% | -47.72% | -1.19 |
| 0.20% + 0.10% | -48.58% | -55.29% | -1.70 |
从零成本到 0.30% 单边,净值差 23.46 个百分点。63 笔交易,最坏情况摩擦吃掉的不是零头,是四分之一本金。
同样这套代码,日线 1000 根上的结果是 -5.30%(21 笔,最大回撤 -37.51%)。同一个策略、同一段行情末尾,切换周期后交易次数翻三倍,成本从 3 个百分点量级变成 23 个百分点量级。
4. 指标层:年化因子、回撤、水下时长
python
def metrics(d, bars_per_year=365, rf=0.0):
eq = d["equity"]
total = eq.iloc[-1] - 1
years = len(d) / bars_per_year
annual = (1 + total) ** (1 / years) - 1 if years > 0 else 0
dd = (eq / eq.cummax() - 1).min()
vol = d["net"].std()
sharpe = (d["net"].mean() - rf / bars_per_year) / vol * (bars_per_year ** 0.5) if vol else 0
return total, annual, dd, sharpe
三个容易写错的地方:
- 年化因子跟着周期走。日线 365,4 小时 6×365=2190。用错因子,夏普会差 2.4 倍。
- 回撤用 equity/cummax - 1,不是 close 的价格回撤。含成本和复利之后,净值回撤才是账户感受。
- 水下时长(underwater duration)比最大回撤更能描述体验。买入持有 BTC 日线的最大回撤 -52.97%,最长水下 382 根;ETH 是 -67.55%,最长水下 515 根。一年半没创新高,这个数字比 -67% 更劝退。
5. 可比性层:样本长度不一致时,别横着比
这是我今天写脚本时差点犯的错。4 小时 3000 根 = 500 天,日线 1000 根 = 1000 天。两段区间不同:
| 数据 | 区间 | 期初收盘 | 期末收盘 | 区间涨跌 |
|---|---|---|---|---|
| BTC 日线 1000 根 | 2024-01-28 ~ 2026-10-23 | 42031.06 | 81706.84 | +94.26% |
| BTC 4 小时 3000 根 | 2025-06-10 ~ 2026-10-23 | 109205.14 | 81706.84 | -25.18% |
4 小时那段是单边下跌,日线那段整体上涨。把我的 -31.40% 和 -5.30% 直接对比,等于拿两段完全不同的行情说事。这两个数字只能用于成本敏感性,不能用于评价策略优劣。
工程上的处理:引擎输出里强制带上 start / end / bars / interval,报告层只要发现两个结果的区间不一致,就禁止横向比较。这条规则是纯流程性的,但它拦住过我三次。
6. 顺手做的参数扫描
python
results = []
for fast in [5, 8, 10, 12, 15, 20]:
for slow in [20, 25, 30, 40, 50, 60]:
if fast >= slow:
continue
r = run(btc_daily, sig_ma(btc_daily, fast, slow))
results.append({"params": f"{fast}/{slow}", **r})
35 组结果:中位数 +14.42%,均值 +17.63%,标准差 17.91,最差 -9.23%(8/30),最好 +65.76%(12/20)。
写这段代码很容易,读这段结果很难。报告里给中位数和离散度,比给最优组诚实得多。
7. 结论
回测引擎的代码量不大,但每一个默认值都是一次假设:数据是否连续、成交用哪个价、成本怎么扣、年化除以几、两段结果能不能比。这些假设不会让程序报错,只会让你的曲线好看或者难看。
把假设写进输出,把区间写进报告,剩下的才轮到讨论策略。
数据说明
- 数据:币安公开 K 线接口,BTCUSDT 日线 1000 根、4 小时 3000 根,读取时间 2026-10-23。
- 成本默认单边 0.05% 手续费 + 0.02% 滑点,敏感性测试中逐档调整。
- 信号收盘计算,下一根开盘成交,买入加滑点、卖出减滑点。
本文为回测工程实现的技术分享,不构成任何投资建议。加密资产价格波动极大,历史回测结果不代表未来表现,任何决策及后果由操作者自行承担。