← 返回教程库

你那份回测的成交价,到底是从哪个数抠出来的

最后更新 2026-08-19
📚 量化与 AI 投研 📖 E 线 · 回测引擎 ⏱ 约 22 分钟 R2 · 注意风险
你将学到
  • 说得出三种常见成交价实现(当根收盘、下一根开盘、盘口逐档)各自的数据代价、乐观方向,以及最容易被它们坑的策略类型
  • 在别人的回测代码或自己的引擎里,能一眼定位到「成交价是从哪个数取的」那一处,并判断它有没有静默回退
  • 会用同一套规则跑多组成交假设,用结果的离散度判断策略脆不脆,而不是挑一个最好看的假设留下

有个场景,写过几行回测代码的人多半遇到过:你把同一套规则,分别塞进两个不同的回测框架里跑,得到两条完全不一样的净值曲线。不是差一点,是一条向上一条向下。

你开始查数据,数据是同一份。查信号,两边的买卖点日期几乎重合。最后你在其中一个框架的角落里翻到一行——它按这根 K 线的收盘价记账;另一个框架按下一根 K 线的开盘价记账。就这一处差别,把结论翻了个面。

这一篇不重讲「有价格不等于能成交」这件事本身,那是回测里的成交假设那张卡的活儿,它把宽松到严格的几档排成了一把尺子,你该先去看一眼自己站在哪一格。这一篇只干工程侧的事:这几种成交价实现,分别要付什么代价,各自朝哪个方向骗你,以及你怎么用同一套规则去量这个骗的幅度。

先约定一个说法:下面反复出现的成交价,指回测在记账时认定「这一笔买卖发生在什么价位」的那个数。它不是市场上真实存在过的某笔成交,它是你的引擎替你选的一个代表值。选法就那么几种,但每一种背后都是一句你未必同意的假设。

先说人话:三种结账方式

假装你在一家不用当场付钱的菜场买菜。你上午挑好了菜,钱月底一起结。那问题来了:结账的时候按什么价算?

第一种算法:按你挑菜那天的收市牌价算。 摊主每天收市会挂一个当日牌价,月底照着表逐笔补记。这个算法最省事——一张表就够了,不用记你几点来的。但它有个别扭的地方:你挑菜的时候,那天的收市价还没出来。也就是说,这个记账方式默认了一件现实中不成立的事——你在决定买的那一刻,就已经知道了收市价,并且按它成交了。

第二种算法:按第二天开门的第一口价算。 你今天挑的菜,明早开门第一口报价是多少就算多少。这个算法诚实得多,它承认了「你做决定」和「钱货两清」之间隔着一段时间。代价是这一夜里可能出事:隔壁进了一车货价格塌了,或者产地下暴雨价格跳上去了。这段隔夜的不确定性不是这个算法的缺点,恰恰是它比第一种真实的地方——现实里那段空档本来就存在。

第三种算法:看你排队那一刻,柜台上一档一档还剩多少。 前面标价便宜的那筐只剩三斤,你要十斤,剩下七斤得从后面标价贵的那筐拿。你的实际均价,取决于你要多少、以及你前面排了几个人。这个算法最接近真相,代价是你得有一份「那一刻柜台上每一档还剩多少」的记录——这种记录,绝大多数人手上根本没有。

三种结账方式,就是三种成交价实现。往下把它们翻译成投资上的意思。

投资层:三种取法各自朝哪边骗你

用当根收盘价:最省事,也最容易自我表扬

这是自制回测里最常见的写法,因为它写起来最顺手——你的信号本来就是拿这根 K 线的收盘价算出来的(均线、涨跌幅、各种指标,多半都以收盘价为原料),顺手拿同一个数当成交价,一行代码搞定,连日期对齐都不用想。

麻烦也正在这里:你用来做决定的那个数,和你用来记账的那个数,是同一个数。 这意味着你的模拟世界里那个人,在收盘价刚刚产生的同一瞬间,完成了「算出信号」和「成交」两个动作。现实里这两个动作之间至少隔着一段:你得看到收盘、算完、下单、排队、成交。这一段在数据上就是下一根 K 线的事了。

