跳转至




2024

为了机器能学习,我标注了 2 万条行情数据

芝加哥大学海德公园。芝大是经济学重镇,其学者开创了著名的芝加哥经济学派,共产生了 100 位诺奖、10 位菲尔兹奖、4 位图灵奖。今天量化人追逐的 Alpha, 最早就来自于 Michael Jessen 在芝大时的博士论文。


很多人对基于机器学习的量化策略很好奇,常常问什么时候有机器学习的课。其实,对很多人(我自己就是)来说,没有能力改进机器学习的算法和框架,机器学习都是作为黑盒子来学习,难度主要是卡在训练数据上。

这篇文章,将介绍一种数据标注方法和工具。

有监督的机器学习需要标注数据。标注数据一般是一个二维矩阵,其中一列是标签(一般记为 y),其它列是特征(一般记为 X)。训练的过程就是:

$$

fit(x) = WX -> y' \approx y

$$

训练就是通过反向传播来调整权重矩阵\(W\),使之得到的\(y'\)最接近于\(y\)

特征矩阵并不困难。它可以是因子在某个时间点上的取值。但如何标注是一个难题。它实际上反应的是,你如何理解因子与标签之间的逻辑关系:因子究竟是能预测标的未来的价格呢,还是可以预测它未来价格的走势?

应该如何标注数据

前几年有一篇比较火的论文,使用 LSTM 来预测股价。我了解到的一些人工智能与金融结合的硕士专业,还把类似的题目布置给学生练习。


作为练习题无可厚非,但也应该讲清楚,使用 LSTM 来预测股价的荒谬之处:你无法利用充满噪声的时序金融数据,从价格直接推导出下一个价格。

坊间还流传另一个方法,既然数据与标签之间不是逻辑回归的关系,那么我们把标签离散化,使之转换成为一个分类问题。比如,按第二天的涨跌,大于 3%的,归类为大幅上涨;涨跌在 1%到 3%的,归类为小幅上涨。在-1%到 1%的,归类为方向不明。

其实这种方法背后的逻辑仍然是逻辑回归。而且,为什么上涨 2.99%是小幅上涨,上涨 3%就是大幅上涨呢?有人就提出改进方法,在每个类之间加上 gap,即 [-0.5%, 0.5%] 为方向不明,[1%,3%] 为小幅上涨,而处在 [0.5%, 1%] 之间的数据就丢掉,不进行训练。这些技巧在其它领域有时候是有效的,但在量化领域,我认为它仍然不够好。因为原理不对。

我们应该回归问题的本质。要判断每一天的涨跌,其实是有难度的。但如果要判断一段趋势是否结束,则相对来讲,特征会多一点,偶然性会低一点。用数学语言来讲,我们可以把一段 k 线中的顶点标注为 1,底部标注为-1,中间的部分都标注为 0。每一个峰都会有一个谷对应,但中间的点会显著多一些,数据分类不够平衡。在训练时,要做到数据分类平衡,把标签为 0 的部分少取一点即可。


顶底数据的标注

鉴于上面的思考,我做了一个小工具,用来标注行情数据的顶和底。

这个工具要实现的任务是:

  1. 加载一段行情数据,绘制 k 线图
  2. 自动识别这段 k 线中的的顶和底,并在图上标记出来
  3. 把这些顶和底的时间提取出来,放到峰和谷两个编辑框中,供人工纠错
  4. 数据校准后,点击“记录 > 下一组"来标注下一段数据

我们使用 zigzag 库来自动寻找 k 线中的顶和底。相比 scipy.signals 包中的 argrelextrema 和 find_peaks 等方法,zigzag 库中的 peaks_valleys_pivot 方法更适合股价数据 -- 像 find_peaks 这样的方法,要求的数据质量太高了,金融数据的噪声远远超过它的期待。

peaks_valleys_pivot 会自动把首尾的部分也标记成为峰或者谷 -- 这在很多时候会是错误的 -- 因为行情还没走完,尾部的标记还没有固定下来。因此,我们需要手动移除这部分标记。此外,偶尔会发现峰谷标记太密的情况 -- 一般是由于股价波动太厉害,但如果很快得到修复,我们也可以不标记这一部分。这也需要我们手动移除。

最终,我们将行情数据的 OHLC、成交量等数据与顶底标记一起保存起来。最终,我们将得到类似下面的数据:

当然,它只能作为我们训练数据的一个底稿。我们说过,不能直接使用价格数据作为训练数据。我们必须从中提取特征。显然,像 RSI 这样的反转类指标是比较好的特征。


另外,冲高回落、均线切线斜率变化(由正转负意味着见顶,反之意味着见底)、两次冲击高点不过、k 线 pattern 中的早晨之星、黄昏之星(如果你将它们的 k 线进行 resample, 实际上它是一个冲高回落过程,或者说长上影、长下影)等等都是有一定指示性的特征。

标注工具构建方法

Tip

这里我们介绍的是 jupyter 的 ipywidgets 来构建界面的方法。此外,Plotly Dash, streamlit, H2O wave 也是主要为此目标设计的工具。

为了在 notebook 中使用界面元素,我们需要先导入相关的控件:

1
2
3
from ipywidgets import Button, HBox, VBox, Textarea, Layout,Output, Box

from IPython.display import display

在一个单元格中,如果最后的输出是一个对象,那么 notebook 将会直接显示这个对象。如果我们要在一个单元格中显示多个对象,或者,在中间的代码中要显示一些对象,就需要用到 display 这个方法。这是我们上面代码引入 display 的原因。

这里我们引入了 HBox, VBox 和 Box 三个容器类控件,Button, TextArea 这样的功能性控件。


Layout 用来指定控件的样式,比如要指定一个峰值时刻输入框的宽度和高度:

1
2
3
4
5
6
peaks_box = Textarea(
    value='',
    placeholder='请输入峰值时间,每行一个',
    description='峰值时间',
    layout=Layout(width='40%',height='100px')
)

按钮类控件一般需要指定点击时执行的动作,我们通过 on_click 方法,将点击事件和一个事件处理方法相绑定:

1
2
3
4
5
6
7
8
save_button = Button(
    description='存盘'
)
save_button.on_click(save)

def save(c):
    # SAVE DATA TO DISK
    pass
这里要注意的是,事件响应函数(比如这里的 save),在函数签名上一定要带一个参数。否则,当按钮被点击时,事件就无法传导到这个函数中来,并且不会有任何错误提示。

HBox, VBox 用来将子控件按行、列进行排列。比如:

1
2
3
4
5
# K 线图的父容器
figbox = Box(layout=Layout(width="100%"))
inputs = HBox((peaks_box, valleys_box))
buttons = HBox((backward_button, keep_button, save_button, info))
display(VBox((buttons, inputs, figbox)))

Output 控件是比较特殊的一个控件。如果我们在事件响应函数中进行了打印,这些打印是无法像其它单元格中的打印那样,直接输出在单元格下方的。我们必须定义一个 Output 控制,打印的消息将会捕获,并显示在 Output 控件的显示区域中。

1
2
3
4
5
6
7
info = Output(layout=Layout(width="40%"))

def save(c):
    global info
    # DO THE SAVE JOB
    with info:
        print("数据已保存到磁盘!")

与此类似,plotly 绘制的 k 线图,也不能直接显示。我们要通过 go.FigureWidget 来显示 k 线图。

1
2
3
4
5
import plotly.graph_objects as go

figure = ... # draw the candlestick with bars
fig = go.FigureWidget(figure)
figbox.children = (fig, )

我们特别给出这段代码,是要展示更换 k 线图的方法。在初始化时,我们就必须把 figbox 与其它控件一起安排好,但如何更新 figbox 的内容呢?

答案是,让 figbox 成为一个容器,而 go.FigureWidget 成为它的一个子控件。每次要更新 k 线图时,我们生成一个新的 fig 对象,通过figbox.children = (fig, )来替换它。


最后,谈一点 troubleshooting 的方法。所有通过 on_click 方法绑定的事件函数,即使在运行中出了错,也不会有任何提示。因此,我们需要自己捕获错误,再通过 Output 控件来显示错误堆栈:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def log(msg):
    global info

    info.clear_output()
    with info:
        if isinstance(msg, Exception):
            traceback.print_exc(msg)
        else:
            print(msg)

def on_save(b):
    try:
        # DO SOMETHING MAY CAUSE CHAOS
        raise ValueError()
    except Exception as e:
        log(e)

info = Output(layout = Layout(...))
save_button = Button()
save_button.on_click(on_save)

利用这款工具,大概花了两小时,最终我得到了2万条数据,其中顶底标签约1600个。

月亮和Pandas - Wes Mckinney的传奇故事

正如死亡和税收不可避免,Pandas对量化人而言,也具有同样的地位 -- 每个人都不可避免地要与之打交道。而Wes Mckinney正是Pandas的创建者。Pandas是有史以来,最成功的Python库之一,以一已之力,开拓了Python的生存空间。


毛姆的《月亮和六便士》讲述了这样一个故事,一个富有的英国股票经纪人,思特里克兰德,为了追求艺术和美,放弃自己的生活和家庭,来到巴黎,以艺术家的身份过着贫穷却目中无人的充足的生活,最终留下无数画作,享誉世界。

这本书表达了这样的意义,不要因终日寻找地上的六便士,而错过了头顶的月亮。金融和量化界充满着浮躁和虚荣,许多人追逐着快钱,然而easy come, easy go,最终却是一无所获。

20世纪初的金融人思特里克兰德,勘破了俗世红尘,转而潜心研究艺术,尽管后半生在物质上不如之前安逸,但能做自己喜欢的事,才是幸福

我们今天要介绍的Wes Mckinney,其经历也与思特里克兰德有异曲同工之处。不过,Wes Mckinney在经济上只有很小一段时间没有收入,随后很快就取得了事业上的成功。


Wes Mckinney, 大学毕业于MIT,后来拿到了杜克大学的数学和统计学两个博士学位。

他于2007年加入投资管理公司AQR。这是一家巨头公司,员工人数有1000人左右,管理规模达到了1400多亿美元。他们为机构客户提供另类和传统投资工具。

在AQR任职期间,大量的基础数据分析和统计工作是基于Microsoft Excel 手工完成的,这种工作极其繁琐和低效率。于是,Wes Mckinney从2008年开始,尝试开发Pandas,并选中了Python作为开发语言。

来源:pandas logo

在当时Python还不是一种很流行的数据分析和统计语言,不过Wes Mckinney很快就让它流行开来,Pandas在某种意义上,成为了“立王者”的角色 -- 当然Python最终取得今天这般地位,还要借助AI的风力,才能够青云直上。


Pandas非常成功,但带来的问题是,Wes Mckinney的业余时间已经不能满足开发的需求。于是,他像思特里克兰德一样,离开了金融行业(并且退学。似乎每一个成功的美国创业者,都必须有dropout的经历),专心一意开发Pandas。

Pandas是开源产品,最初并不能提供任何收入,Wes Mckinney靠着之前工作的丰厚收入,以及兼职来养活自己。这期间,他还出版了一本名为《Python for Data Analysis》的书,获得了一些稿费。

随着Pandas的成功,第二年,Wes Mckinney就从AQR招募了两名同事,与他一起探索Python和Pandas在金融行业的创业机会,随后, 他和Chang一起创建了DataPad公司。

现在, Pandas由超过2500名贡献者共同维护。 Wes Mckinney不再参与日常的pandas开发。但社区授予他终身仁慈独裁者(BDFL)的荣誉,拥有对Pandas未来发展的决定权,就像Guido之于Python一样。

2016年,Wes Mckinney与他人一起共同创建了Apache Arrow项目,现在Arrow是跨编程语言共享科学数据的主要格式。Arrow仍然是开源的项目,但Wes Mckinney提出一个非营利行业联盟的想法,成功地吸引到Two Sigma(就是大家熟悉的量化投资巨头)作为赞助商,此后,又吸引到NVidia, Intel, bloomberg等大公司的赞助,从而能够支持一个有六名全职开发人员的分布式团队。


从AQR开始,到主要以推广开源数据分析和数据科学工具作为职业生涯的主题,Wes Mckinney 经历了华丽的转身。他现在是Usra Labs的负责人,利用他的声誉,为Usra Labs获得资金,以促使开源和创新持续向前发展。

与坐在办公室里日复一日地数着绿纸相比,他显然更享受现在在田纳西纳什维尔的生活,因为他的目标是为这世界增添一份美好:do as much good in the world as I can。

Wes Mckinney的经历是一个传奇,他的成功也为现在想要进入金融行业的新人提供了一个范例:并不一定亲自下场做为金钱厮杀的角斗士,解决行业在软件方面的痛点,也许更容易成功。这世界并不缺少量化研究员,但Pandas和Arrow的创建者,永远只有一个。

如果你盯着月亮看,最终至少也能获得满天的清辉,它的光芒,远超六便士。

最后是一点来自 Wes Mckinney的小福利。

如果要推荐一本讲解Pandas的书,毫无疑问,没人任何书籍能比《Python for Data Analysis》更权威了。因为它是由Pandas的创建者 Wes McKinney 撰写的!这本书现在提供有网页版供开放访问。读者也可点击此链接阅读。在2023年4月进行更新后,它现在支持到了pandas 2.0版本。


75%

1赔10!中证1000应该这样抄底

3月28日那篇文章分析了前一日的下跌为什么是可能预见的。这一篇文章,我将用坚实的统计数据,说明这一天为什么应该抄底,预期的损益比又是多少。

抄底不是贪婪。抄底是拿先手,以降低风险。在至暗时刻,请铭记,没有最终的成功,也没有致命的失败,最可贵的是继续前进的勇气!


赔率与收益期望计算

3月27日,沪指下跌1.26%,中证1000下跌3.33%,亏钱效应明显。在第14课中,讨论过这样一个问题,沪指下跌4%时,此时抄底成功的概率有多高?我们借这个问题,讨论了PDF/CDF的概念,并且给出了通过statsmodels中的ECDF、numpy/scipy中的相关方法求解的思路。

但在现实中,可能更有实际意义的问题是当A股连续下跌到x%时,此时抄底,预期盈亏比,也就是所谓的赔率会有多大?。刚好,在今年的3月27日,我们就遇到了中证1000收盘时,连续下跌达6.943%的情况,随后连续反弹,上涨达5.5%,收益相当可观。

要解决这个问题所需要的知识,我们在前面的课程中已经做好了所有的知识铺垫。这里面最重要的方法,是在第9课介绍的find_runs。

它的作用是,将数组划分为若干个具有相同连续值的分组,返回这些分组的值、起点和长度。

有了这个方法,我们就可以根据每日收益率,找出每个连续下跌区间的端点,并计算该区间的pnl:

1
2
returns = bars.close.pct_change()
v, s, l = find_runs(returns <= 0)

returns数组是每日收益率。通过returns <= 0表达式,我们将获得一个二值数组,元素为True的部分,意味着当天没有上涨(下跌或者平盘)。

我们将得到如下结果:

结果将是一个三元组的数组,每一个元素由(v, s, l)组成,分别代表当前分组的值、起始位置和长度。

接下来,我们遍历这个数组,根据s和l找到区间的起始点和结束点,再用两个端点的收盘价就可以计算出区间的涨跌。下面的代码中,我们只计算了下跌区间:

1
2
3
4
5
6
7
8
close = bars.close
cum_neg_returns = []
for vi, si, li in zip(v, s, l):
    if vi and li > 1:
        cum_neg_returns.append((bars.frame[si-1], bars.frame[si + li - 1], close[si + li - 1]/close[si-1] - 1))

r = pd.DataFrame(cum_neg_returns, columns=["start", "end", "cnr"])
r

我们得到了116个结果:

50%

在3月27日收盘时,我们所处的位置对应上表中的第114行,也就是从3月20日起到27日,中证1000发生连续下跌,幅度为6.94%。

那么,继续下跌的概率是多少呢?它相当于(s < x).sum()/len(s),我们换一个方法:

1
2
p_decline = r.cnr.le(-0.06943).mean()
p_decline

输出结果将是0.0862,也就是从过去4年的统计数据来看,连续下跌超(含)2天的共有116次。在这116次中,如果收盘跌幅达到6.943%,则继续下跌的概率为8.62%,也就是将有91.38%的概率反弹。

如果不反弹,我们就要蒙受抄底的损失。这个损失的期望是多少呢?我们统计一下,下跌6.94%后,继续下跌的情形是:

1
r[r.cnv < -0.0694]

50%

其预期是:

1
2
3
4
# 抄底失败的亏损预期

exp_lose = (r[r.cnr<-0.06943].mean() - (-0.0694))
exp_lose

抄底失败,也就是继续下跌的情形共有10次,平均损失是-10%左右,考虑到我们是在-6.94%时才抄的底,因此,我们将蒙受3.48%左右的损失。请记住,发生这种情况的概率只有8.62%。

如果反弹,我们将得到多少收益呢?这个计算略困难一点。在3月27日之后,反弹达到了5.5%。我们只有这样一个采样数据,但不能保证每次都会有这么大的反弹。

我们考虑一个保守一点的情况,即把下跌[-5%, -6.943%]间的所有反弹都当成下跌-6.943%以后的反弹。显然,在快速下跌中,下跌越多,反弹就越强,我们这样估算,肯定要比每次下跌-6.94%之后的反弹要低估了。不过,这样也使得我们的模型具有了额外的安全边际。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 抄底时间为bounced
bounced = [f.date() for f in (r[(r.cnr >= -0.0695) & (r.cnr<-0.05)]["end"])]

# 抄底收益为bounced之后,连接上涨的收益

cum_pos_returns = []
for vi, si, li in zip(v, s, l):
    end = tf.day_shift(bars.frame[si], -1)
    if end in bounced:
        cum_pos_returns.append((bars.frame[si-1], bars.frame[si + li - 1], close[si + li - 1]/close[si-1] - 1))
1
2
profit = pd.DataFrame(cum_pos_returns, columns=["start", "end", "profit"])
profit

上述代码先找出反弹的时间,然后遍历之前得到的三元组,如果起始点的前一天是某个连续下跌区间的结束日,我们就计算这一组的收益。最终我们得到的收益预期是:

1
2
3
4
# 抄底的收益期望是

exp_win = profit.profit.mean()
exp_win

这个预期是3.299%。看上去与抄底失败的预期损失差不多,但是,计算赔率时,我们要加上实现概率:

\[ exp\_win * 91.38\% / (exp\_lose * 8.62\%) = 10.03\% \]

最后,我们计算抄底的总体期望为:

\[ exp\_win * 91.38\% + exp\_lose * 8.62\% = 2.71\% \]

如果是DMA,加上4倍杠杆,一次获利将在10%以上。

风浪越大鱼越贵?

几年以前,我的合伙人推荐我看塔力布的《黑天鹅》一书。说实在的,这本书前1/4可以说是封神,但看到后面,感觉有点难以落到实处,无法应用。

图片来自电影《黑天鹅》官宣剧照

题外话: 我更喜欢看娜塔莉.波特曼主演的《黑天鹅》。

不过,我从来没有停止对《黑天鹅》事件的思考。我想,换一个角度,关注黑天鹅事件,但把黑天鹅看成是一种低概率事件来加以研究,也未尝不可。虽然脱了《黑天鹅》一书的窠臼,但好歹也有了一个切入点。

但是,可能有人要问,为什么要去接下跌的飞刀?平平稳稳赚钱不好吗?这是因为,在这个市场,本来就缺少平平稳稳赚钱的机会。

如果不在低位拿到先手,拿到便宜的筹码,就很容易被套在山腰甚至山顶。实际上,如果3月27日你没有入场,那么近期的行情是难以把握的。

抄底!基于坚实的统计概率,拿到先手,立于不败之地,才是在这个市场活下来的不二法门。

额外的惊喜

在我们的计算中,如果抄底失败,我们是按最大损失进行了计提。但实际上,在一轮快速下跌中,下跌越狠,反弹力度就越大,读者可以自行验证。

这里要讲的是,我们对抄底失败,是按平均3%计提的损失。但我们没有计算在被套3%之后,反弹的情况。我们以今年1月25到2月5日下跌最狠的那一轮为例,如果我们在下跌6.94%时就抄底,我们真的会损失3%吗?

读者可以自行验证。实际上,这一轮跌得快,反弹力度也大。如果我们在下跌达到6%以后抄底,大概会买在中证1000的4984点,然后会遭受约13%的下跌,但最终会连续反弹到4993点,最终收益0.18%。

没能上热搜,但卡尼曼值得我们纪念

3月27日,行为经济学的开山鼻祖丹尼尔.卡尼曼去世。作为行为经济学的一个分支,行为金融学在量化中的运用越来越广泛,并成功地解释了时序方向上价格波动的诸多原因。

卡尼曼的重要贡献是建立起了一套形式化方法,使得运用心理学来解释和预测经济行为的研究纳入了科学的轨道。


前一段时间,跟一个学员聊天,他跟我讲,市场可以诞生一切,除了市场本身。这句话意涵很深,又出人意料地简洁,还具有修辞之美。

不是每一个经济学家都能懂得这个道理,但我却从学员那里听到了这样深邃的洞见。在深感荣幸之余,也受到启发:在分享纯粹的量化知识之外,也应该可以分享一些我自已学习经济学的一些思考,也许会有一些读者会觉得有所裨益,正如我从与学员的交流中也得到了启发一样。

正如那位学员所说的:

Quote

我们能够做的,就是把这些经济学的想法分享给身边的朋友,让这些经济学理念不断传播蔓延,那么经过几代人的努力,事情会慢慢有所改观。

是的,这很重要。如果有更多的人关注、学习和研究经济学,就有更多的人通过经济学找回逻辑和常识,那么未来会美好许多。

Be the change you seek!

卡尼曼和行为经济学

卡尼曼是犹太人,出生于特拉维夫,二战时在巴黎,他们一家人被Nazi围捕,不过后来被他父亲的雇主设法施救而逃出生天(另一个辛德勒名单的故事)。


图片来自wiki,遵照cc-by-2.0协议使用

战后他在希伯莱大学学习心理学,并在UC Berkeley获得了心理学博士学位。尽管他获得了诺贝尔经济学奖,但并没有系统地接受过经济学专业的培训。

1979年3月,卡尼曼与合作伙伴特沃斯基一起发表了论文《Prospect Theory: An analysis of decision under risk》,提出了前景理论(也称展望理论、视野理论)。论文发表以来,共获得了超过8万次引用。

从亚当.斯密以来,主流经济学假设人的每个决定都是“经济上理性”的,然而现实情况并非如此;前景理论加入了人们对得失、发生概率高低等条件的不对称心理效用,成功解释了许多看来不理性的现象。

Tip

卡尼曼的好友阿莫斯.特沃斯基于1996年因黑色素瘤去世,撼失诺奖。卡尼曼与特沃斯基有着长达数十年的友谊,共同发表了行为经济学的多篇奠基之作。特沃斯基的妻子曾经说过,他们彼此之间的联系比任何人与其他人的联系都更深,甚至超过了婚姻。事实上,两个人在生活习惯(早起和熬夜)、性格(内向和外向上)大相径庭。两个人密不可分,正如披头士乐队的约翰列侬与麦卡特尼一样。


除了专业领域的建树之外,卡尼曼还是畅销书思考快与慢的作者.

前景理论认为,人在不确定条件下的决策选择,取决于结果与展望(预期、设想)的差距,而非单单结果本身。

假设有两名投顾分别向同一位投资者推销同一个基金。其中第一位在介绍产品时,只强调该基金过去三年的平均回报是10%;而第二位投顾在介绍时,还强调了该基金过去十年的回报率高于市场平均,但过去三年中一直在下降。

前景理论认为,尽管投资者被推销了完全相同的基金,但他很可能会从第一位顾问那里购买。因为人们都有厌恶损失(Loss aversion)的倾向,所以,如果向投资者提供基于潜在收益的投资选择,以及基于潜在损失的投资选择,则投资者将选择前者。

前景理论把决策过程描述为一个函数,并提出如下决策得失函数:

\[ U = \sum_{i=1}^{n} \pi(p_i)v(x_i) \]

这里,\(x_i\)是可能发生的各个结果(它们虽然是随机变量,但服从概率分布,可以认为是客观的),\(p_i\)是这些结果发生的概率。

这里概率权重函数π(p)和价值函数v(x)是前景理论的两个核心函数,都具有非线性的特征。

我们先看价值函数v(x)。

价值函数v是一个非对称的\(S\)形函数,在心理中性参考点之上即收益区间,它是凹函数;而在参考点之下即损失区间,它是凸函数。因此,每一单位的收益(损失)增加,只会得到缩小的收益(损失)价值。换言之,当收益从0到100时主观价值感受,要大于收益从100到200时的主观价值感受。此外,价值函数在损失部分更加陡峭,意味着失去100元的痛苦要大于得到100元的愉悦

图片来源: sciencedirect.com

注意在价值函数里,前景理论开始把客观价值(objective value)与主观价值(subjective value)相区分,见上图中的两个坐标轴。它的重要意义,我们会在文末进行讨论。

概率权重函数\(\pi\)描述了事件的客观概率与该概率的主观投影之间的关系。通俗地讲,低概率事件(比如在异常事故中的死亡率)可能被高估,而高概率事件(比如心脏病或者癌症的死亡率)则被低估。它同样被表示为一个非线性(sigmoid)类型的函数。但是,它的形状需要在具体的博弈事件中进行统计。

下面的例子具体说明了前景理论的应用。假设承保风险的概率为 1%,潜在损失为 1,000 美元,保费为 15 美元。如果我们把参考点设置为当前的财富,那么:


  1. 支付$15的保险,其前景效用为v(-15),注意,这里并不是-15,而是它的一个函数。
  2. 不购买保险,这样一旦意外发生(1%的概率),将会损失$1000;或者以99%概率损失0。此时的前景效用为:
\[ \pi(0.01) \times v(-1000) + \pi(0.99) \times v(0) = \\ \pi(0.01) \times v(-1000) \]

最关键的部分来了。根据前景理论: 1. \(\pi(0.01) > 0.01\),因为低概率会被过度加权。 2. \(v(-15)/v(-1000) > 0.015\),由损失中价值函数的凸性决定(如果是线性,则左右两边相等)。

由上述1), 2)可推导出:

