做期货量化的人,十有八九都遇到过同一个场景:回测跑出来的资金曲线漂亮得像印钞机,年化收益30%、最大回撤只有5%,一丢进实盘,第一个月就开始怀疑人生。曲线形状倒是还能对上,可就是比回测少了一大块利润——这是策略失效了,还是行情变了?多数情况下都不是。那部分钱,是被滑点吃掉了。
今天想聊的就是这件事:期货量化里的滑点建模,以及怎么让回测更贴近实盘。滑点是个特别容易被忽略、又特别能“杀死”策略的细节。尤其是在期货这种T+0、双向交易、带杠杆的品种上,滑点会直接影响开平仓价格、止损触发、加仓节奏,甚至决定一个策略是稳定盈利还是稳定亏损。我会从滑点的本质拆起,讲清楚期货市场里滑点到底从哪来,再把几种实用的滑点模型铺开,最后落到backtrader这个回测框架里,用实际代码把带滑点的回测流程走一遍,连带着把参数标定、对比评估、实盘验证和坑点排查都聊透。
这篇内容适合所有做期货量化的人看,不论你是刚上手回测的新手,还是已经在实盘里被滑点虐过的老手,应该都能找到些用得上的东西。特别是那些已经在用backtrader做多品种回测、想把回测质量再往上提一档的朋友,这篇大概能帮你避开几个我踩过的坑。
1. 为什么回测和实盘总差一截:滑点才是那层窗户纸
先把话说清楚:我在这里聊的“滑点”,是指你下委托单时预期的成交价格,和实际成交价格之间的差值。回测软件里,理论上价格到了你的止损位,就按止损位成交;价格突破了你的开仓条件,就按突破价成交。但实盘不是这样,实盘里限价单可能不成交,市价单可能成交在一个比你预期更差的位置,这个差值就是滑点。
有些刚开始接触量化的人觉得滑点不就是一两跳的事嘛,能有多少钱?如果你做的是日线级别的低频趋势策略,滑点确实占比不大,一年几十次交易,每次多付出几跳成本,顶多就是收益缩水几个百分点。但如果你是做日内短线、分钟级别甚至tick级别的策略,滑点就是生死线。日内策略一年可能交易上千次,每次双向都滑点,哪怕只是0.5个最小变动价位,累积起来就能把原本年化20%的策略干到亏损。
1.1 滑点不是小数目,而是收益的“沉默失血”
我见过太多回测报告,里面手续费、保证金比例都设了,唯独滑点设成0,或者设了一个象征性的固定值。结果呢,回测年化40%,实盘做出来15%,中间这25%的差额,你复盘来看,几乎全是滑点加冲击成本贡献的。
举一个直观的例子:螺纹钢期货,盘口买一价3500,卖一价3501,一跳是1元/吨。你下一个市价单买入,通常成交在3501甚至3502。表面上看,你只多花了1到2跳,也就是每吨1到2块钱,一手10吨,也就10到20块钱。但如果你一天做10个来回呢?一手就是200到400块钱的成本。再算上你在这个合约上还要承受价格波动、手续费、资金占用,滑点就从一个“小数目”变成了收益曲线里的一个大窟窿。
更麻烦的是,普通回测框架里的滑点模型通常假设滑点是均匀的、线性的,但实盘里的滑点会随波动率、成交量、盘口厚度的变化而剧烈波动。我早年间吃过一个大亏:一个基于5分钟K线的突破策略,回测时只设了1跳滑点,心里想着“已经很保守了吧”,结果实盘第一周就发现,每次突破时的实际滑点普遍在3到5跳,因为突破的时候往往伴随放量和快速行情,盘口瞬间变薄,滑点自然放大。回测用1跳,实盘吃5跳,这个策略就废了。
1.2 期货市场滑点的四大来源
要把滑点建模做准,先得知道滑点从哪来。期货市场的滑点,我一般归成四类:
第一,盘口深度不足。这是最直接的来源。你手上的单子一多,而盘口的挂单量不够,你就得往更远的价位吃单。买个10手螺纹钢,盘口卖一到卖五也许只有十几手挂单,你的订单就会穿过好几档价位,实际成交价自然比看到的那一档差很多。这个因素的杀伤力和你的订单大小成正比,单子越大,冲击成本越高。
第二,波动率的放大效应。行情剧烈波动时,盘口会瞬间“抽空”,报价的跳动速度大幅加快,你看到价位的下一秒,价格已经跑远了。期货市场里经常出现一根大阴线或者大阳线,分钟级别的K线连续收涨收跌,这时候市价单的滑点会被明显拉大。业界有个经验值:波动率越高,滑点相对于最小变动价位的倍数就越高,通常能到平时的2到3倍。
第三,报价与撮合机制。期货是连续竞价,价格优先、时间优先。限价单如果价格设得不合理,可能只能让你成交在更好的方向(或者根本不成交);市价单则一定会成交,但成交价依赖当时盘口状态。对回测来说,最大的坑是:回测框架假设你以那个K线的收盘价或者下一个K线开盘价成交,但实际盘口的买一卖一价和K线价格之间往往存在偏差。
第四,网络延迟与手动操作。这属于“交易基础设施”带来的滑点。如果你的策略程序是跑在自己家里电脑上,网络延迟几百毫秒,等你信号发出去,行情已经走了一段。量化平台和交易所机房更近,延迟更低,这一部分的滑点差异就出来了。这个在回测里通常不会体现,但如果你做的是高频或者抢突破的玩法,延迟就是滑点的亲兄弟。
2. 滑点建模的四种实用模型
理解了滑点的来源,我们就能开始建模了。市面上的滑点模型五花八门,但在期货回测里,真正常用、可落地、不会把人绕晕的,大致是下面这四种。它们从简到繁,适用场景各不相同。
2.1 固定滑点:最朴素,也最不容易出错
固定滑点,就是不管价格多少、波动多大,每次开平仓都固定扣掉N个最小变动价位。比如螺纹钢一跳是1元/吨,你设置每笔交易滑点2跳,也就是2元/吨。
在backtrader里,这种模型对应的是set_slippage_fixed:
import backtrader as bt cerebro = bt.Cerebro() cerebro.broker.set_slippage_fixed(fixed=2.0) # 固定2个价位(单位需与数据价格单位一致)注意这里的单位问题:backtrader的set_slippage_fixed里的fixed是价格的绝对数值,不是“几跳”。对螺纹钢来说,一跳是1.0,所以fixed=2.0就代表2跳;对焦炭来说,一跳是0.5,那2跳就得传fixed=1.0。这个细节特别容易搞错,我见过有人在焦炭上设fixed=2,结果滑点直接变成4跳,回测结果被白白糟蹋了。
固定滑点模型的优点就是简单、直观、跑得飞快,适合策略初步筛选时用。缺点也很明显:它假设所有环境下滑点都一样,对短线高频策略来说这种假设太粗暴了。
2.2 比例滑点:与大票价格挂钩的粗略修正
比例滑点是指按照成交价的比例来计算滑点,比如买入时加0.02%,卖出时减0.02%。这种模型好处是能跟价格水平挂钩,价格越高,滑点越大,这比固定滑点要合理一些,因为高价品种盘口单价高、冲击成本也相应更大。
backtrader里对应set_slippage_perc:
cerebro.broker.set_slippage_perc(perc=0.0002) # 0.02%的滑点比例这里perc其实是“单边比例”,买入时在现价基础上加perc*价格,卖出时减去perc*价格。实操时要特别注意backtrader的perc内部实现会把slippage与价格相乘,但如果你用了apply_to这样的参数,还得搞清楚作用在成交价上还是信号价上。
比例滑点模型的粒度比固定滑点好一些,但它仍没考虑到一个关键因素:波动率变化。同样0.02%的滑点,在平静行情里也许是2跳,在剧烈行情里可能只有1跳都不到(因为价格基数大了,但波动也大了)。所以这个模型适合中低频、比如日线级别策略做粗略评估,但短线策略还是不够精细。
2.3 波动率动态滑点:把ATR搬进来
这是我在期货实战里用得最多的一种模型。思路很简单:滑点应该跟当前市场的波动情况动态挂钩。具体来说,前一天或者当前周期的ATR(平均真实波幅,Average True Range)越大,滑点就设置得越大;波动越小,滑点越小。
具体做法我一般是这样写:
class VolSlippage(bt.SlippageBase): def __init__(self, atr_period=14, slip_ratio=0.05, min_slip=0.0): self.atr = bt.indicators.ATR(period=atr_period) self.slip_ratio = slip_ratio # 滑点占ATR的比例 self.min_slip = min_slip # 最低滑点(绝对数值) def __call__(self, order): if order.executed.exectype == bt.Order.Market: atr_value = self.atr[0] # 当前周期ATR slip = max(atr_value * self.slip_ratio, self.min_slip) if order.isbuy(): return order.executed.price + slip else: return order.executed.price - slip这个模型背后是这样一个逻辑:在波动大的行情里,价格短期内的自然摆动本来就很剧烈,你的订单成交价相对于理论价格的偏离也更大;在波动小的行情里,盘口更稳定,滑点自然小。滑点取ATR的一个比例(一般是0.05到0.1之间),再叠加一个最低滑点下限,防止在极低波动品种上把滑点估得过小。
这种动态模型的好处是,你能根据市场状态自动调整成本预期,回测里在震荡市和趋势市中都更贴近实盘。坏处是,你需要额外调slippage ratio和min_slip两个参数,而且ATR周期选择也会影响结果。我的经验是先用固定滑点做一轮粗筛,等到策略候选池缩小到几个值得深挖的,再换成波动率动态滑点做精细回测。
2.4 冲击成本模型:给大资金和重仓交易准备的
前三种模型都假设你的单量不会自己影响市场价格,但资金量一大,这个假设就不成立了。当你下单的手数超过了盘口深度,你的订单本身就是推动价格的力量,这时候产生的成本叫冲击成本(Impact Cost)。
业界常用的冲击成本模型有几类:
- 线性冲击模型:冲击成本 = k × 成交量占比,k是经验系数,但线性假设太粗糙,实际中冲击成本往往跟成交量的平方根成正比。
- 平方根冲击模型(Sqrt Model):这是相对经典的经验模型,公式为
ImpactCost = λ × sqrt(订单量 / 市场成交量),λ是品种相关的常数。这个模型能更好的刻画“你下了多大单子、市场这段时间的资金吞吐量有多大”之间的关系。 - 分段冲击模型:当订单量小于某个阈值时用线性,大于阈值时用平方根。这更贴近做市商和流动性提供者的实际行为模式。
对绝大多数个人量化交易者来说,冲击成本模型在回测里用得不多,因为资金量没到那个级别。但如果你管理的是千万级别以上的资金,或者做的是某些流动性极差的冷门品种,忽略冲击成本会出大事。我认识一位做一手小品种CTA的朋友,资金不大但经常重仓,结果回测比实盘好得多,后来发现就是没考虑冲击成本——他手数重到采购了自己都推不动市场。
3. backtrader里的滑点实现:从配置到自定义
工具选型上,我一直用backtrader做期货回测框架。这个老牌框架的好处是回测逻辑透明,社区资料多,迭代速度快。尤其是它的broker层处理订单、撮合、滑点的方式,逻辑清晰,回头排查问题很方便。
3.1 内置滑点机制速览
backtrader的broker里,滑点通过set_slippage_perc和set_slippage_fixed这两个方法配置,我再补充几个新增的参数细节,方便你查看backtrader文档时对得上号:
| 方法 | 作用 | 常见参数 | 适合场景 |
|---|---|---|---|
set_slippage_fixed(fixed) | 固定绝对滑点 | fixed=2.0 | 价格单位明确,固定点差 |
set_slippage_perc(perc) | 按价格比例滑点 | perc=0.0002 | 价格基数大或品种多、需统一比例 |
自定义SlippageBase子类 | 任意逻辑滑点 | 自定义参数 | 需要动态滑点、波动率挂钩、冲击成本 |
一个高频踩坑点:set_slippage_perc的perc参数内部计算方式,很多新手会误以为0.0002就是万分之二,但backtrader内部把slippage作用于成交价时,方向是买入加价、卖出减价,而perc的单位不是百分比,是小数比例。我建议你直接在代码里打印一下最终成交价,确认它是不是你想要的那个数字。
3.2 自定义滑点模型的接入方式
如果你用的是内置的固定或比例滑点,那基本不需要写额外代码。但如果你是做动态滑点模型,比如我上面提到的波动率模型,那你需要继承bt.SlippageBase来写一个自定义类。
上面VolSlippage的示例代码里,关键点有两个:
__call__方法的返回值决定了成交价。如果是买入,滑点要给“加价”,返回值就比order.executed.price大;卖出要“减价”,返回值就比order.executed.price小。- 类里的
self.atr是数据序列,self.atr[0]获取当前周期的ATR值。backtrader在撮合时会调用这个自定义滑点对象,对每一笔订单计算一个合适的滑点价格。
从backtrader的架构来说,这种自定义滑点已经能覆盖绝大多数场景。但我一般建议:自定义滑点平滑一点,别设计得太“聪明”,否则回测时候不好解释你的“回测实际情况”。
3.3 参数标定的经验值
我整理过一份我自己在期货品种上常用的滑点标定经验表,仅供参考,实际得按你自己的数据再调:
| 品种类型 | 建议初始滑点(最小变动价位倍数) | 说明 |
|---|---|---|
| 螺纹钢、热卷、铁矿 | 2~3跳 | 流动性好但波动大,日内突破时滑点放大显著 |
| 豆粕、菜粕 | 1~2跳 | 盘口深度相对厚,但夜盘时段滑点偏高 |
| 铜、铝、锌 | 2~4跳 | 合约金额大,打板时滑点会被放大 |
| 黄金、白银 | 1~3跳 | 有明显盘口厚度差异,夜盘时段尤其要注意 |
| 原油、燃料油 | 3~5跳 | 受外盘联动影响,跳空滑点偏高 |
| 冷门品种(如某些农产品果品合约) | 5~10跳 | 流动性差,市价单成交难度大 |
这组数值的来源,其实是我在不同行情、不同时间段用市价单做过几百次“测试单”之后总结出来的。怎么测?拿一小笔资金,分别在不同时段下同等数量的市价单,记录成交价跟同时刻盘口中价的差值,多测几次取中位数。这个方法虽然土,但很有效。比直接拍脑袋设置滑点靠谱一百倍。
提示:滑点参数的标定,最靠谱的方式就是用真实盘口数据做“影子记录”。如果你有模拟盘或者实盘测试环境,每天记录策略信号的虚拟成交价和实际成交价,积累一个月,滑点的分布就是你的标定依据。这个经验我花了很长时间才悟到,比任何模型都准。
4. 实操演示:一个带动态滑点的期货回测案例
这一章我走一遍完整的实操流程。以螺纹钢为例,做一个简单的双均线趋势策略,分别用“无滑点”“固定滑点”“波动率动态滑点”三种配置跑回测,对比结果。
4.1 数据准备与策略搭建
我用的是螺纹钢主力合约日线数据,假设你有本地数据或者从某个数据源导入了CSV文件,字段包含open, high, low, close, volume, openinterest。用backtrader读取:
import backtrader as bt class DualSMA(bt.Strategy): params = dict( fast=5, slow=20, ) def __init__(self): sma_fast = bt.ind.SMA(period=self.p.fast) sma_slow = bt.ind.SMA(period=self.p.slow) self.cross = bt.ind.CrossOver(sma_fast, sma_slow) def next(self): if not self.position: if self.cross > 0: self.buy() elif self.cross < 0: self.sell()加载数据并配置broker:
class CSVPandasData(bt.feeds.PandasData): # 这里假设pandas数据源已经准备好了 pass import pandas as pd df = pd.read_csv('rb_daily.csv', index_col=0, parse_dates=True) data = bt.feeds.PandasData(dataname=df) cerebro = bt.Cerebro() cerebro.adddata(data) cerebro.addstrategy(DualSMA) # 初始资金20万,按20%保证金比例 cerebro.broker.setcash(200000.0) cerebro.broker.setcommission( commission=0.0001, # 万分之一 mult=10.0, # 每点乘数10元/吨 margin=0.2 # 20%保证金 ) results = cerebro.run()这里有个细节:backtrader的期货模拟里,mult表示合约每点价值,螺纹钢mult=10。但别把margin设为0.2就以为万事大吉——backtrader的保证金计算跟实际期货公司有差异,实盘中还需要每日盯市追缴保证金。这个涉及到资金管理的细节,我先不展开,重点还是滑点对比。
4.2 三组滑点配置对比
我现在跑三组对比:
A组:无滑点(基准)
# 不调用set_slippage任何方法,默认就是0滑点B组:固定滑点2跳
cerebro.broker.set_slippage_fixed(fixed=2.0)C组:自定义波动率滑点
# 用前面写的VolSlippage,假设ATR周期14,滑点比例为0.08,最低滑点1.0 cerebro.broker.set_slippage(VolSlippage(atr_period=14, slip_ratio=0.08, min_slip=1.0))每组跑完,记录总收益率、最大回撤、交易次数、胜率这些指标。
假设结果大致如下:
| 指标 | A组无滑点 | B组固定2跳 | C组动态滑点 |
|---|---|---|---|
| 总收益率 | 15.2% | 11.8% | 8.6% |
| 最大回撤 | 5.1% | 5.8% | 6.2% |
| 交易次数 | 58 | 58 | 58 |
| 胜率 | 42% | 42% | 42% |
看出来了吗?滑点对交易次数、胜率这类信号层面的指标基本没影响,但对收益率、回撤的影响是真实存在的。更关键的是,C组动态滑点的回撤明显比A组大,说明滑点不仅拉低收益,还会加大实盘中的回撤幅度——因为亏损交易往往出现在波动剧烈的时候,而那时候滑点恰恰更大。
4.3 结果评估与实盘验证的心法
回测对比的意义不在于“选一个看起来更保守的参数”,而在于找到策略盈亏平衡点和容错边界。
具体做法是:用一个参数网格扫描滑点,从0跳一直试到10跳,记录每个滑点水平下策略的年化收益率和回撤。然后看这个策略在滑点达到几跳时从盈利转亏损,这个“破位点”越靠后,说明策略对交易成本的容忍度越高,实盘容错率也越高。
我见过很多策略在0跳滑点下年化50%,3跳滑点下年化就变负了。这种策略工资不稳定,实盘一上打脸概率极大。反过来,一个策略在0到5跳滑点区间都能保持正收益,那它拿到实盘去打,安全边际就大多了。
至于实盘验证,建议用模拟盘或小资金实盘跑一个月,每天记录“策略信号理论成交价”和“实际成交价”,把每天的滑点分布画出来。如果回测假设的滑点落在实际分布的中间60%区间内,这个回测就是够用的;如果实际滑点显著大于回测假设,那就要回头修正参数,再重新评估策略的预期收益。
5. 常见问题与排查技巧实录
最后,我把这些年实操里遇到的高频问题集中整理一下,做成一个速查表,省得你重复踩坑。
5.1 回测优、实盘差:排查清单
很多时候回测结果和实盘差异不是某个单一原因,而是一堆小问题叠加。我建议按下面清单逐项排查:
| 排查项 | 常见表现 | 处理办法 |
|---|---|---|
| 滑点设置过小 | 回测年化30%,实盘只有10% | 统计实盘滑点分布,回看回测假设是否在实盘分布的下四分位 |
| 忽略了冲击成本 | 重仓交易时实盘明显亏损加剧 | 检查单笔订单占盘口比例,必要时引入平方根冲击模型 |
| 用收盘价成交假设 | 实盘成交价跟当日收盘价偏差大 | 回测改用信号发出后一定延迟(如1根K线后)或开盘价成交 |
| 手续费设错 | 实盘手续费远高于回测(佣金、平今费、申报费) | 按期货公司结算单复核手续费明细 |
| 忽略了保证金追缴 | 行情变动大时被强平,回测却活下来了 | 在回测中加入保证金余量和强平逻辑 |
| 模拟盘与回测环境不一致 | 模拟盘靠谱,回测不靠谱 | 把模拟盘成交数据导出来,反过来校准回测 |
这里面我尤其想强调:很多回测框架默认用K线收盘价做成交,但你的策略大概率是根据这个K线收盘后的信号下单,这么一来真实成交只能是下一根K线的开盘价附近。滑点建模再好,如果成交时机都错了,那回测的参考价值就大打折扣。我自己做回测时,几乎不会用“收盘价立刻成交”这个默认假设,都是把成交价调整到下一根K线开盘价,或者实际测试后加入一个时间延迟。
5.2 品种差异下的标定思路
“一套滑点参数打天下”是我早期犯过的最严重的错误。螺纹钢和苹果,流动性和波动特性是两回事,用同一套滑点,必然有一边被高估、另一边被低估。
不同品种的滑点标定,我一般用这么几条思路:
- 看盘口厚度:盘口挂单量大的品种(螺纹钢、豆粕),滑点天然偏低;挂单薄的(某些工业硅、碳酸锂合约,上市初期),滑点天然偏高。
- 看买卖价差。价差越小越平滑,滑点越可控;价差对本来就大的品种,滑点至少应该能覆盖住一个价差。
- 看合约换月:主力合约临近交割月时流动性收缩,滑点会明显变差,回测如果你一直用主力连续合约,最好换月换月附近的价格跳跃也纳入滑点估算。
我个人的做法是:给每个品种单独维护一套滑点分布数据(用影子记录法采集),一个月更新一次,回测的时候直接用当前品种的滑点分布去抽样,而不是用一个单一数字。
5.3 滑点之外的隐性成本:你还没算的那些钱
这篇文章讲的是滑点,但我不想把话题圈死在一个点上。因为很多人在实盘里亏的钱,名义上叫“滑点”,实际上有三个隐性成本藏在里面。
第一是资金成本,或者叫隔夜持仓成本。期货是有隔夜资金的,虽然保证金制度下资金占用比较少,但你长期持仓的方向与市场利率相关,这个成本你得算进策略成本里。
第二是移仓换月成本。拿着快到期的合约必须移到下个主力,这个过程中旧合约和新合约之间价差、交易摩擦、滑点,都是隐形损益。有些策略在“主力连续合约”回测看起来没问题,一到真实换月,就发现需要挪仓的那几天,滑点跟价差都能让你吐出一大块利润。
第三是开平仓方向的非对称滑点。趋势策略里,开仓往往是行情加速、市场情绪高涨时进行的,这期间的滑点显著高于平仓;而止损平仓往往出现在恐慌或快速反转时,滑点同样会放大。这两种情形下的滑点方向都是“不利方向”,意味着真实交易中成本会集中爆发。我习惯在回测时区分“开仓滑点”和“平仓滑点”,分别设置不同的倍数。
注意:不要因为回测结果变差了就想着去“优化”滑点参数来包装曲线。滑点标定这事的最终目标永远是“尽可能贴近实盘”,而不是“让回测曲线好看”。你骗过了回测,也骗不了实盘。
最后再分享一个小技巧:在backtrader里跑多品种滑点对比时,别一个品种一个品种手动改参数。把滑点参数放进策略的params字典里,在cerebro.addstrategy时按品种动态传入。这样你一次就能跑出一个大网格的参数矩阵,回测质量和排查效率都会大幅提升。我现在的做法是每个品种单独维护一份config.yaml,里面存好品种名、手续费率、滑点模型类型、滑点参数、保证金比例,回测的时候自动化加载。看起来多了一步配置工作,但长期下来,这比在代码里到处硬编码滑点数值省心太多了。