这个错法有个特别难受的性质:它偏袒的方向和你的信号强度正相关。 你的规则越是「今天涨得猛就买」,收盘价这个数就越是被今天那段上涨顶上去的结果,而你按它成交,等于按最贵的那口价买进却记成了合理成本。反过来,「今天跌得狠就买」这类规则,收盘价是被砸下来的最低那一档,你按它买进同样是现实中抢不到的位置。只要你的信号里含有当根 K 线自身的价格信息,用当根收盘价成交就一定是自我表扬,区别只是表扬得多还是少。

顺带说一句,这不算前视偏差最严重的那一类(你毕竟没用到明天的数据),但它属于同一个家族:在信息还没到手的那一刻,就用上了它。

用下一根开盘价:保守,但保守得有道理

把成交挪到下一根 K 线的开盘价,是最常见的一次「变保守」。它一次修掉了两件事:决策与执行的时间差有了,成交价和信号原料脱钩了。

代价是你会看到收益变差——而且有时差得让人心疼。很多人第一次改完这一处,回测年化直接掉一半,第一反应是「这样太苛刻了吧」。

不是苛刻,是那一半本来就不是你的。 你可以拿一个很朴素的问题验证:那笔交易在现实里,你有可能成交在原来那个价上吗?你在收盘那一刻才知道信号,那时市场已经收市了,你手里没有任何办法按收盘价买进。这不是「保守估计」,这是「删掉一笔不存在的交易记录」。

不过这个改动也带来一个新的敏感点:隔夜跳空的风险,从这一刻起全部由你承担。日线级别上这一项很重,尤其是消息驱动的规则。一个「盘后出公告,第二天买」的规则,在收盘价成交的版本里几乎稳赚,换到开盘价成交往往当场亏损——因为消息带来的那段涨幅,正是在你根本没法交易的那段时间里走完的。 这不是引擎在为难你,这是你原本的规则在偷这段时间。

按盘口逐档吃:最真实,门槛也最高

真正接近现实的做法,是把你的单子放进那一刻的盘口里,一档一档往下吃:第一档吃完不够,就接着吃第二档,直到吃满你要的量。得到的是一个加权平均成交价,而且这个价会随着你要的量变大而变差。

这条路子在工程上有三重门槛。第一重是数据:你需要逐笔或者盘口快照级别的历史数据,体量往往是日线的成千上万倍,多数人拿不到也存不下。第二重是速度:同样一段历史,逐档模拟会慢到让你没法反复试规则,而策略开发这件事恰恰是靠反复试出来的。第三重最容易被忽略——你自己那笔单子对市场的影响,盘口数据里没有。 历史盘口记录的是「你不在场时的市场」,你真下进去,你的单子会把对手方吃掉一部分,后面的人看到的盘口就变了。这一层属于冲击成本的范畴,逐档模拟只能给你它的一部分,不是全部。

所以逐档吃盘口的正确定位不是「日常用它」,而是在你已经有一套跑得通的规则之后,用它去检验规则是不是靠某些不存在的成交在赚钱。日常开发用不起,收尾验证用得起。

三种取法摆在一起

取法 要什么数据 朝哪边骗你 谁被坑得最惨
当根收盘价 只要日线四个价 信号里含当根价格信息时,系统性偏好你 拿当根涨跌幅当信号的规则、追涨杀跌类规则
下一根开盘价 只要日线四个价 相对诚实,但把隔夜跳空全算你头上 依赖盘后消息、依赖收盘附近异动的规则
盘口逐档 逐笔或盘口快照 仍缺你自己那笔单的冲击 单笔金额大、换手快的规则

有一条贯穿三行的规律值得单独拎出来:成交假设的杀伤力,和你的换手速度成正比。 一年调仓两次的规则,三种取法跑出来差不太多;一天进出好几回的规则,取法就是决定生死的那个参数。这也是为什么高频方向的回测门槛高得多——不是算法难,是它对这一处假设的容错空间接近于零。

