跳转至




Index

拯救CCI!因子纯化后,证实CCI确实是超有效的技术指标!

CCI(商品通道指数) 由 Donald Lambert 研发,首次发表于 1980 年的《商品期货》杂志,一直以来很受交易大量推崇。但是,简单地将这个指标作为因子进行因子检验,差点使明珠蒙尘。最后,因子密度分布图揭示了真相,通过因子纯化,最终检验结果给出了与传统经验一致的结论!

CCI的计算公式是:

\[ CCI=\frac{Typical Price - MA}{.015 * Mean Deviation} \]

其中,

\[ \text{Typical Price}_t=(H_t+L_t+C_t)\div 3 \\ MA = Moving Average \\ Moving Average = (\sum_{i=1}^PTypical Price)\div P \\ Mean Deviation = (\sum_{i=1}^P|Typical Price - MA|)\div P \]

简单来说,CCI 表示了价格对移动平均线的徧离程度。

Tip

MACD, PPO, CCI 和 BIAS 是一组非常相似的指标,它们的区别主要在于选择的价格序列不同,是否进行了归一化。在本章我们不会介绍 BIAS 指标,这里就顺带提一下。它的公式是:

\[ \text{Bias} = \frac{\text{当前价格} - \text{N 日移动平均线}}{\text{N 日移动平均线}} \times 100 \]

这个对比给我们提示了创新因子的一个思路。

CCI 使用最高价、最低价和收盘价的平均值作为价格序列的想法,在很多地方都很常见。本质上,它是对 vwap 的一种近似。因此,在有 vwap 数据可用的前提下,直接使用 vwap 数据有可能更好,后者的博弈含义更明确。

CCI 公式当中有一个魔术数字:0.15. 它的作用是为了使 CCI 的值标准化到一个合理的范围,并且能在-100和100边界处有信号意义。起初,公式的设计者 lambert 认为,当 CCI 在[-100,100]区间内时,意味着价格在随机波动,是不值得交易的。而只有当 CCI 绝对值超过了 100 时,才认为有趋势出现,即当 CCI 上穿 100 时买入,下穿-100 时卖出。

我们先用一个简单的双轴图观察一下这个指标。

1
2
3
4
5
6
7
8
9
df = PAYH.copy()
df['cci'] = ta.CCI(df.high, df.low, df.close, 14)

axes = df[['close', 'cci']].plot(figsize=(14, 7), 
                            subplots=True, 
                            title=['PAYH', 'cci'])
axes[1].set_xlabel('')
sns.despine()
plt.tight_layout()

这是输出结果:

输出结果中,我在两处CCI穿越 \(\pm 100\) 的位置上标注了交易信号,以说明CCI的信号作用。这只是单个资产、某小段时间上的观察结果,说明不了问题。

现在我们运行因子检验来测试一下:

1
2
3
4
5
_ = alphatest(2000, start, end, 
              calc_factor = lambda x: ta.CCI(x.high, 
                                             x.low, 
                                             x.close, 
                                             14))

看起来因子测试的结果不是很好。

但是,只要对 CCI 的原理略加分析,我们就很容易明白,它不适合直接当成因子来使用。因为CCI的交易信号是,当CCI穿越\(\pm 100\) 时,就发出交易信号。它是一种事件信号,并不是我们通常意义上的因子。

下面,我们从因子分布的角度来讲一下为什么。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
cci = barss.groupby(level="asset")
            .apply(lambda x: ta.CCI(x.high, 
                                    x.low, 
                                    x.close, 
                                    timeperiod=14
                                    )
                )

with sns.axes_style('white'):
    sns.distplot(cci)
    sns.despine()

从密度分布图来看,因子分布出现了双峰。

我们在课程中讲过,如果因子的分布出现双峰,这个因子往往包含了多种因素,它是不纯粹的。现在,我们面临的正是这种情况。在这种情况下,进行因子分析,我们需要先对因子进行“纯化”。

1
2
3
4
5
6
7
8
cci = barss.groupby(level="asset")
            .apply(lambda x: ta.CCI(x.high, 
                                    x.low, 
                                    x.close, 
                                    timeperiod=14))
with sns.axes_style('white'):
    sns.distplot(cci[cci> 0])
    sns.despine()

输出结果如下:

现在,我们看到的 cci 的分布就是单峰的了。然后我们对它进行因子检验,看看结果如何:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def calc_cci(df, n):
    cci = ta.CCI(df.high, df.low, df.close, n)
    cci[cci < 0] = np.nan
    return cci * -1

alphatest(2000, 
         start, 
         end, 
         calc_factor= calc_cci, args=(14,), 
         max_loss=0.55, long_short=False)

注意,这段代码的第三行,我们对返回前的CCI 进行了修正,使其负值部分被置为nan,从而它们将会在因子检验中被抛弃掉。这是之前讲Alphalens框架时讲过的内容。

也正是因为丢弃了一半的因子,所以,在调用Alphalens时,我们需要将max_loss参数设置为大于0.5(具体看maxlosserror报告)。

基于纯化后的因子,回报是惊人的。它没有我们之前调谐过的RSI那么强,但是,我们是在纯多条件下得到的结果,因此它格外吸引人。

年化Alpha图

Alpha 达到了年化 19%。而且这个因子呈现比较好的正向单调性,见分层收益图:

因子分层收益均值图

不过,它在纯多的情况下,累计收益表现不是很稳定。这一点也从前面的年化收益图中的beta值可以看出来,受市场波动影响比较大。

累积收益图

但是我们不一定非要纯多,本来CCI就是期货指标。我们来看看多空组合的情况:

多空组合时的Alpha

不仅Alpha收益很强,而且beta被对冲到几乎没有!在beta为零的情况下,累积收益就应该是平稳向上、且波动很小,我们来看看是否是这样:

多空组合时的累积收益

这也许是 CCI 如此受人推崇的原因之一。

不过,这里的因子检验并不等同于实盘,因为操作手法不一样。在因子检验中,我们是按因子值进行的加权多空操作,在实盘中,会固定按CCI是否穿越\(\pm 100\)来确实是否开仓。在因子检验中,我们的开仓条件会更宽松一些,有一些自适应的味道。

本文附有代码和数据,可复现。加入星球后,即可获取基于Jupyter Notebook的研究环境,直接运行代码。

在该环境中,除本文代码外,之前付费文章的代码也都在。并且,今后的文章只要声明附有代码和数据,可复现的,都能在此环境中找到。

10 月 24 日,庆祝码农节!Python 刚刚发布了 3.13 版本

今天(10 月 24 日)是码农节。这一天也是裘伯君、Chris Lattner, Robert Khan 等人的生日。Lattner 是 LLVM 开源编译器的创始人、Swift 和 Mojo 语言的主要设计者。Khan 是互联网奠基人之一,他与温顿。瑟夫共同发明了 TCP/IP 协议。

不过,最令程序员兴奋的是,Python 3.13 正式版本发布了!

这个版本的重点是,引入了一个新的交互式解释器,并且对自由线程模型(PEP 703)和即时编译器(PEP 744)提供了实验性支持。这是 Python 程序员多少年以来,翘首以盼的性能改进!

REPL

新的交互式解释器这一功能可能会引起误解。它实际上指的是一个新的交互式 shell,而不是语言解释器本身。这个新的 shell 来自于 PyPy 项目。这个解释器支持彩色输出、多行编辑、历史回顾和多行粘贴模式。

Lattner 和 Mojo 语言。Mojo 号称比 Python 快 6.8 万倍

Python 的交互式 shell 一直是它的特色和优势,想了解一个函数的功能和用法,直接在终端中输入 ipython 之后,就可以立即尝试这个函数。我是常常拿 ipython 当计算器使用,特别方便。

JIT

从 3.11 起,Python 开始引进 JIT 的一些特性。在 Python 3.11 版本中,当解释器检测到某些操作涉及的类型总是相同的时候,这些操作就会被“专门化”,替换成特别的字节码,这使得代码中这部分区域的运行速度提升 10%到 25%。到了 3.13 版本,它已经能在运行时生成实际的机器代码,而不仅仅是专门的字节码。现在,提速还不是很明显,但为未来的优化铺平了道路。

不过,目前 JIT 还被认为是实验性的,默认情况下未启用。CPython 团队还在观察它对整个社区的影响,一旦成熟,就会成为默认选项。

Free Threaded CPython

Robert Kahn,互联网之父

之前大家讨论很久的无 GIL 版本,现在官方名称确定为 Free Threaded CPython。在这个版本下,CPython 允许线程完全并行运行。这将立刻数倍提升 Python 的性能。不过,目前该功能也是实验性的。

要启用这两个实验性的功能,你需要自己从源代码编译 CPython。同样地,这已经让人看到了曙光。而且,这个等待时间并不会太长,这些功能已经在 Meta 内部广泛使用了。

其它性能优化

这一版在 Windows 上,将提供精度为 1 微秒的计时器,而不再是过去精度只有 15.6 毫秒的时钟。这一变化将使得 Python 在 Windows 上将能执行一些实时任务。

之前 typing 库的部分模块会导致导入时间过长,现在,这个时间已减少了大约 1/3。当然,我们平常可能感受不出来,但如果你的程序会启动子进程来执行一些简短的计算密集型任务的话,这个区别就比较大了。

说到子进程,subprocess 现在会更多地使用 posix_spawn 函数创建子进程,这将带来一些性能上的提升。

弃用版本管理

在 Python 中,弃用版本管理一直是通过第三方库来实现的。现在,这一特性终于被内置了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
from warnings import deprecated
from typing import overload

@deprecated("Use B instead")
class A:
    pass

@deprecated("Use g instead")
def f():
    pass

@overload
@deprecated("int support is deprecated")
def g(x: int) -> int: ...
@overload
def g(x: str) -> int: ...

不过,第三方库 deprecation 似乎仍然在功能上更胜一筹。这是 deprecation 的用法:

1
2
3
4
5
from deprecation import deprecated

@deprecated("2.0.0", details="use function `bar` instead")
def foo(*args):
    pass

你就是列文。虎克!

这是网上的一个梗,说的是有些人看图特别仔细,拿着显微镜找 bug。列文。虎克就是发明显微镜的人。10月24日也是他的生日。

列文。虎克裁缝学徒出身,没受过正规教育。后来成为一名布匹商,为了检验布匹的质量,他购买了放大镜来观察布匹的纤维,也由此开启了他的大国工匠之路(17 世纪的荷兰的确是大国。世界上的第一个证券交易所 -- 资本主义的标志,就诞生在 17 世纪的荷兰)。

列文。虎克没有受过正规训练,凭着兴趣和热爱,发明了显微镜,为人类打开了从未见过的世界。他的成就最终被英国皇家学会接受,在 1680 年当选为皇家学会成员。终其一生,他为这个世界留下的,除了他自己的名字,还有 cell 这个词。

“我总是尽力做到最好,即使是最小的事物也值得认真对待”。正是凭着这种信仰,他才得以见微知著,于一粒沙中发现宇宙。

Pandas连续涨停统计

题图: 哈佛大学

常常需要快速统计出一段时间内,最强的股和最弱的股,以便研究该区间内,强势股和弱势股有什么特点。

如果使用循环,这就跟掰着手指头数数没啥区别,各位藤校生一定是不屑的。所以,我们来看看如何简洁优雅地实现这一功能,同时可以在同事面前zhuangbility.


这里我们以2023年的数据为例,要求统计出连续涨停在n天以上的个股,并且给出涨停时间。同样的方案也可以找出当年最终的股,以及它们的时间。

你可以对着屏幕把代码copy下来,自己找来数据验证。不过要是赶时间的话,建议加入我的部落:

加入部落者,即可获得Quantide研究环境账号,直接运行和下载本教程。

我们先加载数据:


1
2
3
4
5
6
np.random.seed(78)
start = datetime.date(2023,1,1)
end = datetime.date(2023, 12, 31)

barss = load_bars(start, end, -1)
barss.tail()

load_bars函数在我们的研究环境下可用。这将得到以下格式的数据:

date asset open high low close volume amount price
2023-12-25 **** 30.85 31.20 30.06 30.08 3591121.00 109649397.62 30.14
2023-12-26 **** 30.14 30.25 26.00 27.85 9042296.00 251945474.00 27.90
2023-12-27 **** 27.90 28.89 27.18 28.89 5488847.00 155156381.16 28.58
2023-12-28 **** 28.58 29.85 28.44 29.20 5027247.00 147201133.00 29.25
2023-12-29 **** 29.25 30.14 29.25 29.66 3923048.00 116933800.77 NaN

我们只取价格数据,然后展开成宽表,以求出每天的涨跌符:

1
2
3
pd.options.display.max_columns = 6
returns = barss.close.unstack("asset").pct_change()
returns.tail()

现在我们将得到这样的结果:

date **** **** **** ... **** **** ****
2023-12-25 -0.00 -0.01 -0.02 ... -0.01 -0.03 -0.03
2023-12-26 -0.01 -0.01 -0.02 ... 0.00 -0.02 -0.07
2023-12-27 0.00 0.00 0.02 ... -0.01 0.00 0.04
2023-12-28 0.04 0.03 0.01 ... 0.03 0.02 0.01
2023-12-29 -0.01 -0.01 0.02 ... 0.00 -0.00 0.02

5 rows × 5085 columns

接下来,我们要判断哪一天为涨停。因为我们的目标并不是执行量化交易,只是为了研究,所以,这里可以容忍一定的误差。我们用以下方式决定是否涨停(排除北交所、ST):

1
2
3
criteria = ((returns > 0.095) & (returns < 0.105)) | 
            ((returns > 0.19)& (returns < 0.21))
zt = returns[criteria].notna().astype(int)

这里的语法要点是,如何使用多个条件的组合,以及如何将nan的值转换为0,而其它值转换为1。


这里会出现nan,是因为我们处理的是宽表。在宽表中,有一些列在某个点上(行)不满足条件,而在该点上,其它列满足条件,导致该行必须被保留;不满足条件的列,在该行的值就是nan。然后我们用notna将nan转换为False,其它值转换为True,最后通过astype转换为整数0和1,1代表该天有涨停。

接下来,我们就要对每一个资产,统计它的连续涨停天数。我们用以下函数来处理:

1
2
3
4
5
6
7
8
def process_column(series):
    g = (series.diff() != 0).cumsum()

    g_cumsum = series.groupby(g).cumsum()

    result = series.copy()
    result[g_cumsum > 1] = g_cumsum[g_cumsum > 1]
    return result

这个函数的巧妙之处是,它先计算每一行与前一行的差值,并进行累加。如果有这样一个序列: 0 0 1 1 1 0 0,那么diff的结果就是nan, 0, 1, 0, 0, -1, 0。这里不为0的地方,就表明序列的连续状态发生了变化:要么出现连续涨停,要么连续涨停中止。

然后它通过cumsum累计差分序列。这样就与原序列形成如下的对应关系:

原序列 diff diff!=0 cumsum
0 nan true 1
0 0 false 1
1 1 true 2
1 0 false 2
1 0 false 2
0 -1 true 3
0 0 false 3

如果把这里的cumsum看成组号,那么就可以通过groupby分组,然后计算每组中非0的个数,就得到组内连续涨停次数。这就是第4行的工作。

Marvelous!


最后,我们来应用这个函数:

1
2
df_processed = zt.apply(process_column, axis=0)
df_processed.stack().nlargest(5)

我们得到以下结果(部分):

date asset 连续涨停
2023-10-25 **.XSHG 14
2023-10-24 **.XSHG 13
2023-03-21 **.XSHE 12
2023-10-23 **.XSHG 12
2023-03-20 **.XSHE 11

我们拿其中一个验证一下:

1
2
3
4
5
6
7
code = "******.XSHG"

bars = barss.xs(code, level="asset")
bars["frame"] = bars.index

plot_candlestick(bars.to_records(index=False), 
                ma_groups=[5,10,20,60])

我们来看下k线图:

最后,我们把函数封装一下:


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
def find_buy_limit(closes, low = 0.095, high = 0.105,n=50):
    def process_column(series):
        group = (series.diff() != 0).cumsum()

        group_cumsum = series.groupby(group).cumsum()

        result = series.copy()
        result[group_cumsum > 1] = group_cumsum[group_cumsum > 1]
        return result

    returns = closes.unstack("asset").pct_change()
    criteria = (returns > low) & (returns < high)

    zt = returns[criteria].notna().astype(int)
    df_processed = zt.apply(process_column, axis=0)
    return df_processed.stack().nlargest(n)

find_buy_limit(barss.close)

最后,这一届的奥斯卡颁给...的主力(算了,哪怕是历史数据,也不要透露了)。

当你不知道该往哪里踢时,就往球门里踢!现在,对着你去年错过的连接14个涨停,来找找规律吧!

量化实盘接口

Easytrader

Easytrader是一个通过模拟键鼠事件,操作券商客户端来实现交易功能的交易代理。这种方式中,easytrader提供了buy, sell等交易API,策略调用这些API,easytrader把它转化成对券商交易客户端的鼠标点击事件,最终完成交易。 特点是接入不需要申请,支持的券商较多(除华泰、海通、国金外,其它的可以通过同花顺来接入)。但由于是模拟键鼠事件来操作GUI,所以存在稳定性较差、响应速度慢的问题。 如果一定要通过它来进行实盘,需要找一台性能较好的独立的物理机,只安装券商的交易客户端和Easytrader, Easytrader以服务器模式运行,再在策略端,使用easytrader的remote client连接过去,平时不操作这台物理机,以名对easytrader的操作造成干扰。 此外,还应该关闭该机器上的自动更新等功能。

恒生电子Ptrade

Ptrade是恒生电子开发的量化平台。官方有一个视频教程,免费注册后可收看。在我的《大富翁量化编程实战》课程中也有介绍。 它的运行方式是券商托管式。券商采购Ptrade软件,进行一些定制化后,提供给自己的客户使用。 用户使用Ptrade策略编辑器生成自己的策略,回测通过后,上传到券商机房运行。这种接入方式中,券商提供python版本的sdk,通过sdk中的交易API来进行下单。 托管模式下,一般不能访问互联网、无法更新Python及依赖库的版本,不能自行安装软件。量化策略与交易API、数据获取API等紧密耦合,如果后期想更换券商,成本较高。因为不能自行安装软件和库,因此难以利用较新的第三方算法。如果使用了机器学习、强化学习等人工智能算法,这些库不一定在券商提供的环境下有,即使有的话,版本很可能跟我们常用的不一致,并且可能没有GPU可用。 优点是行情速度更快,省去了机房维护工作。 Ptrade软件网上无法下载,需要找券商工作人员开户后获取,并且一般要满足30万资金门槛才能开通实盘。目前可以向国金、国盛、国元、安信、东莞等券商申请开通Ptrade。如果有调佣(可以做到万一免五)和资金门槛要求(可以做到最低两万)的,也可以找我。

QMT

讯投QMT由北京睿智融科开发。与Ptrade一样,它也是由券商采购定制后,提供给自己的客户使用的。不一样的是,它是本地运行模式,策略安全性更好一点。 QMT提供了两种交易接入方式,一种是文件扫单模式,一种是API式。后者需要在QMT平台里编写策略并运行,对Python版本和可运行库有一定限制(但可以通过白名单增加新的第三方库)。 文件扫单模式则没有上述限制。 QMT软件网上无法下载,需要找券商工作人员开户后获取,目前可以向国金、国盛、国元、安信、东莞等券商申请开通。如果有调佣(可以做到万一免五)和资金门槛要求(可以做到最低两万)的,也可以找我。

东财EMC