\[ \pi(0.01) \times v(-1000) < v(-15) \]

也就是,由于对小概率事件的过度重视,会使得我们在评估一个客观上只会以1%概率发生的事件时,把它当成超过1%的事件,从而最终的损失期望将大于-15美金,从而使得我们倾向于购买保险。

被低估的行为经济学

卡尼曼的去世,在中文世界里似乎没有引起太大的反响。看到有一些纪念文章,更多地是把他当成《思考快与慢》的作者来纪念。


思考,快与慢。图片来自douban

卡尼曼的重要性来自于他的开创性。他不仅是将心理学运用在经济领域研究当中,更重要地是给出了这种研究的形式化方法。我相信,在他们之前,也一定有不少人分析经济活动中的心理学归因,但由于缺乏形式化方法和数学模型,这些研究都无法纳入科学的轨道。

卡尼曼和特沃斯基的成功之处,在于使用数学语言为行为经济学打下了一个基础框架,从而使得后来像塞勒等人可以继续这一领域的研究。

另一个重要性是,从亚当.斯密以来,理性经济人一直是经济学中的重要假设。尽管大家都明白从这一假设是有局限的,但一旦破除了这一假设,经济学应该如何向前推进,一直缺乏有效地示范。行为经济学在它的这一小块领域中,成功地摒弃了理性经济人假设,引入了主观价值的概念,并使得主观价值的计算成为可能。

