
去年有段时间我在指数ETF上做择时把RSRS指标从研报公式搬到本地做回测。第一版结果漂亮得让人兴奋年化大幅跑赢沪深300最大回撤也控制得很好。后来一次偶然的复盘发现这漂亮曲线里藏着一个数据错位——标准分的滚动窗口在回测框架里被提前了一天整条策略的买卖信号全部左移一根K线。听起来只差一天但重新修正后策略收益差了一倍多。这篇文章就把我从RSRS策略回测分析这件事里踩过的坑、重新搭的框架、以及从单标的扩展到多股、再走向模拟盘的完整思路记录一下。适合三类人看准备在指数或ETF上做择时的、已经会Python但想系统验证策略的、以及被回测曲线骗过想搞清楚为什么的人。1. RSRS指标到底在算哪条线从重标极差到回归斜率1.1 重标极差R/S与Hurst指数的关系RSRS这个缩写听起来很玄其实它要回答的问题很朴素过去一段时间市场价格走势到底是有惯性的还是来回震荡的如果价格有惯性那么涨的时候更容易继续涨跌的时候更容易继续跌适合顺势持有如果价格来回震荡那么涨完容易回调跌完容易反弹适合高抛低吸。要量化这种惯性或记忆性金融上有一个经典工具叫重标极差分析也就是R/S分析。它把一个时间序列切割成若干个长度相同的小块对每个小块计算累计离差的极差R再除以该块收益率的标准差S得到R/S值。理论上R/S与区间长度n之间满足一个幂律关系R/S ≈ C × n^H其中H就是Hurst指数。H大于0.5说明序列存在长期正相关性趋势会延续H小于0.5说明序列存在均值回归特性H等于0.5则是标准的随机游走。RSRS策略的核心就是在滚动窗口内估算这个H。具体做法是取过去N日比如18日或20日的收益率序列分别用长度2、3、直到N的子区间计算对应的R/S值然后对log(n)和log(R/S)做一元线性回归得到的斜率就是RSRS指标。所以RSRS本质上不是一个绝对价格强度指标而是一个趋势记忆强度指标。它高不代表股价一定涨而是代表当前价格走势的持续性更强它低也不代表股价一定跌而是代表当前走势更容易反转向下。理解了这一点后面所有的信号设计逻辑才立得住。1.2 斜率、标准分和修正标准分为什么裸斜率不能直接用很多人第一次实现RSRS直接把回归斜率当作信号设置一个固定阈值比如0.5以上买入、0.5以下卖出。这样做的结果通常是不同行情阶段、不同标的上斜率的数值范围差异很大一个固定阈值要么在震荡市里频繁触发要么在牛市中永远不触发。原因很简单回归斜率本身的均值和方差并不是平稳的。同样是强势2015年牛市里斜率的数值可能普遍偏高而2017年慢牛里斜率可能只有前者的一半。这时候需要对斜率做标准化方法就是滚动z-score标准分 (当前RSRS - 过去M日RSRS均值) / 过去M日RSRS标准差这样处理之后无论斜率绝对值是高是低只要它相对于自己最近一段时间的历史分布足够异常就会被捕捉到。这本质上是在问一个问题当前趋势记忆强度相对于最近一段时间是显著偏强还是显著偏弱另一个容易被忽略的细节是回归拟合质量。当窗口内R/S点非常散乱时回归斜率本身的可信度就很低。比如某一天市场突发利空所有子区间的R/S值都异常大但log(R/S)相对log(n)的线性关系很差斜率算出来可能很大却是噪声。所以研报里通常会对标准分再乘一个回归拟合度R²得到修正标准分。R²越高说明Hurst指数的估计越可靠信号可信度越高R²低信号权重自然下降。我在实际回测里见过不少RSRS大法失效的帖子点开代码一看用的全是裸斜率加固定阈值甚至没有R²修正。这不是指标有问题是使用方式太粗糙。1.3 信号规则与常用参数N、M和阈值到底怎么定信号规则常用双阈值设计当修正标准分超过买入阈值th_buy时建立多头仓位当修正标准分跌破卖出阈值th_sell时清仓离场。买入阈值和卖出阈值可以相同也可以不同。我用同阈值的策略在震荡市里容易被反复打脸后来改成买得高、卖得低的区间设计也就是买入阈值高于卖出阈值让空仓状态有一定的容错空间不会一回到中性线就急着接回来。参数方面最核心的是三个N计算RSRS斜率的窗口常见取值15到20之间通常取18或20。N太小斜率噪声大N太大信号反应迟钝。M标准分计算的滚动窗口常见取值从几十到几百都有。M越大标准分越平滑但也会更迟钝M越小对近期变化越敏感但容易频繁交易。阈值买入阈值通常在0.7到1.0之间做测试卖出阈值一般在-0.5到0之间。需要特别说明的是这些参数最早的出处是券商金工研报但研报参数并不是圣杯。换一个标的市场、换一段牛熊周期最优参数可能完全不同。所以正确做法不是找到一个参数就锁死而是把所有参数都参数化后面第3章专门讲参数敏感性和过拟合排查。2. 用backtrader把RSRS写进回测指标自研与日期对齐的坑2.1 为什么推荐backtrader做这类择时回测RSRS是日线级别的低频策略信号一天最多变化一次理论上用任何框架都能做。但实际操作中backtrader有几个地方特别顺手指标可以完全自定义RSRS这种滚动窗口内做回归计算的指标在backtrader里可以通过继承Indicator类实现不需要hack数据流回测的市场、手续费、滑点模型可以直接配置方便做成本敏感性分析有现成的分析器比如收益、回撤、夏普、交易次数、年化波动率这些指标可以一次性收集起来输出它天然是事件驱动框架K线一根一根推进逻辑上更接近真实交易不容易写出向量化那种整段偷看未来的代码。如果你只是想在聚宽、米筐这类在线平台上跑一版那确实更快但平台的底层实现细节被封装死了出了问题不好排查。backtrader的好处是每根K线怎么推进、订单怎么撮合都在你的控制范围内。2.2 RSRS指标类的核心实现deque滚动窗口与最小周期backtrader的Indicator机制本质上是在每根K线上调用一次next方法往line里塞一个当前值。所以实现RSRS的关键就是维护一个滚动窗口的历史收益率序列在next里计算斜率、R²和修正标准分。先写回归计算的核心函数。给定收益率序列返回RSRS斜率和R²import numpy as np def calc_rsrs(rets: np.ndarray): 对收益率序列做重标极差回归返回斜率和R² N len(rets) log_n [] log_rs [] for n in range(2, N 1): k N // n rs_list [] for i in range(k): seg rets[i * n:(i 1) * n] mean_seg seg.mean() dev np.cumsum(seg - mean_seg) R dev.max() - dev.min() S seg.std(ddof1) if S 0 or R 0: continue rs_list.append(R / S) if rs_list: log_n.append(np.log(n)) log_rs.append(np.log(np.mean(rs_list))) if len(log_n) 3: return np.nan, 0.0 slope, intercept np.polyfit(log_n, log_rs, 1) pred slope * np.array(log_n) intercept ss_res np.sum((np.array(log_rs) - pred) ** 2) ss_tot np.sum((np.array(log_rs) - np.mean(log_rs)) ** 2) r2 1 - ss_res / ss_tot if ss_tot 0 else 0.0 return slope, r2这里注意两个细节。第一ndarray切片是视图直接用seg rets[i*n:(i1)*n]不会复制数据计算时没问题但如果要修改就要用.copy()。第二计算标准差时用ddof1即样本标准差避免窗口小时S被低估。接下来写Indicator类import backtrader as bt from collections import deque class RSRS(bt.Indicator): lines (rsrs, zscore, mod_score) params dict(N18, M600) def __init__(self): self.addminperiod(self.p.N 1) self.close_buf deque(maxlenself.p.N 1) self.slope_buf deque(maxlenself.p.M) def next(self): self.close_buf.append(self.data.close[0]) if len(self.close_buf) self.p.N 1: self.lines.rsrs[0] float(nan) self.lines.zscore[0] float(nan) self.lines.mod_score[0] float(nan) return closes np.array(self.close_buf) rets closes[1:] / closes[:-1] - 1.0 slope, r2 calc_rsrs(rets) self.lines.rsrs[0] slope self.slope_buf.append(slope) if len(self.slope_buf) self.p.M: self.lines.zscore[0] float(nan) self.lines.mod_score[0] float(nan) return arr np.array(self.slope_buf) mean_s arr.mean() std_s arr.std() if std_s 0: self.lines.zscore[0] float(nan) self.lines.mod_score[0] float(nan) return z (slope - mean_s) / std_s self.lines.zscore[0] z self.lines.mod_score[0] z * r2这里有一个很关键的坑addminperiod(N1)只保证了计算斜率所需的最少K线数但zscore需要额外的M个历史斜率所以在M个历史斜率积累之前zscore和mod_score必须显式赋为nan否则backtrader会把之前的默认值0当成有效信号导致策略在数据不足的起始阶段就疯狂开仓。我第一版就栽在这里。backtrader的line在未用addminperiod明确宣告之前会有一段数据是0而RSRS的裸斜率在窗口未满时需要用nan显式标记。如果不处理策略可能在回测前几十根K线就带着错误信号开仓后期收益曲线看起来没事但实际持仓起点已经被污染了。2.3 信号生成与成交时点警惕把次日信号当成当日信号策略类本身并不复杂class RSRSStrategy(bt.Strategy): params dict(N18, M600, buy_th0.7, sell_th0.0) def __init__(self): self.rsrs RSRS(self.data, Nself.p.N, Mself.p.M) self.order None def next(self): if self.order: return mod self.rsrs.mod_score[0] if np.isnan(mod): return if not self.position and mod self.p.buy_th: self.order self.buy() elif self.position and mod self.p.sell_th: self.order self.close()重点在于成交时点的理解。backtrader的日线回测中每根日线的next被调用时当前这根K线的收盘价已经确定了。如果你在next里调用buy()订单会被放进broker的订单队列默认在下一根K线的开盘价成交。这种机制对于RSRS策略反而是最正确的用今天收盘后的数据计算修正标准分信号确认后明天开盘再执行。这完全符合真实交易中收盘后算指标、次日挂单的操作流程。但很多人在写回测时为了让曲线更好看会开启cheat_on_close让订单以当前K线收盘价成交。对RSRS这种低频策略来说这个开关会让收益虚高。原因很简单今天的收盘价参与了指标计算而你却能在同一根K线的收盘价上成交等于你知道了今天收盘结果之后再回到今天收盘那一刻去下单这是标准的未来函数。如果你想验证信号延迟一天的影响可以在策略里手动把信号推迟一天对比一下两者收益差多少。我测试的结果是对沪深300ETF做RSRS择时使用收盘价成交会比次日开盘成交年化高2到4个百分点。这多出来的收益就是你在实盘中永远拿不到的部分。还有一个小坑是均线对齐。如果有人在RSRS基础上叠加了价格均线过滤比如RSRS指标大于阈值且价格高于20日均线才买入那么要注意均线用的是当日收盘值还是昨日收盘值。按我的成交逻辑信号在今日收盘后确认次日开盘执行所以均线也应该取今日收盘的值而不是昨日。backtrader的data.close[0]默认就是今日收盘直接用即可。3. 回测结果怎么读账成本、基准与参数敏感性3.1 一张收益曲线图里必须包含的三组数字很多人回测完只看两个数总收益率、最大回撤。这两个数字远远不够。我每次跑完RSRS策略至少会打印三组信息第一组是绝对收益质量年化收益率、年化波动率、夏普比率、卡玛比率年化收益/最大回撤、最大回撤的起止时间。卡玛比率尤其重要RSRS这类择时策略用户最关心的就是回撤能不能控制住卡玛比率比单独看收益和回撤更有判断力。第二组是交易统计总交易次数、做多交易次数、平均持仓天数、胜率、盈亏比、单笔最大盈利和单笔最大亏损、换手率。RSRS如果参数调得太敏感交易次数会急剧上升胜率可能看起来不低但每次盈利都很小亏损时却扛不住最后被手续费磨死。交易次数是一个隐藏的杀手指标。第三组是与基准的对比同期沪深300或者对应ETF的买入持有收益、最大回撤、夏普。RSRS择时策略天然会空仓如果基准本身在大牛市RSRS因为早期信号没触发而踏空收益率低是可以接受的但必须知道低多少。我更关注的是RSRS超额收益和回撤相对基准的改善幅度这两个对比指标。backtrader里可以用Analyzer来完成这些统计cerebro.addanalyzer(bt.analyzers.Returns, _namereturns) cerebro.addanalyzer(bt.analyzers.DrawDown, _namedrawdown) cerebro.addanalyzer(bt.analyzers.SharpeRatio, _namesharpe, timeframebt.TimeFrame.Days, annualizeTrue) cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _nametrades)跑完后再从strats里取出来逐项打印整理成一张表格。这一步建议自动化否则每次手算容易漏。3.2 手续费、印花税和滑点RSRS这种切换型策略尤其怕成本RSRS择时在震荡行情下会频繁空仓、开仓一年下来交易次数远比买入持有多得多。这时候成本模型的精细程度直接决定回测结论靠不靠谱。我常用的成本拆解分四块成本项估算值说明佣金万1~万2.5双边收取最低5元起步印花税卖出万52023年8月调整后的单边费率过户费万0.1双边收取金额很小滑点万分之几到千分之几取决于品种流动性在backtrader里最简单的处理方法是把单边成本全部合并成一个固定值。比如我回测沪深300ETF单边设置万5cerebro.broker.setcommission(commission0.0005, stocklikeTrue, commtypebt.CommInfoBase.COMM_PERC)如果要做更精细的印花税拆分就需要自定义CommissionInfo让它只在卖出时计算额外成本。对于RSRS这种低频策略佣金加印花税加滑点合并成买入万5、卖出万10也能得到足够可靠的结果没必要把模型搞得太复杂。重点不是精确到小数点后几位而是要做成本敏感性测试。同一个策略分别用单边万2、万5、万8、万10去跑看年化收益的衰减曲线。如果万2和万10之间年化收益相差超过3个百分点说明这个策略对交易成本极其敏感实盘执行时任何一点滑点恶化都会明显影响收益。我见过不少RSRS变体交易次数提高之后从万5改到万10直接由盈利变亏损。3.3 参数敏感性与过拟合排查换一段行情就翻车的原因RSRS的参数并不多一共就N、M、buy_th、sell_th四个但四维网格扫描已经足以让人陷入过拟合陷阱。我的标准流程是先固定N18和M600研报参数的常用起点在th_buy和th_sell的二维网格上扫描画出收益热力图再看N在12到25、M在200到800之间变动时最优阈值是否还能保持稳定把所有参数组合中收益排名前20%的区域找出来看它们是否连成一片而不是孤立的几个尖峰。孤立尖峰是最危险的信号。如果某个参数组合N17M350buy_th0.82sell_th0.07在历史回测里收益惊人但相邻参数组合全部表现平平那这个尖峰大概率是在拟合噪声。真正可信的参数应该落在一个参数平原上周围略作调整也不会大幅恶化。另一个必做的检查是分段回测。把历史数据按牛熊震荡切成几段比如2015至2018、2019至2021、2022至2024分别看策略在每段的表现。如果策略只在某一种行情里赚钱在另一种行情里大幅跑输基准那么你在全样本上看到的漂亮收益曲线很可能只是某一段行情的产物。我测试RSRS在沪深300上的结果就很典型震荡市和熊市里它表现很好能躲掉大部分下跌但在单边大牛市的初期标准分经常因为前期波动低而给出强势信号反而会在高位反复进出。这说明RSRS本质上是一个防御型择时工具不是进攻型。你不可能要求它在任何行情里都赚钱能做的是确认它在什么环境下会失效然后决定要不要叠加其他过滤条件。4. 把RSRS从单标的扩展到多股分档横向排序优于绝对阈值4.1 多股票回测的架构问题事件驱动循环、数据对齐与性能RSRS在单指数上做择时只是最基础用法。实践中更常见的需求是在十几只宽基ETF或行业ETF里用RSRS标准分做横向排序每天持有排名最高的几只。很多人以为多股回测是backtrader自动支持的。确实你可以在Cerebro里adddata加入10只ETF然后在Strategy的next里遍历self.datas每个标的分开计算RSRS和信号。但这里有个前提所有标的的交易日历必须对齐。A股各指数ETF基本上共享同一个交易日历问题不大。但如果加入港股ETF、美股ETF或者商品ETF节假日不一样数据长度也不一样backtrader在next里会以所有数据的共同日期为基准推进。这时候标准的遍历写法就需要先判断每个data的当前bar是否有效for d in self.datas: if len(d) 0 or d.datetime.date(0) self.data.datetime.date(0): continue否则你会拿上一根残留的数据当作当天的数据RSRS算出来自然错位。性能方面backtrader在单标的日线回测上毫无压力但标的数量增多后事件驱动循环会明显变慢。10只ETF、十年日线跑一次大约几十秒到几分钟。这还能接受但如果标的数上百建议改用并行多进程每个进程跑一组独立的Cerebro实例最后汇总结果。不要试图在一个Cerebro里塞上百个data那样调试和内存都不划算。4.2 用RSRS标准分做截面排序与等权持仓单标的RSRS是在和时间维度上的自己的历史比较多股RSRS则是和当下所有标的横向比较。这两个维度可以结合先用标准分过滤掉绝对弱势的标的再在剩余标的中选择排名靠前的。我常用的规则是每天收盘后用全部候选标的的修正标准分做排序选出排名前K只标的等权买入如果持仓中的标的排名跌出前K缓冲带次日开盘卖出同时在每天收盘后检查是否有排名更高的标的需要替换。这里的核心思路是绝对阈值很容易被整体市场环境影响。比如大盘系统性上涨时所有ETF的RSRS标准分可能都在1以上这时候按标准分0.8买入会同时买入所有标的失去筛选意义。用截面排序无论整体环境如何只买相对最强的那几只。实现时把信号拆分得更细一些class MultiRSRSStrategy(bt.Strategy): params dict( N18, M600, top_k3, rebalance_interval5, ) def next(self): # 每5个交易日调仓一次减少交易频率 if len(self) % self.p.rebalance_interval ! 0: return scores {} for d in self.datas: if len(d) 0: continue rsrs RSRS(d, Nself.p.N, Mself.p.M) # 这里实际应该预先计算指标避免循环内重复创建 mod rsrs.mod_score[0] if not np.isnan(mod): scores[d] mod ranked sorted(scores.items(), keylambda x: x[1], reverseTrue) target [d for d, s in ranked[:self.p.top_k]] # 再按target和当前持仓比较决定买卖这段代码只是示意。实际项目中RSRS指标应该在Strategy的__init__里预先创建好每个数据对应一个指标实例next里直接取指标值不要在循环里反复实例化否则性能和逻辑都会出问题。多股RSRS和单标的择时的另一个区别是调仓频率。单标的可以在信号变化时立刻交易多股每天调仓的成本会很高。我习惯引入一个调仓间隔参数比如每5个交易日才重新排序换仓中间不操作。这样既保留了RSRS的排序信息又大幅降低了交易次数和成本损耗。4.3 多股回测中的常见隐含bug多股回测的坑比单标的多我踩过几个比较典型的第一个是复权口径不统一。RSRS用的是收益率序列如果某只股票发生除权除息不复权价格会在除权日出现一个人为的大跌这个假跳空会被R/S分析当成真实的极端波动导致RSRS值突变。回测个股时必须使用后复权价格也就是以某个时间点为基准把分红送股折算回来的价格序列。ETF基本没有除权跳空问题可以直接用收盘价。如果混着股票和ETF一起回测必须统一复权口径否则对比结果就是错的。第二个是停牌和一字板。多股回测里停牌股票在停牌期间没有新价格backtrader会沿用最后一条数据或跳过该bar。如果策略在停牌期间生成买入信号实盘根本买不进但回测里可能按停牌前价格成交。最简单的处理办法是在信号生成前检查该data的成交量成交量为0或价格无变化的标的直接剔除。一字涨跌停也有类似问题虽然ETF很少出现但个股回测时一定不要忽略。第三个是存在幸存者偏差。你现在选的候选标的池很可能只包含了存续至今的ETF。回测十年前的行情时某些行业当时有ETF而现在已经退市或更换你没纳入池子收益自然偏高。这个问题没有完美解法但至少要清楚自己的标的池是现在视角的回测结果对未来的参考价值需要打折。5. 回测之外从模拟盘到实盘的三个真实落差5.1 回测收益与实盘收益之间通常隔着什么RSRS这种日线低频策略从回测到实盘的距离比高频策略近得多但仍然有三个落差必须正视。第一个是成交价格落差。回测里用次日开盘价成交实盘里你挂单时点的开盘价可能和回测数据里的开盘价不一样。开盘瞬间价格波动剧烈如果挂限价单可能买不到或卖不掉如果挂市价单又可能吃到滑点。对ETF来说流动性好的品种滑点相对可控但依然存在。解决思路是回测时将次日开盘价人为加一个滑点比如万分之五看收益变化。第二个是数据滞后与信号延迟。RSRS的经典执行方式是收盘后计算信号、次日集合竞价挂单。但如果你用的是盘中实时数据自动计算那么在收盘前几分钟算出的标准分和收盘后算出的标准分可能有差异因为当日K线还没走完。有人为了提前下单用盘中数据近似计算一旦出现尾盘异动信号就可能在收盘后被反转。实盘中最稳妥的信号确认时间点是收盘后不要为了提前一小时下单去赌收盘价。第三个是执行纪律落差。回测代码是冷冰冰的信号出现就执行实盘时需要面对这次会不会是假信号的犹豫。尤其RSRS在震荡市会连续发出多次亏损交易很容易让人中途放弃。解决这个问题没有技巧只能靠事前把策略逻辑写清楚做成完全自动化的定时任务减少人为干预。5.2 把backtrader从回测框架接到模拟盘执行backtrader本身是回测框架不是实盘执行引擎。但它的架构允许你替换数据feed和broker接口社区里也有把backtrader接到模拟交易或程序化交易通道的方案。我的实际建议是不要强行把backtrader当实盘引擎用。日线级别的RSRS策略根本不需要一个完整的事件驱动交易系统常驻运行。更务实的架构是用backtrader完成策略研究和参数确认每天收盘后写一个独立的小脚本拉取当日数据重新计算RSRS标准分和持仓信号如果信号发生变化生成次日开盘的委托单提交到你的交易通道早上开盘前确认委托是否成交把成交结果记录到本地数据库。这个架构比直接在backtrader里挂实时feed要简单得多也更容易调试。backtrader的价值在于回测不在于实盘。你非要把实时tick塞进去反而引入网络延迟、断线重连、消息队列这些和策略本身无关的复杂度。数据源的对接关键是保证回测和实盘用的是同一套复权口径和数据源。回测用日线后复权实盘信号计算也应该用后复权如果实盘接口只提供不复权价格那么除权日的信号计算就会和回测不一致。这个细节虽然不起眼但真能导致实盘和回测出现系统性偏差。5.3 模拟盘应该验证哪些具体指标很多人开模拟盘跑两周就问为什么模拟盘收益和回测差这么多。两周时间太短。RSRS策略一年可能只交易十几次样本量完全不足以验证信号效果。模拟盘阶段我建议验证的是过程指标而不是收益指标信号生成的稳定性每天计算的标准分和回测历史值是否一致有没有因为数据源不同导致的小数位差异成交价格的滑点分布记录每笔委托的计划价格、实际成交价格、两者差值统计滑点的均值和标准差订单执行的成功率限价单有没有经常不成交市价单有没有在开盘瞬间吃到异常价格持仓状态的一致性程序记录的持仓和实际账户持仓是否一致这是程序化交易最容易出错的地方。这些指标验证的不是策略本身赚不赚钱而是你的实盘执行系统有没有正确复现回测逻辑。等执行误差降到可接受范围再谈收益差异才有意义。我在模拟盘阶段就发现过一个有意思的问题我在回测里假设次日开盘价成交但实盘系统在竞价阶段挂单实际成交价往往不是开盘价而是竞价撮合后的加权均价。两者之间经常有千分之一的差异。方向不固定但累计下来对RSRS这种低频换仓策略影响不大。如果你用的是中频策略这个问题就会被放大需要专门建模。回测分析做到这一步其实已经超越了写个指标、跑个曲线的阶段。RSRS这个指标本身不复杂复杂的是围绕它搭建一套可验证、可解释、可上线的流程。单看指标实现网上有无数版本能把参数敏感性、成本模型、多股扩展、模拟盘验证串成完整链路的才是真正值得沉淀下来的东西。我这套框架也是踩了无数坑才逐步搭起来的希望对你重建自己的回测体系有参考价值。