东方财富EMC,开户门槛为100万资金。需要加入它的官方量化技术支持群申请开通。它提供API交易和本地文件扫单两种方式。 本地文件扫单方式响应速度在10ms以内。与量化程序没有耦合,因此量化程序可以运行在任何一台机器上,可以使用任意的Python版本和第三方库。 但是用户需要自己将交易指令(比如buy, sell等)转换成为文件单格式,并且EMC对委托的结果也是以csv方式返回,也需要用户自己解析。 gmadaptor提供了这种封装。不仅如此,它还将自己封装成一个服务器,因此量化策略可以运行在不同的机器和操作系统上(EMC只能运行在Windows上)。

其它接入方式

其它还有华泰MATIC,需要找华泰证券开通,这个资金门槛比较高,需要1000万,我可以帮忙申请到500万门槛的。 一创聚宽也提供了量化交易接入,采用的是托管模式。

参考资源

如果有需要学习Easytrader, Ptrade, QMT和东财EMC的,我这里有相关的学习资料,可以留言获取。

[1020] QuanTide Weekly

本周要闻

  • 幻方量化宣布降低对冲全系产品投资仓位至0
  • 9月CPI、PPI及前三季度GDP数据出炉
  • 潘功胜发声,宏观经济政策应更加重视消费

下周看点

  • 周一:最新LPR报价
  • 周二:华为原生鸿蒙之夜新品发布会
  • 周五:多家银行存量房贷利率调整
  • 周日:全球低空经济论坛年会

本周精选

  • 连载!量化人必会的 Numpy 编程(7)

  • 宁波幻方量化公告,将逐步把对冲全系产品投资仓位降低至0,同时自10月28日起免除对冲系列产品后期的管理费。作出改变的原因是,市场环境变化,对冲系列产品难以同时取得收益和缩小风险敞口,潜在收益风险比明显下降,未来收益将明显低于投资人预期。建议投资者适时调整投资组合,市场低位较适合配置指数增强产品,在风险能力匹配前提下,对冲产品可转至多头。(财联社)
  • 10月13日,国家统计局数据显示,9月份全国居民消费价格(CPI)环比持平,同比上涨0.4%,涨幅回落;工业生产者出厂价格(PPI)环比降幅收窄,同比降幅扩大。CPI、PPI同比表现均弱于市场预期。(证券时报网)
  • 10月18日,统计局公布2024年9月经济数据,9月社零当月同比3.2%,固定资产投资累计同比3.4%,工增当月同比5.4%,三季度GDP同比4.6%。前三季度GDP累计同比4.8%。(财联社)
  • 在10月18日的2024金融街论坛上,央行行长潘功胜重磅发声。谈及实现经济的动态平衡,需要把握好几个重点时,他提到宏观经济政策的作用方向应从过去的更多偏向投资,转向消费与投资并重,并更加重视消费。(财联社)

Numpy量化应用案例[4]

突破旗形整理

最近和一位做量化的私募大佬聊了一下行情,他给我发了这张图片。

75%

这个底部点位,他又一次精准命中了(3143那个点,不是3066。周五上证实际下探到3152点)。不过,我更好奇的是他的研究方法,也就图的下半部分。知道大致的底之后,再结合缺口、前低等一些信息,确实有可能比较精准地预测底部点位。


我当时就回了一句,最近忙着上课,等有时间了,把这个三角形检测写出来。

这个检测并不难,写一个教学示例,一个小时的时间足够了。

在分享我的算法之前,先推荐一个外网的方案。同样是教学代码,显然不如我随手写的优雅,先小小自得一下。不过,这样的好处就是,他的代码可能更容易读懂。

所谓旗形整理(或者说三角形检测),就是下面这张图:

在这张图,每次上涨的局部高点连接起来,构成压力线;而下跌的局部低点连起来,构成支撑线。

如果我们再在开始的位置画一条竖线,就构成了一个小旗帜,这就是旗形的来由。


旗形整理的特别之处是,整理何时结束似乎是可以预测的,因为这两条线之间的交易空间会越来越窄。

当它小于一个ATR时,就是整理必须结束,即将选择方向的时候。

下图显示了随时间推移,震荡幅度越来越小的情况。

75%

最终,股价会选择方向。一旦选择方向,就往往会有一波较大的行情(或者下跌):

75%


所以,能够自动化检测旗形整理,有以下作用:

  1. 如果当前处理在旗形整理中,可以设定合理的波段期望。
  2. 检测临近整理结束,可以减仓等待方向。
  3. 一旦方向确定,立即加仓。

现在,我们就来看如何实现。首先,我们有这样一个标的:

75%

这是已经上涨后的。我们再来看它上涨前的:

75%


肉眼来看,一个旗形整理似有若无。

我们的算法分这样几步:

  1. 找到每阶段的峰和谷的坐标
  2. 通过这些坐标及它们的收盘价,进行趋势线拟合
  3. 通过np.poly1d生成趋势线
  4. 将趋势线和k线图画在一张图上
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
def find_peak_pivots(df, win):
    local_high = (df.close.rolling(win)
                    .apply(lambda x: x.argmax()== win-1))
    local_high[:win] = 0

    # find_runs函数是量化24课内容
    v,s,l = find_runs(local_high)

    peaks = []
    i = 0
    while i < len(v):
        if l[i] >= win // 2:
            if s[i] > 0:
                peaks.append(s[i] - 1)
        for j in range(i+1, len(v)):
            if l[j] >= win // 2:
                peaks.append(s[j] - 1)
                i = j
        if j == len(v)-1:
            break

    return peaks

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def find_valley_pivots(df, win):
    local_min = (df.close.rolling(win)
                .apply(lambda x: x.argmin()== win-1))
    local_min[:win] = 0

    v,s,l = find_runs(local_min)

    valleys = []
    i = 0
    while i < len(v):
        if l[i] >= win // 2:
            if s[i] > 0:
                valleys.append(s[i] - 1)
        for j in range(i+1, len(v)):
            if l[j] >= win // 2:
                valleys.append(s[j] - 1)
                i = j
        if j == len(v)-1:
            break

    return valleys

def trendline(df):
    peaks = find_peak_pivots(df, 20)
    valleys = find_valley_pivots(df, 20)

    y = df.close[peaks].values
    p = np.polyfit(x=peaks, y = y, deg=1)
    upper_trendline = np.poly1d(p)(np.arange(0, len(df)))

    y = df.close[valleys].values
    v = np.polyfit(x=valleys, y = y, deg=1)
    lower_trendline = np.poly1d(v)(np.arange(0, len(df)))

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
    candle = go.Candlestick(x=df.index,
                    open=df['open'],
                    high=df['high'],
                    low=df['low'],
                    close=df['close'],
                    line=dict({"width": 1}),
                    name="K 线",
                    increasing = {
                        "fillcolor":"rgba(255,255,255,0.9)",
                        "line": dict({"color": RED})
                    },
                    decreasing = {
                        "fillcolor": GREEN, 
                        "line": dict(color =  GREEN)
                    })
    upper_trace = go.Scatter(x=df.index, 
                             y=upper_trendline, 
                             mode='lines', 
                             name='压力线')

    lower_trace = go.Scatter(x=df.index, 
                             y=lower_trendline, 
                             mode='lines', 
                             name='支撑线')

    fig = go.Figure(data=[candle,lower_trace, upper_trace])

    fig.show()

最后,我们对该标的在上涨之前的形态进行检测,得到以下结果:


这个结果说明,旗形整理结束时,方向选择受大盘影响,仍有一定不确定性,但没有跌破前低,这是此后能凝聚共识、返身上涨的关键。

我们再来看一个最近一个月翻了7倍的标的:

这是未上涨前的形态:

这是检测出来的旗形整理:


完美捕捉!

当然,这里只是示例代码,在实际运用中,由于我们使用了小样本线性回归,回归结果具有不稳定性,要作为生产代码,还需要辅以其它方法让其预测更稳定。无论如何,我们已经迈出了关键一步。

代码(可运行的ipynb文件)放在知识星球里。正在建设,所以目前是最低价格。

如果有一些代码和术语看不明白(比如为何以ATR来决定整理结束),这些都是我们量化24课的内容,欢迎报名!


好课开讲!


目标清晰 获得感强


为什么选择QuanTide的课程?

第42个因子:年化17.6%,15年累计10倍

题图:第比利斯自由大学,Kahushadze在此任教

《101个公式化因子》是Zura Kahushadze于2015年发表的paper。在这篇paper中,他拿出了在worldquant广泛使用的因子中,便于公式化的因子(约80个),加上其它自创因子,共101个,集结发表在预印论文网站arXiv上。

这一paper甫一发表,便引起业界关注。现在,Alpha101因子已成为国内机构广泛使用的付费因子。但是,Alpha101因子中的公式比较晦涩难懂,使用了自定义的算子、大量魔术数字和数不清的括号嵌套,让无数人不得不从入门到放弃。


然而,如果你因此放弃Alpha101,不能不说是巨大的损失。比如,我们近期对第42个因子进行了回测,发现它在A股有相当好的表现。

Info

回测使用2008年到2022年的数据,随机抽取2000支个股参与回测。考虑到2018年A股才1800支个股左右,这一回测在2018年前几乎是全覆盖。具有很强的代表性。

回测结果表明,这一因子的年代收益达到16.1%, 累计收益达到7倍(15年)。


不过,驾驭Alpha101并不容易。不得不说,它的公式有点晦涩难懂,比如第29号因子,它的公式如下:

1
2
(min(product(rank(rank(scale(log(sum(ts_min(rank(rank((-1 * rank(delta((close - 1),
5))))), 2), 1))))), 1), 5) + ts_rank(delay((-1 * returns), 6), 5))

这只是Alpha101中中等阅读难度的因子。如果我们把它展开,相当于:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
(
    min(
        product(
            rank(
                rank(
                    scale(
                        log(
                            sum(
                                ts_min(
                                    rank(rank((-1 * rank(delta((close - 1), 5))))), 2
                                ),
                                1,
                            )
                        )
                    )
                )
            ),
            1,
        ),
        5,
    )
    + ts_rank(delay((-1 * returns), 6), 5)
)

