从零搭建最小回测引擎

build-minimal-backtest-engine - 智策 SmartQuant
结论先行:最小回测引擎只需三个模块:数据加载、策略逻辑、组合管理。先分开写、保持简单,再用已知策略验证结果。先跑通最小闭环,再谈复杂功能。具体方法与数据详见下文,实操前务必用历史数据回测验证,并结合自身资金与风险承受能力审慎决策。
回测方法 阅读 20 分钟 2026-08-12

一个可信的回测引擎只需要三个模块:数据加载、撮合、绩效统计。其中唯一决定结果真假的细节是成交价——信号在第 t 根K线收盘时产生,就必须用第 t+1 根的开盘价成交。用当根收盘价成交是最常见的未来函数,能凭空造出好几倍的虚假收益。

本文要点

  • 回测引擎的最小可用形态是三个模块:数据加载、逐根撮合、绩效统计,加起来两百行以内。
  • 信号用第 t 根收盘价计算,成交必须用第 t+1 根开盘价,否则是未来函数。
  • 净值曲线要按 mark-to-market 每根K线更新,只在平仓时记账会大幅低估最大回撤。
  • 夏普比率必须按数据频率年化,日频乘 √365,4小时频乘 √(365×6),用错会差出两倍多。
  • 自己写引擎的价值在于每个假设都是你亲手设的,第三方框架的默认成交价假设常常不透明。

为什么建议自己先写一遍

市面上现成的回测框架不少,功能都比自己写的强。但对刚入门的人来说,直接用框架有个隐性代价:你不知道它在哪一步替你做了假设。成交价用的是收盘还是次开、手续费按什么口径扣、限价单未成交时怎么处理、净值是每根更新还是平仓才更新——这些假设每一个都能让年化收益差出十几个百分点。

自己写一遍两百行的最小引擎,是把这些假设逐个显式写出来的过程。写完之后你再用任何框架,都知道该去文档里查哪几件事。这个投入通常是一两天,回报是此后所有回测结论都建立在你理解的地基上。

最小引擎不追求性能和功能,只追求一件事:结果可信。不支持限价单、不支持部分成交、不支持多空同时持仓都没关系;但成交时点、成本口径、净值计算这三处必须严格。

最小引擎的另一个用途是交叉校验。同一条策略在你的引擎和第三方框架上跑,如果收益差异超过一两成,说明其中一方的假设与你的预期不符,这时候的排查往往能发现真问题。

模块一:数据加载

数据层的任务是把交易所返回的原始K线变成一张干净、可信、可复现的表。它看起来最简单,实际上是错误来源排第二的地方(第一是撮合)。要处理的问题有五个,缺一个都会在后面变成难查的怪现象。

第一是时区。所有时间戳统一成 UTC,不要用本地时间。第二是去重。交易所分页拉取时经常在边界重复返回一根K线,重复会让指标的滚动窗口错位。第三是缺失。网络中断或交易所维护会留下空洞,必须显式检测出来并决定策略——跳过还是终止,绝不要静默 ffill 价格。

第四是排序与索引唯一性,取完数据后强制按时间升序并断言索引无重复。第五是缓存,本地存成 parquet 按 symbol 和周期分文件,避免每次调试都重新拉网络,也保证结果可复现。

import pandas as pd
from pathlib import Path

COLS = ["open", "high", "low", "close", "volume"]

def load_ohlcv(path: str, freq: str = "15min") -> pd.DataFrame:
    df = pd.read_parquet(path)

    # 1) 统一 UTC 时间索引
    df["ts"] = pd.to_datetime(df["ts"], unit="ms", utc=True)
    df = df.set_index("ts").sort_index()

    # 2) 去重: 分页拉取的边界重复
    df = df[~df.index.duplicated(keep="last")]

    # 3) 显式检测缺失, 不做价格 ffill
    full = pd.date_range(df.index[0], df.index[-1], freq=freq, tz="UTC")
    missing = full.difference(df.index)
    if len(missing):
        print(f"[warn] 缺失 {len(missing)} 根K线, 首个: {missing[0]}")

    # 4) 断言基本一致性
    assert df.index.is_monotonic_increasing
    assert df.index.is_unique
    assert (df["high"] >= df["low"]).all()
    assert (df["high"] >= df[["open", "close"]].max(axis=1)).all()
    assert (df["volume"] >= 0).all()

    return df[COLS].astype("float64")
