跳转至

为了机器能学习,我标注了 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个。