不仅是了解其含义非常困难,就是实现它也不是件容易的事。而且,Alpha101中还存在大量待优化的部分,以及少部分错误(对于一篇免费、公开的文章,仍然是相当宝贵的资源)。比如,对于42号因子,它仍然有改进空间。这是我们改进后的因子表现(同等条件下,源码仅对学员开放):

我们看到,年化alpha有了1.5%的上涨。而下面这张分层收益图,懂行的人一看就知道,简直是完美。西蒙斯所谓追随美的指引,应该就是指的这种图了。


累积收益图也很完美。A股2008年触顶6124之后,持续下跌数年,但这期间此因子的收益仍然保持上涨。

不过,Alpha101确实很难懂。比如公式001看起来并不复杂:

1
2
3
(rank(Ts_ArgMax(SignedPower((
    (returns < 0) ? stddev(returns, 20) : close), 2.)
    , 5)) -0.5)

但它却做了许多无用操作。它实际上是对现价对近期高点的距离排序,你看明白了吗?所以,这个因子到底有没有效呢?在什么情况下,它会出现出人意料的表现呢?

还有,像这样的因子,从公式到代码,再到结合数据进行因子检验,又该如何操作呢?如果你感兴趣,快来加入我们一起学习吧!

地量见地价?我拿一年的上证数据算了算

多伦多大学校园。2024诺贝尔物理学奖获得者,Geoffrey Hinton在此任教。

股谚云,天量见天价、地量见地价。今天我们就来验证一下。

要把股谚量化,首先要解这道难题:数组中第i个元素是多少周期以来的最小值(最大值)?


比如,有数组如下: 1, 2, 2, 1, 3, 0。那么,第1个元素1,是1周期以来的最小值,第2个元素2,是到目前为止的最大值,所以,也是1周期以来的最小值;但第4个元素1则是从第2个元素以来的最小值,所以它是3周期以来的最小值。

依次计算下去,我们得到这样一个序列: 1, 1, 2, 1, 4, 6。其中的每一项,都是原数组中,对应项到目前为止的最小值。

这个算法有什么用处呢?它可以用在下面的计算当中。

比如,有股谚云,天量见天价,地量见地价。

当行情处在高位,成交量创出一段时间以来的天量之后,后续成交量将难以为继,容易引起下跌;当行情处在低位,成交量创出一段时间以来的地量之后,表明市场人气极度低迷,此时价格容易被操纵,从而引来投机盘。在计算地量时,我们就要知道,当前的成交量是多少期以来的最小值。

比如,如果大盘当前的成交量成为了120天以来的最低量,这时候很可能就会引起大家的关注了。要验证出现地量之后,后面是否真的有行情,就需要进行因子分析或者回测验证。现在的问题是,怎么计算呢?

无脑的双重循环

我们以上面的数组为例,最简单的算法是使用循环:


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def min_range_loop(s):
    minranges = [1]
    for i in range(1, len(s)):
        for j in range(i-1, -1, -1):
            if s[j] < s[i]:
                minranges.append(i - j)
                break
        else:
            minranges.append(i+1)
    return minranges

s = [1,2,2,1,3,0]

min_range_loop(s)

输出为:1, 1, 2, 1, 4, 5

这个算法实现用了双重循环,应该比较耗时。我们生成10000个元素的数组跑一下,发现调用一次需要用时9.5ms。

它山之石,myTT的实现

在myTT中有一个类似的函数实现:

1
2
3
4
5
def LOWRANGE(S):                       
    # LOWRANGE(LOW)表示当前最低价是近多少周期内最低价的最小值 by jqz1226
    rt = np.zeros(len(S))
    for i in range(1,len(S)):  rt[i] = np.argmin(np.flipud(S[:i]>S[i]))
    return rt.astype('int')

它应该也是实现元素i是多少周期之前的最小值,只不过从注释上看,该函数多在计算最低价时使用。但实际上序列s是什么没有关系。

这个函数用了一个循环,还使用了flipuid函数,比较有技巧。这个函数的用法演示如下:

1
2
s = [1, 2, 2, 3, 2, 0]
np.all(np.flipud(s) == s[::-1])

也就是它的作用实际上就是翻转数组。

不过,LOWRANGE函数似乎没有实现它声明的功能,不知道是不是对它的功能理解上有错误。当我们用同一个数组进行测试时,得到的结果与双循环的并不一致。

1
2
s = np.array([1, 2, 2, 3, 2, 0])
LOWRANGE(s)

得到的结果是:

1
array([0, 0, 0, 0, 1, 0])

除此之外,如果同样拿10000个元素的数组进行性能测试,LOWRANGE执行时间要60ms,居然跑输给Python双循环。测试环境使用的Python是3.11版本,不得不说Python3.11的优化非常明显。

如果我们要完全消除循环,应该怎么做呢?

烧脑的向量化

如果我们能把数组[1, 2, 2, 3, 2, 0]展开为:

\(\displaystyle \left[\begin{matrix}1.0 & \text{NaN} & \text{NaN} & \text{NaN} & \text{NaN} & \text{NaN}\\1.0 & 2.0 & \text{NaN} & \text{NaN} & \text{NaN} & \text{NaN}\\1.0 & 2.0 & 2.0 & \text{NaN} & \text{NaN} & \text{NaN}\\1.0 & 2.0 & 2.0 & 3.0 & \text{NaN} & \text{NaN}\\1.0 & 2.0 & 2.0 & 3.0 & 2.0 & \text{NaN}\\1.0 & 2.0 & 2.0 & 3.0 & 2.0 & 0.0\end{matrix}\right]\)

然后实现一个函数,接收该矩阵输入,并能独立计算出每一行最后一列是多少个周期以来的最小值,这个问题就得到了求解。

要实现这个功能,我们可以通过numpy的masked array和triu矩阵来实现。


1
2
3
4
n = len(s)
mask = np.triu(np.ones((n, n), dtype=bool), k=1)
masked = np.ma.array(m, mask=mask)
masked

triu中的k参数决定了生成的三角矩阵中主对角线的位置。k=0,对角线取在主对角线上;k<0,对角线取在主对角线之个k个单位;k>0,对角线取在主对角线之上k个单位。

我们将得到以下输出:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
masked_array(
  data=[[1.0, --, --, --, --, --],
        [1.0, 2.0, --, --, --, --],
        [1.0, 2.0, 2.0, --, --, --],
        [1.0, 2.0, 2.0, 3.0, --, --],
        [1.0, 2.0, 2.0, 3.0, 2.0, --],
        [1.0, 2.0, 2.0, 3.0, 2.0, 0.0]],
  mask=[[False,  True,  True,  True,  True,  True],
        [False, False,  True,  True,  True,  True],
        [False, False, False,  True,  True,  True],
        [False, False, False, False,  True,  True],
        [False, False, False, False, False,  True],
        [False, False, False, False, False, False]],
  fill_value=1e+20)

mask flag为True的部分将不会参与运算。如果我们把masked转给sympy,就可以验证这一点:


1
2
3
4
5
6
from sympy import Matrix

n = len(s)
mask = np.triu(np.ones((n, n), dtype=bool), k=1)
masked = np.ma.array(m, mask=mask)
Matrix(masked)

我们得到了与期望中一样的展开矩阵。

\(\displaystyle \left[\begin{matrix}1.0 & \text{NaN} & \text{NaN} & \text{NaN} & \text{NaN} & \text{NaN}\\1.0 & 2.0 & \text{NaN} & \text{NaN} & \text{NaN} & \text{NaN}\\1.0 & 2.0 & 2.0 & \text{NaN} & \text{NaN} & \text{NaN}\\1.0 & 2.0 & 2.0 & 3.0 & \text{NaN} & \text{NaN}\\1.0 & 2.0 & 2.0 & 3.0 & 2.0 & \text{NaN}\\1.0 & 2.0 & 2.0 & 3.0 & 2.0 & 0.0\end{matrix}\right]\)

现在,我们要求解的问题变成,每一行最后一个数是多少周期的最小值。我们进行一个变换:

1
2
3
4
s = np.array([1, 2, 2, 3, 2, 0])
diff = s[-1] - s
rng = np.arange(len(diff))
rng - np.argmax(np.ma.where(diff > 0, rng, -1))

我们用最后一个元素减去数组,然后再比较元素是否大于零,如果大于零,我们就将值设置为索引(rng),否则设置为-1,然后再通过argmax找到最后一个非零值。这样输出元素的最后一个值,就是最小周期数。在此例中是5。

如果s = np.array([1, 2, 2, 3, 2]),那么计算出来的最后一个值是4。 如果s = np.array([1, 2, 2, 3]),这样计算出来的最后一个值是1。 依次类推。这刚好就是在masked array中,按axis = 1计算的结果。

下面是完整的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def min_range(s):
    """计算序列s中,元素i是此前多少个周期以来的最小值"""
    n = len(s)

    diff = s[:,None] - s
    mask = np.triu(np.ones((n, n), dtype=bool), k=1)
    masked = np.ma.array(diff, mask=mask)

    rng = np.arange(n)
    ret = rng - np.argmax(np.ma.where(masked > 0, rng, -1), axis=1)
    ret[0] = 1
    if filled[1] <= filled[0]:
        ret[1] = 2
    return ret

我们来验证一下结果:

1
2
s = np.array([1, 2, 2, 3, 2, 0])
min_range(s)

输出结果是1, 1, 2, 1, 4, 6

在最后一个数字上,与loop略有差异。不过,如果是用来寻找地量条件,这个数值一般要比较大才生效,所以,有一点误差可以接受。

消除了两个循环,性能应该有很大的提升吧?

