回测引擎的五个工程细节:3000 根 4 小时 K 线跑出来的成本差

回测引擎的五个工程细节: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% 滑点,敏感性测试中逐档调整。
  • 信号收盘计算,下一根开盘成交,买入加滑点、卖出减滑点。

本文为回测工程实现的技术分享,不构成任何投资建议。加密资产价格波动极大,历史回测结果不代表未来表现,任何决策及后果由操作者自行承担。

相关推荐
刘马想放假1 小时前
CANviz 使用指南|Linux 安装、DBC 解码、实时曲线与 J1939 实战
linux·python·websocket
2601_967987372 小时前
python判断变量名不要调用任何函数实现
python·函数·语法·判断·变量名
卷无止境3 小时前
SALib敏感性分析的python库学习笔记
后端·python
duansamve4 小时前
Python3+Flask+PostgreSQL18+Vue3项目生产环境用docker如何部署?
python·docker·flask
特立独行的猫A4 小时前
少 3 个环节、少 1 次 ABI、少 3500 行代码:cj-tauri 与 Ionic/Capacitor 的鸿蒙桥接机制对比
java·后端·python
特立独行的猫A4 小时前
cj-tauri vs Rust/Tauri 同款播放器实测:编译 21 秒 vs 11 分钟,磁盘 12 MB vs 1.2 GB
java·后端·python
duansamve5 小时前
详解Flask中Session和Cookie
python·flask
威哥爱编程6 小时前
模拟面试智能体开发实战 04:面试聊到哪算哪?用状态机把整场面试编排起来
python·agent·harmonyos
威哥爱编程6 小时前
模拟面试智能体开发实战 03:AI 面试官太客气?人设 Prompt 让它像个真考官
python·agent·harmonyos
量化分析码农6 小时前
股票数据接口返回字段逐项解析:从在线验证到 Python 数据校验
python