第 6 章 · 01 回测引擎原理 本节摘要:本节是回测章的理论基石,讲透 freqtrade 回测引擎的工作方式与固有局限。回测(Backtesting)的本质是「逐根 K 线回放」——把历史 OHLCV 数据按时间顺序一根根喂给策略,策略算指标、产信号、模拟下单、记账,最终汇总表现。但回测永远不等于实盘,因为它必须做一系列假设:K 线内部价格怎么走的、订单是否成交、滑点和手续费如何。本节重点讲三件事:回测引擎的事件循环与「K 线内假设」、回测与实盘的固有差异、以及最危险的「未来函数(lookahead)」本质——如果你的指标在 K 线还没收盘时就用了它的收盘价,回测会「作弊」式盈利,实盘却会亏光。读完本节,你才能带着批判的眼光看回测报告。
本节摘要:本节是回测章的理论基石,讲透 freqtrade 回测引擎的工作方式与固有局限。回测(Backtesting)的本质是「逐根 K 线回放」——把历史 OHLCV 数据按时间顺序一根根喂给策略,策略算指标、产信号、模拟下单、记账,最终汇总表现。但回测永远不等于实盘,因为它必须做一系列假设:K 线内部价格怎么走的、订单是否成交、滑点和手续费如何。本节重点讲三件事:回测引擎的事件循环与「K 线内假设」、回测与实盘的固有差异、以及最危险的「未来函数(lookahead)」本质——如果你的指标在 K 线还没收盘时就用了它的收盘价,回测会「作弊」式盈利,实盘却会亏光。读完本节,你才能带着批判的眼光看回测报告。
内容来源:原项目文档
docs/backtesting.md的 Assumptions 与 Improved backtest accuracy 章节、源码freqtrade/optimize/backtesting.py,汉化并套用体系化模板。
⚠️ 风险提示:回测盈利绝不保证实盘盈利。任何回测都建立在假设之上,过度乐观的假设(无滑点、K 线内价格理想化)会让回测好看但实盘崩溃。回测是「必要但不充分」的验证手段。
阅读完本节,你应当能够:
--timeframe-detail、Dry-Run 验证)。回测引擎(freqtrade/optimize/backtesting.py 中的 Backtesting 类)的核心逻辑可以概括为「加载 → 回放 → 汇总」:
关键点:
populate_indicators 算好所有指标列。这与实盘「每来一根新 K 线才算」不同——正是这个差异,埋下了「未来函数」的隐患(第 04 节详讲)。enter_long / exit_long 等信号列,按一套假设决定是否成交。💡 为什么一次性算指标:为了速度。回测要在几秒内跑完几千根 K 线,如果每根都重算指标会慢几个数量级。但代价是策略作者必须自律,不能在指标计算里「偷看」未来数据。
回测「不知道 K 线内部价格怎么走」——一根 1h K 线只有 4 个数(开、高、低、收),但实际这一小时内价格可能走出任意路径。为此回测必须做假设:
| 场景 | 假设 |
|---|---|
| 入场 | 在 K 线开盘价成交(除非自定义了入场价逻辑) |
| 出场信号 | 在下一根 K 线的开盘价成交(信号在收盘才确认) |
| 所有订单 | 只要价格在当根 K 线 high/low 范围内,就全额成交、无滑点 |
| ROI 平仓 | 与 high 比较,但用 ROI 值(如 ROI=2%,high=5%,按 2% 成交);不会「低于 K 线」 |
| 止损 | 在止损价精确成交,即使 low 更低;但损失会比止损价多 2 * 手续费 |
| 出场信号 vs 止损 | 同根 K 线同时触发时,出场信号优先(假设信号在开盘触发) |
| 止损 vs ROI | 止损先于 ROI 评估(保护资本优先) |
| 止损内 high/low 顺序 | low 先于 high(保守,先假设最坏情况) |
一根 K 线上可能同时有出场信号、止损、ROI、追踪止损,回测按这个顺序判断:
⚠️ 这些假设都有偏向:「止损精确成交」「low 先于 high」「出场信号优先于止损」——这些都是保守假设。但「无滑点」「订单全额成交」是乐观假设。回测结果总是「真实表现的一个有偏估计」,方向取决于哪边假设更偏离实际。
即便没有未来函数,回测也永远不等于实盘,差异来自:
| 维度 | 回测 | 实盘 |
|---|---|---|
| 行情 | 历史 K 线回放 | 实时真实行情(含 tick 级噪声) |
| K 线内信息 | 只有 OHLC,猜内部走势 | 看到真实 tick 路径 |
| 成交 | 假设无滑点、全额 | 有滑点、可能部分成交、可能被拒 |
| 数据延迟 | 无 | 网络延迟、API 限流 |
| 心理 | 无情绪干扰 | 贪婪恐惧影响干预 |
| 流动性 | 假设无限 | 大单会推高/砸低价格 |
最致命的差异是K 线内走势的未知。一根 1h K 线,你只知道开高低收,但不知道「是先涨后跌,还是先跌后涨」。如果是先涨后跌,止损可能在高位触发;如果是先跌后涨,止损可能在低位触发——这俩结果天差地别。回测只能假设一个顺序(默认 low 先于 high)。
为了缓解「K 线内未知」问题,freqtrade 提供了 --timeframe-detail:
freqtrade backtesting --strategy AwesomeStrategy --timeframe 1h --timeframe-detail 5m
它的逻辑:主周期(1h)用于策略信号;但当某根 1h K 线「有活动」(有信号或正持仓)时,引擎会切到更细的 5m 周期去模拟这一小时内的价格走势。
效果:
custom_exit / custom_stoploss 等回调会在每根 5m 上跑(1h 内跑 12 次)。⚠️ 代价:需要额外下载 5m 数据、内存翻倍、运行时间变长。建议作为策略开发的最后一步验证——如果普通回测和
--timeframe-detail结果接近,说明策略没在「K 线内假设」上钻空子,实盘存活概率更高。
未来函数(lookahead bias)是回测最隐蔽、最危险的陷阱:策略在 K 线还没收盘时,就用了这根 K 线的收盘价(甚至更晚的数据)来决策。
回测引擎「一次性加载 + 一次性算指标」的工作方式,让未来函数极易混入而不自知:
dataframe.shift(-10):往后看 10 根 K 线(直接用未来数据)。dataframe.iloc[...] 在 populate_* 里访问特定行:可能读到当前时间点之后的数据。for 循环遍历 DataFrame:循环边界没控制好就读到未来。.mean() / .min() / .max() 不带 rolling:对整列求统计量,信号那根 K 线「看到」的值里混入了未来数据。ta.MACD(dataframe, 12, 26, 1):signalperiod=1 会引入未来函数。未来函数让回测看起来「印钞」,但实盘里那些信号根本不会产生(因为实盘是「逐根 K 线」算的,看不到未来)。90% 的「好得难以置信」的回测,都是未来函数在作祟。
lookahead-analysis 命令(第 04 节详讲),它会故意「切断」未来数据,看信号是否变化。shift(正数) 或 rolling 窗口往回看。populate_* 里用 iloc / loc 按位置访问。rolling(N) 或 .expanding(),绝不裸用 .mean()。for 循环,改用向量化操作。💡 第 04 节预告:lookahead 太容易引入又太难肉眼发现,所以 freqtrade 提供了
lookahead-analysis自动检测。任何策略上实盘前,都必须过这一关。
把回测理解成「从粗到细」的三种粒度,有助于选择合适的验证手段:
| 粒度 | 命令 | 特点 | 用途 |
|---|---|---|---|
| 普通回测 | backtesting |
主周期 K 线回放,K 线内靠假设 | 快速迭代、调参 |
| 细节回测 | backtesting --timeframe-detail 5m |
用更细周期模拟 K 线内 | 上线前最终验证 |
| Dry-Run | trade --dry-run |
实时真实行情,模拟下单 | 接近实盘的最终验证 |
三者「越往下越接近实盘,也越慢」。推荐流程:普通回测调逻辑 → 细节回测验证 K 线内假设 → Dry-Run 验证配置和流程 → 实盘。
shift(-N)、裸 .mean()、iloc、signalperiod=1 等都会引入;让回测「印钞」、实盘亏光。下一节,我们实打实跑一次回测,逐表解读回测报告——总收益、胜率、最大回撤、Sharpe、按交易对/出场原因的拆分。