上周有人丢给我一句话:策略里要 BOLL 和 RSI,别的指标不要,帮我把条件写进去。等我拿到他所谓"写好的条件",发现就是一句收盘价突破上轨 且 RSI 大于 70 就做多。回测曲线漂亮得像画出来的,实盘跑了不到一个月,把前面三个月的利润全交回去了。问题不在于 BOLL 和 RSI 这两个指标不好用,而在于很多人把它们当成了两个可以随手拼在一起的开关,却从来没想过这两个东西测量的根本不是同一件事。
这篇东西就是围绕"期货量化里怎么把 BOLL 和 RSI 用成一个能落地的策略"来写的。我会把布林带的统计含义、RSI 的平滑口径、两个指标怎么分工、参数怎么定、手数怎么算、代码骨架怎么搭、回测里哪些地方最容易自欺欺人,一条一条摊开讲。适合的人群很明确:手上有点 Python 基础,开过期货账户,脑子里有个模糊的想法,但真到写代码那一步就卡住的人。看完你至少能自己写出一版跑得通、看得懂、知道它为什么亏的版本,而不是从别处抄一段信号逻辑然后祈祷它有效。
1. BOLL 和 RSI 不是两个条件,是两种完全不同的测量
很多人把指标组合理解成"多一道保险":突破上轨是一个条件,RSI 超买是另一个条件,两个都满足就更可靠。这个思路在逻辑上就错了,因为这两个指标压根不是在验证同一件事,它们之间不存在"互相印证"的关系,只有"互相补充"的关系。搞不清这一点,后面所有的参数调优都是在给一个错误的框架抛光。
1.1 布林带量的是"价格相对自己的位置",不是"贵还是便宜"
布林带的三条线是这么来的:中轨是 N 周期收盘价的简单移动平均,上下轨是中轨加减 k 倍标准差。常用的 N 是 20,k 是 2。写成公式就是中轨等于最近 20 根收盘价的算术平均,标准差按sqrt(Σ(Ci - MA)² / N)算,上轨等于中轨加两倍标准差,下轨等于中轨减两倍标准差。
关键在于,标准差是跟着波动率自己伸缩的。行情剧烈的时候带宽自动张开,行情清淡的时候自动收窄。所以价格碰到上轨,只能说明"当前价格比最近 20 根的平均水平高出了两个标准差",这是一个纯粹的相对位置描述,它既不告诉你后面会跌,也不告诉你趋势有多强。我见过太多人把"碰到上轨"直接翻译成"超买",这是把统计学描述当成了交易结论。
还有一个更隐蔽的坑:布林带的推导隐含了"收益率近似正态分布"的假设。期货的日内收益分布是典型的高峰厚尾,极端行情出现的频率远高于正态分布的预测。这意味着带宽在极端行情里永远张开得不够快,你看到价格"贴着上轨走"的时候,往往不是超买,而是波动率正在被行情重新定价。这个认知直接决定了后面策略该选"反转"还是"跟随"。
1.2 RSI 量的是"涨跌动能的占比",Wilder 平滑这一步千万别换
RSI 的原始定义是把 N 根内的涨幅和跌幅分别做平滑,然后算涨势占总波动的比例,最后映射到 0 到 100 之间。公式主体是RSI = 100 - 100 / (1 + RS),其中 RS 等于平均涨幅除以平均跌幅。
问题出在"平滑"这两个字上。现在市面上至少流传着三种写法:第一种是直接用简单移动平均,第二种是用指数移动平均,第三种是用 Wilder 原始论文里的递推平滑,也就是avgGain = (avgGain_prev × (N-1) + gain) / N。这三种算法在小参数下算出来的值能差出十几个点,信号出现的时间点能差两三根 K 线。
我个人的做法是固定用 Wilder 递推,原因是它的等效平滑周期和参数 N 是对应的,而且大部分行情软件的默认值也是这个口径。如果你用简单移动平均算 RSI,然后拿它去和自己软件上看到的 RSI 比对,发现数值对不上就开始怀疑数据源,那纯属白折腾。统一口径这件事,比选哪个口径重要得多。
另外提一句首值处理:Wilder 递推需要一个初始值,标准做法是用前 N 根的涨幅、跌幅的简单平均作为种子,从第 N+1 根开始递推。很多实现图省事直接从第一根开始递推,导致前面几十根的 RSI 全是垃圾值,如果你的回测样本比较短,这些垃圾值会污染整个统计结果。
1.3 为什么会有人把两个指标用成互相打架:三种典型错配
我复盘过不少出问题的组合,毛病基本集中在三种模式上。
第一种是方向错配。布林带突破上轨本来描述的是"波动率放大 + 价格向上偏离",属于趋势跟随的语境;RSI 大于 70 属于均值回归的语境。你把这两个条件用"且"连起来做多,等于要求"行情既要强势突破,又要已经涨到该回调的位置",这两个诉求本身就是矛盾的,最后能同时满足的样本极少,而且集中在行情末端的衰竭位置,正好是最容易亏的那一段。
第二种是周期错配。用日线的布林带配 6 周期的 RSI,等于拿一个慢变量去过滤一个快变量。6 周期 RSI 在日线上一天能给你翻好几次脸,它根本承担不了"确认"的职责。反过来,用 5 分钟的布林带配 24 周期的 RSI,RSI 会滞后到几乎不反应,等于没加这个条件。
第三种是语义错配。有人在震荡策略里用"价格触及下轨 + RSI 小于 30"做多,听起来很合理,但他没检查中轨的方向。如果中轨正在快速下行,说明 20 根的均价还在往下掉,这时候的"下轨"是移动靶,你接的每一刀都在往下走。加上一句"中轨斜率为正"的条件,同一个策略的胜率能有肉眼可见的差别,这不是玄学,是把语义对齐了。
1.4 组合的三种正确范式
把两个指标真正用对,核心思路是让它们分工:一个管"现在是不是该做",一个管"做了之后往哪个方向"。我常用的组合范式有三种,适用场景完全不同。
| 范式 | 布林带职责 | RSI 职责 | 适用行情 | 主要代价 |
|---|---|---|---|---|
| 顺势回调 | 中轨定方向,下轨定入场区 | 从低位回升确认动能修复 | 单边趋势中的回调 | 趋势末端会连续止损 |
| 收口突破 | 带宽分位判断挤压状态,上轨定触发 | RSI 大于 55 过滤假突破 | 长期盘整后的启动 | 假突破多,滑点大 |
| 区间反转 | 上下轨定边界 | 超买超卖确认极端 | 明确的高低点区间 | 趋势一旦形成会逆势扛单 |
顺势回调是我最常用的一个。逻辑是:中轨向上说明最近 20 根的均价在抬升,大方向偏多;价格回落到下轨附近说明短期出现了偏离;RSI 从 35 以下回升说明抛压正在衰竭。三个条件分别对应"方向""位置""时机",互不重叠,这才是组合该有的样子。
收口突破则完全反过来,它盯的是带宽。带宽等于上下轨之差除以中轨,这个值在盘整期会压到很低。当带宽处在过去 100 到 200 根的低分位区间,同时价格收盘站上上轨,RSI 高于 55,说明这不是随机波动而是一次有动能的启动。这里 RSI 的作用是过滤——盘整里经常有价格虚破上轨但 RSI 趴在 50 以下的假动作,加这个过滤能砍掉相当一部分无效信号。
区间反转用得最少,因为期货带杠杆,逆势扛单的代价太高。真要用,必须叠加"带宽不能处于扩张状态"这个条件,带宽一扩张就说明行情性质变了,这时候再按区间做就是送钱。
2. 参数不是玄学:周期、口径和品种节律怎么定
参数这个问题上,我见过两种极端。一种是死磕优化,把 N 和 k 用网格搜索扫一遍,挑夏普最高的那组;另一种是彻底不信参数,随便用个 20 和 2 就不管了。这两种都会出问题,前者过拟合,后者根本没考虑品种差异。参数的意义不在于"找到最优",而在于"让指标的响应速度匹配这个品种的波动节律"。
2.1 同一组参数换个品种就失效的原因
同样是 20 周期的布林带,放在一个日内波动 0.5% 的品种上和一个日内波动 2.5% 的品种上,效果天差地别。前者 20 根 K 线可能只覆盖了 1% 的价格区间,带宽窄到一点风吹草动就打穿轨道,信号密度高得离谱;后者 20 根可能跨越 5% 的区间,带宽宽得价格根本碰不到轨道,一个月也出不了几个信号。
真正的决定因素是这个品种完成一次有意义的波段需要多少根 K 线。判断方法很土但有效:把历史价格拉出来,目测统计一下从局部低点到局部高点的平均跨度。如果一个品种的平均波段跨度在 15 到 25 根之间,那 20 周期的布林带是合适的;如果平均跨度在 40 根以上,20 周期就偏快了,信号会在波段中途反复触发,你得往 30 甚至 40 上调。
还有一个更容易被忽略的因素:交易时段结构。有夜盘的品种,一天有多个连续交易时段,中间隔着休市,价格在休市期间的跳空会被算进下一根 K 线的开盘点差里。这种情况下,用纯粹的 K 线根数做周期,实际上是混合了不同长度的"时间块"。我的处理办法是在日线级别用标准周期,在分钟级别上尽量选能整除单个交易时段的周期,避免一根 K 线跨越休市。
2.2 我常用的一套参数起点
下面这套参数不是什么最优解,是我试下来比较"稳"的起点,适合拿来当第一版跑通流程,然后再根据品种微调。
布林带:N = 20,k = 2.0,标准差口径为总体标准差(ddof=0) RSI:N = 14,Wilder 递推平滑 趋势过滤:中轨 5 根斜率 带宽分位:过去 150 根的分位数 RSI 低位阈值:35(做多),高位阈值:65(做空) 突破确认阈值:55(做多),45(做空)有人会问为什么 RSI 用 14 而不是国内软件常见的 6 或 12。原因在于 14 是 Wilder 原始论文的参数,平滑效果和布林带的 20 周期在时间尺度上比较接近,两者配合起来不会出现严重的快慢错配。6 周期 RSI 太灵敏,日线上基本等于噪音;24 周期又太钝,等它从 30 回升到 40,价格早就跑出去一大截了。当然这不是绝对的,如果你做的是日内分钟级别,6 或 9 的 RSI 反而更合适,因为分钟级别的波段跨度本身就短。
关于 k 值,2.0 是个约定俗成的值,对应正态分布下约 95% 的覆盖区间。调到 2.5 会让轨道变宽,信号变少但质量提高;调到 1.5 会让信号变多,但假信号也成比例增加。我倾向于不要在这上面花太多时间,k 值从 1.8 到 2.2 之间的绩效差异,通常小于手续费和滑点假设变动带来的差异。
2.3 计算口径必须统一:收盘价、标准差 ddof、复权方式
这三件事只要有一样没对齐,你的回测和实盘就对不上,而且这种对不上是隐性的,你看不出报错,只会觉得"实盘怎么跟回测差这么多"。
标准差口径上,ddof=0是除以 N,ddof=1是除以 N-1。N 等于 20 的时候,两者差 2.6% 左右,反映到轨道宽度上是零点几个百分点。单看不大,但它会系统性地让一部分临界信号在你的回测里有、在实盘里没有。我的做法是跟着行情软件走,先在软件上量一下某个位置的轨道值,再拿自己的代码对一遍,差多少一目了然。
复权方式更麻烦。期货主力合约是拼出来的,换月的时候新旧合约有价差,直接拼接会在换月那天造出一根巨大的假 K 线。处理方式有两种:价差复权是在历史价格上加减一个常数,让拼接处连续;比例复权是乘一个系数。这两种复权方式对绝对价格的影响不同,而布林带是基于绝对价格算标准差的,所以复权方式会直接影响轨道位置。我一般用价差复权,因为它对同一品种不同月份合约之间的价差结构保持得比较好,而且在计算 ATR、止损点位的时候,加减法的口径更直观。
最后是收盘价的使用。日内策略如果用当日收盘价算指标,那这个指标在盘中是拿不到的,你只能等收盘才知道信号,也就是只能在下一根开盘或者次日开盘执行。这一点在回测代码里必须严格体现,否则就是未来函数,后面第 5 节会专门讲这个。
2.4 把信号写成可执行的布尔表达式
指标算完之后,下一步是把它翻译成明确的、没有歧义的条件。这一步最忌讳写成"感觉像是回调到位了"这种描述,必须是布尔表达式。
我定义的多头入场条件是这样三条同时成立:
- 方向条件:
mid_slope > 0 且 close > mid,也就是中轨在往上走,而且价格在中轨上方; - 位置条件:
low <= lower,本根最低价触及或跌破下轨; - 时机条件:
rsi > 35 且 rsi.shift(1) <= 35,RSI 从 35 以下上穿 35。
注意第二条我用的是"最低价触及",第三条我用的是"上穿"而不是"小于"。这两个细节都是有讲究的。用最低价触及下轨,是为了捕捉盘中的瞬时偏离,如果要求收盘价也跌破下轨,那大部分回调根本不会触发,信号会少得可怜。用 RSI 上穿而不是小于,是为了避免在下跌还在加速的时候进场——RSI 小于 35 可能持续七八根,你永远不知道哪一根才是底,等它重新站上 35 再进,虽然少赚了一点,但躲掉了最陡的那一段。
空头条件完全对称,这里不重复。需要额外说明的是,多空条件必须互斥,不能在同一个 bar 上同时成立。如果出现这种情况,说明你的参数让上下轨和 RSI 阈值产生了重叠,这种时候我的处理是一律不开仓,等下一根重新判断。宁可错过,不要在逻辑冲突的地方下注。
3. 从信号到持仓:手数、止损、离场这三件事决定生死
信号只是告诉你"什么时候可以考虑动手",真正决定这套策略能不能活下来的是后面三件事:开多少手、亏多少走、什么时候主动撤。我在实盘里见过的爆仓案例,几乎全是因为这三件事里的某一件没做好,而不是信号本身不准。指标决定你出手的频率,风控决定你能不能撑到指标生效的那一天。
3.1 用 ATR 和保证金双约束算手数
手数的计算我坚持用两个约束同时卡,取两者的小值。
第一个约束是单笔风险。假设账户权益 20 万,单笔最大亏损接受 1%,也就是 2000 元。止损距离用 2 倍 ATR 来定,某个黑色系品种 ATR 是 60 元/吨,合约乘数 10 吨/手,那么每手止损的金额就是 60 × 2 × 10 = 1200 元。用 2000 除以 1200,得到 1.67,向下取整是 1 手。
第二个约束是保证金占用。同样这个品种,价格 4000 元/吨,合约价值 4000 × 10 = 4 万元,保证金率按 10% 算,每手占用 4000 元。如果设定保证金使用率上限 40%,那 20 万 × 40% = 8 万,最多能开 20 手。
两个约束一取小,最终是 1 手。可以看到,在这个例子里真正起作用的约束是风险预算,而不是保证金。很多人算手数只看"能开几手",结果每次都顶格开,止损一打就是账户的两个点,连亏五次心态就崩了。风险预算是硬约束,保证金只是上限,这个顺序不能反。
写成函数大概是这样:
def size_position(equity, price, atr, cfg): risk_per_lot = cfg["atr_stop"] * atr * cfg["lot"] if risk_per_lot <= 0: return 0 by_risk = int(equity * cfg["risk_pct"] / risk_per_lot) margin_per_lot = price * cfg["lot"] * cfg["margin_rate"] by_margin = int(equity * cfg["max_margin_use"] / margin_per_lot) return max(0, min(by_risk, by_margin))还有一个细节:ATR 用的是入场那根之前的值,不是入场当根的。用当根的值会有轻微的未来信息,虽然影响很小,但在波动率突变的行情里会造成手数偏差。
3.2 止损放轨道外还是按波动率:两种做法的代价
止损位置的选择,本质上是选"被扫的概率"和"单笔亏损幅度"之间的平衡。
第一种做法是把止损放在轨道外侧,比如做多时放在下轨再往下一点点。好处是逻辑自洽——你是因为触及下轨才进的场,那跌破下轨就说明这个判断错了。坏处是布林带的宽度是变化的,盘整期带宽很窄,止损距离可能只有 0.3%,随便一个毛刺就打掉了;趋势期带宽很宽,止损距离可能到 2%,一笔亏损就吃掉好几笔盈利。
第二种做法是固定波动率止损,比如统一的 2 倍 ATR。好处是每笔风险可控,账户曲线平滑;坏处是它和入场逻辑脱钩,有时候价格还没到位就被扫了。
我现在的做法是两者取宽的那个:入场时同时算出轨道外侧的距离和 2 倍 ATR 的距离,取更大的那个作为止损距离。这样在盘整期用 ATR 兜底,不至于被窄轨道扫掉;在趋势期用轨道兜底,不至于在正常回撤里被赶下车。这个规则听起来有点粗糙,但实测下来比任何一种单一规则都稳,因为它同时照顾到了两种市场状态。
3.3 离场规则:中轨、RSI 回归、时间止损三条腿
出场比入场难写,因为入场只有一个触发点,出场要考虑的情况多得多。我一般同时挂三条规则,谁先满足谁执行。
第一条是回到中轨。顺势回调的多单,目标是价格回到 20 周期均价附近,也就是中轨。这里不是碰到就走,而是要求收盘价站上中轨之后的下一根开盘再走,避免盘中假突破。如果行情强势,价格会直接穿过中轨冲向上轨,这时候不急着走,让利润继续跑,但把止损上移到成本价。
第二条是 RSI 回归中性。如果做多入场时 RSI 是 35 附近,那么当 RSI 冲到 70 以上并且出现回落,就考虑减仓或离场。这条规则的实际作用是防止"行情已经走完了但价格还没回到中轨"这种情况,尤其在波动率收缩的阶段很有用。
第三条是时间止损。如果开仓之后 N 根 K 线内既没到止损也没到目标,一律平掉。N 一般设成布林带周期的 1.5 到 2 倍,也就是 30 到 40 根。这条规则很多人不写,但它是控制回撤的关键——一笔单子横在那里不动,占着保证金,占着风险预算,还让你错过别的机会,机会成本比亏损还高。
三条规则之间会有冲突,比如价格在中轨附近震荡,同时时间也快到了。我的处理优先级是:止损最高,时间止损其次,中轨和 RSI 最后。止损是硬性的,时间止损是纪律性的,另外两个是战术性的,顺序不能乱。
3.4 期货特有的摩擦:乘数、最小变动、手续费、滑点
股票策略里这些摩擦相对小,期货不一样,杠杆放大了所有成本的比例。下面这张表是我在回测里必须逐项填进去的参数,少填一项,回测结果就会偏乐观。
| 项目 | 取值方式 | 对回测的影响 |
|---|---|---|
| 合约乘数 | 按品种实际值 | 直接影响盈亏金额,写错等于全盘皆错 |
| 最小变动价位 | 按品种实际值 | 决定滑点最小粒度 |
| 手续费 | 按成交金额万分比或每手固定 | 高频信号下可能吃掉三成利润 |
| 滑点 | 至少 1 个最小变动价位 | 突破类策略必须给到 2 个以上 |
| 保证金率 | 按交易所标准上浮 2 到 3 个点 | 影响可用手数,也影响强平风险 |
滑点这一项我想多说两句。布林带突破类的策略,入场点往往在价格快速运动的时刻,这时候的成交价和信号价差得最远。回测里如果按信号价成交,等于假设自己每次都能在最好的位置成交,这是最典型的自欺欺人。我的做法是入场滑点给 2 个最小变动价位,止损滑点给 3 个,因为止损触发的时候往往是行情加速的时候,实际成交更差。
手续费看着不起眼,但要看信号频率。如果一个策略平均每天开平各一次,一年 240 个来回,每手手续费按 5 元算,10 手就是 24000 元。账户 20 万的话,光手续费就是 12% 的年度成本,这个数字足以让一个"年化 20%"的策略变成"年化 8%"。
4. 一套能跑起来的 Python 骨架:指标、状态机、撮合
理论讲完,落到代码。下面这套骨架我刻意写得比较朴素,没有引入任何回测框架,原因是框架会隐藏太多细节,你自己写一遍撮合循环,才会真正明白"信号价"和"成交价"之间的差距在哪里。
4.1 数据层与两个指标的实现
数据要求很简单,一个 DataFrame,包含datetime、open、high、low、close、volume六列,按时间升序排列,不能有重复时间戳和缺口。
import numpy as np import pandas as pd def add_indicators(df, n_boll=20, k=2.0, n_rsi=14, ddof=0): df = df.copy() c = df["close"] # 布林带 df["mid"] = c.rolling(n_boll).mean() sd = c.rolling(n_boll).std(ddof=ddof) df["upper"] = df["mid"] + k * sd df["lower"] = df["mid"] - k * sd df["width"] = (df["upper"] - df["lower"]) / df["mid"] df["pctb"] = (c - df["lower"]) / (df["upper"] - df["lower"]) # RSI,Wilder 递推 diff = c.diff() gain = diff.clip(lower=0.0) loss = (-diff).clip(lower=0.0) ag = pd.Series(np.nan, index=df.index, dtype="float64") al = pd.Series(np.nan, index=df.index, dtype="float64") if len(df) > n_rsi: ag.iloc[n_rsi] = gain.iloc[1:n_rsi + 1].mean() al.iloc[n_rsi] = loss.iloc[1:n_rsi + 1].mean() for i in range(n_rsi + 1, len(df)): ag.iloc[i] = (ag.iloc[i - 1] * (n_rsi - 1) + gain.iloc[i]) / n_rsi al.iloc[i] = (al.iloc[i - 1] * (n_rsi - 1) + loss.iloc[i]) / n_rsi rs = ag / al.replace(0.0, np.nan) df["rsi"] = (100 - 100 / (1 + rs)).where(al > 0, 100.0) df.loc[ag.isna() | al.isna(), "rsi"] = np.nan # 辅助量 df["mid_slope"] = df["mid"] - df["mid"].shift(5) tr = pd.concat([ df["high"] - df["low"], (df["high"] - c.shift()).abs(), (df["low"] - c.shift()).abs() ], axis=1).max(axis=1) df["atr"] = tr.rolling(14).mean() df["width_q"] = df["width"].rolling(150).rank(pct=True) return df这段代码里有几个需要注意的地方。rolling().rank(pct=True)需要 pandas 1.4 以上版本,低版本会报错。RSI 的首值我用的是前 N 根涨幅跌幅的简单平均,从第 N+1 根开始递推,这样前 N 根是 NaN,不会污染信号。where(al > 0, 100.0)处理的是"连续上涨导致平均跌幅为 0"的情况,这时候数学上 RS 趋于无穷,RSI 应该是 100。
4.2 用状态机代替一堆 if:避免重复开仓和信号打架
很多人写信号就是一堆if,结果跑出来发现同一个方向上开了七八次仓,或者多空信号在同一根上打架。正确做法是把持仓状态显式管理起来,用一个状态变量控制所有转移。
def gen_signals(df, rsi_lo=35, rsi_hi=65, brk_lo=55, brk_hi=45, wq=0.30): df = df.copy() df["signal"] = 0 trend_up = (df["mid_slope"] > 0) & (df["close"] > df["mid"]) trend_dn = (df["mid_slope"] < 0) & (df["close"] < df["mid"]) touch_low = (df["low"] <= df["lower"]) & (df["close"] > df["lower"]) touch_high = (df["high"] >= df["upper"]) & (df["close"] < df["upper"]) rsi_up = (df["rsi"] > rsi_lo) & (df["rsi"].shift(1) <= rsi_lo) rsi_dn = (df["rsi"] < rsi_hi) & (df["rsi"].shift(1) >= rsi_hi) # 顺势回调型 df.loc[trend_up & touch_low & rsi_up, "signal"] = 1 df.loc[trend_dn & touch_high & rsi_dn, "signal"] = -1 # 收口突破型 squeeze = df["width_q"] < wq df.loc[squeeze & (df["close"] > df["upper"]) & (df["rsi"] > brk_lo), "signal"] = 1 df.loc[squeeze & (df["close"] < df["lower"]) & (df["rsi"] < brk_hi), "signal"] = -1 # 逻辑冲突直接作废 df.loc[df["signal"] != 0, "signal"] = df["signal"] df["signal"] = df["signal"].astype("int8") return df状态转移在回测循环里处理:只有当position == 0时才响应开仓信号,只有position != 0时才响应平仓信号。这样一个 bar 上的信号不会重复触发,也不会出现多空同时持仓的荒唐情况。
关于"逻辑冲突作废"那句话,实际实现比上面写的复杂一点:需要先算出多头信号和空头信号两个布尔序列,如果有任何一根上两者同时为真,就把这一根的信号清零。这个检查很便宜,但能帮你发现参数设置上的叠加错误。
4.3 回测撮合:信号在收盘、成交在下一根开盘
这是整个回测里最容易出问题的一环。原则只有一条:决策只能用当根及之前的信息,成交必须发生在之后。具体落实就是:在第 i 根收盘时计算信号,在第 i+1 根开盘时成交。
def run_backtest(df, cfg): cash = cfg["init_cash"] pos, entry, stop = 0, np.nan, np.nan pending = None trades, eq_curve = [], [] for i in range(1, len(df)): bar = df.iloc[i] o, h, l, c = bar["open"], bar["high"], bar["low"], bar["close"] prev = df.iloc[i - 1] # 1) 执行上一根收盘做出的决策,按本根开盘价成交 if pending is not None and not np.isnan(prev["atr"]): action = pending pending = None if action == "close" and pos != 0: px = o - cfg["slip"] * cfg["tick"] * np.sign(pos) cash += (px - entry) * pos * cfg["lot"] cash -= px * cfg["lot"] * cfg["fee_rate"] trades.append((bar["datetime"], "close", px, pos)) pos, entry, stop = 0, np.nan, np.nan elif action in ("long", "short"): lots = size_position(cash, o, prev["atr"], cfg) if lots > 0: side = 1 if action == "long" else -1 px = o + side * cfg["slip"] * cfg["tick"] pos, entry = side * lots, px dist = max(cfg["atr_stop"] * prev["atr"], abs(px - (prev["lower"] if side > 0 else prev["upper"]))) stop = px - side * dist cash -= px * lots * cfg["lot"] * cfg["fee_rate"] trades.append((bar["datetime"], action, px, pos)) # 2) 盘中检查止损,用最不利价成交 if pos != 0: if (pos > 0 and l <= stop) or (pos < 0 and h >= stop): px = stop - np.sign(pos) * cfg["slip"] * cfg["tick"] cash += (px - entry) * pos * cfg["lot"] cash -= abs(px * pos * cfg["lot"]) * cfg["fee_rate"] trades.append((bar["datetime"], "stop", px, pos)) pos, entry, stop = 0, np.nan, np.nan # 3) 用本根收盘做下一根的决策 if pos != 0: hold_bars = sum(1 for t in trades[::-1] if t[1] in ("long", "short")) exit_flag = ( (pos > 0 and c < bar["mid"]) or (pos < 0 and c > bar["mid"]) or (pos > 0 and bar["rsi"] > 70 and bar["rsi"] < prev["rsi"]) or (pos < 0 and bar["rsi"] < 30 and bar["rsi"] > prev["rsi"]) ) if exit_flag: pending = "close" else: if bar["signal"] == 1: pending = "long" elif bar["signal"] == -1: pending = "short" # 4) 记录权益 mark = (c - entry) * pos * cfg["lot"] if pos != 0 else 0.0 eq_curve.append((bar["datetime"], cash + mark)) return pd.DataFrame(eq_curve, columns=["datetime", "equity"]), trades这段代码是骨架,能跑但有简化。比如hold_bars那段我写得很粗糙,实际应该用一个变量记录开仓的 bar 索引,然后算差值。时间止损也一样,需要自己补上。但核心结构是对的:决策、撮合、止损、记账四段分开,每一段都能单独调试。
有一个细节值得单独说:止损用的是盘中最高最低价判断,成交价用止损价加减滑点。这里隐含了一个假设——价格触及止损价时能以止损价成交。在流动性好的品种上这个假设基本成立,但在跳空开盘的时候不成立,实际成交可能是开盘价,比止损价差很多。严谨的做法是加一条:如果开盘价已经越过止损价,就按开盘价成交。
4.4 看哪几个数才有意义:不止胜率
回测跑完,很多人第一眼看胜率。胜率是最没用的指标之一,因为它是可以靠调整止盈止损比例随意操纵的。把止盈设成一个点、止损设成一百个点,胜率能到 95%,然后一笔亏损把一百笔盈利全吃掉。
我实际会看的顺序是这样的:
| 指标 | 关注点 | 经验参考 |
|---|---|---|
| 最大回撤 | 账户能不能扛住 | 超过 25% 就要重新审视仓位 |
| 盈亏比 | 平均盈利除以平均亏损 | 低于 1.2 说明止损太松或目标太近 |
| 交易次数 | 统计意义够不够 | 少于 100 笔的结论基本不可信 |
| 手续费占比 | 摩擦成本吃掉多少 | 超过总盈利 30% 说明频率过高 |
| 连续亏损次数 | 心理承受极限 | 超过 8 次要检查是不是参数过拟合 |
最大回撤我放在第一位,因为它直接决定你能不能拿住这套策略。一个年化 40%、回撤 50% 的策略,实盘里 99% 的人会在回撤 30% 的时候手动关掉,然后在行情回来的时候拍大腿。
5. 回测漂亮实盘难看:五个我踩过的坑和排查顺序
下面这些坑,每一个我都真实踩过,而且每一个在当时都让我怀疑是"市场变了",实际上全是代码或者假设的问题。我把它们按隐蔽程度排序,越靠前的越难发现。
5.1 未来函数:最隐蔽的一种是"用当根收盘价成交"
用当根收盘价成交这个错误,看起来很低级,但它的变种非常隐蔽。比如你在收盘价上算指标,然后假设成交价是"收盘价加一个滑点",这在代码上看起来是两件事,但本质上你还是在用当根信息做决策、又在当根成交,等于拥有了当根的完整信息还能回到过去下单。
更隐蔽的变种是止损的判定顺序。如果你在一根 K 线内同时判断"最高价触及止盈"和"最低价触及止损",而且把止盈写在前面,那么在那些既触及止盈又触及止损的大波动 K 线上,你会系统性地假设自己先止盈。这个偏差在日线级别上能凭空造出 10% 以上的年化收益。
排查方法很土:在回测循环里,把所有决策用的变量都加上_prev后缀,逼自己只能访问iloc[i-1]及之前的数据。凡是需要访问iloc[i]的地方,只允许出现在撮合和记账环节。
5.2 主力连续合约拼接的跳空会凭空造出信号
这个问题在换月频繁的品种上特别明显。主力合约切换的时候,新合约和旧合约之间有价差,直接拼接会在换月那天出现一根巨大的跳空 K 线。这根假 K 线会让布林带的中轨剧烈跳动,标准差瞬间放大,RSI 也会算出一个极端值。
后果是双向的:一方面换月当天可能凭空触发一个信号,另一方面换月之后几十根的指标全被污染,因为 20 周期的均线和标准差里都包含那根假 K 线的数据。
处理方式有三种,我按推荐程度排:
- 按价差复权,把历史价格整体平移,让拼接处连续。这是最常用的做法,对绝对价格类指标(布林带、ATR)比较友好。
- 按比例复权,乘一个系数让价格连续。适合价差本身随价格水平变化的品种。
- 换月日强制平仓,并且换月后的前 N 根不产生信号。这是最保守的做法,损失一点样本,但绝对干净。
我一般用第一种加第三种:复权之后,换月当天如果持仓,强制平掉,然后跳过接下来 20 根不产生新信号,等指标重新稳定。
5.3 夜盘、跳空与信号闪烁
有夜盘的品种,日内的跳空比想象中多。白天收盘到夜盘开盘之间隔了几个小时,夜盘收盘到第二天白天开盘又隔了几个小时,这些时段的跳空都会体现在下一根的开盘价上。
如果你的止损是基于收盘价算的,那跳空开盘的时候,实际成交价会远离止损价。这一点在回测里必须显式处理:如果开盘价已经越过止损价,按开盘价成交。我见过不少回测代码直接按止损价成交,结果实盘一跑,第一笔跳空止损就比回测多亏了 40%。
信号闪烁是另一个问题。布林带突破类的信号,如果要求"收盘价站上上轨",那盘中价格穿上去又掉下来就不会触发;如果用"盘中最高价触及上轨"作为条件,那就会频繁触发又在收盘时失效。这两种做法的回测结果差别很大,而且前者在实盘里会遇到"收盘前一分钟才确认,来不及下单"的问题。
我的折中方案是:用收盘前若干秒的价格作为准收盘价做判断,用收盘集合竞价或者下一根开盘成交。这样既避免了盘中闪烁,又不至于完全错过。当然这个方案对执行系统有要求,手动交易的人做不到,那就只能老老实实用下一根开盘价成交,接受一点滑点。
5.4 参数敏感性测试:把"最优点"变成"平台"
网格搜索出来的最优参数,往往是一个孤立的尖峰。什么意思呢?N 等于 20 的时候年化 25%,N 等于 19 的时候年化 8%,N 等于 21 的时候年化 6%。这种参数就是过拟合的典型特征,它拟合的是历史上某几段特定行情的噪音。
健康的参数应该是一个平台:N 在 18 到 24 之间,年化都在 15% 到 20% 之间波动,没有剧烈跳变。这种参数才可能在未来继续有效,因为它不依赖于某个精确的数值。
我的测试流程是这样的:先固定 k 和 RSI 参数,把 N 从 10 扫到 40,步长 2,看年化收益和回撤的曲线形状。如果曲线是平滑的丘陵,说明这个维度是稳健的;如果是一根根尖刺,直接放弃这个维度。然后对 k 和 RSI 参数重复同样的过程。最后取所有维度都处在平台区的参数组合,而不是取全局最优。
还有一件事必须做:样本外验证。把数据按时间切成 7:3,前 70% 调参,后 30% 只跑一次,不许回头改参数。如果样本外和样本内的绩效差了一倍以上,那说明参数根本不能用。更严格的做法是滚动前推,每次用过去两年的数据调参,跑下三个月,然后窗口往前滚,看整体表现。
5.5 一份上线前的自查清单
下面这张表是我每次把策略从回测搬到模拟盘之前必过的项目,漏一项都不行。
| 检查项 | 具体做法 | 不通过的典型症状 |
|---|---|---|
| 未来函数 | 决策变量全部加_prev后缀 | 样本外绩效断崖式下跌 |
| 复权处理 | 手动检查换月日前后 5 根 K 线 | 换月日出现异常大信号 |
| 跳空止损 | 开盘价越界则按开盘价成交 | 实盘单笔亏损远超预期 |
| 滑点假设 | 入场 2 跳、止损 3 跳 | 回测与模拟盘成交价系统性偏离 |
| 手续费 | 按实际费率逐笔扣除 | 高频策略利润被吃掉一半 |
| 保证金 | 按交易所标准上浮 3 个点 | 模拟盘被强制平仓 |
| 参数平台 | 三维网格看曲线形状 | 换个月份绩效就崩 |
| 样本外 | 最后 30% 数据只跑一次 | 一改参数就"复活" |
最后再补一个不在表里但很重要的:模拟盘至少跑够 200 笔。回测再严谨,也只是在历史数据上的拟合,模拟盘能暴露出来的问题(成交延迟、部分成交、信号在极端行情下的行为)是回测永远模拟不出来的。我自己有个规矩,任何新策略在模拟盘跑满三个月并且交易笔数超过 200 之前,绝不投入真金白银。这条规矩救过我至少两次,其中一次是一个在回测里年化 30% 的组合策略,模拟盘第一个月就暴露了在涨跌停板附近无法成交的问题,实际收益直接归零。
这套东西写下来,核心其实就一句话:BOLL 告诉你价格现在站在什么位置,RSI 告诉你推动价格到这个位置的力气还有没有,两个加起来能描述一个相对完整的市场状态,但它们描述不了明天。真正让策略活下来的,是后面那些不起眼的细节——手数怎么算、止损放哪、滑点给多少、换月怎么处理。指标决定你出手的次数,工程细节决定你出手之后还能不能站着。