经济学最基础的问题之一,就是价值的主观与客观之分。

一些理论认为,价值是凝聚在商品中的无差别人类劳动,因而商品的价值是客观的。商品的生产时间越长、越复杂,它的价值就越高,而与后面的交换过程完全无关(卖不出去的商品,与卖得出去的商品,只要生产所用的时间一样,它们的价值就相同)。


它确实能解释许多经济现象,但是不能解释这样的现象:生产1万砘水泥,会产生一万吨水泥的价值;但生成1亿吨水泥(2021年全球产量为44亿吨),不但不产生任何价值,反而会造成生态灾难。当然就更不用说如何解释人类在美容、旅游、艺术品等方面的经济行为了。

主观价值理论(subjective theory of value)则认为商品之价值并非由于内在的客观属性或生产它的必要劳动等客观事物所决定,而是由行动着的个体对商品实现期望目标之作用大小所决定。

本质上,主观价值理论是一种以人为本的理论,你需要的,才是有价值的

主观价值论能解释更普遍的经济现象。比如,它能解释同样是一坨屎,对屎壳郎会是精美的粮食,具有重要的价值,而对于牛来讲,这样“有价值”的东西,牛并没有生产它,并没有花费任何必要的社会劳动时间--它只是纯粹的排泄物。这是客观价值理论无法解释的现象之一。