系统层:三个开源引擎各自把这件事做成什么样

概念讲完,看看真实系统里这一处长什么样。三个引擎的处理风格差得很远,摆在一起读比单看任何一个都有收获。

freqtrade:难的不是选哪个价,是防止选出一个不存在的价

freqtrade 的官方文档里有一节专门列回测假设,态度很坦白:回测不知道一根 K 线内部发生了什么,所以必须先假设一些事。 它的选择是——进场按开盘价成交(除非你自己写了报价逻辑),离场信号触发的卖出成交在下一根 K 线的开盘价。

真正有意思的是它源码里那一大坨算离场价的逻辑。你以为「取哪个数」是一行代码的事,翻开看才发现,那个决定成交价的函数分了好几支,每一支底下还挂着一串条件判断。这些判断绝大多数不是在选价,而是在兜底:止盈按目标价走,但如果这根 K 线的走势让那个价位根本没出现过,就退回用开盘价;止损按止损价记,哪怕这根 K 线的最低价比它更低;同一根 K 线里止损和止盈都可能触发时,假定最低价先于最高价发生,先保护本金。它甚至专门区分了阳线和阴线两种情况,来限制「离场价不许落在 K 线之外」。

再配上那个短得可以直接背下来的成交判据——报价落在这根 K 线的最高价和最低价之间,才算成交——整幅图就清楚了:

一个成交价实现里,真正的代码量不在「取哪个数」,而在「保证取出来的这个数,在这根 K 线里真的出现过」。

这句话值得你写进自己的引擎里当规矩。自制回测最常见的塌方,就是止损价、止盈价这类由你算出来的价格被直接当成成交价记了账,而那个价位当天根本没到过。它不会报错,只会让你的止损看起来执行得干净利落——现实里你那单要么没触发,要么滑出去老远。

Vibe-Trading:把「我会成交在什么价」收敛成唯一一处

这个项目的引擎基类里有一个很小但很关键的设计:它把「这一笔如果成交,会成交在什么价」抽成了一个独立函数,别处要用就调它。函数体本身平平无奇——取当根 K 线的开盘价,再按交易方向叠上滑点。注释里那句说明才是重点,大意是:这个函数必须和真正定仓位、真正记账时走的是同一条路径。

为什么要这么讲究?因为成交价这个数在一个引擎里至少有三处要用到:判断这一笔能不能成交(比如拿去和涨跌停价带比)、算这一笔要买多少股最后记这一笔的账。这三处如果各自算各自的,早晚会不一致——而不一致的后果不是崩溃,是校验的时候用一个价通过了,记账的时候用另一个价落了库,于是你的成交明细里出现了一笔理论上已被拒绝的交易。

同一个项目里还有一处配套的克制:算涨跌停价带用的基准价,必须是下单那一刻市场已经知道的价格(比如前收盘),而且当基准价压根取不到时,它选择不阻断而不是阻断,注释写得很直白——不能凭空捏造一次阻断。宁可漏判,不制造现实中不存在的限制。

把这两处放在一起,你能提炼出一条对自己写代码直接有用的规矩:校验的对象和记账的对象必须是同一个数,而这个数应该只有一个出处。 成交价在你代码里出现几次,就是几个将来会对不上的地方。

qlib:成交价是一个可配置字段——包括它悄悄换掉的时候

qlib 走的是另一条路:成交价用哪个字段,是一个配置项。你可以给一个字段名让买卖都用它,也可以给一对字段名,买用一个、卖用另一个

这一处设计对本篇后半段特别重要,因为它天然就是一个敏感度旋钮:改一处配置,你就能把整个回测从「买卖都按收盘价」切成「买按偏高的那个价、卖按偏低的那个价」的悲观版,同一套规则重跑一遍,差多少一目了然。能被一行配置切换的假设,才有可能被系统地量。 如果你的引擎把成交价写死在代码中间,这种对照实验就得改代码,改代码就意味着你多半不会做。