数据层必须处理的五个问题
问题不处理的后果处理方式
时区混乱多币种/多周期对齐时错位若干小时全部转为 UTC 带时区索引
分页重复滚动窗口错位,指标值偏移按索引去重,保留最后一条
K线缺失指标窗口跨越空洞,波动率失真显式检测并打印,交由策略跳过
顺序错乱shift 与 rolling 结果完全无意义强制升序并断言索引唯一
无缓存每次调试重拉网络,结果不可复现按 symbol+周期存 parquet
不要对价格列做前向填充。填充出来的是一根开高低收全相等、成交量为零的假K线,会让波动率指标失真、让止损在不存在的行情里被触发。缺失就是缺失,让策略跳过这一根。
python

模块二:撮合逻辑(最关键的一步)

撮合的唯一职责是回答:给定一个信号,我在什么价格、什么时刻、付多少成本成交。核心规则一句话——用第 t 根K线的收盘数据决策,用第 t+1 根K线的开盘价成交

为什么必须这样?因为你在 t 根收盘那一瞬才知道收盘价,此时该K线已经结束,你能提交的最早订单会在下一根开始时成交。如果回测里用 t 根的收盘价成交,等于假设你在知道收盘价的同时还能按那个价格买到,这在物理上不成立。

这个错误的影响有多大?对趋势突破类策略,突破根往往是当天涨幅最大的一根,用当根收盘成交相当于每次都在最有利的价格入场。一条日频突破策略因此虚增的年化收益,实测常见在十几到几十个百分点区间,足以把一条亏钱策略包装成漂亮曲线。

止损止盈的处理另有一条规则:同一根K线内若最高价与最低价同时触及止盈和止损,按最坏情况处理,即假定先触发止损。你无法从K线数据知道谁先到,乐观假设会系统性高估收益。

def backtest(df, signal_long, signal_exit,
             init_cash=10_000.0,
             fee_rate=0.0005,        # 单边 0.05%
             slip_rate=0.0005,       # 单边滑点 0.05%
             stop_rate=0.03):
    """最小撮合循环。signal_* 为与 df 同索引的布尔序列。"""
    cash, qty, entry, stop = init_cash, 0.0, 0.0, 0.0
    equity_curve, trades = [], []

    idx = df.index
    for i in range(len(idx) - 1):        # 注意: 到 -1, 因为要用 i+1
        ts, row = idx[i], df.iloc[i]
        nxt = df.iloc[i + 1]
        fill_px = nxt["open"]            # ★ 下一根开盘价成交

        # --- 1) 先处理离场(风控优先于入场) ---
        if qty > 0:
            # 最坏情况假设: 同根内先看止损
            if row["low"] <= stop:
                px = stop * (1 - slip_rate)
                cash += qty * px * (1 - fee_rate)
                trades.append((ts, "stop", entry, px, qty))
                qty, entry, stop = 0.0, 0.0, 0.0
            elif bool(signal_exit.iloc[i]):
                px = fill_px * (1 - slip_rate)
                cash += qty * px * (1 - fee_rate)
                trades.append((ts, "exit", entry, px, qty))
                qty, entry, stop = 0.0, 0.0, 0.0

        # --- 2) 再处理入场 ---
        if qty == 0 and bool(signal_long.iloc[i]):
            px = fill_px * (1 + slip_rate)
            qty = (cash * 0.99) / (px * (1 + fee_rate))
            cash -= qty * px * (1 + fee_rate)
            entry, stop = px, px * (1 - stop_rate)

        # --- 3) 每根都按市价重估净值 (mark-to-market) ---
        equity_curve.append((idx[i + 1], cash + qty * nxt["close"]))

    eq = pd.Series(dict(equity_curve)).sort_index()
    return eq, pd.DataFrame(
        trades, columns=["ts", "reason", "entry", "exit", "qty"])