遗憾的是,在同样的测试条件下,这个函数需要822ms,比双循环慢了100倍。花了这么多功夫,还引入了一点小误差,许诺的性能提升不仅没有实现,反而更糟糕了。真是意外啊。

地量见地价?

最后,我们以上证为例,看看这个算法的实际作用。

1
2
3
4
5
6
7
8
import akshare as ak
df = ak.stock_zh_index_daily(symbol="sh000001")

df_one_year = df.tail(250)
df_one_year["minrange"] = min_range_loop(df_one_year["volume"].to_numpy())

ax = df_one_year.plot(x='date', y='close', label='close', color='blue', secondary_y=False)
df_one_year.plot(x='date', y='minrange', label='Min Range', color='red', secondary_y=True, ax=ax)

这里我们使用了akshare数据源,所以,所有人都可以复现。

我们得到的输出如下:

这个图显示了惊人的结果。几乎在每一次地量(大于50天)出现之后,都能立刻迎来一个小的反弹。但大级别的反弹,还需要在地量之后,随着资金不断进场,成交量放大才能出现。

比如,在8月底,上证出现了一年以来的最低地量,随后立即迎来一个小反弹。在反弹失败之后,其它指标也逐渐见底回升,最终迎来了9月底的十年不遇的暴涨行情。

[1013] QuanTide Weekly

本周要闻

  • 10月25日起,存量房贷统一下调!
  • Robotaxi Day草草收场,特斯拉暴跌
  • 一揽子增量财政策略超预期,规模或在5万亿以上
  • 化债概念出炉!

下周看点

  • 周日:9月PPI和CPI指数公布
  • 周一(10月14日)国新办就前三季度进出口数据举办新闻发布会

本周精选

  • 连载!量化人必会的 Numpy 编程(6)

  • 工商银行发布存量房贷利率调整常见问答,透露存量住房贷款都可以调整为不低于LPR-30BP(除京沪深二套外),并在10月25日统一批量调整。以100万、25年期、等额本息计,调整后每月可省支出469元,共节省利息14.06万元。
  • We Robot发布会召开,此前马斯克称其为载入史册,但等到大幕拉开,却只有短短20多分钟的主题介绍,重要技术指标和参数均未公布。随后特斯拉大跌8.78%,其对手莱福特(Lyft)则大涨9.59%。
  • 财政部周六召开发布会,一揽子增量财政政策落地,有分析师认为,保守估计,本次一揽子增量财政政策规模或在5万亿元以上,重点是化债和基层三保。
  • 紧随财政部发布,化债概念受到市场热议。证券时报.数据宝梳理,AMC、城投平台、PPP概念和REITs概念共约37家公司可能受益。在周五大跌中,这些公司多数逆市大涨或者跑赢大盘。

消息来源:东方财富


Numpy量化应用案例[3]

向量化又一例: 多资产中位数去极值

去极值是量化分析预处理中的常见步骤,在机器学习中也很常见。在各种去极值方法中,中位数拉回是对数据分布特性适应性最广、最鲁棒的一种。

我们先介绍绝对中位差(median absolute deviation)的概念:

\[MAD = median(|X_i - median(X)|)\]

为了能将 MAD 当成与标准差\(\sigma\)相一致的估计量,即 \(\(\hat{\sigma} = k. MAD\)\)

这里 k 为比例因子常量,如果分布是正态分布,可以计算出: $$ k = \frac{1}{(\Phi^{-1}(\frac{3}{4}))} \approx 1.4826 $$


基于这个 k 值,取 3 倍则近似于 5。

代码实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from numpy.typing import ArrayLike

def mad_clip(arr: ArrayLike, k: int = 3):
    med = np.median(arr)
    mad = np.median(np.abs(arr - med))

    return np.clip(arr, med - k * mad, med + k * mad)

np.random.seed(78)
arr = np.append(np.random.randint(1, 4, 20), [15, -10])
mad_clip(arr, 3)

这段代码只能对单一资产进行mad_clip。如果要同时对A股所有资产的某种指标去极值,上述方法需要循环5000多次,显然速度较慢。此时,我们可以使用下面的方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def mad_clip(df: Union[NDArray, pd.DataFrame], k: int = 3, axis=1):
    """使用 MAD 3 倍截断法去极值"""

    med = np.median(df, axis=axis).reshape(df.shape[0], -1)
    mad = np.median(np.abs(df - med), axis=axis)

    magic = 1.4826
    offset = k * magic * mad
    med = med.flatten()
    return np.clip(df.T, med - offset, med + offset).T

这一版的 mad_clip 可以接受 numpy ndarray 和 pandas dataframe 作为参数。输入的数据格式是什么,它返回的数据格式就是什么。

我们在np.median调用中,传入了 axis参数。如果axis=0, 表明按列的方向遍历,因此是按行取中位数;axis=1,表明按行的方向遍历,因此是按列取中位数。

我们使用真实数据测试一下:

1
2
3
4
5
6
7
# 加载测试数据
start = datetime.date(2023, 1, 1)
end = datetime.date(2023, 12, 29)
barss = load_bars(start, end, 7)

closes = barss["close"].unstack("asset").iloc[-5:]
closes

输出数据为:

asset/date 002095.XSHE 003042.XSHE 300099.XSHE 301060.XSHE 601689.XSHG 603255.XSHG 688669.XSHG
2023-12-25 23.400000 18.090000 6.10 13.00 73.910004 36.799999 18.080000
2023-12-26 21.059999 17.520000 5.94 12.83 72.879997 37.000000 18.080000
2023-12-27 20.070000 17.590000 6.04 12.84 72.000000 36.840000 18.049999
2023-12-28 20.010000 18.139999 6.11 13.14 72.199997 38.150002 18.440001
2023-12-29 20.270000 18.580000 6.19 13.29 73.500000 37.299999 18.740000

为了测试效果,我们将k设置为较小的值,以观察其效果:

1
mad_clip(closes,k=1)
asset/date 002095.XSHE 003042.XSHE 300099.XSHE 301060.XSHE 601689.XSHG 603255.XSHG 688669.XSHG
2023-12-25 23.400000 18.090000 10.217396 13.00 25.962605 25.962605 18.080000
2023-12-26 21.059999 17.520000 10.296350 12.83 25.863649 25.863649 18.080000
2023-12-27 20.070000 17.590000 10.325655 12.84 25.774343 25.774343 18.049999
2023-12-28 20.010000 18.139999 10.582220 13.14 26.297781 26.297781 18.440001
2023-12-29 20.270000 18.580000 10.659830 13.29 26.820169 26.820169 18.740000

我们看到,原始数据中的73.9被拉回到25.9,6.1被拉回到10.2(以第一行为例),并且都是以行为单位计算的。

min_range: 多少周期以来的最小值?

这是一个很常见的需求,比如,有股谚语云,天量见天价,地量见地价。当行情处在高位,成交量创出一段时间以来的天量之后,后续成交量将难以为继,容易引起下跌;当行情处在低位,成交量创出一段时间以来的地量之后,表明市场人气极度低迷,此时价格容易被操纵,从而引来投机盘。


在通达信公式中有此函数,在麦语言中,对应的方法可能是LOWRANGE。以下是myTT中LowRange函数的实现:

1
2
3
4
5
def LOWRANGE(S):                       
    # LOWRANGE(LOW)表示当前最低价是近多少周期内最低价的最小值 by jqz1226
    rt = np.zeros(len(S))
    for i in range(1,len(S)):  rt[i] = np.argmin(np.flipud(S[:i]>S[i]))
    return rt.astype('int')

这是一个看似简单,但实际上比较难实现的功能。如果我们对上述函数进行测试,会发现它不一定实现了需求(也可能是本文作者对此函数理解有误)。

1
2
3
s = [ 1, 2, 2, 1, 3, 0]

LOWRANGE(np.array(s))

在上述测试中,我们希望得到的输出是[1, 1, 1, 3, 1, 6],但LOWRANG将给出以下输出:

1
array([0, 0, 0, 2, 0, 0])

下面,我们给出该函数的向量化实现。

Warning

该函数在开头的几个输出中,存在出错可能。因不影响因子分析,暂未修复。


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def min_range(s):
    """计算序列s中,元素i是此前多少个周期以来的最小值

    此方法在个别数字上有bug

    Example:
        >>> s = np.array([5, 7, 7, 6, 5, 8, 2])
        >>> min_range(s)
        array([1, 2, 1, 2, 3, 1, 6])
    """
    n = len(s)

    # handle nan
    filled = np.where(np.isnan(s), -np.inf, s)
    diff = filled[:,None] - filled
    mask = np.triu(np.ones((n, n), dtype=bool), k=1)
    masked = np.ma.array(diff, mask=mask)

    rng = np.arange(n)
    ret = rng - np.argmax(np.ma.where(masked > 0, rng, -1), axis=1)
    ret[0] = 1
    if filled[1] <= filled[0]:
        ret[1] = 2
    return ret

s = np.array([5, 7, 7, 6, 5, 8, 2])
min_range(s)

最终输出的结果是:

1
array([1, 1, 2, 3, 4, 1, 6])

在第2个7的位置,输出与期望不一致,但此后计算都正确。这个实现非常有技巧,运用了三角矩阵做mask array,从而消解了循环。


均线计算:SMA和分时均线

使用numpy计算移动均线非常简单,使用np.convolve()即可。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def moving_average(ts: ArrayLike, win: int, padding=True)->np.ndarray:
    kernel = np.ones(win) / win

    arr = np.convolve(ts, kernel, 'valid')
    if padding:
        return np.insert(arr, 0, [np.nan] * (win - 1))
    else:
        return arr

moving_average(np.arange(5), 3)

输出结果为array([nan, nan, 1., 2., 3.])

移动均线是只考虑价格信息的一种均线。分时均价线则则同时纳入成交量和成交价信息的均线,在日内交易中有特别重要的含义。比如,在市场不好的情况下,如果个股价格位于分时均线下方,此前两次上冲均线失败,那么,一旦冲第三次失败,一般认为要尽快卖出。反之亦然。

