关于寻找顶和底的一些思考


Table of Content

scipy当中有一些信号处理函数,可以用来发现顶和底,比如argrelextrema。 比函数名上看,这是一个寻找相对极值点坐标的函数。我们来看一下它的用法:

 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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
def bs_signals(bars):
    from scipy.signal import argrelextrema
    ma = moving_average(bars["close"], 5)

    peak_indexes = argrelextrema(ma, np.greater)
    peaks = peak_indexes[0]

    # Find valleys(min).
    valley_indexes = argrelextrema(ma, np.less)
    valleys = valley_indexes[0]

    assert abs(len(peaks) - len(valleys)) <= 1

    bars = bars[4:]
    # Plot main graph.
    (fig, ax) = plt.subplots()
    ax.plot(np.arange(len(bars)), bars["close"], color='c')
    ax.plot(np.arange(len(bars)), ma, color='b')

    # Plot peaks.
    peak_x = peaks
    peak_y = bars['close'][peak_x]
    ax.plot(peak_x, peak_y, 'gv', label="Peaks")

    # Plot valleys.
    valley_x = valleys
    valley_y = bars['close'][valley_x]
    ax.plot(valley_x, valley_y, 'r^', label="Valleys")

    trades = []
    gains = 1
    order = None

    vertex = sorted([*peaks, *valleys])
    for x in vertex:
        buy = x in valleys
        sell = x in peaks
        if buy and order is None:
            order = {
                "buy": bars["close"][x],
                "buy_at": bars["frame"][x]
            }

        elif sell and order:
            buy = order["buy"]
            sell = bars["close"][x]
            gain = sell / buy
            order.update({
                "sell": sell,
                "sell_at": bars["frame"][x],
                "gain": gain
            })

            gains *= gain
            trades.append(order)
            order = None

    return gains - 1, trades
上面的函数使用argrelextrema来查找均线的顶和底。这里使用均线,是因为均线相对平滑,去除了一些噪音。这里的moving_average定义为:
1
2
3
import numpy as np
def moving_average(ts, win):
    return np.convolve(ts, np.ones(win) / win, mode="valid")
它使用了卷积来求简单移动平均值。numpy的卷积运算速度很快,但如果想要更快的速度,可以使用bottleneckmove_mean,最高可以快6000多倍(取自官方文档,不一定是和np.convolve方法比较)。

通过给argrelextrem传入不同的比较函数(例子中是np.lessnp.greater),我们可以分别查找极大值和极小值,记为peaks和valleys。

然后我们把均线、收盘价线和极值点画在同一个图上。最后,我们假设在低点买入,在高点卖出,就可以计算出策略的收益。

下图是我们以东方电气2021年11月2日为止点,对60个交易日内进行信号发现的结果:

可以看出,仅仅使用默认参数,argrelextrem就能较好地发现顶和底。我们得到的最终收益是35%,而同期该股实际上是不涨不跌。

非常惊人,just too good to be true.

那么倒底哪里错了?

原因出在argrelextrem的工作方式。它只有在股价已经向下走时,才能检测出一个顶点已经存在。这是一个未来函数。它意味着,只有在明天才可能识别出今天应该发出的信号。但我们不可能返回昨天来完成交易。

为了进一步验证上述观点,我们取2021年9月3日前后的两张图来对比: 上图是截止9月3日的图。从后面的结果来看,argrelextrem应该在这一天标注出一个底点。但是它并没有。 上图的时间是9月6日,这次它标注出来一个底点,但是标注在前一天。

由于均值线使用的是收盘价,这意味着,只有在9月6日收盘后,我们才能识别9月3日是一个低点。如果这时我们按9月6日的收盘价(实际交易还要晚一点,只能使用第二天的开盘价)来买入,情况会如何?

我们把上面的方法中,买卖价都顺延一天,得到的收益率是-13%。如果我们在起点时,以开盘价买入,在终点以收盘价卖出,则还可以收益8.4%。在动用高大上的数学方法后,成功地亏掉了21%。这里我们取得是5日均线。取10日、或者3日,都逃不掉亏损的命运。

理想是美好的,现实是残酷的。

然而。。。

老股民很容易观察到,在位置5附近,股价已经滞涨好几天了,均线也在走平。有句股谚,三天不创新高就要抛(这是对运做强势股而言)。这是因为,如果三天不创新高,则均线就会象上图中位置5附近一样走平,接下来既可能突破平台开启第二浪,也可能下跌。因此,我们的策略可以是这样,通过算法检测到均线走平,发出预警(此时可以部分减仓),等待明确的信号再决定是否清仓(反之则是买入)。

假设我们能在argrelextrem前一天发现走平的信号。如果此时处于高位,则卖出一半,待argrelextrem报出信号后,再卖出全部仓位。这样,上面函数的开仓、平仓部分就改为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
if buy and order is None:
    order = {
        "buy": close[x] * 0.5 + close[x+1] * 0.5,
        "buy_at": bars["frame"][x+1]
    }

elif sell and order:
    buy = order["buy"]
    sell = close[x+1] * 0.5 + close[x] * 0.5,
    gain = sell / buy
    order.update({
        "sell": sell,
        "sell_at": bars["frame"][x+1],
        "gain": gain
    })
这样我们得到的收益是5%。虽然不如买入并持有好,但毕竟这是我们凭实力挣的钱,而不是靠赌运气。所以这个收益可重复。

现在,问题就变成了,如何检测均线走平?这个问题回答起来也并不那么容易。不过我们先来看看这里的检测峰和谷的方法的实际作用。

从上面的分析可以看出,峰和谷的检测是后验的,即只有当峰和谷已经走出来,上面的算法才能检测到。虽然它对投资可能没有直接的指导意义,但我们也可以用它来标注数据,以供机器学习使用。

下图显示了使用上述方法,对上证指数一段时间的峰和谷进行标注的结果:

我们先是使用均线对波动进行平滑,然后通过argrelextrem进行峰和谷的检测,然后在此基础上,对峰和谷进行一些小的修正,使之与股价、而不是均线对齐。我们采用沪指过去6年的30分钟线,共发现734个顶点和738个底点,制作成为标注数据。

有了这些标注数据,我们就可以训练和检验自己的机器学习模型了。出人意料的是,数据量并不大。这也说明在A股市场上,趋势一旦形成,往往还能持续一段时间。这也是量化模型能够有用武之地的一个注脚。