循环里有两个容易忽略的顺序问题。一是离场必须先于入场判断,否则同一根上会出现先加仓再止损的诡异序列。二是净值每根都更新,而不是只在平仓时记一笔。只在平仓记账会完全看不见持仓期间的浮亏,最大回撤能被低估一半以上。

python

模块三:绩效统计

有了净值曲线,绩效计算是纯数学。但有两个地方经常算错:年化的频率因子,和最大回撤的定义。

年化收益用几何方式算:总倍数开 (年数) 次方再减一,不要用平均日收益乘 365。年化波动率是收益率序列标准差乘以周期数的平方根,周期数取决于数据频率——加密市场全年无休,日频是 365,4小时频是 365×6=2190,15分钟频是 365×96=35040。用错这个因子,夏普会差出两三倍。

最大回撤是净值相对历史最高点的最大跌幅,不是最高点到最低点的跌幅。还要一并输出回撤持续时间(从创新高到回到新高的天数),这个指标对能否坚持执行策略的影响,往往比回撤幅度本身更大。

import numpy as np

PERIODS = {"1d": 365, "4h": 365 * 6, "1h": 365 * 24,
           "15min": 365 * 96}

def performance(eq: pd.Series, freq="15min", rf=0.0) -> dict:
    ret = eq.pct_change().dropna()
    n = PERIODS[freq]
    years = len(ret) / n

    total = eq.iloc[-1] / eq.iloc[0] - 1
    cagr = (eq.iloc[-1] / eq.iloc[0]) ** (1 / years) - 1 if years > 0 else 0.0
    vol = ret.std(ddof=1) * np.sqrt(n)
    sharpe = (cagr - rf) / vol if vol > 0 else 0.0

    # 下行波动 -> Sortino
    dn = ret[ret < 0].std(ddof=1) * np.sqrt(n)
    sortino = (cagr - rf) / dn if dn > 0 else 0.0

    # 最大回撤: 相对历史最高点
    peak = eq.cummax()
    dd = eq / peak - 1
    mdd = dd.min()

    # 回撤持续期(根数): 未创新高的最长连续段
    under = (dd < 0).astype(int)
    longest, cur = 0, 0
    for v in under:
        cur = cur + 1 if v else 0
        longest = max(longest, cur)

    return {
        "total_return": round(total, 4),
        "cagr": round(cagr, 4),
        "vol": round(vol, 4),
        "sharpe": round(sharpe, 2),
        "sortino": round(sortino, 2),
        "max_drawdown": round(mdd, 4),
        "dd_bars": longest,
        "calmar": round(cagr / abs(mdd), 2) if mdd < 0 else None,
    }
python

三个模块的职责边界

把职责写成表贴在项目 README 里,能避免后期代码互相污染。判断某段逻辑该放哪一层,只问一句:它需要知道账户有多少钱吗?需要就属于撮合层,不需要就属于数据或信号层。

最小回测引擎模块职责表
模块输入输出必须做对的事常见错误
数据加载交易所K线 / parquet 缓存UTC 索引、无重复的 OHLCV去重、缺失检测、断言 high≥low价格前向填充造出假K线
撮合逻辑OHLCV + 布尔信号 + 成本参数净值曲线 + 成交明细用 t+1 开盘价成交;离场先于入场用当根收盘价成交(未来函数)
绩效统计净值曲线年化、夏普、回撤等指标字典按数据频率正确年化夏普统一乘 √252,加密市场应为 √365

成本参数虽然只是撮合层的两个数字,但它对结论的影响远超其他所有细节,值得单独研究,可以看手续费与滑点建模那篇的具体演算。