均价线的计算如下:


如果当前时刻为t,则用开盘以来,直到时刻t为止的成交金额除以成交量,即得到该时刻的累积成交均价。将所有时刻的成交均价连接起来,即构成了分时均价线。

这个功能看似复杂,但由于numpy提供了cumsum函数,因此实际上计算非常简单:

1
2
3
4
5
def intraday_moving_average(bars: DataFrame)->np.ndarray:
    acc_vol = bars["volume"].cumsum()
    acc_money = barss["amount"].cumsum()

    return acc_money / acc_vol

在本环境中,只提供了日线数据,我们以日线代替分钟线进行测试:

1
2
3
4
5
start = datetime.date(2023, 1, 1)
end = datetime.date(2023, 12, 29)
barss = load_bars(start, end, 1)

intraday_moving_average(barss)

计算最大回撤

最大回撤(MDD)是指投资组合从最高点到最低点的最大观察损失,直到达到新的最高点。最大回撤是一定时间周期内的下行风险指标。


\[ MDD = \frac{Trough Value - Peak Value}{Peak Value} \]

max drawdown是衡量投资策略风险的重要指标,因此,在empyrical库中有实现。不过,作为策略风险评估指标,empyrical没必要返回duration等信息,也没有实现滑动窗口下的mdd。现在,我们就来实现滑动版本。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# https://stackoverflow.com/a/21059308
from numpy.lib.stride_tricks import as_strided
import matplotlib.pyplot as plt

def windowed_view(x, window_size):
    """Creat a 2d windowed view of a 1d array.

    `x` must be a 1d numpy array.

    `numpy.lib.stride_tricks.as_strided` is used to create the view.
    The data is not copied.

    Example:

    >>> x = np.array([1, 2, 3, 4, 5, 6])
    >>> windowed_view(x, 3)
    array([[1, 2, 3],
           [2, 3, 4],
           [3, 4, 5],
           [4, 5, 6]])
    """
    y = as_strided(x, shape=(x.size - window_size + 1, window_size),
                   strides=(x.strides[0], x.strides[0]))
    return y

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def rolling_max_dd(x, window_size, min_periods=1):
    """Compute the rolling maximum drawdown of `x`.

    `x` must be a 1d numpy array.
    `min_periods` should satisfy `1 <= min_periods <= window_size`.

    Returns an 1d array with length `len(x) - min_periods + 1`.
    """
    if min_periods < window_size:
        pad = np.empty(window_size - min_periods)
        pad.fill(x[0])
        x = np.concatenate((pad, x))
    y = windowed_view(x, window_size)
    running_max_y = np.maximum.accumulate(y, axis=1)
    dd = y - running_max_y
    return dd.min(axis=1)


np.random.seed(0)
n = 100
s = np.random.randn(n).cumsum()
win = 20

mdd = rolling_max_dd(s, win, min_periods=1)

plt.plot(s, 'b')
plt.plot(mdd, 'g.')

plt.show()

测试表明,当时序s长度为1000时,rolling_max_dd的计算耗时为100𝜇S。

滑动窗口下,生成的mdd与原序列对照图如下:


该方法中,还简单地封装了一个将一维数组转换为滑动窗口视图的函数,可以在其它地方使用。

寻找自适应参数

很多基于技术指标的交易策略往往指定了固定的阈值。比如,一些人会在RSI 80以上做空,在RSI 20以下做多。即使是用在指数和行业板块上,这样的指标仍然不够精确,因为在上行通道中,RSI的顶点会高于下行通道中的RSI顶点;在下行通道中,RSI的底部则会比上行通道中的RSI底部低很多。


此外,不同的标的,RSI取值范围也不一样。不仅仅是RSI,许多技术指标都存在需要根据当前的市场环境和标的,采用自适应参数的情况。

其中一个方案是使用类似于布林带的方案,使用指标均值的标准差上下界。但这个方案隐含了技术指标均值的数据分布服从正态分布的条件。

我们可以放宽这个条件,改用分位数,即numpy的percentile来确定参数阈值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
%precision 2

from IPython.core.interactiveshell import InteractiveShell
InteractiveShell.ast_node_interactivity = "all"

np.random.seed(78)
s = np.random.randn(100)

hbound = np.percentile(s, 95)
lbound = np.percentile(s, 5)

s[s> hbound]
s[s< lbound]

通过percentile找出来超过上下界的数据,输出如下:

1
2
array([2.09, 2.27, 2.21, 2.12, 2.19])
array([-1.68, -2.4 , -1.97, -1.7 , -1.46])

一旦指标超过95%的分位数(hbound),我们就做空;一旦指标低于5%的分位数(lbound),我们就做多。

这里我们也可以使用中位数极值法。一旦指标超过中位数MAD值的3倍,就发出交易信号 。


《因子投资与机器学习策略》开课啦!


目标清晰 获得感强


为什么你值得QuanTide的课程?

[0929] QuanTide Weekly

本周要闻

  • 大涨!沪指本周大涨12.8%,沪深300上涨15.7%。
  • 首份市值管理指引文件出炉,明确指数成分股与破净股的市值管理
  • 长江证券:银行、地产、建筑和非银等板块或更有可能受益于破净公司估值提升计划

下周看点

  • 周一:财新发布9月PMI数据
  • OpenAI10月1日起举办2024年度DevDay活动
  • 周二(10月1日)至10月7日休市

本周精选

  • 连载!量化人必会的 Numpy 编程(5)

  • A股本周全线大涨。消息面上,周二国新办举行新闻发布会,介绍金融支持经济高质量发展有关情况。盘前降准、降低存量房贷利率提振市场信心,盘中,资本市场密集释放重磅利好:证监会研究制定“并购6条”,创设首期3000亿元股票回购增持再贷款,首期5000亿元规模证券基金保险公司互换便利操作,支持汇金公司等中长期资金入市,正在研究创设平准基金。 消息来源:东方财富
  • 财联社9月28日:近日,A股首份市值管理指引文件出炉,明确了指数成分股与破净股的市值管理具体要求。业内人士表示,市值管理新政策有助于被低估的优质资产进行重新定价,尤其是破净幅度较深但盈利能力稳定的央国企,可能存在价值重估空间,带来投资机遇。另有分析人士表示,而在破净的同时还具备高股息率的股票,更加值得投资者关注。
  • 央行公开市场7天期逆回购操作利率调整为1.5%。本周央行还对存准金下调了0.5%。

消息来源:财联社


Numpy量化应用案例[2]

动量和反转是最重要的量化因子。有很多种刻画动量的算法,但拟合直线的斜率无疑是最直观的一种。


在上面的图形中,如果两条直线对应着两支股票的均线走势,显然,你更愿意买入橘色的那一支,因为它的斜率更大,也就是涨起来更快一些。

人类的视觉有着强大的模式发现能力。我们很容易看出橙色点的上升趋势更强。但是,如果要让计算机也知道哪一组数据更好,则需要通过直线拟合,找出趋势线,再比较趋势线的斜率才能确定。直线拟合,或者曲线拟合(cureve fit),或者多项式拟合,都是线性回归问题。

在这一章中,我们就来讨论直线拟合的方法,并且要从最普通的实现讲到能提速百倍的向量化实现。

线性回归与最小二乘法

如何从一堆随机的数字中,发现其中可能隐藏的、最能反映它们趋势的直线呢?这个问题从大航海时代开始起就困扰着科学家们。

在那个时代,水手们需要通过星相来确定自己的纬度(经度则是通过日冕来计算的),这就需要基于人类的观测,准确地描述天体的行为。


在解决天体观测所引起的误差问题时,法国人阿德里安.玛丽.勒让德(Adrien Marie Legendre)最先发现了最小二乘法(1805年),此后,高斯在1809年发表的著作《天体运动论》中也提出了这一方法。最终,他在1829年,给出了最小二乘法是线性回归最佳拟合的理论证明。这一证明被称为高斯-马尔科夫定理。

Info

在勒让德与高斯之间,存在着一场最小二乘法发现权之争。这场争战一点也不亚于牛顿与莱布尼茨关于微积分发现权之争。不过,勒让德提出最小二乘法时,还只是一种猜想,其理论证明是由高斯完成的。勒让德是18世纪后期,与拉格朗日、拉普拉斯齐名的数学家,被称为三L(取自三个人的名字)。

所谓直线拟合(或者线性回归),就是要在由(x,y)构成的点集中,找到一条直线,使得所有点到该直线的距离的平方和最小。在Numpy中,我们最常用的方法是通过Polynomial.fit来进行拟合:

1
2
3
4
5
from numpy.polynomial import Polynomial
import matplotlib.pyplot as plt


x = np.linspace(0, 100, 100)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
rng = np.random.default_rng(seed = 78)
y = 0.05 * x + 2 + rng.normal(scale = 0.3, size=len(x))

# 绘制这些随机点
plt.scatter(np.arange(100), y, s=5)

# 最小二乘法拟合出趋势线
fitted = Polynomial.fit(x, y, deg=1, domain=[])
y_pred = fitted(x)
plt.plot(y_pred)

# 我们关注的斜率
b, a = fitted.coef
print("slope is {a:.3f}")

在很多教程上,我们看到在Numpy中进行多项式回归,使用的是polyfit。但实际上从1.4起,我们应该使用polynomial模块下的类和方法。polyfit被看成是过时的接口。要注意,尽管polynomial被设计为polyfit等函数的替代物,但在行为上有较大不同。

关于Polynomial.fit,需要注意我们传入了deg=1domain=[]两个参数。

指定deg=1,是因为我们希望将由(x,y)表示的点集拟合成为直线;如果希望拟合成为二次曲线,则可以指定deg=2,高次曲线依次类推。