从这个意义上讲,如果要像物理学领域那样建立起经济学领域的大一统理论,从主观价值理论的公设出发,更有可能得到结果。

尽管行为经济学只在经济活动中的一小段过程中(价值交换)引入了主观价值,而且还保留着客观价值的定义,但是,这可能是人类第一次通过形式化方法计算主观价值,其背后的意义之重要,自不待言。这可能是行为经济学真正被低估的地方。

交割日魔咒?

周日莫斯科的恐袭,让所有的A股交易者捏了一把汗,怕不是我A又要买单?果然,短短三日,沪指跌去1.8%,中证1000跌去5.89%,亏钱效应还是非常明显的。

痛定之后,留下几个复盘问题,首先是,下跌的原因是什么?当然,我们求解的方法,都是量化思路。

交割日魔咒?

关于下跌的原因,最被人采信的说法是今天是ETF期权交割日。上一个交割日,2月28日,沪指大跌1.91%。

顺便,我们也把今年的几个重要的交割日分享一下:

股指期货的交割日为每月的第三周周五;ETF期权交割日为每月第四周的周三;A50交割日为相关月的倒数第二个交易日。


作为量化研究员(或者quant developer),我们需要自己有能力计算出上述交割日。

Tip

在量化二十四课中,这个问题是作为一道练习题出现的(有参考答案)。不过,这里我们可以介绍一下解题思路。

核心是要使用calendar这个库。我们可以通过它的monthcalendar来构建每月的日历:

1
calendar.monthcalendar(2024, 2)

这会得到以下结果:

表格中为0的部分,表明那一天不属于当月。因此,我们要知道每月的周五,只需要将上述输出转换为DataFrame,再取第4列,就可以得到所有的周五了。然后判断第三个周五是否为交易日,如果不是,则取第四个周五。

大摩的预言

另一个传闻则是一个未经证实的大摩的小作文,说是反弹到3090点以来,A股获得超过15%,可以看成兑现2024年利润预期。

实际上,这应该是一则谣言,原因是,除了今天之外,本周大小摩一直在加仓。

趋势分析方法

在量化二十四课中,我们介绍了一些趋势分析方法。实际上,运用这些方法,我们不需要去猜测大跌背后的直接原因;相反地,近期A股的走势已经预示了下跌的可能,随后的测试确认了下跌趋势成立。

首先是3100整数关口压力。我们在《左数效应 整数关口与光折射》那篇文章中介绍过左数效应和整数关口压力/支撑。上周二、周四确认了压力存在。

其次是对RSI的分析。本轮日线RSI的高点在2月23日打出,随后分别在3月5日、3月11日和3月18日形成顶背离。这期间在30分钟级别上,RSI得到多次调整,但在3月21日上午10点,30分钟的RSI也再次形成顶背离。在课程中,我们介绍RSI超过前期高点,是一个值得注意的见顶信号,如果此时均线是走平或者向下的,则基本可以确认。

如何寻找RSI的前期高点呢?我们介绍的方法是,通过zigzag库,寻找5日均线的上一个最高点,则它对应的RSI也就是前期的高点。下一次当RSI在数值上逼近或者超过前高时,也往往就是局部的高点。为什么不用zigzag来寻找本次的最高点?这是因为zigzag寻找高低点需要延时几个周期才能找到。如果我们使用移动平均线的话,还要再多几个周期的延时,这样等zigzag确认最近的高点已经出现时,往往已错过了最好的交易时机。改用RSI是否逼近前期高点,我们可以立即发出信号,并且在下一周期,就可以根据k线走势来确认这个信号是否得到市场认可。

通过zigzag来寻找局部最高点的方法如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from zigzag import peak_valley_pivots
close = ...
ma = moving_average(close, 5)

pct_change = ma[1:]/ma[:-1] - 1
std = np.std(pct_change)

up_thresh, down_thresh = 2*std, -2*std

peak_valley_pivots(ma, up_thresh, down_thresh)

peak_valley_pivots需要我们传入两个threshold参数,以帮助它来确认一个局部极值是否可以作为峰谷。只有当该点大于左右侧up_thresh,down_thresh以上时,一个点才会被确认为峰。对谷的确认也是一样。

这里我们用了一点小小的统计技巧,即使用近期涨跌幅的2倍标准差来作为阈值。

如果一个点高于平均值的两倍标准差,也确实可以看作是一个离群值 -- 从几何意义上来讲,可以当成一个峰值或者谷值。这种处理方法,我们在布林带、或者seaborn的包络图中已经见识过了。

通过上述方法,在绝大多数情况下,我们可以很准确地找出过去出现的峰和谷:

75%

通过这些峰和谷,我们就能找出上一次RSI的最高点和最低点。当下一次RSI再次逼近这个值的时候,我们就可以发出信号,并且在下一周期检测信号质量。

最后是对均线拐头的判断。在上周五10时后,30分钟线的20周期均线已经拐头,且股价运行在均线下方,结合前面的整数关口、RSI高点,此时可以立即减仓。

随后在3月25日下午14时,三个30分钟线确认无法突破这条下行的均线,确认反弹失败。此时的20周期线就象一个屋顶一样盖下来。我们把这种均线的压力称为穹顶压力。传统的一些经典技术分析中,也把它当成抛物线来拟合和分析。在量化二十四课中,我们介绍了一种更鲁棒的方法来判断均线的走势。毕竟,多项式拟合在多数情况下是不可用的。

方法的有效性分析

我们这里讲到的整数关口、穹顶压力和RSI前高,都有精准的定义,是完全可以量化的。读者也可以自行观察它们的有效性。

你可能要问,如果这些方法有效,那岂不是制造出来一台印钞机?

实际上,印钞机要复杂许多。戏法人人会变,各有巧妙不同。要做到实时跟踪这些信号,你得有一个稳定、高性能的量化框架。

其次,这里的方法更适用于大盘分析,但投资最终是要落实到个股上的。只有能选取出跟大盘走势高度相关的个股,大盘分析才能发挥作用。

此外,尽管我们使用了多种方法来提前捕捉信号,但信号发出到确认仍然有一个延时。这会使得我们逃顶和抄底都会晚一些,从而损失部分利润。如果趋势频繁翻转,这种情况下,利润损失加上交易成本,也有可能失败。但是,它显然能帮我们避免大的损失。

量化分析的作用不是保证你百分之百成功,但它能保证在应该成功的时候,你比别人更有可能成功。

资产全球配置

在A股下跌比较确定时,我们应该及时退出市场。也可以考虑一些跨境ETF。在这今年以来的行情中表现的特别明显。比如今天盘中沪指14点开始反弹时,某跨境ETF从高点下跌1.6%;而当14:15,沪指上攻到30分钟20周期均线遇阻后,该ETF立刻反弹,最后半小时上涨1.37%,最终收报3.67%。在一些极端时刻,这些跨境ETF与沪指形成了翘翘板效应。