它还有个默认行为值得念一句:不指定的时候,按收盘价成交(这是当前版本的默认值,以你手上那个版本的文档为准)。也就是说,上面说的那个「最省事也最容易自我表扬」的取法,很可能就是你什么都没配时跑出来的那个结果。

最该记住的是另一处——静默回退。取指定字段的时候,如果那个值是空的、或者小到不合理,它会打两条警告日志,然后把成交价换成收盘价接着跑:

if method is not None and (deal_price is None or np.isnan(deal_price) or deal_price <= 1e-08):
    self.logger.warning(...)
    self.logger.warning("setting deal_price to close price")
    deal_price = self.get_close(stock_id, start_time, end_time, method)

这个设计的用意是好的——不让一两个缺失值打断一整段几年的回测。但它对你的意义是:你以为自己在跑一个成交价假设,实际上某些日子跑的是另一个。 而这件事只体现在日志里,不体现在结果里;日志级别一调,连那条也没了。

这类回退在数据处理里到处都是,行情数据里那些会让你回测结果全错的坑那篇讲的补齐、填值,都是同一个模式。工程上有一条判断可以直接抄:凡是「取不到就换一个凑合用」的地方,都必须同时留下一个能被下游统计的记号,而不是只留一行日志。你至少要能回答「这次回测里有多少笔是回退成交的」,答不出来,这份结果的口径就是不明的。

本篇的正题:用同一套规则,跑几组假设

到这儿,工具都齐了。真正该做的事只有一件——别去找那个最好看的假设,去量结果对假设有多敏感。

该跑哪几组

不用多,三到四组就够,关键是拉开档位:

乐观档:当根收盘价成交,不设可成交性检查。这一档不是拿来信的,是拿来做上界的——它告诉你,这套规则在最理想的世界里能挣多少。

中性档:下一根开盘价成交,加上基本的可成交性检查(该停牌不成交、封板不成交)。这一档最接近你日常该用的默认。

悲观档:买卖两边分别用偏不利的那个价(买按偏高、卖按偏低),滑点调宽,成交量约束打开。这一档也不是拿来信的,是拿来做下界的。

不对称档(可选但很有价值):只把卖出侧改悲观,买入侧保持中性。很多规则的收益其实全靠离场时那个理想价位撑着,这一档专门戳这个。

再往上一层,还有一种更狠的验证方式:用更细的周期把有动作的那些 K 线重放一遍。 freqtrade 提供了这种做法——主周期照常算信号,但在可能发生交易的那些 K 线上,切到一个更细的周期去模拟 K 线内部的走势。它的文档把这个功能的定位说得很清楚:这是策略开发的最后一道自检,用来确认你的成绩不是靠钻回测假设的空子得来的。 qlib 那边则是用嵌套执行器来表达同一个思路——外层按粗粒度决策,内层在更高频率的环境里去执行。

两条路子的共同点是:它们都不试图算得更准,它们试图证明你的结果不依赖于那些猜出来的东西。 这个目的比「更准」重要得多。

结果该怎么读

跑完几组,你手上是几条曲线。看三件事,顺序别乱:

第一,方向有没有翻面。 乐观档挣钱、悲观档亏钱,这套规则就是不成立的——它落在假设的缝里,不落在市场上。别去争论「悲观档是不是太苛刻了」,先接受一个事实:一个结论在合理的假设区间内不稳定,那它就不是结论,是巧合。

第二,衰减是不是均匀的。 从乐观档到悲观档,收益一定会掉,掉多少不重要,怎么掉才重要。如果每一笔都均匀地掉一点,那是正常的成本效应,符合直觉;如果总收益的大头集中在少数几笔上,而这几笔在换了假设之后全部消失,那说明你的规则是靠一小撮特殊成交在活着——这一小撮,往往正是现实中最抢不到的那些位置。

第三,排名稳不稳。 如果你在比几套规则谁更好,一定要看在不同假设下它们的排序会不会翻个儿。排序一翻,你之前那个选优过程本身就作废了,因为你选的不是更好的规则,是更贴合某一组假设的规则。

一条必须守住的纪律

