回测引擎的五个工程细节: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 小时前
255 次尝试就能恢复 CA 私钥?这次 TLS 信任链事故,值得每个 AI 开发者警惕
人工智能·python·https·tls·信任链·ca
happylifetree1 小时前
Python68:核心语法-函数进阶-匿名函数(lambda表达式)
python
专注仿真1 小时前
从「能跑通」到「能交付」:一套可直接落地的电商用户行为分析平台(Python + SQLite/MySQL + Streamlit 全栈源码与部署文档)
python·mysql·sqlite
小小张说故事1 小时前
Python 的 for 到底怎么跑起来的?拆开迭代协议:GET_ITER 之后 FOR_ITER 在循环什么
python
凯瑟琳.奥古斯特2 小时前
动态调优IoU提升OCR文本合并精度
开发语言·python·算法
蒸鱼Yuzheng2 小时前
自动化长任务治理:租约、心跳、孤儿进程与安全回收
自动化测试·python·任务调度·可靠性·分布式系统
2601_962885723 小时前
从 baostock 迁移到 AlphaFeed:接口对照与代码迁移指南
python
AI情绪识别开源4 小时前
检信ALLEMOTION 标定治理与信号质量闸门 · 老年无感认知评估分析器
python
七夜zippoe4 小时前
启元 Q1 腿部运动学逆解:Python 算站立下蹲关节角
开发语言·python·ai·机器人·腿部运动学