7因子模型,除了规模、市场、动量和价值,还有哪些?

这篇文章的源起是有读者问,七因子模型除了规模、市场、动量和价值之外,还包括哪几个因子?就这个题目,正好介绍一下Fung & Hsieh的七因子模型。

七因子模型一般是指David Hsieh和William Fung于2004年在一篇题为《Hedge Fund Benchmarks: A Risk Based Approach》中提出的7 factor model。

L50 作者David Hsieh 出生于香港,是Duke大学教授,在对冲基金和另类beta上有着深入而广泛的研究。William Fung则是伦敦商学院对冲基金教育研究中心的客座教授。

这篇论文发表以来,共获得了1300多次引用。作者也以此论文获得了由CFA颁发的格雷厄姆和多德杰出贡献奖及费雪.布莱克纪念基金会奖等几个奖励。因此,这篇论文在量化史上还是有一定地位的,值得我们学习。

这篇论文中,7因子模型指的是以下7个:

  1. Bond Trend-Following Factor 债券趋势跟随因子
  2. Currency Trend-Following Factor 货币趋势跟随因子
  3. Commodity Trend-Following Factor 商品趋势跟踪因子
  4. Equity Market Factor 股票市场因子
  5. The Equity Size Spread Factor 股票规模利差因子
  6. The Bond Market Factor 债券市场因子
  7. The Bond Size Spread Factor 债券规模利差因子

这几个因子中,股票市场因子、规模利差因子本质上就是市场因子和规模因子。

前三个因子来自Fung和Hsieh另一篇论文:《The Risk in Hedge Fund Strategies: Theory and Evidence from Trend Followers》。 这篇论文发表几年后, Fung和Hsieh又增加了第8个因子,即MSCI新兴市场指数。

这篇论文中还介绍了对冲基金研究中几个常见的偏差(bias),这里也重点介绍下。

Tip

在投资中了解什么是错的、怎么错的,可能比了解什么是对的更重要,毕竟,投资中,只有正确的方法和方法论是持久发作用的,而所谓“正确的结论”,都只是一时一地的产物。

研究对冲基金时,第一个容易出现的偏差是选择偏差。共同基金(即公募基金)需要公开批露他们的投资活动,但对冲基金则不需要。对冲基金的数据一般由数据供应商收集,在这种收集过程中,就可能出现选择偏差,从而使得数据库中的基金样本不是整个基金样本的代表性样本。

第二是幸存者偏差。这是所有基金研究中的一个常见问题。我们在课程中讲过,股票的上市和退市都比较严格,公司即使退市,它的历史数据也能很容易获取到;但已停止运营的基金则会从数据库中剔除掉。这一点除了本文有提到,在其它许多论文中也有提到。

第三个偏差是即时历史偏差(instant history bias)。当基金进入数据库时,它会把过去的业绩历史记录也带入进来,尽管这些业绩记录是在孵化期创建的。并且,如果一支基金在孵化期的业绩不够好,他们也往往会停止运营。显然,这样的业绩记录并不完全真实可靠。

在量化研究中如何避免各种系统偏差是很重要的经验和技巧。这些经验并不来自于学术研究,掌握这些经验需要我们了解数据加工处理的过程 -- 很多人无法直接了解到数据收集到加工的全过程,因此行业交流是十分重要的。

回到正题。读者的提问是,在七因子模型中,除了市场、规模、价值和动量,其它几个因子是什么。这个问题可能来源于国内私募在用的一个8因子模型。

这个模型由清华大学国家金融研究院在2017年3月的一个简报(中国私募基金8因子模型)中作出批露。它参考了Fung和Hsieh的7因子模型,提出了8个因子,分别是:

  1. 股票市场风险因子(MKT)
  2. 规模因子(SMB)
  3. 价值因子(HML)
  4. 动量因子(MOM)
  5. 债券因子(BOND10)
  6. 信用风险因子(CBMB10)
  7. 债券市场综合因子(BOND_RET)
  8. 商品市场风险因子

不难看出,这个8因子模型是在经典的FF三因子(规模、市场、价值)基础上,增加了动量因子(Titman和Jegadesh),再结合Fung和Hsieh的七因子中的一些因子构成的。

在这个模型中,股票市场风险因子定义为:

\(RET_HS300_t\)为第\(t\)月的沪深300指数的月收益率, \(RF_t\)为第\(t\)月1年期定期存款利率的月利率。这点比较意外,一般来说,国债的风险比存款还要低(大额存款有50万的止付额),但收益要高一些,一般多会使用国债利率作为无风险收益率。

它的规模因子构建方法是,以一年为期进行一次换手。在每年6月底构建一次投资组合,将A股按流通市值将样本股票等分为小盘组和大盘组,再根据T-1期年报中的账面市值比和A股流通市值(ME)计算出账面市值比后,将股票按30%, 40%, 30%的比例划分为成长组、平衡组和价值组。最后,将两种分组方式所得的结果,按笛卡尔积的方式组成为六组,再计算各组的月收益率。

它的价值因子、动量因子构建方法与规模因子类似。

债券因子公式为:

75%

信用风险因子为:

75%

债券市场综合因子公式为:

75%

数据使用的是中债综合全价指数。文章虽然只有10页的篇幅,但在因子构建方面讲解得比较详细,感兴趣的同学可以找来一读。

题图是杜克大学的地标 - Duke Chapel(杜克教堂)。我曾经开车路过杜克大学,当时心想,哈,这就是杜克大学了,可惜事前没有安排,没能进去参观。

从CAPM、APT以来,各种因子被源源不断地提出,形成了所谓的因子动物园一说。这么多因子,如何进行学习?如何梳理它们的脉络?对初学者而言,可能会一时没有头绪。我们准备了一个系统的量化课程(《量化二十四课》),并且即将开设新的因子分析及机器学习策略课程,欢迎咨询。现在报名《量化二十四课》,还可以免费升级到《因子分析及机器学习策略》课程。

文中提及的两篇论文,可以在这里找到链接下载。

4k stars! 如何实现按拼音首字母查询证券代码?

一个可能只有少数量化人才需要的功能 -- 按拼音首字母来查找证券。比如,当我们键入ZGPA时,就能搜索出中国平安,或者是它的代码。这是我们使用行情软件时常用的一个功能。

这个功能的关键是要实现汉字转拼音。有的数据源已经提供了这个查询。但不是所有的数据源都有这个功能。


如果我们使用的数据源是聚宽(jqdatasdk),当我们调用jq.get_all_securities时,它返回的证券列表,将包括以下几项:

  • index,证券代码代码,比如000001.XSHE
  • display_name,证券的中文名,比如平安银行
  • name,证券的拼音首字母名,比如PAYH
  • start_date,证券IPO日
  • end_date,该品种退市日。只有在已退市时,此时间才有效。
  • type,证券类型,取值可能是stock, index, fund等。

我们要的关系反映在字段name与index, display_name中。当用户输入PAYH时,通过查找上述表格,就能找到对应的中文名,或者它的代码。

但如果我们使用的数据源(比如QMT)没有提供这个信息呢?这时我们就需要通过第三方库来将汉字转换成为拼音。

这里的难点在于多音字,比如,平安银行应该转换成PAYH,而不是PAYX。我们考察了好几个python第三方库,比如pinyin, xpinyin,最后发现只有pypinyin能较好地实现这个功能。

功能介绍

pypinyin库在github上获得了4.6k stars。与之相对照,xpinyin有800多个stars,pinyin是超过200个stars。

你可能会好奇,这个功能主要是什么人在用,为什么能拿到这么多star。这么多star,可能主要是做AI的人给的。


将汉字转换成拼音再进行深度学习,是内容审查的一个研究方向。

pypinyin成功的原因主要是在多数情况下,能给出正确的拼音。如果遇到无法正确处理的词,我们还可以通过自定义词组拼音库来进行修正。其次,它提供了简单的繁体支持、多种注意风格支持等。

安装和使用

我们通过下面的命令来安装:

1
pip install pypinyin

它主要提供了两个API,即pinyin和lazy_pinyin。但在使用时,我们还需要指定风格(Style)。下面我们看几个简单的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
>>> from pypinyin import pinyin, lazy_pinyin, Style
>>> pinyin('中心')
[['zhōng'], ['xīn']]

# 启用多音字模式
>>> pinyin('中心', heteronym=True) 
[['zhōng', 'zhòng'], ['xīn']]

# lazy_pinyin
>>> lazy_pinyin('中国平安') 
['zhong', 'guo', 'ping', 'an']

lazy_pinyin这个API的特点是(与pinyin相比),它的返回值中,每一个字都只包含一个读音,因此,对每一个字,它返回的是一个字符串,而不是一个数组。

我们来看看它会不会把中国银行拼成zhong guo yin xing:

1
2
>>> lazy_pinyin('中国银行') 
['zhong', 'guo', 'yin', 'hang']

这个结果是正确的。再回到我们最初的问题,如何得到拼音首字母呢?这就需要我们传入style参数了:

