跳转至




strategy

暴力美学!洗盘模式如何检测?

无洗盘,不拉升。 筹码收集阶段,股价呈现出上涨形态,也吸引到许多不坚定的跟风盘,它们将成为主升过程中的不利因素。

因此,在拉升之前,主力会采用洗盘的方式,将这些不坚定的低价筹码洗下车。这个过程中往往暴涨暴跌,犹如一匹烈马,要摆脱它身上的骑手一样。

暴力洗盘,某种程度上就成为了行情快速上涨之前的信号之一。

这篇文章,我们量化实现的技术问题:如何快速检测出洗盘模式?


定义

L50

暴力洗盘是在证券市场上观察到的一种经验模式,因此没有严格的定义。一般把两阳夹一阴、且涨跌幅都巨大的情况认为是暴力洗盘。在本文中我们把两阳夹两阴、且涨跌幅都较大的情况定义为暴力洗盘。但我们介绍的方法,也完全适用于其它模式,只需要微调参数即可。

如左图所示,标的在1号位置之前,经过一段时间的吸筹,由于期间股价上涨,已经吸引了一些跟风盘。主力在1号位置拉出20cm,在这一过程中,较多跟风筹码被锁定在涨停位置。

第2天起主力开始洗盘,连续两天,分别下跌14.4%和18.9%。此时在1号位置买入的筹码因为忍受不住巨大的跌幅,忍痛交出筹码。主力筹码增加,成本降低,为后面的拉升留出了空间。

第4天主力将个股拉涨9.4%,表明洗盘结束。

随后几天的整理,主要是留出时间,让下一波的跟风盘有时间发现这支标的,并且有信心跟随买入。紧接着使用一系列小阳线做出上升趋势,最终再拉出一个20cm,从第4天起,短期涨幅高达87%。


我们为什么要使用两阳夹两阴的4天模式来定义洗盘?

因为经过两天的洗盘,从时间和空间上看,洗盘效果会更好(考虑到交易者心理因素,一些人第一天亏损后,往往还不会绝望,第二天继续下跌,更容易崩溃卖出)。另外,从一些技术指标上来看,经过连续两天大幅下跌,技术指标修复比较到位,也更能为后面的拉升腾出上涨空间。

我们为涨跌幅设置一个阈值,如果期间的每个bar的涨跌幅超过这个阈值,我们就认为发生了洗盘。在我们的示例中,使用的阈值是0.05,即涨跌5%。

下面我们来看代码实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 示例1
def feature_washout(bars, threshold=0.05):
    """返回在bars中最后一次洗盘结束的位置,-1表示最后一个bar,
        0表示不存在洗盘模式
    """
    close = bars["close"]
    opn = bars["open"]
    truerange = np.maximum(np.abs(close[1:] - close[:-1]), 
                           np.abs(opn-close)[1:]) 
    # 百分比化
    tr = truerange / close[:-1]
    sign = (opn < close)[1:] * 2 - 1
    signed_tr = tr * sign

这里我们使用了truerange这个变量名,是因为这段代码脱胎于技术指标TR

这段代码解决如何将涨跌幅转换为由1,-1和0表示的模式,以便我们后面进行模式检索。

如果当天涨跌超过5%,或者实体的振幅超过5%,我们就将其标记为1或者-1,否则标记为0。标记的符号由它的形态是阴线还是阳线决定。阴线为-1,阳线为1.

我们通过这样一段简单的代码就实现了求阴阳线的功能:

1
(opn < close) * 2 -1

其结果将生成由1和-1组成的数组。无论是涨还是跌,我们总是认为,阴线是洗盘。所以,高开大阴线,即使收盘是上涨的,我们也当成洗盘来处理。

下图就是高开大阴线洗盘一例:

75%