domain是可以省略的一个参数。省略它之后,对我们后面通过fitted(x)求预测直线并没有影响,但是,它对我们要求的斜率(第19行)会有影响。如果在这里我们省略domain = []这个参数,我们得到的系数会是 [2.47, 4.57]。这将与我们造数据时使用的[0.05, 2]相去甚远。而当我们指定domain = []后,我们将看到拟合直线的系数变回为我们期望的[0.05, 2]。

Tip

我们也可以这样调用fit:

1
2
3
fitted = Polynomial.fit(x, y, deg=1, window=(min(x), max(x)))
intercept, slope = fitted.coef
print(f"slope is {slope:.3f}")

或者:

1
2
3
fitted =  Polynomial.fit(x, y, deg=1)
intercept, slope = fitted.convert().coef
print(f"slope is {slope:.3f}")

convert是一个对domain和window进行平移和缩放的转换函数。

线性回归是非常常用的技巧,在scipy, sklearn和statsmodels等库中都有类似的实现。


正因为如此,求价格或者均线的斜率因子,在技术上是一件轻而易举的事。

但是,如果我们要求移动线性回归呢?这可能就需要一点技巧了。

移动线性回归

移动线性回归是指对一个时间序列\(T\)和滑动窗口\(win\),计算出另一个时间序列\(S\),使得

\[ S_i = Slope(T_{[i-win+1, i-win+2, ..., i]}) \]

最简单直接的实现是通过一个循环:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from numpy.polynomial import Polynomial
import matplotlib.pyplot as plt


x = np.linspace(0, 100, 100)
y = np.sin(x/10) + x/10

# 曲线拐点
flags = np.sign(np.diff(np.diff(y)))
pivots = np.argwhere(flags[1:] != flags[:-1]).flatten()

plt.plot(y)

win = 10
S = []

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
for i in range(win, len(y)):
    xi = x[i-win:i]
    yi = y[i-win:i]
    fitted = Polynomial.fit(xi, yi, deg=1, domain=[])
    S.append(fitted.coef[1])

    if i in pivots:
        xj = x[i-win-10:i+10]
        y_pred = fitted(xj)
        plt.plot(xj, y_pred, '--')

print(S)

这段代码计算了从第10个周期起,过去10个点的拟合直线的斜率,并且在曲线的转折点上,我们绘制了它的切线。这些切线,正是通过拟合直线的参数来生成的。


在这里,我们生成曲线的方法是使用了方程x + sin(x)。你会发现,这样生成的曲线,有几分神似股价的波动。这并不奇怪,股价的波动本来就应该可以分解成为一个直流分量与许多正弦波的叠加。直流分量反映了公司的持续经营能力,正弦波则反映了各路短炒资金在该标的上操作留下的痕迹。

回到我们的正题来。现在,我们去掉绘图功能,测试一下这段代码需要执行多长时间:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
x = np.linspace(0, 100, 100)
y = np.sin(x/10) + x/10

def moving_lsq(ts, win: int):
    x = np.arange(len(ts))
    S = []
    for i in range(win, len(ts)):
        xi = x[i-win:i]
        yi = ts[i-win:i]
        fitted = Polynomial.fit(xi, yi, deg=1, domain=[])
        S.append(fitted.coef[1])
    return S

%timeit moving_lsq(y, 10)

90次循环,总共用去25ms的时间。考虑到A股有超过5000支股票,因此全部计算一次,这将会超过2分钟。


要加速这一求解过程,我们必须借助向量化。但是,这一次,再也没有魔法一样的API可以调用,我们必须挽起袖子,从理解底层的数学原理开始,做出自己的实现。

向量化

考虑到一个有m个点的线性回归,对其中的每一个点,都会有:

如果所有的点都落在同一条直线上,那么意味着以下矩阵方程成立:

\[ Y = A\beta + b \]

这里\(A\)即为X,\(\beta\)为要求解的系数:

\[ \beta = {(A^TA)}^{-1}A^TY \]

关于公式推导,可以见《Python programming and Numerical Methods - A Guide for Engineers and Scientists》

我们可以手动来验证一下这个公式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
x = np.linspace(0, 9, 10)
y = x + np.sin(x/10)

win = 10

A = np.arange(win).reshape((win,1))
pinv = np.dot(np.linalg.inv(np.dot(A.T, A)), A.T)
alpha_1 = np.dot(pinv, y[:, np.newaxis]).item()
alpha_2 = Polynomial.fit(x, y, deg=1, domain=[]).coef[1]

np.isclose(alpha_1, alpha_2, atol=1e-2)

我们用两种方法分别求解了斜率,结果表明,在1e-2的绝对误差约束下,两者是相同的。

注意在第7行中, 我们使用了pinv这个奇怪的变量名。这是因为,在numpy.linalg中存在一个同名函数,正好就是计算\({(A^TA)}^{-1}A^T\)的。

不过,到现在为止,我们仅仅是完成了Polynomial.fit所做的事情。如果我们要一次性计算出所有滑动窗口下的拟合直线斜率,究竟要如何做呢?


注意,我们计算斜率,是通过一个矩阵乘法来实现的。在Numpy中,矩阵乘法天然是向量化的。在第7行中,pinv是一个(1,10)的矩阵,而y则是一个(10,)的向量。如果我们能把滑动窗口下,各组对应的pinv堆叠起来,并且y也堆叠起来成为一个矩阵,那么就能通过矩阵乘法,一次性计算出所有的斜率。

我们先从y看起。当我们对y = np.arange(5)按窗口为3进行滑动时,我们实际上是得到了这样一个矩阵:

\[ \begin{bmatrix}0&1&2\\1&2&3\\2&3&4\\\end{bmatrix} \]

要得到这个矩阵,我们可以使用fancy index:

1
2
3
4
5
y[
    [0, 1, 2],
    [1, 2, 3],
    [2, 3, 4]
]

因此,我们要实现滑动窗口下的y的矩阵,只需要构建出这个fancy index矩阵即可。好消息是,fancy index矩阵非常有规律:


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def extract_windows_vectorized(array, win:int):
    start = 0
    max = len(array) - win + 1

    sub_windows = (
        start +
        # expand_dims are used to convert a 1D array to 2D array.
        np.expand_dims(np.arange(win), 0) +
        np.expand_dims(np.arange(max), 0).T
    )

    return array[sub_windows]

arr_1d = np.arange(10, 20)

extract_windows_vectorized(arr_1d, 4)

如果你觉得这个方法难以理解的话,Numpy已经为我们提供了一个名为as_strided的函数,可以一步到位,实现我们要的功能,并且比上述方法更快(1倍):

1
2
3
4
5
6
7
8
9
from numpy.lib.stride_tricks import as_strided

y = np.arange(3, 10)
stride = y.strdies[0]

win = 4
shape = (len(y) - win + 1, win)
strides = (stride, stride)
as_strided(y, shape, strides)

矩阵pinv由x产生。如果时间序列y有100个周期长,那么x的值将会是从0到99。


其中\([x0, x1, ..., x_{win-1}]\)对应\([y0, y1, ..., y_{win-1}]\), \([x1, x2, ..., x_{win}]\)对应\([y1, y2, ..., y_{win}]\)\([x_{-win}, x_{-win+1}, ... x_{-1}]\)对应\([y_{-win}, y_{-win+1}, ..., y_{-1}]\)

因此,我们需要构照的系数矩阵\(A\)即:

1
2
A = as_strided(np.arange(len(y)), shape, strides)
pinv = np.linalg.pinv(A)

接下来的回归运算跟之前一样:

1
alpha = pinv.dot(y).sum(axis = 0)

完整的代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def moving_lsq_vector(ts, win:int):
    stride = ts.strides[0]

    strides = (stride, stride)
    shape = (win,len(ts)-win+1)
    A = as_strided(np.arange(len(ts)), shape= shape, strides=strides)
    pinv = np.linalg.pinv(A)
    y = as_strided(ts, shape=shape, strides = strides)

    return pinv.dot(y).sum(axis=0)

这一版本,比之前使用Polynomial.fit加循环的快了100倍。你可能猜到了,这个100倍近似于我们循环的次数。这就是循环的代价。


现在,运用这里的方法,我们还可以加速别的计算吗?答案是显然的。如果你要快速计算5000支股票过去10天的的5日平均,在获取这些股票最近14天的股价之后,组成了一个(5000, 14)的矩阵\(A\)。现在,我们要做的就是将这个矩阵转换成为3维矩阵,然后再与一个卷积核做乘法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from numpy.typing import NDArray
def batch_move_mean(A: NDArray, win:int)->NDArray:
    """批量计算移动平均线

    Args:
        A: (m*n)的价格矩阵。m为股票支数
        win: 移动平均窗口
    Returns:
        (m * (n-win+1))的矩阵.
    """
    kernel = np.ones(win)/win

    s0, s1 = prices.strides
    m, n = prices.shape

    pm = as_strided(prices, shape=(m, n-win + 1, win), strides=(s0, s1, s1))
    return np.dot(pm, kernel.T).squeeze()

我们通过下面的代码来测试它:

1
2
3
4
5
6
prices = np.array([
    np.arange(0, 14),
    np.arange(10, 24)
])

batch_move_mean(prices, 5)

输出为:

1
2
array([[ 2.,  3.,  4.,  5.,  6.,  7.,  8.,  9., 10., 11.],
       [12., 13., 14., 15., 16., 17., 18., 19., 20., 21.]])

正如我们期待的一样。但快如闪电。

题外话

要不要使用拟合趋势线作为一种动量因子?这是一个值得深入讨论的话题。斜率因子最大的问题,不是所有的时间序列,都有明显的趋势。从数学上看,如果拟合残差较大,说明该时间序列还没有形成明显的趋势,那么斜率因子就不应该投入使用。另一个问题就是线性回归的老问题,即个别outlier对拟合的影响较大。拟合直线究竟是应该使得所有点到该直线的距离和最小,还是应该使得大多数点到该直线的距离和更小(小于前者)?

结论

我们讨论了如何通过numpy来进行线性回归(直线拟合),并且介绍了最新的polynomial API。然后我们介绍了如何利用矩阵运算来实现向量化。