这件事有个反噬风险,说出来你就明白:成交假设本身也可以当成一个可调参数用来刷成绩。 手上有四组假设,挑那个最好看的写进报告,跟挑一个最好看的参数没有区别——这就是参数高原背后同一种病,只是换了个旋钮。

所以顺序不能反:先把你要跑哪几组、以哪一组为准,写下来,再去跑。 「以中性档为准,乐观档和悲观档只用来看离散度」——这句话必须在你看到结果之前就定死。看完再定,你定的就不是纪律,是借口。

还有一条配套的:别只让回测保守。 你在回测里限制自己不碰封板的票,实盘里手一痒追了进去,那份回测就不再是对你行为的模拟了。假设的严格程度,两边得对齐。

这一篇明确不管的事

  • 成交价取哪个数是这一篇,一个引擎从头到尾在做什么(怎么排事件、怎么推进时间、状态怎么落库)是一个回测引擎内部到底在做什么的事。
  • 手续费、印花税、滑点该设多少、怎么设才不骗自己,是成本模型该怎么设才不骗自己的事。这里只需要记住一个分界:滑点是「成交了,但价格差一点」,是价格上的修正;成交假设是「这一笔到底存不存在、按什么价记」。调大滑点救不了一笔本就不该存在的成交,那一层的辨析在滑点那张卡上。
  • 你的单子大到会把价格推走,那属于流动性与容量,见流动性与容量
  • 各种偏差在整条流水线上分别卡在哪个工位回测偏差地图画成了一张图。
⚠️ 风险提示

本文讲的是回测引擎在「成交价怎么取」这一处的工程实现差异与自查方法,不构成任何投资建议、买卖信号或收益承诺,不推荐任何框架、平台、数据源或标的。文中所有关于开源项目的描述,均来自对上述指定快照版本源码与文档的阅读,不是运行结果,也不保证在你的版本、你的数据、你的市场上同样成立——具体行为与默认值请以你手上那个版本的文档和你自己的核对为准。文中不给任何阈值、比例或参数数值,这类数字随版本、市场制度和券商口径变化。把成交假设改到最严格,也只是让回测不再明显失真,它依然不等于实盘结果,历史表现更不预示未来收益。 完整风险提示见免责声明

小结

  • 成交价不是市场上的真实成交,是你的引擎替你选的一个代表值。选法就那么几种,每一种背后都是一句你未必同意的假设。
  • 用当根收盘价最省事,但只要你的信号里含有这根 K 线自身的价格信息,它就一定在自我表扬;用下一根开盘价诚实得多,代价是隔夜跳空全算你头上,而那段风险本来就该算你头上;按盘口逐档吃最真实,但要逐笔数据、跑得慢,而且仍然缺了你自己那笔单的冲击。
  • 一个成交价实现里,真正的代码量不在「取哪个数」,而在保证取出来的这个数在这根 K 线里真的出现过。你算出来的止损价、止盈价,不等于成交价。
  • 成交价在代码里应当只有一个出处:校验用它、定仓位用它、记账也用它。出现几处,就是几个将来会对不上的地方。
  • 警惕静默回退——取不到指定字段就换收盘价接着跑,这种设计会让你以为自己在跑 A 假设,实际有一部分跑的是 B。回退必须留下能被统计的记号,不能只留一行日志。
  • 正确的做法不是找最好看的那组假设,而是跑三四组拉开档位的假设,看方向会不会翻面、衰减是不是均匀、多策略的排名稳不稳。而这几组跑什么、以哪一组为准,必须在看到结果之前就写死——否则成交假设就变成了又一个用来刷成绩的旋钮。

一句话说完这篇:回测里那个「买入价」,是你的程序替你挑出来的一个数字,挑法不同,同一套规则能从赚钱变成亏钱——所以先去看清楚它是从哪儿抠出来的,再去看那条曲线。

内容有错、看不懂、或想看下一篇?告诉我们 →

本文为投资教育整理,不构成任何投资建议;不荐个股/基金、不预测点位、不承诺收益。关键数据与结论请结合权威来源自行验证,并见风险免责声明