1
2
3
4
5
6
7
>>> lazy_pinyin('中国银行', style=Style.FIRST_LETTER) 
['z', 'g', 'y', 'h']

# 将其转换成为大写
>>> py = lazy_pinyin('中国银行', style=Style.FIRST_LETTER)
>>> "".join(py).upper()
'ZGYH'
这里我们传入的参数是Style.FIRST_LETTER。还有一个与此相混淆的参数,style.INITIALS。如果我们传入此参数:

1
2
>>> lazy_pinyin('中国银行', style=Style.INITIALS)
['zh', 'g', '', 'h']

结果可能令人意外。要理解它的输出,需要一些拼音的知识。我们只要记住,要得到拼音首字母,应该传入Style.FIRST_LETTER参数。

令人吃惊的是,通过pypinyin给出的首字母结果,竟然比聚宽给出的结果要正确。


比如,像重药控股,该公司处在重庆,因此第一个字应该发音chong。聚宽数据给出的拼音是ZYKG,而不是CYKG。又比如长源电力,该公司地处湖北,“长”字可能来源于长江,因此一般读出Chang Yuan Dian Li,聚宽的数据给出的结果是ZYDL。再比如,聚宽拼音一般把晟拼作Cheng,因此象广晟有色会拼成GCYS,而正确的拼法是GSYS。这样的不同之处,大约有30多个。

但pypinyin也有出错的时候,比如重庆港会拼成ZQG。此时,我们就需要使用自定义词典了:

1
2
3
4
5
>>> from pypinyin import load_phrases_dict, lazy_pinyin, Style

>>> load_phrases_dict( {"重庆港": [[u"c"], [u"q"], [u"g"]]}, style=Style.FIRST_LETTER)
>>> lazy_pinyin("重庆港", style=Style.FIRST_LETTER)
['c', 'q', 'g']

由于我们只关心首字母,因此在加载自定义词典时,指定了Style.FIRST_LETTER,这样将只影响后面对此类风格的查询。

如果你想了解哪些拼音聚宽给出的与pypinyin不一样,可以通过下面的代码来检查:

1
2
3
4
5
6
7
8
from pypinyin import Style, lazy_pinyin

for code in await Security.select().eval():
    name = await Security.alias(code)
    jq = await Security.name(code)
    py = "".join(lazy_pinyin(name, style=Style.FIRST_LETTER)).upper()
    if jq != py:
        print(name, py, jq)

提速100倍!QMT复权因子高效算法

QMT的XtQuant库提供了量化研究所需要的数据。它在一些API设计上面向底层多一些,应用层在使用时,还往往需要进行一些包装,比如复权就是如此。

这篇文章介绍了将XtQuant的除权信息转换成常常的复权因子的高性能算法。与官方示例相比,速度快了100多倍。


Info

通过XtQuant的API get_market_data_ex,我们可以直接获得经过前后复权处理的行情数据。但是,如果你希望自己使用更高效的方式来存储行情数据的话,就需要存储未复权的原始价格数据和复权因子,在需要使用前后复权价格时,根据选择的时间区间,临时计算复权价格。这就需要也存储复权因子。

XtQuant没有提供复权因子,相反,它通过 get_divid_factors 方法提供了更详尽的分红、送股、配股信息。数据如下所示:

date interest stockBonus stockGift allotNum allotPrice gugai dr
20121019 0.100 0.0 0.0 0.0 0.0 0.0 1.007457
20130620 0.170 0.6 0.0 0.0 0.0 0.0 1.614093
20230614 0.285 0.0 0.0 0.0 0.0 0.0 1.025261


提供的信息非常全,但要利用这些数据来进行价格复权,会比较烦琐。在量化中,多数场合我们可以仅使用复权因子(factor-ratio)来计算前后复权价格。这就需要将上述信息转化为复权因子。

XtQuant的示例中,已经提供了一个由上述信息,计算复权因子的示例。由于它是示例性质的,所以在代码逻辑上需要做到简单易懂,因此它在计算中,使用了循环,而不是向量化的运算方法。


这是官方给出的示例代码:

 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
def gen_divid_ratio(bars, divid_datas):
    drl = []
    dr = 1.0
    qi = 0
    qdl = len(bars)
    di = 0
    ddl = len(divid_datas)
    while qi < qdl and di < ddl:
        qd = bars.iloc[qi]
        dd = divid_datas.iloc[di]
        if qd.name >= dd.name:
            dr *= dd['dr']
            di += 1
        if qd.name <= dd.name:
            drl.append(dr)
            qi += 1
    while qi < qdl:
        drl.append(dr)
        qi += 1
    return pd.DataFrame(drl, index = bars.index, 
                        columns = bars.columns)

# 获取除权信息
dd = xtdata.get_divid_factors(s, start_time="20050104")

# 获取未复权行情
bars = xtdata.get_market_data(field_list, ["000001.SZ"], 
                                '1d', 
                                dividend_type = 'none', 
                                start_time='20050104', 
                                end_time='20240308')
%timeit gen_divid_ratio(bars["close"].T dd)

这段代码计算了000001.SZ从2005年1月4日以来的复权因子。如果当天没有发生除权,则当天因子从1开始,后面每发生一次除权除息,因子就在前一天的基础上增加dr倍。因此,这样算出来的复权因子,一般情况下是一个以1开始的递增序列。

在notebook中运行时,上述代码的执行时间是407ms±14ms。

下面,我们就介绍如何将其向量化,将速度提升100倍。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def get_factor_ratio(symbol: str, start: datetime.date, end: datetime.date)->pd.Series:
    """获取`symbol`在`start`到`end`期间的复权因子

    复权因子以EPOCH日为1,依次向后增加。返回值取整个复权因子区间
    中[start, end]这一段。

    Args:
        symbol: 个股代码,以.SZ/.SH等结尾
        start: 起始日期,不得早于EPOCH
        end: 结束日期,不得晚于当前时间

    Returns:
        以日期为index的Series
    """
    if start < tf.int2date(EPOCH):
        raise ValueError(f"start date should not be earlier than {EPOCH}: {start}")

    start_ = tf.date2int(start)
    end_ = tf.date2int(end)
    df = xt.get_divid_factors(symbol, EPOCH)

1
2
3
4
5
6
7
8
    df.index = df.index.astype(int)
    frames = pd.DataFrame([], index=tf.day_frames)
    factor = pd.concat([frames, df["dr"]], axis=1)
    factor.sort_index(inplace=True)
    factor.fillna(1, inplace=True)

    query = f'index >= {start_} and index <= {end_}'
    return factor.cumprod().query(query)["dr"]

我们设置的EPOCH时间是2005年1月4日。这一年是全流通股改启动之年。以此为界,上市公司在治理结构上发生了较大变化,因此,进行量化回测,似乎一般也没必要使用在此之前的数据。

Info

罗马不是一天建成的。股改也是这样。在此后相当长一段时间内,你还能看到一些股票的名字以S开头,意味着该股还未完成股权分置改革。不过,尽管如此,我们也只能以大多数为主。很多人以为量化是一个纯算法的活儿。但是,了解脏数据、处理脏数据,对收益的影响并不比算法少。

这段代码的核心逻辑是,dd['dr']是一个带时间戳的稀疏数据。我们首先要把它展开成:


在此基础上,通过一个cumprod运算,我们就可以求出符合要求的factor ratio。

第一步的运算实际上是一个join运算。我们使用一个在交易日上连续的空的dataframe与上述dd['dr']进行join,其结果就是,如果记录在dd['dr']中存在,就使用dd['dr']中的数值,如果不存在,就使用空值。

然后我们使用pandas.fillna来将所有的空值替换为1.最后,由于我们只需要在[start,end]期间的因子值,所以通过datataframe.query来进行过滤。

上述代码将得到与官方示例一致的结果,但执行时间仅3.96ms±251,比使用循环的版本快了100倍还多。

本方法是作为zillionare接入XtQuant数据的方案的一部分开发的。在这个方案中,我们将采用clickhouse来存放行情数据,以获得更好的回测性能。因此,我们还必须考虑到每种数据如何进行持续更新。这个更新的大致思路是,我们把上述计算中得到的factor ratio存入clickhouse中,在每日更新时,先取得所有股票的factor ratio的最后更新日期(T0),以此日期为下界,调用xtdata.get_divid_factors来下载最新的除权信息,通过同样的方法求得T0日以来的因子,乘以T0日因子值,即可存入到clickhouse中。

Zillionare接入XtQuant的版本将是2.1,预计在6月发布。

后见之明!错过6个涨停之后的复盘

在今年1月2日和1月3日,旅游板块两支个股先后涨停,此后一支月内三倍,另一支连续6个涨停。事后复盘,我们如何在1月2日第一支个股涨停之后,通过量化分析,找出第二支股?

Warning

无论复盘多么精彩,请注意,本文写作的目的只是分享量化技术:如何进行相关性分析。即使本文观点能够策略化,普通人也不具有这样的条件来实施一个量化系统。


一个3倍,一个6连板