在判断每个bar的涨跌幅、或者实体的振幅是否超过阈值时,我们使用了一个简单的技巧,即通过np.maximimum来从多个数组中,以 element-wise 的形式选取最大值。即,如果有数组\(A\)\(B\),那么\(np.maximum(A, B)\)将返回一个数组,其元素为\(A\)\(B\)对应位置的元素中的较大值。

也就是,如果结果是\(C\),那么\(C_0\)将是\(A_0\)\(B_0\)中的较大值,\(C_1\)将是\(A_1\)\(B_1\)中的较大值,以此类推。

除了使用\(np.maximimum\)这种 ufunc 之外,实际上\(np.max\)也可以用来完成这项任务,只是我们需要先将数组\(A\)\(B\)堆叠成一个矩阵:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# 示例2
A = np.arange(4)
B = np.arange(3, 7)
C = np.arange(8, 4, -1)

Z = np.vstack((A,B,C))

# 通过np.max求每列最大值
r1 = np.max(Z, axis=0)

# 通过np.maximum求最大值
r2 = np.maximum.reduce([A, B, C])

# 比较两种方法的结果是否相同
np.array_equal(r1, r2)

为了提供更多信息,示例中我们演示了三个数组按元素求最大值的情况。答案是要使用reduce方法。如果只在两个数组间进行比较,则可以只使用np.maximum

经过示例1处理后,我们可能得到如下所示的数组:

[ ... 0.04 -0.02 -0.06 0.04 -0.04 -0. 0.2 -0.14 -0.19 0.09 -0.03 ...]

显然,我们还应该把它二值化,转换成为[大阳,大阴,大阴,大阳](即[1, -1, -1, 1])这样的模式:

1
2
3
4
5
6
7
8
9
# 示例3
encoded = np.select([signed_tr > threshold, 
                    signed_tr < -threshold], 
                    [1, -1], 0)

for i in range(len(encoded) - 3, 0, -1):
    if np.array_equal([-1, -1, 1], encoded[i:i+3]):
        return i - len(encoded) + 2
return 0

我们通过select方法完成了二值化转换。接下来我们通过一个逆序的循环,通过array_equal完成了模式匹配。

在回测中,我们可能需要一次性地提取出很长一段行情中所有的洗盘模式,并对其效果进行检验。上面的代码还可以通过numpy.lib.stride_tricks.sliding_window_view进行优化:


1
2
3
4
5
6
7
8
def feature_washout(bars):
    ...
    washouts = []
    for i, patten in enumerate(sliding_window_view(encoded, window_shape = 4)):
        if np.array_equal(patten, [1, -1, -1, 1]):
            washouts.append(i)

    return washouts

通过将涨跌幅二值化,我们就可以在随后方便地通过array_equal来匹配模式。我们这样做,是因为在这里定性分析基本就够了,只要涨跌幅超过5%,那么无论是跌了5.1%还是7.2%,我们都认为是洗盘。

但是,如果你觉得定量分析仍然有意义,也可以通过求皮尔逊相关系数的方法来进行模式匹配。

不过,被匹配的模式,应该使用多少呢?如果你对此感兴趣,可以评论区留言,获得推荐参数。

终极猜想!底蓓离的成因分析

这几天圈内都在传底蓓离什么的。作为严肃的量化自媒体,我们就不跟着吃这波瓜了。不过,我一直很关注技术指标的顶背离和底背离,一直在追问它的成因如何,以及如何预测。

底蓓离把我目光再次吸引到这个领域来,于是突然有了一个猜想。虽然我还没来得及完全证实它,但这个猜想,值得你锁定我的频道。

这个猜想是:RSI的日线顶背离,是因为周线还有上升空间。当周线RSI也达到前期高点时,就会触发回调。此时日线虽然创新高,但高位筹码不稳,回调增多,导致RSI下降。


背离的定义

Investopedia对技术指标的#顶背离定义为:

Quote

背离是指资产价格的走势与技术指标(例如震荡指标)相反,或者与其他数据相反。背离警告当前价格趋势可能正在减弱,并且在某些情况下可能导致价格改变方向。

