做期货量化这几年,我电脑里始终留着一套每次写新策略都要先拷一遍的代码框架。说它多高深谈不上,就是把数据清洗、指标计算、信号生成、仓位管理和订单执行这几件事拆成独立模块,让新策略只需要改两三个函数就能跑通回测。之前有朋友问我,为什么写那么多策略不直接用现成的策略平台,而是自己维护一套模板——答案其实很简单:策略研究的大部分时间其实花在重复工程上,而模板的价值就是把这个重复部分彻底固定下来。这篇文章就是来聊聊这套 Python 期货量化策略模板,以及我基于它沉淀出来的常用策略代码框架。
不管你是刚接触 CTA 想搭一套自己的研究环境,还是已经有几个策略在跑但总觉得代码越改越乱,这篇内容都能给你一个可以直接落地的处理思路。我会把它拆成几个层面来讲:为什么期货策略代码要单独设计、整个模板的骨架长什么样、核心代码怎么复用、三类常见策略如何套进去,以及回测和实盘里那些文档上不会写、但是一定会踩的坑。
1. 期货的脾气和股票完全不同,模板必须为双边市场服务
1.1 双向开平仓,代码里不能只有 buy/sell
很多刚接触期货量化的人会做一个动作:把股票策略的代码拿过来,把周期换成 60 分钟或日线,就直接跑期货的回测。结果往往很惨。原因不是策略本身不好,而是期货的交易机制决定了代码设计必须多考虑一层状态。
股票策略只需要一个多头状态,代码里往往只有买入和卖出两个指令就能闭环。但期货天然支持做多和做空,同一时刻策略状态至少有四种:空仓、持有多头、持有空头,以及个别策略会出现的多空同时持有。这意味着代码里不能只用一份买单价和卖单价去表达交易动作,而要维护一个“当前持仓方向”字段。我习惯用 position 这个变量来表示,取值 -1、0、1,分别代表空头、空仓、多头。任何下单逻辑都先解析这个字段,再决定是开仓、平仓还是反手。
反手是期货里最经常出现的操作。股票里没有“持仓从多翻空”这个动作,期货里却很常见。比如趋势跟踪策略,均线从金叉变死叉,理论上就该立刻从多头翻到空头。如果代码写得不够干净,反手时会出两个问题:一是先平多再开空之间隔了一个信号周期,等于空了一根 K 线;二是如果平仓委托没成交就发开仓反向单,账户会处于同时持有多空两个方向的实际仓位,一旦遇到极端行情,两边保证金占用会非常难受。所以在模板里,我把反手封装成一个内部动作,它做的事情是先按当前持仓方向平仓,再以反方向重新建仓,中间不留时间缝隙。
1.2 保证金和杠杆,决定了仓位计算不可能照搬股票
股票里计算仓位,通常是用总资金乘以仓位比例,再除以股票价格得到股数。期货里因为杠杆的存在,同一笔资金可以撬动数倍于名义价值的头寸,所以手数计算必须经过保证金折算这一步。
我见过有人拿股票那套直接套期货,结果就是回测出来的收益曲线极其漂亮,但实盘第一周就被风控电话打爆——因为实际保证金占用远超预期,或者单笔止损亏损金额大得离谱。正确的做法是先把合约乘数、保证金比例和止损幅度考虑进去。举个例子,螺纹钢合约乘数是每手 10 吨,假设价格 4000,一手名义价值就是 4 万。保证金比例 10% 的话,占用资金 4000。如果策略计划止损 50 个点,一手亏损就是 500 块。那么用总资金的 2% 作为单笔最大亏损预算,能开的手数就是总资金乘以 2% 再除以 500。这套逻辑在模板里我封装成了一个目标手数计算函数,策略层只需要传入止损距离,代码会自动算出手数。
1.3 合约有到期日,换月问题必须在架构层面留口子
期货还有一个股票完全不会遇到的机制:合约到期和主力合约切换。做滚动回测时,如果不处理换月,K 线数据会在两个合约之间跳变,出现巨大的价格断层。哪怕用复权处理,不同合约的价差也存在,简单拼接会产生假信号。
模板在数据层就预留了主力合约映射表的接口。数据导入时,每一天都要明确对应的是哪个月份的合约,以及底层的结算价或者收盘价是从哪个合约来的。回测引擎逐 bar 执行时,一旦发现当前 bar 的合约号与上一根不同,就触发一次自动的换月平仓操作:先平掉旧合约的持仓,再用新合约建立等量仓位。这个逻辑在实盘里同样需要,否则交易账户会一直挂着一个临近到期、流动性变差的旧合约。
2. 模板的骨架:数据、信号、风控、执行四层各干各的事
2.1 为什么必须把信号计算和订单执行拆开
我的策略模板之所以能反复复用,核心在于分层足够干净。整个框架分成四块:数据层、信号层、风控层、执行层。每一层只跟相邻层发生联系,策略开发者的绝大部分工作都集中在信号层,其他三层基本不用动。
这套设计有一个很实际的好处:一个信号逻辑可以被两套环境共用。同一份信号代码,在回测环境里就是逐根 K 线调用,在实盘环境里就是订阅实时行情触发,两边的输出保持一致。很多策略在回测里表现很好,实盘却莫名其妙走形,很大一部分原因就是信号代码和交易代码耦合在一起,回测时用的是一套判断逻辑,实盘时又因为行情推送方式不同而产生了偏差。分层之后,这个问题被强制规避了。
2.2 数据层要处理的,不只是“把行情读进来”
数据层在外人眼里最简单,实际上最琐碎。它要做的事包括:把不同时间周期的 K 线对齐到同一时间戳、处理主力合约拼接和换月、补充涨跌停价格和持仓量这些由交易所直接提供的字段、以及按合约乘数和价格精度对原始数据做规整。
我自己在数据层里保存了完整的字段清单,包括 open、high、low、close、volume、open_interest、pre_settle。其中 open_interest(持仓量)是期货特有的,很多价量类策略都会用到它来过滤假突破,如果数据层漏掉这个字段,后面策略无论怎么写都补不回来。另一个容易被忽略的是 pre_settle,也就是昨结算价。期货每天的涨跌停板幅度是相对昨结算价计算的,而不是相对昨收盘价。有些新手拿股票思维以为收盘价就是基准,结果在计算涨跌停位置时全盘算错。
2.3 风控层不只在止损,还在管“能下多少单”
风控层在模板里承担的不单是止损止盈,还包括交易频率限制、单笔最大手数、品种持仓上限、账户总风险度这些前置约束。止损是两个维度:价格止损和时间止损。价格止损好理解,时间止损则是当持仓超过一定 K 线数仍没有盈利时强制离场,这个在震荡行情里能显著减少磨损。
还有一个很少被写进框架、但实盘非常重要的小模块:撤单逻辑。如果发出的限价单超过一定时间没有成交,风控层可以主动撤单并重新评估是否继续追单。股票里限价单没成交一般只是错过机会,期货里在剧烈行情下,一笔挂着却没成交的单子会让策略的实际持仓偏离预期方向,风险敞口完全失控。
2.4 执行层的核心:委托状态与成交回报
执行层是我踩坑最久的地方。早期我总觉得发出委托就是成交了,回测引擎也是这样假设的,结果一到模拟盘就开始出问题。真实的期货交易流程是:策略发出下单指令,交易所返回委托回报(挂单中),之后要么成交回报、要么撤单回报、要么部分成交。这些状态必须被底层框架跟踪,策略层查询持仓方向时要把它和未成交委托合并计算。
这个合并逻辑我称之为“目标持仓和现实持仓之间的差值”。模板里策略层只负责计算目标方向,执行层负责把目标方向转成可执行的委托,并对未成交单做跟踪。如果目标方向是持多,但当前有一笔多单还挂在队列里,执行层就不会重复发单,而是等待成交回报后再计算剩余量。这样一套状态机一旦写好,几乎所有策略都可以通用。
3. 一个可以直接套用的 Python 策略模板核心代码
3.1 策略基类:把公共流程固化下来
模板里最核心的是一个策略基类,下面这段代码是所有策略的父类。要写一个新策略,只需要继承它然后实现 compute_indicator 和 on_bar 两个方法。
class BaseStrategy: def __init__(self, params): self.params = params self.position = 0 # 当前持仓方向: -1空, 0空仓, 1多 self.target_position = 0 # 目标持仓方向 self.indicators = {} # 指标序列 self.broker = None # 执行层接口 self.risk = RiskManager(params.get('risk', {})) def set_broker(self, broker): self.broker = broker # 子类需要实现的方法 def compute_indicator(self, history_df): raise NotImplementedError def on_bar(self, bar): raise NotImplementedError def update_position(self, target): if target == self.position: return # 反手或平仓时,先平旧仓 if self.position != 0 and target != self.position: self.broker.close_position(self.position) # 开新仓 if target != 0: volume = self.risk.calc_volume(target) if volume > 0: self.broker.open_position(target, volume) self.target_position = target这里值得注意的是 position 和 target_position 两个变量分开处理。策略层在更新目标方向时,并不会立刻改变当前持仓状态,而是要等 broker 的成交回报确认后,position 才更新。这个细节直接关系到回测和实盘的一致性。
3.2 以双均线为例,一个完整的策略模板实现
双均线虽然简单,却是所有趋势策略的原型。把它跑通之后,换动量突破、通道突破都只是改造信号函数的问题。下面是模板里对应的子类实现。
class DualMATemplate(BaseStrategy): def compute_indicator(self, history_df): fast = self.params['fast'] slow = self.params['slow'] self.indicators['ma_fast'] = history_df['close'].rolling(fast).mean() self.indicators['ma_slow'] = history_df['close'].rolling(slow).mean() def on_bar(self, bar): fast = self.indicators['ma_fast'] slow = self.indicators['ma_slow'] # 关键: 用前一根K线判断信号, 避免使用当前bar的收盘价做未来函数 if len(fast) < 2: return if fast[-2] <= slow[-2] and fast[-1] > slow[-1]: target = 1 elif fast[-2] >= slow[-2] and fast[-1] < slow[-1]: target = -1 else: target = self.position self.update_position(target)有几个地方值得展开。一是信号判断用了 [-2] 和 [-1] 两个数据点,这表示金叉死叉发生在本根 K 线,而不是用上一根已经存在的金叉重复触发。很多第一次写策略的人会直接判断 fast[-1] 和 slow[-1] 的大小关系,那会造成单边行情里每天都发一次反向信号,仓位反复开平。
二是 on_bar 里的 target 取值逻辑。当没有新信号时,target 等于当前持仓方向,这就天然形成了“趋势跟踪:持有直到信号反转为止”的效果。要改成均值回归策略,只需要反转这个判断条件,并且把 target 在信号出现时设为相反值。
3.3 手数计算:按风险度而不是按资金比例
仓位管理这块我再贴一段模板里的风险计算函数。它从策略参数中读入最大单笔亏损比例,然后结合止损距离、合约乘数、保证金比例来计算开仓手数,这个函数在实盘和回测中完全通用。
class RiskManager: def __init__(self, risk_params): self.risk_pct = risk_params.get('risk_pct', 0.02) self.max_position = risk_params.get('max_position', 10) self.margin_ratio = risk_params.get('margin_ratio', 0.1) def calc_volume(self, direction, price, multiplier, stop_distance): value_per_lot = price * multiplier stop_loss_per_lot = stop_distance * multiplier max_loss_budget = self.account_equity * self.risk_pct if stop_loss_per_lot <= 0: return 0 n = int(max_loss_budget / stop_loss_per_lot) return min(n, self.max_position)这样设计有一个直观的逻辑:同一策略在不同品种上跑,止损幅度不同,自动算出来的手数也不同。比如止损距离大的品种,手数自动变小;止损距离小的品种,手数自动放大。这比“每次都固定开 2 手”要科学得多,因为它把单笔亏损控制在了同一个风险水平。
需要提醒的是,这里的 self.account_equity 在回测里是账户净值序列,在实盘里是账户实时权益。回测时如果直接拿总资金算,实盘里权益波动会导致手数波动,这个差异要在参数里留出缓冲,否则资金曲线接近回撤低点时,策略自动缩量,反而可能错过行情恢复的机会。
4. 常见期货策略怎么往模板里套:三个案例拆解
4.1 趋势跟踪型:唐奇安通道突破
趋势跟踪是目前 CTA 里应用最广的一类,模板里的信号层换成一个通道突破逻辑,其余部分完全不动。唐奇安通道的核心是维护过去 N 根 K 线的最高价和最低价,价格突破上轨开多,跌破下轨开空。代码只需要改写 compute_indicator 和 on_bar 里的信号判断条件。
class DonchianTemplate(BaseStrategy): def compute_indicator(self, history_df): n = self.params['channel'] self.indicators['upper'] = history_df['high'].rolling(n).max() self.indicators['lower'] = history_df['low'].rolling(n).min() def on_bar(self, bar): upper = self.indicators['upper'] lower = self.indicators['lower'] if bar['close'] > upper[-1] and self.position <= 0: target = 1 elif bar['close'] < lower[-1] and self.position >= 0: target = -1 else: target = self.position self.update_position(target)这里有个经验:通道参数 N 在模板里不要单独设置成一个值,而是要设成两组,一组用于入场,一组用于离场。经典的“海龟法则”就是入场用 20 日突破,离场用 10 日反向突破,这样能过滤掉一部分反复打脸的震荡。模板的 target 逻辑天然支持这个做法,只要再传入一个 exit_channel 参数即可。
4.2 均值回归型:布林带乖离
均值回归类策略在期货上不如趋势跟踪应用广,但在螺纹、铁矿这些波动较强的品种上,日内级别的回归策略一直有人用。它的信号逻辑几乎就是把趋势型的判断反转过来,价格触及布林带下轨做多,上轨做空,回归中轨后平仓。
模板的使用方式不变,只是 compute_indicator 要计算布林带的中轨、上轨和下轨,on_bar 里判断价格是否穿越轨道。这类策略最需要注意的是,均值回归在趋势市里容易连续止损。所以我通常在风控层加一条硬规则:当账户当日亏损达到一定阈值后,全天停止新开仓。这个规则在趋势策略里可以放宽,在回归策略里必须严格执行。
4.3 跨期套利:价差序列的信号处理
跨期套利和前两类完全不同,它的交易标的不再是单一合约,而是两个不同月份合约的价差。要在模板里做成套利策略,数据层需要返回价差序列,信号层仍然只负责判断价差回归的方向。
class SpreadTemplate(BaseStrategy): def compute_indicator(self, history_df): spread = history_df['near_price'] - history_df['far_price'] self.indicators['spread'] = spread self.indicators['ma_spread'] = spread.rolling(self.params['lookback']).mean() self.indicators['std_spread'] = spread.rolling(self.params['lookback']).std() def on_bar(self, bar): spread = self.indicators['spread'][-1] ma = self.indicators['ma_spread'][-1] std = self.indicators['std_spread'][-1] zscore = (spread - ma) / (std + 1e-8) if zscore > self.params['entry']: target = -1 # 做空价差: 空近月多远月 elif zscore < -self.params['entry']: target = 1 # 做多价差: 多近月空远月 else: target = 0 # 价差回归后平仓 self.update_position(target)套利策略的 target 方向含义和平常不一样,1 表示近月多头加上远月空头,-1 表示相反。这段逻辑里持仓对象不是一个品种,而是一对合约,所以底层执行时需要把一次目标信号拆成两条腿的委托。模板在这个位置预留了 pair_orders 的接口,信号层输出一个方向,执行层负责处理两条腿的二元组。
4.4 三类策略在模板里各自要改的地方
整理一下三类策略对应模板动作的区别,方便对照参考:
| 策略类型 | 需要改的数据层 | 需要改的信号层 | 需要特别关注的风控 |
|---|---|---|---|
| 趋势跟踪 | 主力合约拼接 | 通道/均线突破信号 | 反向止损距离设定 |
| 均值回归 | 日内分钟K线对齐 | 布林带/乖离率信号 | 单日最大亏损熔断 |
| 跨期套利 | 近月远月价差拼接 | 价差Z-Score信号 | 单腿成交不一致处理 |
数据层和风控层在不同场景下要做微调,但接口保持不变。这个统一性就是模板最值钱的地方。
5. 回测里那些看起来没事、实盘必然出事的坑
5.1 未来函数:最常见的三种写法
回测结果虚高,十次有九次和未来函数有关。第一种是用了整段数据的统计量去计算指标,比如在全样本上做了标准化再回放,等于开卷考试。第二种是在当前 bar 上同时用收盘价产生信号并用同一根收盘价成交,实际盘中根本做不到。第三种比较隐蔽,是对未来价格做了滚动 window 的隐含引用,比如用了包含当前 bar 在内的未来 N 根极值去判断突破。
排查未来函数有一个笨但有效的办法:把回测里的信号绘制到 K 线图上,随机抽几个月人工比对。如果发现某个多单刚好开在最低点相邻的 K 线上,基本可以断定逻辑里掺了未来数据。模板里的做法是强制要求信号判断只用 [-2] 和 [-1] 的数据点,成交默认发生在下一根 K 线的开盘价附近。这个保守规则会让回测收益低一些,但实盘可复制性强得多。
5.2 手续费和滑点模型:双向收费和双边滑点
期货手续费的计算方式比股票复杂,不是简单按成交额乘一个固定比例就完事。一部分品种按成交额的比例收取,比如万分之几;另一部分按每手固定金额收取,比如一手几块钱;还有品种是平今仓单独收费,和普通平仓价差能到数倍。模板里我把手续费拆成三个参数:按比例费率、按手固定费用、平今费率。回测引擎在每次开平仓时自动判断当前委托属于开仓、平仓还是平今,然后分别计费。
滑点处理上,我建议至少开仓和平仓各设一个最小变动价位的滑点,也就是直接在成交价上加减一个 tick。很多人只设开仓滑点,平仓按理想价成交,资金曲线就会高估。尤其在趋势策略频繁反手的场景下,双边滑点的影响会被杠杆放大。
5.3 涨跌停与不可成交的模拟
股票策略极少考虑一字板无法买入的问题,但期货极端行情时会连续封板。回测如果不处理这个,可能在跌停板上开出了空单,或者涨停板上开出了多单,而实际都是不可能成交的。
模板里的解决方案是在执行层加一个成交可行性验证:委托价格超出当日涨停价或低于跌停价时,自动转为废单。这个逻辑在正常行情里几乎不触发,但在极端行情下能阻止回测产生大量虚假收益。另一个相关细节是开盘集合竞价时段,价格跳动大,很多平台策略直接跳过集合竞价,模板里我也建议只处理连续竞价阶段的 bar。
6. 把模板接到实盘,需要补上的几块拼图
6.1 委托状态管理:从“发了单”到“确认成交”
实盘和回测最大的差异,就是委托状态不再一触即达。模板里 broker.open_position 方法在实盘接口下要做的事,比回测接口复杂一个量级。它要维护一个 pending_orders 字典,记录所有未成交委托的编号、合约、方向、价格、数量。收到成交回报时,再把成交回报和 pending_orders 匹配,更新策略的 position。
这个逻辑听起来繁琐,但如果没有它,策略层在几秒钟内连续收到 tick 走势时,会重复发出方向相同的委托,造成超额开仓。我早期就是没做这层管理,一套双均线策略在模拟盘跑了一个小时就开出了 6 手本不该存在的仓位。
6.2 断线重连、本地仓位和账户仓位对账
实盘运行中,断线是常态,一年下来你至少会遇到几次网络中断或者交易所连接被重置。这时候本地程序里记录的仓位和账户真实仓位可能已经不一致了。模板里我预留了一个 recovery 方法,每次重连后强制拉取账户当前持仓明细,并用它覆盖本地 position 字段。如果发现本地持仓和账户持仓不一致,策略立刻停止信号计算,进入手动确认模式,而不是继续盲目下单。
我见过实盘程序在断线重连后直接按错误仓位发单的情况,几分钟内造成的亏损比之前几个月的回撤还大。这个保护逻辑是实盘模板的底线,不能省。
6.3 主力换月的实盘处理
回测里的换月是自动的,因为数据层已经标记好了合约切换日期。实盘里换月需要更谨慎:临近换月日时,旧合约流动性开始变差,买卖价差扩大,滑点成本显著上升。模板里的做法是提前几天就开始监控主力合约的切换信号,一旦确认新的主力合约,就按固定时间窗口拆分成若干笔,逐步把旧合约仓位换过去,避免一次性大单冲击市场。
我习惯把换月窗口设置在切换日的最后两小时,同时把新合约的开仓价格设为当时的市价附近,不做激进限价。这套逻辑既保证策略方向不最终偏移,又尽量控制换月时的冲击成本。
7. 模板用久了之后,我总结的几条参数管理心得
7.1 参数分组:数据参数、策略参数、风控参数各自独立
新策略在模板里迭代的时候,参数管理最容易失控。我把所有参数放进一个 dict,并且强制分三组。数据参数包括时间周期、主力合约筛选规则、换月窗口;策略参数包括均线周期、通道长度、入场阈值;风控参数包括单笔亏损比例、最大持仓手数、日内熔断线。三组参数在参数寻优时采取完全不同的策略:数据参数几乎不参与优化,策略参数是主要调优对象,风控参数则是根据账户资金和风险偏好人工设定,绝不交给优化器自动搜索。
这样做的好处是,不同策略之间的对比变得公平。两个策略如果用的是同一套数据参数和风控参数,信号层的胜负就是纯粹的能力差别,而不是谁多偷了一段未来数据或者谁把止损设得更宽。
7.2 用风险度而不是收益预期去计算仓位
关于仓位公式,还有一个进阶用法。如果某一个策略的胜率比较高、止损距离又相对稳定,可以考虑把风险度调大一点,用收益波动换更高的资金利用率。但我不建议单独看策略收益率来决定仓位,我更倾向于先确定账户能承受的最大回撤,再反推开仓手数。模板里 RiskManager 的账户权益参数,在实盘运行时会动态读取实时权益,一旦回撤接近最大回撤线附近,系统会自动把手数降低 50%。
这种“回撤速度控制”比单纯设置止损价更有保护力,因为它让风险暴露随着账户状态收缩。很多策略死亡不是死于某次大亏,而是死于连续小亏后仓位不变、心态失衡,最后一把梭。
7.3 迭代新策略时的准入准出标准
我最后想聊一句模板之外的东西。策略模板解决的是“代码怎么写”的问题,但真正让这三年少走弯路的,反而是一套我总结地心得:任何新策略要替换模板里的旧策略,必须同时过三道关。
第一关是样本外表现:把历史数据切成训练段和验证段,训练段调参,验证段不允许再动参数。第二关是回撤质量:如果新策略的年化收益高但回撤远大于旧策略,我会直接否决,而不是告诉自己“收益风险比高就行”。第三关是实盘小资金验证:用最小可交易手数跑一个月,观察实际成交回报和回测的偏差是否在预期范围内。
这个准入准出机制帮我把很多“回测看起来很美、实盘一上就废”的策略挡在了外面。尤其是第三关,它不需要复杂代码,只需要把模板接到模拟盘或者小资金账户上,让它自己跑。模板里真正有价值的东西其实不是代码本身,而是这套可复用的、从回测到实盘闭环的思维框架。策略可以一天写十个,框架却值得用几年慢慢打磨。