这是两支个股在2024年1月17日前60日收盘价图。图中红色虚线是个股启动时间。A大概是受董宇辉小作文、或者尔滨旅游热题材发酵带动,于1月2日率先启动。

尽管A在半个月内股价接近3倍,但从量化的角度,目前还难以精准地实现事件驱动上涨这种类型的建模。但是,如果我们在A启动之后,在1月2日收盘前买入B(就这次而言,B次日开盘仍有买入机会),连续收获6个涨停,也完全可以满意。

现在,我们就来复盘,如何从A涨停,到发现B。


首先,运行策略的时间应该放在14:30分之后,此时对市场进行扫描,找出首板涨停的个股。当日涨停数据在Akshare可以获得,印象中,它能区分首板涨停和连板。

对首板涨停的个股,我们先获取它所在的概念板块。然后对每个板块的成员股进行遍历,通过相关性分析,找到关联度较高的个股。

Tip

概念板块的编制没有严格的规范。有一些软件在编制概念时,甚至允许网友参与。一般地,编制者会从新闻报道、公司报告或者董秘的回答中发掘概念。比如在编制英伟达概念时,如果有人在互动平台上问董秘,你们与英伟达有合作关系吧?董秘回答,我们购买了他们的GPU多少台,这样这家公司就有可能被编入英伟达概念,其实它与英伟达产业链基本上没有什么关系。

与之相反,行业板块的编制相对严谨一些。它是根据公司的主营业务来划分的。

如果仅仅是和龙头同在一个板块是无法保证资金眷顾的。而且,一支个股往往身兼多个概念,在极短的时间里要弄清楚究竟是炒的它的哪一个概念也不容易。不过,通过数据挖掘,我们可以完全不去理会炒作背后的逻辑 -- 何况很多逻辑,根本就是狗p不通。

我们用相关性检测来进行数据挖掘。


相关系数

在概率论和统计学中,相关性(Correlation)显示了两个或几个随机变量之间线性关系的强度和方向。

通常使用相关系数来计量这些随机变量协同变化的程度,当随机变量间呈现同一方向的变化趋势时称为正相关,反之则称为负相关。

我们通过以下公式来计算两个随机变量之间的相关性:

\[ \rho_{XY} = \frac{cov(X, Y)}{\sigma_X\sigma_Y} \]

这样定义的相关系数称作皮尔逊相关系数。一般我们可以通过numpy中的corrcoef,或者scipy.stats.pearsonr来计算。

下面的代码演示了正相关、负相关和不相关的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# x0与x1正相关, 与x2负相关, 与x3分别为不同的随机变量
x0 = np.random.normal(size=100)
x1 = 10* x0 + 1
x2 = -10 * x0 + 1
x3 = np.random.normal(size=100)

x = np.vstack((x0, x1, x2, x3))
rho = np.corrcoef(x)

fig, ax = plt.subplots(nrows=1, ncols=3, figsize=(12, 3))

for i in [0,1,2]:
    ax[i].scatter(x[0,],x[1+i,])
    desc = "Pearson: {:.2f}".format(rho[0,i+1])
    ax[i].title.set_text(desc)
    ax[i].set(xlabel='x',ylabel='y')

plt.show()

绘图时,我们以\(x_0\)为x轴,以\(x_i\)为y轴,如果\(x_0\)\(x_1\)完全正相关,那么将绘制出一条\(45^。\)向上的直线。这其实就是QQ-Plot的原理。

从左图过渡到右图,只需要在\(x_0\)中不断掺入噪声即可。读者可以自己尝试一下。

皮尔逊相关系数要求只有变量之间是线性相关时,它才能发现这种关联性。很多时候我们必须放宽条件为:标的A上涨,则B也跟着涨。但不管A涨多少,B跟涨又是多少,都不改变它们联系的强度。此时,就要用Spearman相关性。


Tip

无论是皮尔逊相关,还是Spearman,运用在时间序列分析(比如股价)上时,都不完全满足随机变量独立性条件。不过,从经验得知,这种影响还没有大到使它们失去作用的地步。但我们也确实需要了解这一点。有能力啃学术论文的,可以用how-to-use-pearson-correlation-correctly-with-time-series搜索一下stackexchange上的回答。

上面的例子演示的是皮尔逊相关系数的求法,这里使用的是np.corrcoef。它的结果是一个矩阵,所以上例中的变量rho,其取值实际上是:

50%

在这个矩阵中,对角线上的值是自相关系数,显然它们都应该为1。实际上我们要得到时间序列\(s_1\)\(s_2\)之间的相关系数,应该取\(\rho[0][1]\),对\(s_1\)\(s_3\)之间的相关系数,应该取\(\rho[0][2]\),依次类推,这些可以在代码第13行看到。

我们通过scipy.stats.spearmanr来计算Spearman相关。我们将通过真实的例子来进行演示。


发现强相关个股

假设我们已经拿到了概念板块的个股名单。现在,我们两两计算它们与龙头个股之间的相关性,如果相关系数在0.75以上,我们就认为是强相关,纳入备选池。

相关系数是一个无量纲的数,取值在[-1,1]之间。因此,可以把0.75看成具有75分位的含义

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
async def qqplot(x, y, n=60, end):
    xbars = await Stock.get_bars(x, n, FrameType.DAY, end=end )
    ybars = await Stock.get_bars(y, n, FrameType.DAY, end=end)
    xclose = xbars["close"]
    yclose = ybars["close"]

    pearson = scipy.stats.pearsonr(xclose, yclose)[0]
    spearman = scipy.stats.spearmanr(xclose, yclose).statistic

    if pearson < 0.75:
        return

    a, b = np.polyfit(xclose, yclose, 1)
    ax = plt.subplot(1,1,1)
    ax.scatter(xclose, yclose)
    ax.plot(xclose, a * xclose + b)

    namex = await Security.alias(x)
    namey = await Security.alias(y)
    ax.title.set_text(f'{namex} <=> {namey} pearson: {pearson:.2f} spearman: {spearman:.2f}')
    plt.show()

假设现在是1月2日的下午2时,已经能确认标的A不会开板。现在,我们就拿它与板块内的其它个股逐一计算相关性,排除掉弱相关的个股,因为,既然是弱相关,那么它们就不会跟涨,也不怎么跟跌(在我A,跟跌是必须的)。

当我们使用 pearson > 0.75的判断条件时,在该板块的22支个股中,筛选出5支个股。如果使用spearman > 0.75来判断,则只会选出4支,并且这4支都在pearson筛选出的范围内。这里为排版美观起见,只给出共同的4支:

L50

R50

L50

R50


很幸运,我们要找的标的正在其中。

你肯定想知道另外三支的结果如何。它们有连板吗?有大幅下跌吗?

没有下跌。别忘了,我们是通过相关系数选出来的标的,只要这种关联还存在,即使不跟随上涨,也不应该大幅下跌,不是吗?

实际上,有一支在我们讨论的区间里持平,一支上涨5%,另一支最高上涨16.9%。但如果你有更高的期望,在这个case中,一点点看盘经验可以帮助我们过滤掉另外两只,最终,我们会买入上涨16.9%和6连板的股票。

这个看盘经验是,不要买上方有均线,特别是中长均线的股。这种股在上攻过程中,将会遇到较大的抛压。如果一个很小的板块,资金已经有了一到两个进攻的标的了,是不会有多余的钱来关照这些个股的。

这个策略还有一个很好的卖出条件。如果龙头股一直保持上涨,而个股的关联系数掉出0.75,显然,我们可以考虑卖出。如果龙头股出现滞涨(开盘半小时内不能封住),则也是离场时机。

这一篇我们讨论的是同一板块个股的相关性。如果是处在上下游的两个板块,它们也可能存在相关性,但会有延时。这种情况称作cross correlation。它应该如何计算,又如何使用,也许后面我们会继续探索。

量化人如何用好 Jupyter?(二)

当我们使用 Jupyter 时,很显然我们的主要目的是探索数据。这篇文章将介绍如何利用 JupySQL 来进行数据查询--甚至代替你正在使用的 Navicat, dbeaver 或者 pgAdmin。此外,我们还将介绍如何更敏捷地探索数据,相信这些工具,可以帮你省下 90%的 coding 时间。


JupySQL - 替换你的数据库查询工具

JupySQL 是一个运行在 Jupyter 中的 sql 查询工具。它支持传统关系型数据库(PostgreSQL, MySQL, SQL server)、列数据库(ClickHouse),数据仓库 (Snowflake, BigQuery, Redshift, etc) 和嵌入式数据库 (SQLite, DuckDB) 的查询。

之前我们不得不为每一种数据库寻找合适的查询工具,找到开源、免费又好用的其实并不容易。有一些工具,设置还比较复杂,比如像 Tabix,这是 ClickHouse 有一款开源查询工具,基于 web 界面的。尽管它看起来简单到甚至无须安装,但实际上这种新的概念,导致一开始会引起一定的认知困难。在有了 JupySQL 之后,我们就可以仅仅利用我们已知的概念,比如数据库连接串,SQL 语句来操作这一切。