引擎跑通之后,下一步不是调参提高收益,而是验证结果的稳定性,方法见Walk-Forward 滚动前推验证。单次全样本回测的数字,无论多漂亮,都不足以支持任何决策。

怎么确认引擎本身没有bug

引擎写完,第一件事不是跑策略,而是用四个已知答案的极端案例做校验。这些测试跑起来只要几秒,但能拦住大多数实现错误。

  1. 买入持有测试:信号恒为 True、离场恒为 False、成本设为 0,结果必须精确等于 (末价/首价 - 1)。误差超过千分之一说明净值或仓位算错了。
  2. 零信号测试:信号全为 False,净值必须是一条完全水平的直线,最大回撤为 0。
  3. 成本单调性测试:手续费从 0 递增到 0.2%,总收益必须严格单调下降。若出现上升,说明成本符号或方向搞反了。
  4. 未来函数探测:把成交价从 t+1 开盘改成 t 收盘,收益应明显上升。若两者几乎相同,说明你的信号触发点其实和K线关系不大,或者 shift 写错了位置。

第四项值得展开一句:它不是在鼓励你用当根收盘价,而是把差值当成诊断量。这个差值越大,说明策略越依赖入场时点的精确性,实盘中滑点对它的伤害也越大。差值特别大的策略,实盘落差通常最严重。

四项都过了,你的引擎就可以用来做初筛。之后需要补的功能按优先级排:成本模型细化、多币种支持(见多周期与多币种策略的工程结构)、限价单与部分成交、资金费率。功能可以慢慢加,但前面那三个正确性要求一开始就不能让步。

回测引擎输出的所有指标均为历史数据推演结果,不构成收益承诺或投资建议。即使引擎完全正确,实盘仍会因延迟、限频、深度不足、交易所故障而低于回测表现。涉及杠杆时,回撤会按倍数放大并可能触发强制平仓,请只用能承受完全亏损的资金验证。

什么是常见问题?

用下一根开盘价成交,会不会太保守了?

不保守,这是能实际执行的最早价格。真实情况可能更差:市价单会吃掉盘口若干档,成交均价往往比开盘价还要不利。若你的策略只在用当根收盘价时才盈利,结论是这条策略不成立,而不是撮合规则太严。

最小引擎需要支持限价单吗?

初期不需要。限价单要模拟是否成交、成交多少,需要盘口或成交明细数据,复杂度上升一个量级。用市价单加较保守的滑点做初筛已经够用,等策略确定要靠限价挂单降成本时再补这一块。

净值为什么必须每根K线更新?

因为最大回撤要衡量的是你实际承受的账面波动。只在平仓时记账,持仓期间从盈利 30% 跌回 5% 的过程完全不会出现在曲线上,最大回撤会被显著低估——而这恰恰是决定你能否坚持执行策略的关键数字。

加密市场的夏普比率年化因子该用多少?

加密市场 7×24 交易,日频用 √365 而非股票市场的 √252。更高频率按实际根数换算:4 小时是 √2190,15 分钟是 √35040。用 √252 去算加密日频数据,会把夏普系统性低估约 20%;反过来把高频当日频算则会大幅高估。

风险提示:量化交易同样存在亏损风险。本站所有策略、信号、收益数据均为历史回测数据,不构成收益承诺,也不构成投资建议。加密货币波动剧烈,请先用小资金验证,并遵守所在地法律法规。

📺 相关视频

TradingView Automated Trading with AI
TradingView Automated Trading with AI
AI自动化交易演示(OctoBot)
📚 参考来源
Google AI 优化指南(2026-06更新)

developers.google.com/search/docs/fundamentals/ai-optimization-guide — Google官方对生成式AI搜索优化的指引。

SE Ranking AI引用研究

seranking.com/blog/ai-search-research/ — AI答案引用来源研究(44%引用来自页面前30%)。

量化分析维基百科

en.wikipedia.org/wiki/Quantitative_analysis_(finance) — 量化分析的基础定义与学术脉络。