上期发布了文章《手把手教你构建与改进ETF轮动策略》,主要目的是帮助量化萌新从零开始,在本地构建和改进ETF轮动策略,从获取数据到交易信号计算,再到最后的策略回测。
个人感觉上期的内容已经有点儿多了,为了保证萌新的学习节奏,当中有一个统计上的错误就没有指出来,不然为了说明和修正这个错误,就又需要岔出很长一截内容。
到底是什么错误呢?我先把上期最终形成的那个策略的统计图po上来。


这周我把该ETF轮动策略移植到了QMT上,在相同的日期范围进行回测,策略绩效如下。


在2015年2月6日至2025年8月28日,原版策略的累计收益率是1906.09%,年化收益是21.69%,由于底层数据和结算机制的差异,QMT版策略的累计收益是1866.30%,年化收益却是33.46%。
原版策略累计收益率明显比QMT版的要高,但年化收益却是少了10%+,到底哪个结果是对的呢?
咱来估算一下,2015年2月至2025年8月,这中间大概是10年半,也就是10.5年,根据年化收益推算最终的策略净值。
原版策略:(1 + 0.2169) ^ 10.5 = 7.86 (实际20.06)
QMT策略:(1 + 0.3346) ^ 10.5 = 20.71 (实际19.66)
很明显,QMT策略的推算净值与回测最接近,原版策略推算净值与回测差别巨大,原版策略的年化收益率明显是错的。
原版策略是使用quantstats库对策略进行绩效统计,它为什么会出现这个错误呢?其实这是一个历史遗留bug引起的。
先来说复合年化增长率(Compound Annual Growth Rate,CAGR),也就是咱常说的"年化收益率",它的通用计算公式如下。

EV是期末值,BV是期初值,n是年数,CAGR的计算就是期末值除以期初值,再开n次方,最后减1再乘100。
这式子不复杂,但是相同净值序列放到不同的量化平台/软件,统计出来的策略年化收益率CAGR却是不尽相同的,特别是时间范围小的,相差可能非常大,这里面最关键的因素就是n值,也就是年数的确定。
如果你每次交易都是从某一年年头交易到后面某一年年尾,那这样的年数很好确定,因为都是整数嘛。但现实是回测的起始时间和结束时间,圈出来的时间范围是不整的,那年数是怎么确定呢?
有两条路子,一个是按照交易日数换算,另一个是按照自然日数换算。
常见的量化平台,比如说QMT和JoinQuant就是按照交易日换算,认为一年是250个交易日,回测当中有多少个交易日,除以250,就是年数。

quantstats库走的路子就有点儿奇怪,咱进入它的底层源代码,看看它的CAGR是怎么算的,returns的index是日期,首尾相减就是自然日天数,然后除以periods。

本来以为它要按照自然日换算,一年是365个自然日,其实按照自然日换算也没有问题,十年范围这么长期的回测,两种方式换算出来的年数差别不大,关键是quantstats的底层计算CAGR时参数默认值periods = 252有问题。
怎么回事呢?就是统计时,quantstats已经用期末日期减期初日期,算出这之间有多少个自然日,坑爹就坑爹在,它除以的参数默认值**是252,而不是365,又要按自然日算,除以的却是交易日数,本来年数应该是10,现在一晃却变成15了,同样的累计收益,花更长的时间达成,年化收益可不是就低嘛~~**如果把periods设置成365,计算出来的CAGR看上去是不是正常很多了呢,所以QMT策略的年化收益是正确的,quantstats的是错的。

那是不是说,只要把quantstats中的类似于periods参数设置为365,年化收益的计算就正确了呢?
先说答案:不是的!要是这么简单,我之前早就这么设了,不信呐,咱来做个试验~


看上面两张截图,年的日数分别设置成了252和365,统计出来的年化收益还是21.67%,是一样的,并没有发生改变。
这是不是很奇怪,哪怕算错都好啊,不同参数怎么会出来相同的年化收益,这里就要引出quantstats中遗留多年的bug了,请看下图的红框部分。

前面咱已经看过cagr函数的源码,它是有periods参数的,默认值是252,但是在最终回测统计调用cagr函数时,并没有把periods参数传进去!!!而下面计算夏普率调用sharpe函数的时候却传了,实参是win_year,它等于basic函数的输入参数periods_per_year。
这个bug就是所有问题的根源,在最终回测统计时,年化收益率的计算只依赖periods=252这个参数默认值,所以统计出来的年化收益都会偏低。
那如何解决呢?最简单的办法有两个,第一是在调用cagr函数时,把periods=365传进去,第二就是在cagr函数当中,把periods的默认值改为365,任意选一种就可以了。
如果是第一种,找到你安装的Python环境,也就是python.exe文件所在的目录,找到"Python环境\Lib\site-packages\quantstats"文件夹下的stats.py文件中的cagr函数,大概在文件中的第510行附近。
把下面这行代码
def cagr(returns, rf=0.0, compounded=True, periods=252):
修改为
def cagr(returns, rf=0.0, compounded=True, periods=365):
如果是第二种,则找到"Python环境\Lib\site-packages\quantstats"文件夹下的reports.py文件中的metrics函数,大概在文件中的第879行附近。
把下面这行代码
metrics["CAGR﹪%"] = _stats.cagr(df, rf, compounded) * pct
修改为
metrics["CAGR﹪%"] = _stats.cagr(df, rf, compounded, 365) * pct
要特别说明的是,在修改之后,类似于periods和periods_per_year的参数值保留默认值252就好了,因为除了cagr函数之外,quantstats的主体计算逻辑跟QMT和JoinQuant是一样的,都是按照交易日换算,只有cagr函数是按照自然日。
选择两种当中任意一种方式修改就可以了,两种方式都修改也行,效果是一样的。修改后重启策略跑一遍,发现年化收益就正常了。

修正后的累计收益率是1906.09%,保持不变,年化收益变为了32.86%,细心的小伙伴可能发现了,QMT版策略的累计收益是1866.30%,年化收益是33.46%,还是有"累计收益高但年化收益低"的现象。
其实呢,这个现象很正常,它们之间的差别是由于自然日和交易日换算年数的差别带来的。回测的时间范围里大概有3857个自然日和2567个交易日,换算下来就分别是10.567年和10.268年,根据累计收益率和年数计算年化收益。
原版策略:20.061 ^ (1 / 10.567) - 1.0 = 32.82%
QMT策略:19.663 ^ (1 / 10.268) - 1.0 = 33.66%
上面的数值误差是由于日期首尾是否包含/前推和净值精度等因素引起的,大伙儿明白这个收益产生差别的原理就行了。
量化就是这样的,魔鬼藏在细节里,多注重细节,少踩坑,少割肉。