存在正背离和负背离。正背离表明资产价格可能上涨。负背离表明该资产可能会下跌。

在国内,我们一般使用顶背离和底背离这两个术语。最早大V中较多讨论这两个术语的,可能是炒股养家或者缠中说禅,说明这一现象在实战中是比较受到关注的。

下图中的右图展示了一个日线的顶背离。左图则是对应的周线情况。

附图指标我使用的是RSI,它是一个非常好的震荡指标,深刻反映了当前市场的赚钱效应和强弱力量对比。

在图中,程序自动标注出来了k线的每一个峰和谷。这些标注使用了我们自己的自适应参数算法,在绝大多数时间、无论周期是哪个级别,都能工作得很好。不过这一次,在周线级别上,没能触发它标注出3月22日那一周的高点。


从右图中我们注意到,日线上存在2月27日和3月18日两个峰,后一个峰的价格高于前一峰,但后一个峰的RSI略小于前一峰的RSI(前高78.3,后高77.7),形成了顶背离。

我们在课程中详细介绍过一个独创但更准确地理论,在运用RSI时,不是低于30就会反弹,高于70就会回调,而是要看它跟前一个峰(或者谷)的RSI相比较,如果上涨时,RSI高于前一个峰对应的RSI,则有可能回调。

但这个结论也有自身的问题:一是在图中,日线在2月27日之前,就已经突破了2024年1月25日下降通道中高点RSI,为何一直到2月27日才回调?我们在课程中已经解决了这个问题。


二是为何在2月27日之后,股价还能一直上涨,直到3月18日出现顶背离?这个顶背离问题,我也一直没有思路,但自己着手写了好几个检测顶背离的例程。

猜想和验证

现在我们给出回答第二个问题的一个猜想,即尽管2月27日的日线RSI已经达到高位,但周线RSI仍在低位,它没有表示反对和阻止上涨的意思,因此日线短暂调整后,在其它因素趋动下继续上涨,直到3月22日那一周,盘中突破前高60.5,才引发了一个周线级别的大回调。

Tip

从3月21日起,到3月27日止,中证1000连续回调超过6.94%,从而引发反弹,这是另外一个故事和机会。我们在前面的一篇文章中介绍过,统计数据给出3月27日收盘后,反弹概率超过91.4%的结论,这种情况,应该坚决抄底

RSI是反转指标。它只能以一定概率给出会不会反转,但你不应该指望它给出趋势延续的预测。那是趋势类指标的任务。最重要的趋势类指标,莫过于判断均线的斜率,如果线性回归误差在可接受范围以内的话。在回归误差较大而失效的情况下,课程中也给出了一个非常鲁棒的方法。

我们还是拿数据验证一下:


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 日线2月27日、3月18日顶背离,前者RSI 78.3,后者RSI 77.7
dclose = dbars["close"].astype(np.float64)
drsi = ta.RSI(dclose, 6)

for dt in (datetime.date(2024, 2, 27), 
           datetime.date(2024, 3, 18)):
    mask = dbars["frame"] == np.datetime64(dt)
    i = np.flatnonzero(mask)[0]
    print(dt, dclose[i], drsi[i])

# --- output ---
# 2024-02-27 5394.03 78.3
# 2024-03-18 5648.01 77.7

从数据看出,尽管日线价格在上涨,但RSI却下降了,构成了日线顶背离。

接着看周线:

1
2
3
4
5
6
7
8
# 周线rsi前高出现于2023年11月17日,数值为60.5
wclose = wbars["close"].astype(np.float64).copy()

nov_17 = np.datetime64(datetime.date(2023,11, 17))
i = np.flatnonzero(wbars["frame"] == nov_17)[0]

rsi = np.round(ta.RSI(wclose, 6), 1)
rsi[i]

前高为60.5。这里有一个技巧,如果没看明白,可以给我留言:

1
2
3
4
5
6
7
8
9
# 3月22日,周线rsi数值盘中突破 61.9,高于前高60.5,触发回调

mar_22 = np.datetime64(datetime.date(2024, 3, 22))
i = np.flatnonzero(wbars["frame"] == mar_22)[0]

wclose[i] = wbars["high"][i]

rsi = ta.RSI(wclose.astype(np.float64), 6)
rsi[i]

rsi后高为61.9,因此触发回调,并且这个阻力得到了确认。

Tip

注意这里rsi没有到70就回调了。所以,你看到的所有关于RSI的教科书,都该改改了。是不存在 one size fits all。不同的标的、不同的周期以及不同的阶段,应该有不同的参数。这些参数应该可以通过量化程序(或者机器学习)计算出自适应的值。

引申结论及思考

在这个市场上,存在各种不同操作频率(这是FFT和wavelet应该有用的原因)的资金。


对高频量化,他们看的是tick级数据,可能持有几分钟就会调仓;散户和量化多在日线和周线频率上操作,持有数天就调仓;长线资金以季度为单位。越是长线的资金,资金量越大,调仓时对走向的影响越强。

现在,你应该已经猜到了,有一部分资金会在日线RSI高点时撤出;大量的资金会在周线的RSI高点撤出;而更大量的资金会在月线的RSI高点撤出。

但我猜没有资金会根据季线的RSI高点撤出。许多真理,都不能线性外推。

我这么说的原因是,季线资金会按照基本面来进行操作,而不是技术面。有的同学会拿基本面因子与技术面因子揉在一起,无论是多因子回归,还是机器学习,这都是不对的。它们只会相互打架、抵消。

所以,从现在起,你应该给你的股票上个闹钟,计算出它的日线、周线和月线RSI前期高点,然后实时监控这些指标。

一旦三者都达到高点,这轮行情就结束了。如果月线达到高点,日线和周线不在高位,但发生过顶背离,那么,势必在高位产生过滞胀(量在堆积,但上涨幅度不大),此时主力的筹码很多已经被交换掉了。

此时不走很危险。接下来的回调时间,可能以月计。老胡说,“我不割肉,你怎么割我?”但胡锡进终究是等不到那一天。

机器学习(XgBoost)预测顶和底

之前的文章中,我们对中证1000指数进行了顶和底的标注。这一篇我们将利用这份标注数据,实现机器学习预测顶和底,并探讨一些机器学习的原理。