核心点是通过as_strided方法将数组升维,再对升维后的数组执行矩阵运算,以实现向量化。我们还用同样的思路,解决了一次性求5000支股票的移动均线问题。您还看到了像fancy index的实用性举例。这一章技巧较多,算是对前面章节的小结。


《因子投资与机器学习策略》开课啦!


目标清晰 获得感强


为什么你值得QuanTide的课程?

[0922] QuanTide Weekly

本周要闻

  • 何立峰会见中美经济工作组美方代表团
  • 公安机关严查资本市场“小作文”,三名造谣者被罚
  • 证监会全面优化券商风控指标体系
  • 美“生物安全法案”未被纳入参议院2025财年国防授权法案
  • 贵州茅台:拟以30亿元-60亿元回购股份用于注销,上市以来首次发布回购计划

下周看点

  • 中证A500指数周一发布。此前传相关基金提前结束募集
  • 2024深圳eVTOL暨低空经济展开幕,首届中国数字人大会在京举办
  • 周三、周五,再迎ETF及A50交割日!

本周精选

  • 连载!量化人必会的 Numpy 编程 (4)

  • 中美经济工作组9月19日至20日在京会晤。此次会议由财政部副部长廖岷与美国副财长尚博共同主持,两国经济领域相关部门到会交流。何立峰20日会见美国财政部副部长尚博一行。
  • 公安机关近日依法查处一起自媒体运营人员恶意编造网络谣言进行吸粉引流、非法牟利、扰乱社会秩序的案件。经公安部网安局调查,刘某(男,36岁)、陈某(男,46岁)、邵某(男,26岁)为博取关注、吸粉引流、谋取利益,故意编造发布涉转融通谣言信息,误导公众认知,涉嫌扰乱金融秩序。
  • 证监会发布《证券公司风险控制指标计算标准规定》。业内人士指出,此举预计将释放近千亿元资金,促进有效提升资本使用效率,加大服务实体经济和居民财富管理力度。
  • 美当地时间19日,美参议院军事委员会对外公布了参议院版本2025财年国防授权法案(简称NDAA),其中纳入93项修正案,但不包含“生物安全法案”相关提案。此前,美众议院已通过不包含生物安全法案的NDAA,下一步美参众两院将对NDAA进行谈判合并。此前受相关传闻影响,CXO板块持续受到压制。
  • 美股三大指数周五收盘涨跌不一,均录得周线两连涨。道指续创新高,本周累涨1.61%;标普累涨1.36%;纳指累涨1.49%。

消息来源:财联社


Numpy量化场景应用案例[1]

连续值统计

在很多量化场景下,我们都需要统计某个事件连续发生了多少次,比如,连续涨跌停、N连阳、计算Connor's RSI中的streaks等等。比如,要判断下列收盘价中,最大的连续涨停次数是多少?最长的N连阳数是多少?

1
2
a = [15.28, 16.81, 18.49, 20.34, 21.2, 20.5, 
     22.37, 24.61, 27.07, 29.78, 32.76, 36.04]

假设我们以10%的涨幅为限,则可以将上述数组转换为:

1
2
pct = np.diff(a) / a[:-1]
pct > 0.1

我们将得到以下数组:

1
2
flags = [True, False,  True, False, False, False,  True, False,  True,
        True,  True]

这仍然不能计算出最大连续涨停次数,但它是很多此类问题的一个基本数据结构,我们将原始的数据按条件转换成类似的数组之后,就可以使用下面的神器了:


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
from numpy.typing import ArrayLike
from typing import Tuple
import numpy as np

def find_runs(x: ArrayLike) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Find runs of consecutive items in an array.

    Args:
        x: the sequence to find runs in

    Returns:
        A tuple of unique values, start indices, and length of runs
    """

    # ensure array
    x = np.asanyarray(x)
    if x.ndim != 1:
        raise ValueError("only 1D array supported")
    n = x.shape[0]

    # handle empty array
    if n == 0:
        return np.array([]), np.array([]), np.array([])

    else:
        # find run starts
        loc_run_start = np.empty(n, dtype=bool)
        loc_run_start[0] = True
        np.not_equal(x[:-1], x[1:], out=loc_run_start[1:])
        run_starts = np.nonzero(loc_run_start)[0]

        # find run values
        run_values = x[loc_run_start]

        # find run lengths
        run_lengths = np.diff(np.append(run_starts, n))

        return run_values, run_starts, run_lengths


pct = np.diff(a) / a[:-1]
v,s,l = find_runs(pct > 0.099)
(v, s, l)

输出结果为:

(array([ True, False, True]), array([0, 3, 6]), array([3, 3, 5]))

输出结果是一个由三个数组组成的元组,分别表示:


  • value: unique values
  • start: start indices
  • length: length of runs

在上面的输出中,v[0]为True,表示这是一系列涨停的开始,s[0]则是对应的起始位置,此时索引为0; l[0]则表示该连续的涨停次数为3次。同样,我们可以知道,原始数组中,最长连续涨停(v[2])次数为5(l[2]),从索引6(s[2])开始起。

所以,要找出原始序列中的最大连续涨停次数,只需要找到l中的最大值即可。但要解决这个问题依然有一点技巧,我们需要使用第4章中介绍的 mask array。

1
2
3
4
v_ma = np.ma.array(v, mask = ~v)
pos = np.argmax(v_ma * l)

print(f"最大连续涨停次数{l[pos]},从索引{s[pos]}:{a[s[pos]]}开始。")

在这里,mask array的作用是,既不让 v == False 的数据参与计算(后面的 v_ma * l),又保留这些元素的次序(索引)不变,以便后面我们调用 argmax 函数时,找到的索引跟v, s, l中的对应位置是一致的。

我们创建的v_ma是一个mask array,它的值为:

1
2
3
masked_array(data=[True, --, True],
             mask=[False,  True, False],
       fill_value=True)

当它与另一个整数数组相乘时,True就转化为数字1,这样相乘的结果也仍然是一个mask array:


1
2
3
masked_array(data=[3, --, 5],
             mask=[False,  True, False],
       fill_value=True)

当arg_max作用在mask array时,它会忽略掉mask为True的元素,但保留它们的位置,因此,最终pos的结果为2,对应的 v,s,l中的元素值分别为: True, 6, 5。

如果要统计最长N连涨呢?这是一个比寻找涨停更容易的任务。不过,这一次,我们将不使用mask array来实现:

1
2
3
4
v,s,l = find_runs(np.diff(a) > 0)

pos = np.argmax(v * l)
print(f"最长N连涨次数{l[pos]},从索引{s[pos]}:{a[s[pos]]}开始。")

输出结果是: 最长N连涨次数6,从索引5:20.5开始。

这里的关键是,当Numpy执行乘法时,True会被当成数字1,而False会被当成数字0,于是,乘法结果自然消除了没有连续上涨的部分,从而不干扰argmax的计算。

当然,使用mask array可能在语义上更清楚一些,尽管mask array的速度会慢一点,但正确和易懂常常更重要。

计算 Connor's RSI中的streaks

Connor's RSI(Connor's Relative Strength Index)是一种技术分析指标,它是由Nirvana Systems开发的一种改进版的相对强弱指数(RSI)。Connor's RSI与传统RSI的主要区别在于它考虑了价格连续上涨或下跌的天数,也就是所谓的“连胜”(winning streaks)和“连败”(losing streaks)。这种考虑使得Connor's RSI能够更好地反映市场趋势的强度。


在前面介绍了find_runs函数之后,计算streaks就变得非常简单了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def streaks(close):
    result = []
    conds = [close[1:]>close[:-1], close[1:]<close[:-1]]

    flags = np.select(conds, [1, -1], 0)
    v, _, l = find_runs(flags)
    for i in range(len(v)):
        if v[i] == 0:
            result.extend([0] * l[i])
        else:
            result.extend([v[i] * x for x in range(1, (l[i] + 1))])

    return np.insert(result, 0, 0)

这段代码首先将股价序列划分为上涨、下跌和平盘三个子系列,然后对每个子系列计算连续上涨或下跌的天数,并将结果合并成一个新的数组。在streaks中,连续上涨天数要用正数表示,连续下跌天数用负数表示,所以在第5行中,通过np.select将条件数组转换为[1, 0, -1]的序列,后面使用乘法就能得到正确的连续上涨(下跌)天数了。

中位数极值法去极值

在因子分析中,我们常常需要对数据进行去极值处理,以减少对异常值的影响。中位数极值法(Median Absolute Deviation,MAD)是一种常用的去极值方法,它通过计算数据中中位数(median)和绝对离差(absolute deviation)来确定异常值。

这里需要先介绍绝对中位差(median absolute deviation) 的概念:

\[MAD = median(|X_i - median(X)|)\]

为了能 MAD 当成与标准差\(\sigma\)估计相一致的估计量,即


\[\hat{\sigma} = k. MAD\]

这里 k 为比例因子常量,如果分布是正态分布,可以计算出: $$ k = \frac{1}{(\Phi^{-1}(\frac{3}{4}))} \approx 1.4826 $$

基于这个 k 值,取 3 倍则近似于 5。

在对多个资产同时执行去极值时,我们可以使用下面的方法,以实现向量化并行操作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def mad_clip(df: Union[NDArray, pd.DataFrame], k: int = 3, axis=1):
    """使用 MAD 3 倍截断法去极值

    Args:
        df: 输入数据,要求索引为日期,资产名为列,单元格值为因子的宽表
        k: 截断倍数。
        axis: 截断方向
    """

    med = np.median(df, axis=axis).reshape(df.shape[0], -1)
    mad = np.median(np.abs(df - med), axis=axis)

    return np.clip(df.T, med.flatten() - k * 1.4826 * mad,
                   med.flatten() + k * mad).T


《因子投资与机器学习策略》开课啦!


目标清晰 获得感强


为什么你值得QuanTide的课程?