除了查询支持之外,JupySQL 的另一特色,就是自带部分可视化功能。这对我们快速探索数据特性提供了方便。

安装 JupySQL

现在,打开一个 notebook,执行以下命令,安装 JupySQL:

1
%pip install jupysql duckdb-engine --quiet

之前你可能是这样使用 pip:

1
! pip install jupysql

在前一篇我们学习了 Jupyter 魔法之后,现在你知道了,%pip 是一个 line magic。

显然,JupySQL 要连接某种数据库,就必须有该数据库的驱动。接下来的例子要使用 DuckDB,所以,我们安装了 duckdb-engine。

Info

DuckDB 是一个性能极其强悍、有着现代 SQL 语法特色的嵌入式数据库。从测试上看,它可以轻松管理 500GB 以内的数据,并提供与任何商业数据库同样的性能。

在安装完成后,需要重启该 kernel。


JupySQL 是作为一个扩展出现的。要使用它,我们要先用 Jupyter 魔法把它加载进来,然后通过%sql 魔法来执行 sql 语句:

1
2
3
4
5
6
7
%load_ext sql

# 连接 DUCKDB。下面的连接串表明我们将使用内存数据库
%sql duckdb://

# 这一行的输出结果为 1,表明 JUPYSQL 正常工作了
%sql select 1

数据查询 (DDL 和 DML)

不过,我们来点有料的。我们从 baostock.com 上下载一个 A 股历史估值的示例文件。这个文件是 Excel 格式,我们使用 pandas 来将其读入为 DataFrame,然后进行查询:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import pandas as pd

df = pd.read_excel("/data/.common/valuation.xlsx")
%load_ext sql

# 创建一个内存数据库实例
%sql duckdb://

# 我们将这个 DATAFRAME 存入到 DUCKDB 中
%sql --persist df

现在,我们来看看,数据库里有哪些表,表里都有哪些字段:

1
2
3
4
5
# 列出数据库中有哪些表
%sqlcmd tables

# 列出表'DF'有哪些列
%sqlcmd columns -t df

最后一行命令将输出以下结果:

name type nullable default autoincrement comment
index BIGINT True None False None
date VARCHAR True None False None
code VARCHAR True None False None
close DOUBLE PRECISION True None False None
peTTM DOUBLE PRECISION True None False None
pbMRQ DOUBLE PRECISION True None False None
psTTM DOUBLE PRECISION True None False None
pcfNcfTTM DOUBLE PRECISION True None False None

作为数据分析师,或者量化研究员,这些命令基本上满足了我们常用的 DDL 功能需求。在使用 pgAdmin 的过程中,要找到一个表格,需要沿着 servers > server > databases > database > Schema > public > Tables 这条路径,一路展开所有的结点才能列出我们想要查询的表格,不免有些烦琐。JupySQL 的命令简单多了。


现在,我们预览一下这张表格:

1
%sql select * from df limit 5

我们将得到如下输出:

index date code close peTTM pbMRQ psTTM pcfNcfTTM
0 2022-09-01 sh.600000 7.23 3.978631 0.370617 1.103792 1.103792
1 2022-09-02 sh.600000 7.21 3.967625 0.369592 1.100739 1.100739
2 2022-09-05 sh.600000 7.26 3.99514 0.372155 1.108372 1.108372
3 2022-09-06 sh.600000 7.26 3.99514 0.372155 1.108372 1.108372
4 2022-09-07 sh.600000 7.22 3.973128 0.370105 1.102266 1.102266

%sql 是一种 line magic。我们还可以使用 cell magic,来构建更复杂的语句:

1
2
3
4
5
# EXAMPLE-1
%%sql --save agg_pe
select code, min(peTTM), max(peTTM), mean(peTTM)
from df
group by code

使用 cell magic 语法,整个单元格都会当成 sql 语句,这也使得我们构建复杂的查询语句时,可以更好地格式化它。这里在%%sql 之后,我们还使用了选项 --save agg_pe,目的是为了把这个较为复杂、但可能比较常用的查询语句保存起来,后面我们就可以再次使用它。


Tip

在 JupySQL 安装后,还会在工具栏出现一个 Format SQL 的按钮。如果一个单元格包含 sql 语句,点击它之后,它将对 sql 语句进行格式化,并且语法高亮显示。

我们通过 %sqlcmd snippets 来查询保存过的查询语句:

1
%sqlcmd snippets

这将列出我们保存过的所有查询语句,刚刚保存的 agg_pe 也在其中。接下来,我们就可以通过%sqlcmd 来使用这个片段:

1
2
3
4
5
6
7
query = %sqlcmd snippets agg_pe

# 这将打印出我们刚刚保存的查询片段
print(query)

# 这将执行我们保存的代码片段
%sql {{query}}

最终将输出与 example-1 一样的结果。很难说有哪一种数据库管理工具会比这里的操作来得更简单!

JupySQL 的可视化

JupySQL 还提供了一些简单的绘图,以帮助我们探索数据的分布特性。


1
%sqlplot histogram -t df -c peTTM pbMRQ

JupySQL 提供了 box, bar, pie 和 histogram。

超大杯的可视化工具

不过,JupyerSQL提供的可视化功能并不够强大,只能算是中杯。有一些专业工具,它们以pandas DataFrame为数据载体,集成了数据修改、筛选、分析和可视化功能。这一类工具有, Qgrid(来自 Quantpian),PandasGUI,D-Tale 和 mitosheet。其中D-Tale功能之全,岂止是趣大杯,甚至可以说是水桶杯。

我们首先探讨的是Qgrid,毕竟出自Quantpian之手,按理说他们可能会加入量化研究员最常用的一些分析功能。 他们在 Youtube 上提供了一个 presentation,介绍了如何使用 Qgrid 来探索数据的边界。不过,随着 QuantPian 关张大吉,所有这些工具都不再有人维护,因此我们也不重点介绍了。

PandasGUI 在 notebook 中启动,但它的界面是通过 Qt 来绘制的,因此,启动以后,它会有自己的专属界面,而且是以独立的 app 来运行。它似乎要求电脑是 Windows。

Mitosheet的界面非常美观。安装完成后,需要重启 jupyterlab/notebook server。仅仅重启 kernel 是不行的,因为为涉及到界面的修改。


重启后,在Notebook的工具条栏,会多出一个“New Mitosheet”的按钮,点击它,就会新增一个单元格,其内容为:

1
2
import mitosheet
mitosheet.sheet(analysis_to_replay="id-sjmynxdlon")

并且自动运行这个单元格,调出 mito 的界面。下面是 mitto 中可视化一例:

mitto 有免费版和专业版的区分,而且似乎它会把数据上传到服务器上进行分析,所以在国内使用起来,感觉不是特别流畅。

与上面介绍的工具相比,D-Tale 似乎没有这些工具有的这些短板。


我们在 notebook 中通过pip install dtale来安装 dtale。安装后,重启 kernel。然后执行:

1
2
3
import dtale

dtale.show(df)

这会显加载以下界面:

75%

在左上角有一个小三角箭头,点击它会显示菜单:

75%


我们点击describe菜单项看看,它的功能要比df.describe 强大不少。df.describe 只能给出均值、4 分位数值,方差,最大最小值,dtale 还能给出 diff, outlier, kurtosis, skew,绘制直方图,Q-Q 图(检查是否正态分布)。

注意我们可以导出进行这些计算所用的代码!这对数据分析的初学者确实很友好。

这是从中导出的绘制 qq 图的代码:

1
2
3
4
5
# DISCLAIMER: 'DF' REFERS TO THE DATA YOU PASSED IN WHEN CALLING 'DTALE.SHOW'

import numpy as np
import pandas as pd
import plotly.graph_objs as go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
if isinstance(df, (pd.DatetimeIndex, pd.MultiIndex)):
    df = df.to_frame(index=False)

# REMOVE ANY PRE-EXISTING INDICES FOR EASE OF USE IN THE D-TALE CODE, BUT THIS IS NOT REQUIRED
df = df.reset_index().drop('index', axis=1, errors='ignore')
df.columns = [str(c) for c in df.columns]  # update columns to strings in case they are numbers

s = df[~pd.isnull(df['peTTM'])]['peTTM']

import scipy.stats as sts
import plotly.express as px

qq_x, qq_y = sts.probplot(s, dist="norm", fit=False)
chart = px.scatter(x=qq_x, y=qq_y, trendline='ols', trendline_color_override='red')
figure = go.Figure(data=chart, layout=go.Layout({
    'legend': {'orientation': 'h'}, 'title': {'text': 'peTTM QQ Plot'}
}))

有了这个功能,如果不知道如何通过 plotly 来绘制某一种图,那么就可以把数据加载到 dtale,用 dtale 绘制出来,再导出代码。作为量化人,可能最难绘制的图就是 K 线图了。这个功能,dtale 有。

最后,实际上dtale是自带服务器的。我们并不一定要在 notebook 中使用它。安装 dtale 之后,可以在命令行下运行dtale命令,然后再打开浏览器窗口就可以了。更详细的介绍,可以看这份 中文文档