我们选取的特征非常简单--上影线和WR(William's R)的一个变种。选取这两个因子,是基于东吴证券高子剑在2020年6月的一份研报:上下影线,蜡烛好还是威廉好?

他们的结论是,根据这两类指标的变种得到的综合因子,在2009到2020年4月,以全A为样本,进行5组分层多空测试,得到年化收益为15.86%,最大回撤仅为3.68%,可以说具有非常明显的信号意义。


Info

L33 在这次实验中,我们将使用XgBoost。

它的开发者是陈天奇,上海交大ACM班06级毕业,华盛顿大学博士。现任卡内基.梅隆大学助理教授。

除了XgBoost,他还是MXNet的开发者,这个框架是一度与Tensorflow, Pytorch等齐名的四大深度学习框架之一。

题图即为卡内基.梅隆大学校园。陈天奇现在任教于此。

在上一篇文章中,我们提到机器学习总是把要解决的问题归类为两类,一类是回归,一类是分类。如果要预测的target取值处在连续实数域上,这往往是个回归问题;如果target的值域为有限个离散状态,则是一个分类问题。

然而,具体问题总是复杂许多。初学者会觉得,既然股价的取值是在连续实数域上,因此可以把它看成回归问题,使用类似LSTM之类的神经网络来预测股价。但实际上由于金融数据的噪声问题,这么做并没有什么道理。

很可能只有在构建资产定价模型时,才可以当成回归来处理,也就是,根据公司的基本面和宏观经济指标来确定公司的市值,进而推算出股价。这本质上跟预测落杉叽的房价是同样的问题。

如果我们要构建时序方向上的预测信号呢?很可能只能用我这里的方法,不去预测每一个bar的涨跌和价格,而是改为预测顶和底,最终实现买在底部,卖出在顶部。

安装XgBoost

我们一般通过conda来安装它的Python包,但pip(需要版本在21.3以上)也是可以的。

1
conda install -c conda-forge py-xgboost

在Windows上安装时,还需要额外安装VC的分发包。

如果你的机器安装有支持cuda的GPU,那么conda会自动安装带GPU支持的xgboost。

不过,GPU对xgboost的加速并没有对CNN这样的神经网络那么明显。也就是说,即使有GPU,xgboost也只会在某些阶段利用到GPU加速,总体上可能会快几倍而已。考虑到我们的标注数据本身比较小,这个加速并不重要。

数据构造

经过顶底数据标注之后,我们已经获得了一份如下格式的数据:

这份数据包括了标签(即flag一列),但没有我们要的特征工程数据。因此,我们要先从OHLC数据中提取出特征。

我们决定先从最简单的特征提取--上影线和WR(William's R)的一个变种。选取这两个因子,是基于东吴证券高子剑在2020年6月的一份研报:上下影线,蜡烛好还是威廉好?

他们的结论是,根据这两类指标的变种tr得到的综合因子,在2009到2020年4月,以全A为样本,进行5组分层多空测试,得到年化收益为15.86%,最大回撤仅为3.68%,可以说具有非常明显的信号意义。

66%

基于这个基础,我们改用机器学习的方法来做一遍。我们用来提取上下影线和WR的方法如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def wr_up(bars):
    h, c, l = bars["high"], bars["low"], bars["close"]
    shadow = h - c

    # 技巧:避免产生除零错误,且不影响结果正确
    return shadow/(h - l + 1e-7)

def wr_down(bars):
    h, c, l = bars["high"], bars["low"], bars["close"]
    shadow = c - l
    return shadow/(h - l + 1e-7)

def upper_shadow(bars):
    h, c, l = bars["high"], bars["low"], bars["close"]
    o = bars["open"]
    shadow = h - np.maximum(o, c)
    return shadow/(h - l + 1e-7)

def lower_shadow(bars):
    h, c, l = bars["high"], bars["low"], bars["close"]
    o = bars["open"]
    shadow = np.minimum(o, c) - l
    return shadow/(h - l + 1e-7)

xgboost是基于树模型的,对数据的正则化本来没有要求,不过,为了便于分析和对比,我们对这四个指标都进行了归一化处理,使得数据的取值都在[0,1]之间。

如果是上下影线,值为0.5时,表明影线占了当天振幅的一半高度。如果为1,则当天收T线或者倒T(也称为墓碑线)。

William's R 是美国作家(不要脸一下,就是博主这一类型)、股市投资家拉里.威廉在1973年出版的《我如何赚得一百万》中首先发表的一个振荡类指标,它的公式是:

\[ W\%R = \frac{H_n - C_n}{H_n - L_n} x 100\% \]

计算向下支撑的公式略。

n是区间长度,一般设置为14天。这样\(H_n\)即为14天以来的最高价。其它变量依次类推。如果我们把n设置为1天,就回归成类似于上下影线的一个指标。

与K线上下影计算方法不同之处是,它只使用收盘价,而不是像上下影线那样,使用收盘价与开盘价的最大者(计算上影线时)或者最小者(计算下影线时)。

这里还有一些技巧,比如我们使用了numpy的ufunc之一, maximum来挑选开盘价和收盘价中的最大者。另一个显而易见的方法是:

1
np.select([c>o, o<c], [c, o])

但此处使用ufunc会得到加速。

接下来,我们就可以构建训练数据集了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
data = {
    "label": raw["flag"].values,
    "data": np.vstack(
        (wr_up(bars), 
         wr_down(bars), 
         upper_shadow(bars), 
         lower_shadow(bars)
        )
        ).T
}

bars是numpy structured array, 包含了OHLC数据和flag,由之前的raw变量转换过来。

最终我们生成了一个字典,训练数据存放在"data"下,标签数据存放在"label"下。使用了np.vstack来将特征合并起来。这些函数在《量化交易中的Numpy与Pandas》课程中有讲解。

接下来,我们引入sklearn的中的方法,将上述数据集切分为训练集和测试集,然后进行训练:

1
2
3
4
from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = 
                train_test_split(..., test_size=.2)

我们保留了20%的数据作为测试数据。

1
2
3
4
5
bst = XGBClassifier(n_estimators=3, max_depth=2, learning_rate=0.5)
# fit model
bst.fit(X_train, y_train)
# make predictions
preds = bst.predict(X_test)

现在,训练完成,并且我们在测试数据集上进行了预测。接下来,我们希望知道这个模型的有效性。为此我们要引入sklearn.metrics中的一些度量方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from sklearn.metrics import *

acc = accuracy_score(y_test,preds)
print(f"ACC: {acc:.3f}")

recall = recall_score(y_test,preds, average='weighted')
print(f"Recall:{recall:.1%}")

f1 = f1_score(y_test,preds, average='weighted')
print(f"F1-score: {f1:.1%}")

pre = precision_score(y_test,preds, average='weighted')
print(f"Precesion:{pre:.1%}")
mx = confusion_matrix(y_test,preds)

我们得到的结果看上去很完美:

1
2
3
4
ACC: 0.930
Recall:93.0%
F1-score: 89.6%
Precesion:86.5%

但是,这些数据能表明模型真的有效吗?幸福会不会来得太容易?所以,我们还得往下深挖一层,看看实际的预测效果究竟如何。在分析大量样本预测结果时,我们有一个利器,称为困惑矩阵(confusion matrix)。


Tip

关于confusion,有这样一则笑话。在一个选美比赛上,一位美女被要求解释孔子(Confucius)的名言:"Reading without meditating is a useless occupation (学而不思则惘)"。显然这位美女不知道Confucius是谁,于是,她望文生义地猜测, Confucius was one of the men who invented confusion。不过,中庸有时候确实让人confused,猜测孔夫子发明了困惑,也是歪打正着。

我们要将矩阵mx可视化。人类,无论男人还是女人,都是视觉动物。我们无可救药地偏好各种色图。

1
2
sns.heatmap(mx/np.sum(mx), cmap="YlGnBu", 
            annot=True, fmt=".1%")

我们会得到这样一张图:

50%

这张图表明:大约有3.8%的0类数据,被错误分类为标签1;大约有3.2%的2类数据,被错误地分类为标签1;所有的1类数据,都正确地分类为1。

从这张图我们还可以知道,这是一个严重有偏的数据集。但我们最为关注的第0类(对应于flag = -1)和第2类(对应于flag = 1),它没能正确识别。当然,它也没错到把第0类识别为第2类,或者相反。

不过,无论如何,我们有了一个好的开端。随着我们往训练数据里加入更多的数据行、更多的特征,并且使得数据按类别均匀分布后,这个模型一定会有提升。

不过,在改进之前,我们还需要掌握更多关于xgboost及评估指标的理论知识。下期见!

新国九条下,低波动因子重要性提升!

Robert (Bob) Haugen, 低波动因子之父。图片来自MarketWatch


之前我们有一期文章介绍了低换手率因子。它的背后的原理是,要买在无人问津处,藏器待时,最终卖在人声鼎沸时。这是一种博弈逻辑。

今天介绍的低波动因子,同样强调无人问津处的价值。由于它的低波动特性,在实操上,不受短线资金青睐;在学术上,它与CAPM、EMH等流行的理论相悖,因而甫一提出,就被华尔街和学术界视为异类。直到2008年,MSCI才开始涉及这一因子,编制了MSCI全球低波动率指数。

大量的实证研究表明,在长达数十年的投资史中,低波动率因子都具有明显的优势:人迹罕至的道路,有时提供更好的旅程

新国九条之后,红利股在投资中的重要性将大大加强,而低波动因子在发现红利股、白马股方面有优秀的选择能力。这是我们介绍低波动因子的时间背景。

低波动因子的数据表现

从CAPM模型发表以来,华尔街和学术界坚信风险与回报是紧密相连的:市场是有效的,投资者要获得较高的收益,就必须承担更多的风险。

然而,Robert Haugen和他的老师 James Heins 教授在60~70年代就发现,与流行的理论相反,低风险股票实际上能产生更高的回报。

Tip

Robert Haugen, 金融经济学家、量化投资和低波动性投资领域先驱。伊利诺伊大学-香槟分校金融经济学博士,教授。

Haugen认为,有关投资和公司金融的重要教科书“大错特错,需要重写”。由于Haugen对低波动因子的大力推崇,从而获得了“低波动性投资之父”的非正式称号。

Tip

查理.芒格也对现代金融理论报有类似的观点。他曾经讥讽地说,一些把现代金融理论鼓吹得头头是道的诺奖经济学家,不得不把自己管理的基金破产,转而投资伯克希尔.哈撒韦。

根据S&P Global的Tim Edwards博士等人的研究,低波动性指数的风险调整回报在多个国家的表现都超过了母指数,在日本甚至超出近一倍。这个对比图我们不放了,放一个累积收益对比图。

数据来源于S&P Dow Jones Indices, 1990~2018

这个图是低波动因子标的池与市场(标普500)历史累积回报相对比的一个图。从图中可以看出,几乎在任何一个时期,低波动因子的总体收益(而不仅仅是风险调整收益)都超过了市场表现。

他们的构建方法是,从标普500中,挑出波动率最低的20%,并按与波动率成反比的方式进行加权,并且每季度进行一次调仓。

这份研究报告是教育性质的,在jieyu.ai 上提供了免费下载。

MSCI在去年三季度的一篇文章中,披露了他们构建的低波动性指数的表现情况:

图表来源:MSCI网站

上图显示出,低波动因子在市场衰退期反而能逆市上涨,其它多数时间也跑赢指数。

低波动因子的有效性解释

关于低波动性因子有效的原因,学界也是用了很多数学和数据来解释。但实际上,回到问题的本原上来,低波动性是如何造成的?是由于它的投资者长期看好公司,并且以持有吃分红为主,很少交易造成的。

投资者为什么会长期看好一支股票呢?是因为这些公司的业务逻辑很简单(能看懂,所以投资者不会反反复复)、护城河深(竞争格局很难改变)、盈利足够好(核心还是要能赚钱),是那些世界无法改变的公司(摘自但斌近期路演)。

这就有点价值投资的味道了。实际上,学界有人做了很多研究,最后发现,低波动因子与价值因子有较强的关联性:低波动性的股票、往往也是低市净率的股票。因此,白马股、长期红利股,也往往是低波动率的个股。

Tip

既然低波动因子与价值因子强关联,如果一个市场里的财务数据不那么可靠,那么我们就应该使用低波动因子来代替。量价数据永远不说谎。如果一家过去看起来很好的公司,最近出了问题,看财报的人永远是最后一个知道的。但股价会提前反映。

一个有趣的事实是,夏普率与波动率的关系。夏普率是一种风险调整收益率,它的分母--资产收益的标准差,正是波动率的线性函数。

夏普率的提出者,正是CAPM理论的创始人威廉.夏普。而低波动率投资方法的支持者们,正是用低波动率作为武器,举起了反抗以CAPM为代表的现代金融理论的旗帜。如此说来,夏普在发表CAPM模型时,也为这一理论安排了自己的掘墓人。这很辩证法。

波动率的计算

根据investopedia,波动率的公式是:

\[ vol = \sqrt{var(R)}/T \]

这里的T是产生回报率R的周期数。但也有不除以T的做法。

在金融领域,波动率常以年化方式进行呈现和比较。这可以用pandas来计算:

1
2
3
4
5
bars = ...
close = bars["close"]

close.pct_change().rolling(window_size)
     .std() * (252**0.5)

在进行单因子检验时,我们需要求得每一天的标的的波动率,因此会需要这里的rolling版本。

或者,更简单地,使用quantpian的开源库empyrical:

1
2
3
4
from empyrical import annual_volatility

daily_returns = close.pct_change()
annual_volatility

低波动率策略

一般而言,我们无须自行检验低波动率因子的有效性,可以直接使用它来进行选股。

Tip

由于低波动率因子的特性,要检验它,必须要跨越至少一个牛熊经济周期,这样引入的数据量非常大。对A股来讲,我们每天不一样,因此还很难说走过了多少个经济周期。

要注意的是,如果我们以短期的波动率进行选股,非常有可能选中下跌状态中的个股。下图显示了相同的波动率,可以出现完全不同的股价走势:

在低波动率条件下,判断股价走势非常容易,我们对价格进行回归,如果得到的直线斜率大于0,则走势是向上的。

这里的关键点是,我们首先要使用月线,至少24个周期以上。在较短的周期上,低波动率的背后没有经济学上的意义支撑。主要代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
for symbol, name, _ in get_secs():
    bars = get_bars(symbol, 24, ...)
    if len(bars) < 24:
        continue

    close = bars["close"][-24:]
    returns = close[1:]/close[:-1] - 1
    # 计算波动率
    vol = np.std(returns)

    # 计算斜率
    a, b = np.polyfit(np.arange(24), close/close[0], degree=1)
    result.append((name, symbol, vol, a))

df = pd.DataFrame(result, columns=["name", "symbol", "vol", "slope"])

df[df.slope>0].nsmallest(10, "vol")

这样我们就选出了趋势向上,波动率最小的10支。当slope接近0时,实际上还不能认为趋势向上,所以,我们可以通过分位数来进行筛选:

1
2
3
quant_25 = df[df.slope>0].slope.quantile(0.25)

df[df.slope > quant_25].nsmallest(10, "vol")

结束语

人迹罕至的道路,有时提供更好的旅程。新国九条之后,红利股在投资中的重要性将大大加强,而低波动因子在发现红利股、白马股方面有优秀的选择能力。如果一个市场里的财务数据不那么可靠,那么我们就应该使用低波动因子来代替价值因子。量价数据永远不说谎。如果一家过去看起来很好的公司,最近出了问题,看财报的人永远是最后一个知道的。但股价会提前反映。

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%。

交割日魔咒?

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

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

后见之明!错过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。它应该如何计算,又如何使用,也许后面我们会继续探索。

左数效应 整数关口与光折射

常常有人问,新的因子/策略从哪里来?今天的笔记或许能启发你的思路。

从1932年起,研究人员就注意到以9结尾的价格(比如$3.99),在消费者的认知中,要远远小于邻近的整数价格($4.00)。后来这一效应被称为 left-digit effect。在证券交易中,类似的情况一样存在,不过它的表现形式是整数关口压力。

龙凤呈祥:这种无底限炒作,如何用量化方法发现它?

作为量化人,我们敏锐地观察市场,不放过任何一个可能产生利润的机会。一旦发现这样的机会,我们决不会在乎其它人怎么看怎么想,书上有没有这么讲。但是,大胆假设,小心求证。

今天带来的因子,挺魔幻的,我把它叫做魔性汉字。如果你难以接受这种无底线的炒作,那么,我们换一个名字:另类因子。