回测里的成交假设:你以为你买到了,那天可能根本没人卖给你
有个画面你大概不陌生:某天早上一条大消息出来,你打开账户想买,盘口上买一档挂着几十万手,卖档空空如也——一字涨停,成交为零。你眼睁睁看着它当天涨了一成,一股也没买到。
现在把这一天塞进回测里。程序读到那根 K 线,看到有开盘价、有收盘价、有成交量,于是心安理得地按某个价格记了一笔买入,第二天卖出,账面多出一段漂亮的收益。回测里那笔交易,现实中根本不存在。
这就是成交假设在做的事:一套回测系统在「这一笔到底成不成、成在什么价、成多少」这三个问题上替你做了默认选择。多数人从来没打开看过那个默认值是什么,却拿着它算出来的年化去下真钱的决定。
先说人话:撮合到底是谁在决定
买卖股票不像在便利店买水——货架上标着价,你拿了就走。它更像旧货市场里的对眼:你出五十块想买,得正好有个人愿意五十块卖,这笔才算数。没人接,你的报价就只是一张挂在墙上的纸条。
交易所里那个负责「配对」的机制,就叫撮合。你的委托单进去,系统看看对手方有没有价格合适、数量够用的单子,有就配上,没有就让你的单子在队列里排着。所以任何一笔成交都需要两个条件同时成立:有人愿意跟你做对手盘,而且他愿意做的量足够多。
而回测最省事的干法,是把这两个条件全部跳过:查到那一天的价格,直接判定「成交」。它不问对面有没有人,也不问对面有多少货。这不是 bug,是简化——问题只在于,你有没有意识到自己在用这个简化,以及它在什么时候会崩。
投资层:一把从「白日梦」到「保守」的尺子
不同回测系统的成交假设,可以按乐观程度排成一把尺子。看懂这把尺子,你才能判断手里那份报告站在哪一格。
第一格,最乐观:只要有价格,就一定能成交,还是你想要的那个价。 停牌期间用停牌前的价格照买不误,涨停一字板照样吃进,跌停砸不动照样跑掉。这一格的回测结果基本可以当小说读。
第二格:加上价格的合理性检查。 成交价必须落在当根 K 线的最高价和最低价之间——你不能用一个当天从未出现过的价格成交。这一步排除了最离谱的作弊,但仍然默认「只要价位对,量总是够的」。
第三格:加上可成交性检查。 停牌的日子不许交易,封死涨停的票不许买入,封死跌停的票不许卖出。到这一格,回测才开始承认「有价格 ≠ 能成交」。
第四格:加上成交量约束。 你这一笔的数量不能超过当天成交量的某个比例,超出的部分要么不成交,要么只成交一部分——这叫部分成交,你以为买了一万股,实际只买到三千股,剩下七千股的仓位从来没存在过。到这一格,策略的容量问题才会浮出水面。
第五格:连你自己的钱和手数都算进去。 现金不够就按现金能买的量截断,A 股买入必须凑够整百股,佣金有最低收费门槛,卖出还要交印花税。
越往后越接近现实,也越难做、越慢。关键不是追求第五格,而是知道你在第几格,并且不去做那些只在你这一格才成立的交易。 一个只在第一格成立的策略,比如「涨停当天追进去第二天卖」,你在第三格重跑一遍,收益会当场蒸发。
系统层:三种系统各自把线划在哪
概念讲完了,看看真实系统里这几格长什么样。三个开源系统的选择很不一样,摆在一起对照着读,比单看任何一个都清楚。
| 系统 | 主战场 | 成交假设的重心 |
|---|---|---|
| freqtrade | 全天候的加密市场 | 价格是否落在 K 线区间内、K 线内部顺序的推定 |
| Vibe-Trading | A 股这类有涨跌停的市场 | 价带能不能让这一笔落地 |
| qlib | 组合级的多股票调仓 | 停牌、限价、成交量、现金四道裁剪 |
freqtrade:价格落在这根 K 线里,才算成交
freqtrade 的文档里专门列了一节回测假设,态度很坦诚——回测不知道一根 K 线内部发生了什么,所以必须假设一些事。它列的清单值得当检查表用:
进场默认按开盘价成交;订单在没有滑点的前提下按报价成交,前提是这个价格落在当根 K 线的最高价与最低价之间;离场信号触发的卖出,成交在下一根 K 线的开盘价。代码里那句判断短得可以直接抄下来:
def _get_order_filled(self, rate: float, row: tuple) -> bool:
"""Rate is within candle, therefore filled"""
return row[LOW_IDX] <= rate <= row[HIGH_IDX]
这就是上面那把尺子的第二格:价格在区间内则视为成交,不考虑那个价位上有没有足够的量。挂出去一直没成交的单子会不会一直挂着?不会——它有一套超时逻辑,超时的进场单直接取消、这笔交易作废,超时的离场单撤掉、等下一次信号再试。这一层很重要,因为现实里挂单不成交是常态,而多数简易回测连「没成交」这个状态都不存在。
更值得琢磨的是它对 K 线内部顺序的推定:同一根 K 线里若止损和止盈都可能触发,它假定最低价先于最高价发生,先保护本金;离场信号优先于止损,因为信号被假定发生在开盘那一刻。这些推定没有对错——一根日 K 线只给你四个价格,先到最高还是先到最低,数据里根本没写。它给的缓解办法是用更细的周期去还原大周期内部的走势,官方对此的定位很清楚:这是策略开发的最后一道自检,用来确认你的成绩不是靠钻这些假设的空子得来的。
Vibe-Trading:涨跌停不是价格问题,是「存不存在」问题
到了 A 股这类有涨跌幅限制的市场,问题变了性质。价格再合理也没用——封死的板上没有对手方。
它的 A 股引擎在下单前先过一道 can_execute:不许融券做空;当天买入的股票当天不许卖(T+1);然后判断涨跌停价带会不会挡住这一笔。价带的算法有个容易被忽略的讲究——基准价必须是下单那一刻市场已经知道的价格,比如上一交易日的收盘价(pre_close)或前结算价,而不是当天的收盘价。用当天收盘价推价带是典型的偷看未来,这层道理在 前视偏差 那篇讲得更细。
真正精彩的是拿去比对的那一头。它不是拿收盘价去比价带,而是拿「这一笔真要成交,会成交在什么价」去比:
fill = engine.prospective_fill_price(bar, fill_direction)
这个价格取的是当根 K 线的开盘价再叠加滑点,方向和这笔交易一致。为什么要这么绕?因为买入时滑点让价格往上走,本来卡在涨停边缘的一笔,加完滑点就越界了。如果只拿原始开盘价去检查,你会批准一笔最终被记在合法价带之外的成交——校验的对象必须和记账的对象是同一个数,这是很多自制回测栽跟头的地方。
还有一处克制的处理:当拿不到任何历史基准价时(比如整段回测的第一根 K 线),它返回「不阻断」而不是「阻断」。注释写得很直白——价带未知的时候不能凭空捏造一次阻断。宁可漏判,也不制造一个现实中不存在的限制,这是个值得学的态度。
平仓方向上还有一层反直觉的细节:平掉一个多头是卖出,会被下边界(跌停)挡住;平掉一个空头是买入,被上边界挡住。同样一句「平仓」,挡它的是哪一头,取决于你手上原本是什么方向。
qlib:四道裁剪,一层层削掉你的成交量
qlib 面对的是组合级调仓——一次几十只股票同时进出,它的做法是让订单数量经过一连串裁剪。
第一道是可交易性。停牌怎么表示?它用的是「收盘价为空值」——那一天没有价格,就是没得交易。涨跌停则被预先算成两个布尔列,一列管买、一列管卖,停牌的日子被直接并进这两列,因为停牌是买卖双向都不通。这里有个默认值值得你注意:涨跌停限制的开关默认是不启用的,不设它的时候系统会打一条警告日志,提醒你封在板上的票会被照买照卖。这是当前版本的默认行为、可配置——但它说明了一件事:你不主动打开,第三格的检查就不存在。
第二道是成交量上限。你可以设一个上限表达式,比如当日累计成交量的某个比例,或者盘口某一档的挂单量;订单数量会被削到这个上限之内,多个上限一起生效时取最小的那个。代码里那行注释解释了为什么裁剪要放在取整之前——放在前面模拟的是「这单太大,交易所直接拒了」,放在后面模拟的则是「大单递进去了,只成交了一部分」。两种建模对应两种现实,选哪个由你,但你得知道自己选了哪个。
第三道是冲击成本,它把成交额占当日总成交额的比例做平方后再乘一个系数,算进交易成本里。占比越大惩罚越重,这一层的道理见 冲击成本。
第四道是现金与手数。买入时现金连手续费都覆盖不了,直接归零;现金够但不够全额,按能买的量反推再取整;A 股这类市场按整手取整,零股只能卖不能买。卖出同理,还会拿当前持仓截一次——你不能卖掉你没有的东西。
四道裁剪走完,你最初那个「买一万股」的意图,可能只剩三千股。回测报告上那条曲线,是这个被削过四轮的版本走出来的,不是你原始意图走出来的。 这也是为什么资金规模一变大,同一套规则的回测结果会明显变差——这层关系属于流动性与容量的范畴,看 流动性 那张卡先建立概念。
别把成交假设和滑点当成一回事。 滑点 说的是「成交了,但价格比你想的差一点」,它是一个价格上的修正项,加大滑点参数能让回测更保守。成交假设说的是「这一笔到底存不存在」。一笔涨停板上根本没成交的买入,你把滑点调到多大都救不回来——因为它需要被删掉,而不是被罚款。用调高滑点来冒充保守,是自欺欺人里最常见的一种。
为什么这件事特别容易被放过
它不会报错,只会让曲线变好看。 和大多数回测毛病一样,成交假设过于乐观从来不会让你的成绩变差。那些现实中吃不到的涨停、跑不掉的跌停,在回测里全部按你希望的方向记了账。
吃亏的方向是不对称的。 你想买的时候常常涨停买不到(少赚),你想卖的时候常常跌停卖不掉(多亏)。也就是说,乐观的成交假设在两头都在给你送好处。策略越是追涨杀跌、越是想在极端行情里动手,这份虚假的好处就越大。
它和策略的换手速度绑在一起。 一年调仓两次的规则,成交假设怎么设都不会差太多;一天进出好几回的规则,成交假设就是决定生死的那个参数。你的 下单方式 选限价还是市价,也会让「能不能成交」和「成在什么价」这两个风险在两者之间转移——限价单保价格但可能不成交,市价单保成交但不保价格。回测里若默认「限价单总能按你的报价成交」,等于把两边的好处全占了。
默认值往往不在你视野里。 上面三个系统的默认行为各不相同,有的默认不开涨跌停检查,有的默认不做量的约束。你换一个框架、换一份配置,同一套规则跑出来的结果可能差出一大截,而你以为自己什么都没改。
失效边界与常见误用
以为「成交量够大」就等于「我能成交」。 一天成交十个亿的票,不代表你的单子能在你想要的那个价位上成交。成交量是全天所有人加起来的量,分布在一整天和一整条价格带上,你想在某一秒的某个价位吃到其中一块,是另一回事。
把部分成交当成不存在。 现实里大额委托常常只成交一部分,剩下的挂着或撤掉。如果你的回测只有「全成」和「不成」两种状态,那它对大资金的模拟从根子上就是假的。
在开盘价上做文章。 很多回测按开盘价成交,于是有人写出「开盘价符合某某条件就买入」的规则——请注意,你是在开盘那一刻同时知道开盘价并按它成交,这个「同时」在现实中不成立。想在开盘成交,你得在开盘前挂单,那时你并不知道开盘价是多少。
把「更严格的假设」当成万灵药。 把所有约束全部打开,回测确实更接近现实,但它仍然只是一个近似——一根 K 线里价格先到哪、盘口上那一刻有多厚,数据里就是没有。严格假设的价值在于让那些靠钻空子挣钱的规则原形毕露,而不是让剩下的成绩变得可信。
只在回测里保守,实盘里激进。 两边的规则要对齐。回测里你限制自己不碰涨停板,实盘里你却手动追了进去,那份回测就不再是对你行为的模拟了。
小结
- 任何回测都在替你回答三个问题:这一笔成不成、成在什么价、成多少。默认答案通常比现实乐观得多。
- 乐观程度从低到高是一把尺子:只看价格 → 价格须落在 K 线区间内 → 停牌与涨跌停不许成交 → 成交量上限与部分成交 → 现金、整手与费用一并约束。要紧的不是站到最右边,是知道自己站在哪一格。
- 三个系统的重心各不相同:freqtrade 判断报价是否落在 K 线区间内并给挂单设了超时作废,Vibe-Trading 用严格属于历史的基准价算出价带、再拿「真要成交会成在什么价」去比对,qlib 用可交易性、成交量、冲击成本、现金与手数四道裁剪逐层削掉订单。
- 成交假设 ≠ 滑点。前者决定交易存不存在,后者只是价格上的修正。调大滑点救不了一笔本就不该存在的成交。
- 越是高频、越是想在极端行情里动手的规则,对成交假设越敏感;改一个默认开关,结论就可能翻面。
一句话记住这篇:历史上有那个价格,不等于当时有人肯把货卖给你。
本文所引源码与文档均来自上述快照版本,是阅读代码得出的机制说明,不是运行结果;具体默认值与行为请以你手上那个版本为准,规则类数字也会随市场制度调整。想先搞清回测这件事本身在算什么,见 回测 这张术语卡;回 量化与 AI 投研概念库 看其它概念各自在说什么。本站内容为投资教育,不构成任何投资建议,边界见 免责声明。
本文为投资教育整理,关键数据与结论请结合下列权威来源验证。
- freqtrade 源码快照 b3404c9(2026-08-18):docs/backtesting.md「Assumptions made by backtesting」、freqtrade/optimize/backtesting.py(_get_order_filled、check_order_cancel) ↗
- qlib 源码快照 79633dd(2026-07-23):qlib/backtest/exchange.py(_update_limit、check_stock_suspended、is_stock_tradable、_clip_amount_by_volume、_calc_trade_info_by_order) ↗
- Vibe-Trading 源码快照 3a752d5(2026-08-04):agent/backtest/engines/china_a.py、agent/backtest/engines/base.py(historical_base_price、prospective_fill_price、limit_band) ↗