hdbscan 聚类算法扫描配对交易 速度提升99倍

配对交易是一种交易策略,由摩根士丹利的量化分析师农齐奥.塔尔塔里亚在 20 世纪 80 年代首创。他现在是意大利雅典娜资产管理公司的创始人。该策略涉及监控两只历史上相关性较强的证券,并监控两者之间的价格差。一旦价格差超出一个阈值(比如 n 倍的标准差),价格回归就是大概率事件,于是交易者就做空价格高的一支,做多价格低的一支,从而获利。

睽违17年,ta-lib重装出发!

在你看到这篇文章时,2024年已经余额不足,而新的一年,正在等待我们冲刺。新的一年,作为量化人,你将在新年里收到哪些礼物呢?

先来晒一下我个人收到的礼物吧。昨天一早,收到了孙乐总赠送的《山河独憔悴》,并且很贴心地为我题了字。孙乐总是民主党派人士,江苏省收藏家协会理事和勋奖章收藏专业委员会副秘书长、美国钱币学会会员。

在书序中有这样一段话:

知识的本质被认为是『看』世界,是去看出事物的本质和真相。这本书则是关于换一个角度去理解历史,把『什么看成什么』和把『什么不看成什么』,将这种观看变成一种哲学行为。我想,我们做量化,归根结底也是要从数据的表现中跳出来,把『什么看成什么』这是找到规律,把『什么不看成什么』,这是去伪存真,过滤噪声。

这里也有一点小花絮。这本书作者倾向的名字是《江山独自憔悴》。我不懂声律,但也觉得这个标题更响亮和更有韵律感。不过,书中搜集的百余张精美的图片(铜版纸彩印)就在这里,并没有隐去,也没有打码,它们就是曾经的世界。期待这本书尽快在京东和当当上上架。

这个新年,你会收到什么样的礼物呢?不过,作为量化人,我们所有人都收到了一份重磅礼物,ta-lib的c库更新了!hV3*ifARjC@cp8O!

ta-lib重装出发!

就在新年前几天,ta-lib悄没声地更新了0.6.1版本。而上一个版本,还是17年之前发布的0.4.0.

这么久没有更新的原因是,原作者Mario Fortier一直希望找到一位更年轻的开发者来维护这个库,他本人觉得自己对更现代的C++语言特征,尤其是跨平台编译这一块有些陌生。尽管实际上他是一名非常资深的C++网络开发者,并且发明了在3G通信中使用的实时数据压缩算法。不过,他最近的工作已转向了Python,并且创办了自己的公司(从事区块链和网络软件开发)。

不过久久没能找到接班人,Mario决定继续干下去。于是,在12月23日,他发布了0.6.1版本。这个版本没有增加新的功能,主要是解决编译和自动化工具相关的问题。之前安装ta-lib的c库对初学者而言并非坦途,特别是在Windows下:要么接受恶意软件的风险,要么自己从下载好几个G的Visual studio编译器开始。这也是为什么《量化24课》需要讲解Ta-lib安装问题的原因之一。

0.6.1一发布就收到热烈反馈--包括bug report,于是,在三天后, Mario又发布了0.6.2这个版本 -- 得益于在0.6.1上所做的工具,我们看到新版的ta-lib的可维护性大大增强,以致于可以在3天之内发布新的版本 -- 这包括引入了Github Actions使得整个打包和发布工作自动化。

现在,在windows下安装ta-lib变得轻而易举:

不过,它的python-wrapper尽管也为0.6.1进行了更新,但是,没能通过我们的安装测试 -- 在安装python-talib(通过pip install TA-Lib)时,仍然提示需要有vsc++ build tools 14。相信这个问题能很快解决。

在mac下,最新的ta-lib安装非常简单,只需要执行:

1
brew install ta-lib

如果之前已经安装了ta-lib的旧版本,它会提示我们此次安装将会更新。

在debian系列的Linux(即Ubuntu, Mint)下,安装也很容易,下载后缀为*.deb的安装包,再执行命令:

1
sudo dpkg -i ta-lib_0.6.0_*.deb

即可。在Linux下支持的cpu架构包括了386, amd64架构和arm64架构,*号就是用来匹配这个架构的。对其它系列的Linux,则仍然要从源码构建,不过,在Linux下进行构建非常容易。

ta-lib的周边

ta-lib最重要的周边应该是github上的ta-lib-python这个库了。它集得了接近1万的star,这个级别的star数本来应该是被AI项目占据的 -- 这也充分说明近年来量化金融的受众正在迅速扩大。

ta-lib-python也迅速响应了ta-lib的更新,最新发布的0.5.2,已经适配了ta-lib的0.6.1版本。根据我们的测试,在mac上整个安装过程非常丝滑,先安装ta-lib的c库,再安装ta-lib-python不会出任何错误。但是在Windows下,即使已经通过msi安装了ta-lib的c库,但它的python-wrapper似乎仍然无法找到已经安装的c库和头文件,因此,仍然需要本地构建ta-lib的c库。

在等待ta-lib c库更新的日子里,ta-lib-python也并不有闲着。它先是完成了通过cython,而不是swig来绑定c库这一转换,据称带来了2~4倍的性能提升。更激动人心的是,Python 3.13放出了GIL-Free模式之后,作者正在尝试这个GIL-Free版本。一旦Cython 3.1正式发布(ta-lib-python依赖于这个版本),很有可能ta-lib-python就会立即支持GIL-Free。

另一个重要的周边是polars-talib,也正在积极开发中,尽管目前还只是0.1.4版本。它是polars的一个扩展,根据测试,计算速度比在pandas中使用talib(ta-lib-python)快了100多倍。

除此之外,一个用rust实现的ta-lib也正在开发中。

美好的事情正在发生,就在这个新年将来到来的时刻。

下一站

ta-lib的下一站更精彩。在扫除了构建和自动化障碍之后,ta-lib将可以以更快的速度、更小的精力来发布新的功能。长期以来,ta-lib缺失了一些深受大家喜爱的技术指标,比如KDJ, PVT, TMO等等。社区已经提出了14个待实现的指标,其中有两个,即RMA和PVI已经排上日程。

Tip

如果你正在发愁挖不到新的因子,建议自己先实现RMA和PVI。毕竟这两个指标优先被排上日程是有原因的。当然,我也很期待Connors RSI和Awesome Oscillator指标的实现。

摇人!

如果你喜欢Quantide公众号的文章,也想和我们一起研究量化,学习、开发、分享,欢迎加入匡醍!我们欢迎 the crazy ones, the misfits,the rebels!

草丛后面藏着什么?雄狮少年2

如果模型预测准确率超过85%,这台印钞机应该值多少马内?

在第13课中,拿决策树介绍了机器学习的原理之后,有的学员已经积极开始思考,之前学习了那么多因子,但都是单因子模型,可否使用决策树模型把这些因子整合到一个策略里呢?

在与学员交流之后,我已经把思路进行了分享。这也是我们第13课的习题,我把参考答案跟大家分享一下。

预告一下,我们训练出来的模型,在验证集和测试集上,三个类别的f1-score都取得到85%左右的惊人分数!

这个模型是如何构建的呢?

特征数据

首先是选定特征,我们选择了以下几个特征,都是我们在课程中介绍过、已经提供了源码的:

  1. 一阶导因子。这个因子能反映个股一段时间内的趋势
  2. 二阶导因子。这个因子能较好地预测短期趋势变盘。
  3. RSI因子。经典技术因子,能在一定程度上预测顶底。
  4. weekday因子。主要增加一个与上述量价信息完全无关的新的维度。

这些因子的实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
def d2_factor(df, win: int = 2)->pd.Series:
    close = df.close/df.close[0]
    d1 = close.diff()
    d2 = d1.diff()
    factor = d2.rolling(win).mean()

    return factor

def rsi_factor(df, win: int = 6)->pd.Series:
    return ta.RSI(df.close, win)


def d1_factor(df, win: int = 20)->pd.Series:
    df["log"] = np.log(df.close)
    df["diff"] = df["log"].diff()
    return df["diff"].rolling(win).mean() * -1

def week_day(dt: pd.DatetimeIndex)->pd.Series:
    return dt.weekday

训练数据使用了2018年以来,到2023年底,共6年的数据,随机抽取2000支个股作为样本池。

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

np.random.seed(78)
barss = load_bars(start, end, 2000)

上述因子中,前三个因子都是时序因子。但我们挖掘这些因子时,都是通过alphalens进行挖掘的,alphalens通过分层对其进行了排序,所以,我们还要对上述因子按横截面进行排序,这样得到一些rank_*因子。这个方法相法于进行特征增广(Feature Augmentation)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
features = {
}

def rank_feature(feature):
    return feature.groupby(level=0).rank(pct=True)

for name, func in [("d2", d2_factor),
                   ("rsi", rsi_factor),
                   ("d1", d1_factor)]:
    feature = barss.groupby(level=1).apply(d2_factor).droplevel(0)
    features[name] = feature
    features[f"rank_{name}"] = rank_feature(feature)

# 计算rank
features = pd.DataFrame(features)
features['weekday'] = week_day(features.index.get_level_values(0))

for win in (1, 5, 10):
    features[f"ret_{win}"] = barss.groupby(level=1)["price"].pct_change(win)

features.dropna(how='any', inplace=True)
features.tail()

输出得到的是特征数据集。

其中的"ret_1", "ret_5", "ret_10"是每列对应的未来1,5和10日的收益率。这里延续了之前的做法,对每一个时间点T0,按T1开盘价买入,T2开盘价卖出的规则计算收益率。在训练模型时,一次只使用其中的一列。

回归还是分类?

现在,面临的选择是,如何将上述数据转化为可训练的数据。这里有三个问题:

  1. 如何选择模型?即使我们确定要使用决策树,也还有一个问题,是要使用回归还是分类?
  2. 如何划分训练集和测试集?这是所有问题中看起来最简单的一步。
  3. 我们能把不同样本的数据同时作为训练的输入吗?这个问题看起来有点费思量。

从我们选定的特征来看,要让模型执行回归任务是有点太扯了,做分类更有道理,但也要进行一些转换。我们推荐的方案是使用分类模型,测试集划分先用sklearn自带的train_test_split来完成。

Tip

为什么说从选定的特征来看,不能让模型执行回归任务?把机器学习运用到量化交易,决不是照着模型的文档来套用这么简单,你必须深谙各个模型的原理,以及领域知识如何适配到模型。这些原理,在《因子分析与机器学习策略》中有讲解,老师还提供一对一辅导。

按分类模型进行训练之前,我们还要把ret_1转换成分类标签。转换方法如下:

1
2
3
4
5
6
7
from sklearn.model_selection import train_test_split

X = features.filter(regex='^(?!ret_).*$')
y = np.select((features["ret_1"] > 0.01, 
               features["ret_1"] < -0.01), 
              (1, 2), default=0)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

我们把涨幅大于1%的标注为类别『1』,跌幅大于1%的标为类别『2』,其余的打上标签『0』。标签0也可以认为是无法归类。

通过下面的代码进行训练:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import accuracy_score, classification_report

# 初始化并训练模型
clf = DecisionTreeClassifier(random_state=42)
clf.fit(X_train, y_train)

# 预测并评估模型
y_pred = clf.predict(X_test)

print("Accuracy:", accuracy_score(y_test, y_pred))
print("Classification Report:\n", classification_report(y_test, y_pred))

运行结果让人极度舒适。因为在一个三分类问题中,随机盲猜的准确率是33%,我们首次运行的结果就已经遥遥领先于随机盲猜了。

不过,三类目标的support数量并不均衡,这还是让我们有一点点担忧:目标0的分类一家独大,会不会导致准确率虚高?

再平衡!欠采样后,报告会不会很难看?

让我们先解决这个问题。我们可以采用一种名为欠采样(undersampling)的方法,分别抽取三类样本数中最小个数的样本数进行训练,这样一来,模型看到的训练数据就相对平衡了。

尽管undersampling实现起来很简单,不过,我们还有更简单的方法,就是使用imbalance库:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from imblearn.under_sampling import RandomUnderSampler

rus = RandomUnderSampler(random_state=42)
X_resampled, y_resampled = rus.fit_resample(X, y)

X_train_resampled, X_test_resampled, y_train_resampled, y_test_resampled = train_test_split(
    X_resampled, y_resampled, test_size=0.2, random_state=42
)

# 训练模型
clf_resampled = DecisionTreeClassifier(random_state=42)
clf_resampled.fit(X_train_resampled, y_train_resampled)

# 预测并评估模型
y_pred_resampled = clf_resampled.predict(X_test_resampled)

print("Resampled Accuracy:", accuracy_score(y_test_resampled, y_pred_resampled))
print("Resampled Classification Report:\n", classification_report(y_test_resampled, y_pred_resampled))

经过类别平衡后,准确率基本不变,但在我们关注的类别1和类别2上,它的准召率还分别提升了8%和3%

Tip

imblearn还提供另一种平衡数据的方法,称为SMOTE(Synthetic Minority Over-sampling Technique),它通过复制或者合成少数类样本来平衡数据集。不过,合成数据这事儿,对金融数据来说,可能不太靠谱,我们宁可严格一点,使用欠采样。如果我们使用SMOTE方法进行过采样,训练后的模型在类别1和类别2上能达到60%和57%的f1-score。

为什么这么优秀?

我们刚刚得到的结果无疑是超出预期的优秀!

在欠采样平衡的版本中,如果模型预测次日买入会盈利,它的精确率是54%,也即在100次预测为盈利的情况下,有54次是正确的;而在余下的不正确的46次中,也只有18次是亏损,剩下的则是涨跌在1%以内的情况

这个分析数据可以通过confusion matrix得到:

1
2
3
4
5
6
7
8
9
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay
import matplotlib.pyplot as plt

cm = confusion_matrix(y_test_resampled, y_pred_resampled)

disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=clf_resampled.classes_)
disp.plot(cmap=plt.cm.Blues)
plt.title('Confusion Matrix')
plt.show()

在上图中,预测分类为1的样本中,有15%(3556个)应该是下跌的样本,有27%(6374)个则是未知(或者平盘)的样本。

实际上,这是57:15的盈亏比例,或者说,已经达到了79%的精确率。这有多厉害呢?在索普的赌局中,他计算出赢的概率只要超过50%就会下注。因为只要胜得的天平偏向自己一点点,通过不断的累积中,高频交易最终会积累可观的利润!

这可能跟你在别处看到的模型很不一样。你可能从来没有想到过,机器学习模型在预测中竟然能如此有效。你可能开始寻找一些说辞,模型泛化肯定不好,这些数据只能代表过去等等。但其实这还只是前戏,高潮还在后面。

这个模型在构建过程中,固然还有一些可以探讨的地方,但它确实够好,原因是:

  1. 我们提供了高质量的特征数据。我们没有盲目地追求特征数量。得益于我们在《因子分析与机器学习策略》课程前半部分的深入学习,我们很清楚,什么样的特征能够在一个模型中相融共洽,我们甚至能猜到每个特征在什么节点下产生什么样的贡献。
  2. 我们知道特征能产生什么样的结果,于是使用了正确的任务模型(分类),并且使用了一定的构建技巧(gap)。

Tip

如果你想预测次日的价格呢?这不一定无法做到,在课程练习中,我们就给出过一个示例,在某种情况下能够预测价格可以达到的最高点。随着这些特征的增加,就会有更多的场景被覆盖。换句话说,要预测价格,你得提取跟预测价格相关的特征。

再思考:训练集与测试集的划分

将训练数据划分为训练集与测试集是机器学习中的一个非常基础的步骤。像很多其它教程一样,这里我们使用的是train_test_split函数。它简单好用,但并不适合量化交易

它会随机抽取训练数据和测试数据,这样就撕裂开了数据之间的天然联系,甚至可能导致测试集数据早于训练集数据,从面可能产生未来数据的情况。

正确的做法是按时间顺序划分。在sklearn中还提供了名为TimeSeriesSplit的类,它能够为交叉验证提供正确训练、测试集划分。

你也可以使用我们之前介绍过的tsfresh库中的类似方法来完成这个任务。

不过,在这里,我们还用不上交叉验证,所以,我们手动将数据划分为训练集、验证集和测试集。

Tip

决策树并不支持增量学习。也就是说,你不能拿已经训练好的模型,在此基础上通过新的数据进行新的训练。在这种情况下,如果数据量不足,可能就不适合交叉验证。

 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
from sklearn.tree import DecisionTreeClassifier

X = features.filter(regex='^(?!ret_).*$')
y = np.select((features["ret_1"] > 0.01, 
               features["ret_1"] < -0.01), 
              (1, 2), default=0)

n = len(X)
X_train = X_train[:int(n*0.7)]
y_train = y_train[:int(n*0.7)]

X_validation = X[int(n*0.7):int(n*0.9)]
y_validation = y[int(n*0.7):int(n*0.9)]

X_test = X[int(n*0.9):]
y_test = y[int(n*0.9):]


clf = DecisionTreeClassifier(random_state=42)
clf.fit(X_train, y_train)

y_pred = clf.predict(X_validation)

from sklearn.metrics import accuracy_score, classification_report

print("Accuracy:", accuracy_score(y_validation, y_pred))
print("Classification Report:\n", classification_report(y_validation, y_pred))

WorldQuant? Word Count!

如果你去商场逛,你会发现,销量最好的店和最好的商品总是占据人气中心。对股票来说也是一样,被新闻和社交媒体频频提起的个股,往往更容易获得更大的成交量。

如果一支个股获得了人气,那它的成交量一定很大,两者之间有强相关关系。但是,成交量落后于人气指标。当一支个股成交量开始放量时,有可能已经追不上了(涨停)。如果我们能提前发现人气指标,就有可能获得提前介入的时机。

那么具体怎么操作(建模)呢?

我们首先讲解一点信息检索的知识,然后介绍如何运用统计学和信息检索的知识,来把上述问题模型化。

TF-IDF

TF 是 Term Frequency 的意思。它是对一篇文档中,某个词语共出现了多少次的一个统计。IDF 则是 Inverse Document Frequency 的意思,大致来说,如果一个词在越多的文档中出现,那么,它携带的信息量就越少。

比如,我们几乎每句话都会用到『的、地、得』,这样的词几乎在每个语境(文档)中都会出现,因此就不携带信息量。新闻业常讲的一句话,狗咬人不是新闻,人咬狗才是新闻,本质上也是前者太常出现,所以就不携带信息量了。

最早发明 TF-IDF 的人应该是康奈尔大学的杰拉德·索尔顿(康奈尔大学的计算机很强)和英国的计算机科学家卡伦·琼斯。到今天,美国计算机协会(ACM)还会每三年颁发一次杰拉德·索尔顿奖,以表彰信息检索领域的突出贡献者。

TF-IDF 的构建过程如下:

假如我们有 3 篇文档,依次是:

  1. 苹果 橙子 香蕉
  2. 苹果 香蕉 香蕉
  3. 橙子 香蕉 梨

看上去不像文档,不过这确实是文档的最简化的形式--就是一堆词的组合(在 TF-IDF 时代,还无法处理词的顺序)。在第 1 篇文档中,橙子、香蕉和苹果词各出现 1 次,每个词的 TF 都记为 1,我们得到:

1
2
3
4
5
TF_1 = {
    '苹果': 1/3,
    '香蕉': 1/3,
    '橙子': 1/3,
}

在第二篇文档中,苹果出现 1 次,香蕉出现 2 次,橙子和梨都没有出现。于是得到:

1
2
3
4
TF_2 = {
    '苹果': 1/3,
    '香蕉': 2/3,
}

第三篇文档中,TF 的计算依次类推。

IDF 实际上是每个词的信息权重,它的计算按以下公式进行:

\[ \text{IDF}(t) = \log \left( \frac{N + 1}{1 + \text{DF}(t)} \right) + 1 \]
  1. DF:每个词在多少篇文档中出现了。
  2. N 是文档总数,在这里共有 3 篇文档,所以\(N=3\)
  3. 公式中,分子与分母都额外加 1,一方面是为了避免 0 作为分母,因为\(DF(t)\)总是正的,另外也是一种 L1 正则化。这是 sklearn 中的做法。

这样我们可以算出所有词的 IDF:

\[ 苹果 = 橙子 = \log \left( \frac{4}{2+1} \right) + 1 = 1.2876 \]
\[ 梨 = \log \left( \frac{4}{1+1} \right) + 1 = 1.6931 \]

因为梨这个词很少出现,所以,一旦它出现,就是人咬狗事件,所以它的信息权重就大。而香蕉则是一个烂大街的词,在 3 篇文档中都有出现过,所有我们惩罚它,让它的信息权重为负:

\[ 香蕉 = \log \left( \frac{4}{3+1} \right) + 1 = 1 \]

最后,我们得到每个词的 TF-IDF:

\[ TF-IDF=TF\times{IDF} \]

这样我们以所有可能的词为列,每篇文档中,出现的词的 TF-IDF 为值,就得到如下稀疏矩阵:

苹果 香蕉 橙子
文档 1 1.2876/3 1/3 1.2876/3 0
文档 2 1.2876/3 1/3 * 2 0 0
文档 3 0 1/3 1.2876/3 1.6931/3

在 sklearn 中,最后还会对每个词的 TF-IDF 进行 L2 归一化。这里就不手动计算了。

我们把每一行称为文档的向量,它代表了文档的特征。如果两篇文档的向量完全相同,那么它们很可能实际上是同一篇文章(近似。因为,即使完全使用同样的词和词频,也可以写出不同的文章。

比如,『可以清心也』这几个字,可以排列成『以清心也可』,也可以排列成『心也可以清』,或者『清心也可以』,都是语句通顺的文章。

插播一则招人启事,这是我司新办公地:

新场子肯定缺人。但这个地方还在注册中,所以提前发招聘信息,算是粉丝福利。

Info

急招课程助理(武汉高校,三个月以上实习生可)若干人。课程助理要求有一定的量化基础,能编辑一些量化方向的文章,热爱学习,有自媒体经验更好。

在实际应用中,我们可以使用 sklearn 的 TfidfVectorizer 来实现 TF-IDF 的计算:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import pandas as pd
def jieba_tokenizer(text):
    return list(jieba.cut(text))

d1 = "苹果橙子香蕉"
d2 = "苹果香蕉香蕉"
d3 = "橙子香蕉梨"

vectorizer = TfidfVectorizer(tokenizer = jieba_tokenizer)
matrix = vectorizer.fit_transform([d1, d2, d3])

df = pd.DataFrame(matrix.toarray(), columns = vectorizer.get_feature_names_out())
df
橙子 苹果 香蕉
0 0.000000 0.619805 0.619805 0.481334
1 0.000000 0.000000 0.541343 0.840802
2 0.720333 0.547832 0.000000 0.425441

结果与我们手工计算的有所不同,是因为我们在手工计算时,省略了计算量相对较大的L2归一化。

从上面的例子可以看出,TF-IDF 是用来提取文章的特征向量的。有了这个特征向量,就可以通过计算余弦距离,来比较两篇文档是否相似。这可以用在论文查重、信息检索、比较文学和像今日头条这样的图文推荐应用上。

比如,要证明曹雪芹只写了《红楼梦》的前87回,就可以把前87回和后面的文本分别计算TF-IDF,然后计算余弦距离,此时就能看出差别了。

又比如,如果用TF-IDF分析琼瑶的作品,你会发现,如果去掉一些最重要的名词之后,许多她的文章的相似度会比较高。下面是对《还珠格格》分析后,得到的最重要的词汇及其TF-IDF:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
紫薇: 0.0876
皇帝: 0.0754
尔康: 0.0692
皇后: 0.0621
五阿哥: 0.0589
容嬷嬷: 0.0573
小燕子: 0.0556
四阿哥: 0.0548
福晋: 0.0532
金锁: 0.0519

跟你的印象是否一致?但是,TF-IDF的分析方法,在量化交易中有何作用,目前还没有例证。

讲到这里,关于 TF-IDF 在量化交易中的作用,基本上就讲完了。因为,接下来,我们要跳出 TF-IDF 的窠臼,自己构建因子了!

word-count 因子

根据 TF-IDF 的思想,这里提出一个 word-count 因子。它的构建方法是,通过 tushare 获取每天的新闻信息,用 jieba 进行分词,统计每天上市公司名称出现的次数。这是 TF 部分。

在 IDF 构建部分,我们做法与经典方法不一样,但更简单、更适合量化场景。这个方法就是,我们取每个词 TF 的移动平均做为 IDF。这个IDF就构成了每个词的基准噪声,一旦某天某个词的频率显著大于基准噪声,就说明该公司上新闻了!

最后,我们把当天某个词的出现频率除以它的移动平均的读数作为因子。显然,这个数值越大,它携带的信息量也越大,表明该词(也就是该公司)最近在新闻上被频频提起。

获取新闻文本数据

我们可以通过 tushare 的 news 接口来获取新闻。 这个方法是:

1
2
3
4
news = pro.news(src='sina', 
                date=start,
                end_date=end,
)

我们把获取的新闻数据先保存到本地,以免后面还可能进行其它挖掘:

 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
请在本地尝试以下代码,不要在Quantide Reseach环境中运行!!

```python
def retry_fetch(start, end, offset):
    i = 1
    while True:
        try:
            df =pro.news(**{
                "start_date": start,
                "end_date": end,
                "src": "sina",
                "limit": 1000,
                "offset": offset
            }, fields=[
                "datetime",
                "content",
                "title",
                "channels",
                "score"])
            return df
        except Exception as e:
            print(f"fetch_new failed, retry after {i} hours")
            time.sleep(i * 3600)
            i = min(i*2, 10)

def fetch_news(start, end):
    for i in range(1000):
        offset = i * 1000
        df = retry_fetch(start, end, offset)

        df_start = arrow.get(df.iloc[0]["datetime"]).format("YYYYMMDD_HHmmss")
        df_end = arrow.get(df.iloc[-1]["datetime"]).format("YYYYMMDD_HHmmss")
        df.to_csv(os.path.join(data_home, f"{df_start}_{df_end}.news.csv"))
        if len(df) == 0:
            break

        # tushare 对新闻接口调用次数及单次返回的新闻条数都有限制
        time.sleep(3.5 * 60)
```

在统计新闻中上市公司出现的词频时,我们需要先给 jieba 增加自定义词典,以免出现分词错误。比如,如果不添加关键词『万科 A』,那么它一定会被 jieba 分解为万科和 A 两个词。

增加自定义词典的代码如下:

1
2
3
4
5
6
7
8
def init():
    # get_stock_list 是自定义的函数,用于获取股票列表。在 quantide research 环境可用
    stocks = get_stock_list(datetime.date(2024,11,1), code_only=False)
    stocks = set(stocks.name)
    for name in stocks:
        jieba.add_word(name)

    return stocks

这里得到的证券列表,后面还要使用,所以作为函数返回值。

接下来,就是统计词频了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def count_words(news, stocks)->pd.DataFrame:
    data = []
    for dt, content, _ in news.to_records(index=False):
        words = jieba.cut(content)
        word_counts = Counter(words)
        for word, count in word_counts.items():
            if word in stocks:
                data.append((dt, word, count))
    df = pd.DataFrame(data, columns=['date', 'word', 'count'])
    df["date"] = pd.to_datetime(df['date'])
    df.set_index('date', inplace=True)

    return df

tushare 返回的数据共有三列,其中 date, content 是我们关心的字段。公司名词频就从 content 中提取。

然后我们对所有已下载的新闻进行分析,统计每日词频和移动均值:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def count_words_in_files(stocks, ma_groups=None):
    ma_groups = ma_groups or [30, 60, 250]
    # 获取指定日期范围内的数据
    results = []

    files = glob.glob(os.path.join(data_home, "*.news.csv"))
    for file in files:
        news = pd.read_csv(file, index_col=0)

        df = count_words(news, stocks)
        results.append(df)

    df = pd.concat(results)
    df = df.sort_index()
    df = df.groupby("word").resample('D').sum()
    df.drop("word", axis=1, inplace=True)
    df = df.swaplevel()
    unstacked = df.unstack(level="word").fillna(0)
    for win in ma_groups:
        df[f"ma_{win}"] = unstacked.rolling(window=win).mean().stack()

    return df

count_words_in_files(stocks)

最后,完整的代码如下:

 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
59
60
61
import os
import glob
import jieba
from collections import Counter
import time

data_home = "/data/news"
def init():
    stocks = get_stock_list(datetime.date(2024,12,2), code_only=False)
    stocks = set(stocks.name)
    for name in stocks:
        jieba.add_word(name)

    return stocks

def count_words(news, stocks)->pd.DataFrame:
    data = []
    for dt, content, *_ in news.to_records(index=False):
        if content is None or not isinstance(content, str):
            continue

        try:
            words = jieba.cut(content)
            word_counts = Counter(words)
            for word, count in word_counts.items():
                if word in stocks:
                    data.append((dt, word, count))
        except Exception as e:
            print(dt, content)
    df = pd.DataFrame(data, columns=['date', 'word', 'count'])
    df["date"] = pd.to_datetime(df['date'])
    df.set_index('date', inplace=True)

    return df

def count_words_in_files(stocks, ma_groups=None):
    ma_groups = ma_groups or [30, 60, 250]
    # 获取指定日期范围内的数据
    results = []

    files = glob.glob(os.path.join(data_home, "*.news.csv"))
    for file in files:
        news = pd.read_csv(file, index_col=0)

        df = count_words(news, stocks)
        results.append(df)

    df = pd.concat(results)
    df = df.sort_index()
    df = df.groupby("word").resample('D').sum()
    df.drop("word", axis=1, inplace=True)
    df = df.swaplevel()
    unstacked = df.unstack(level="word").fillna(0)
    for win in ma_groups:
        df[f"ma_{win}"] = unstacked.rolling(window=win).mean().stack()

    return df.sort_index(), unstacked.sort_index()

stocks = init()
factor, raw = count_words_in_files(stocks)
factor.tail(20)

这里计算的仍然是原始数据。最终因子化要通过 factor["count"]/factor["ma_30"] 来计算并执行 rank,这里的 ma_30 可以替换为 ma_60, ma_250 等。

跟以往的文章不同,这一次我们没有直接得到好的结果。我们的研究其实多数时候都是寂寞的等待,然后挑一些成功的例子发表而已。毕竟,发表不成功的例子,估计意义不大(很少人看)。

但是这一篇有所不同。我们没有得到结果,主要是因为数据还在积累中。这篇文章从草稿到现在,已经半个月了,但是我仍然没能获取到 1 年以上的新闻数据,所以,无法准确得到每家公司的『基底噪声』,从而也就无法得到每家公司最新的信息熵。但要获得 1 年以上的数据,大概还要一个月左右的时间。所以,先把已经获得的成果发出来。

尽管没有直接的结果,但是我们的研究演示了对文本数据如何建模的一个方法,也演示了如何使用TF-IDF,并且在因子化方向也比较有新意,希望能对读者有所启发。

我们已经抓取的新闻数据截止到今年的 8 月 20 日,每天都会往前追赶大约 10 天左右。这些数据和上述代码,可以在我们的 quantide research 平台上获取和运行。加入星球,即可获得平台账号。

周一到周五,哪天能买股?做对了夏普22.5!

在第12课我们讲了如何从量、价、时、空四个维度来拓展因子(或者策略)。在时间维度上,我们指出从周一到周五,不同的时间点买入,收益是不一样的。这篇文章我们就来揭示下,究竟哪一天买入收益更高。

问题定义如下:

假设我们分别在周一、周二,...,周五以收盘价买入,持有1, 2, 3, 4, 5天,并以收盘价卖出,求平均收益、累积收益和夏普率。我们选择更有代表性的中证1000指数作为标的。

获取行情数据

1
2
3
4
5
6
7
df = pro.index_daily(**{
    "ts_code": "000852.SH"
})

df.index = pd.to_datetime(df.trade_date)
df.sort_index(ascending=True, inplace=True)
df.tail(10)

我们得到的数据将会是从2005年1月4日,到最近的一个交易日为止。在2024年11月间,这将得到约4800条记录。

我们先看一下它的总体走势:

1
df.close.plot()

如果我们从2005年1月4日买入并持有的话,19年间大约是得到5倍的收益。记住这个数字。

计算分组收益

接下来我们计算不同日期买入并持有不同period的收益。这里实际上有一个简单的算法,就是我们先按持有期period计算每天的对应收益,然后再按weekday进行分组,就得到了结果。

我们先给df增加分组标志:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 给df增加一列,作为分组标志
df["weekday"] = df.index.map(lambda x: x.weekday())

# 将数字转换为更易阅读的周几
df["weekday"] = df.weekday.map({
    0: "周一",
    1: "周二",
    2: "周三",
    3: "周四",
    4: "周五"
})
df = df[["close", "weekday"]]
df.tail()

此时我们会得到:

close weekday
trade_date
2024-11-11 6579.0054 周一
2024-11-12 6491.9723 周二
2024-11-13 6474.3941 周三
2024-11-14 6272.1911 周四
2024-11-15 6125.5126 周五
2024-11-18 5974.5576 周一
2024-11-19 6130.2848 周二
2024-11-20 6250.8029 周三
2024-11-21 6262.1644 周四
2024-11-22 6030.4882 周五

接下来我们计算每期的收益。

1
2
3
4
for period in range(1, 6):
    df[f"{period}D"] = df.close.pct_change(period).shift(-period)

df.tail(10)

此时我们会得到:

close weekday 1D 2D 3D 4D 5D
trade_date
2024-11-11 6579.0054 周一 -0.013229 -0.015901 -0.046635 -0.068930 -0.091875
2024-11-12 6491.9723 周二 -0.002708 -0.033854 -0.056448 -0.079701 -0.055713
2024-11-13 6474.3941 周三 -0.031231 -0.053886 -0.077202 -0.053149 -0.034535
2024-11-14 6272.1911 周四 -0.023386 -0.047453 -0.022625 -0.003410 -0.001599
2024-11-15 6125.5126 周五 -0.024644 0.000779 0.020454 0.022309 -0.015513
2024-11-18 5974.5576 周一 0.026065 0.046237 0.048139 0.009361 NaN
2024-11-19 6130.2848 周二 0.019659 0.021513 -0.016279 NaN NaN
2024-11-20 6250.8029 周三 0.001818 -0.035246 NaN NaN NaN
2024-11-21 6262.1644 周四 -0.036996 NaN NaN NaN NaN
2024-11-22 6030.4882 周五 NaN NaN NaN NaN NaN

现在,我们来计算不同时间点买入时的累积收益率:

1
2
3
4
5
def cum_weekday_returns(df, period):
    return ((1 + df[f"{period}D"]).cumprod() - 1).reset_index(drop=True)

returns_1d = df.groupby('weekday').apply(lambda x: cum_weekday_returns(x, 1))
returns_1d.swaplevel().unstack().plot()

从累积收益图中我们可以看到,周五买入的收益最高,约为5.47倍。看起来这个结果只比买入并持有略好一点,但实际上,资金占有率只有买入并持有的20%。因此,如果算年化Alpha的话,它要比买入并持有高许多。

当然,我们有更好的指标来评估周五买入策略的效果,即夏普率。我们先来看每天交易的夏普率:

1
2
from empyrical import sharpe_ratio
sharpe_ratio(df.close.pct_change())

我们得到的结果是0.46。下面我们计算从周一到周五,不同时间点买入的夏普率:

1
2
3
4
for tm in ("周一", "周二", "周三", "周四", "周五"):
    returns = returns_1d.swaplevel().unstack()[tm]

    print(tm, f"{sharpe_ratio(returns):.1f}")

从结果中看,周二买入的夏普甚至更高。但周三和周四买入的夏普率都为负,这解释了为什么每日买入的夏普率不高的原因。

终极boss

上面我们仅仅介绍了周五买入,持有一天的收益。考虑到周一、周二买入的夏普都很高,显然,如果周五买入,并持有多天,有可能收益会更高。具体应该持有几天会更好,收益会高多少呢?可能会超出你的想像

你可能读了很多文章,花了很多时间尝试复现它,最终却一无所获:要么代码不完整、要么数据拿不到,或者文章根本就是错的。但我们不想给你带来这样负面的体验。跟本号的其它文章一样,这篇文章的结论是可复现的,并且使用的数据你一样可以获得。你可以加入尝试加入我的星球,通过Quantide Research平台运行和验证本文。如果证实了它的效果,再把代码拷贝到本地,加入你的择时策略中。如果效果不能验证,你也可以退出星球。

1
2
3
4
5
6
def cum_weekday_returns(df, period):
    return ((1 + df[f"{period}D"]).cumprod() - 1).reset_index(drop=True)

for period in range(1, 6):
    returns = df.groupby('weekday').apply(lambda x: cum_weekday_returns(x, period))
    returns.swaplevel().unstack().plot(title=f"持有{period}天")

机器的觉醒!人工智能风云激荡70年

机器学习是人工智能的一个子集。人工智能是指使计算机系统能够执行通常需要人类智能才能完成的任务的技术和方法。人工智能涵盖了多种技术和子领域,如机器学习、深度学习、自然语言处理、计算机视觉、专家系统等。

人工智能的概念正式提出是在1956年达特矛斯的夏季人工智能研究会上。达特矛斯学院(Dartmouth College)虽小,但却是常春藤大学之一。这次会议由约翰·麦卡锡发起,克劳德.香农等人参与了研讨会。约翰·麦卡锡是计算机科学家和认知科学家,也是人工智能学科的创始人之一。他还是著名的编程语言Lisp的发明者,早期许多人工智能系统和算法都是用Lisp语言编写的。

达特茅斯AI研讨会

发起会议的约翰·麦卡锡最初认为,只要集中全美最好的几位科学家,大概只要8周就能攻克人工智能问题。不想从1956年发起的宏伟梦想,经过近70年的筚路褴褛,今天仍然只能算是半道其中。

这一路上,就像是光的微粒说与波动说的争论一样,关于人工智能的发展方向,也一直存在两种主要的思想,即人工智能应该基于规则还是基于数据来构建模型?两种思想之间的争斗跌宕起伏,最终在三位华人科学家的加持下,数据派占据了上风,同时铸就了当代人工智能波澜壮阔的发展。

人工智能最先从仿生学得到启发。1890年代,Santiago Ramón Cajal(圣地亚哥·拉蒙·卡哈尔)提出了神经元学说,后来被Camillo Golgi(Camillo Golgi)通过高尔基染色法所证实。1943年,Warren MeCulloch(沃伦.麦卡洛克)和Walter Pitts(沃尔特.皮茨)将复杂的神经元电化学过程简化为相对简单的信号交换[^activation],最终为人工智能仿生打下坚实的基础。

高尔基染色和海马体

1958年,Frank Rosenblatt(弗兰克·罗森布拉特)根据仿生学原理,提出了感知机(Perceptron),这是最早的神经网格模型之一,感知机能够通过学习线性分类器来解决二分类问题。Rosenblatt的感知机是个天才的发明,因为当时的计算技术还没有数字化,Rosenblatt训练感知机的过程,都是靠手动切换开关完成的。尽管很原始,但通过训练,感知机最终获得了形状识别的可靠能力。

感知机示意图

感知机一度被誉为重大的技术进步。但这一发明过于超前,世界还未为它的诞生做好准备 -- 直到50年以后,人们才明白,神经网络需要数字化输入输出设备、大量的算力和存储,而最终还需要海量的数据。毕竟,人脑有超过1000亿个神经元组成,在1958年,人类只能模拟大脑容量的几亿分之一。

因此,感知机带来的热浪仅持续不到一年,就受到猛烈的攻击。其中最大的反对者,正是达特矛斯AI研讨会的发起人之一马文.明斯基,和另一位数学家、计算机科学的先驱 -- 西摩.佩珀特。他们在1969年出版了一本名为《感知机》的书,抨击感知机缺乏严谨的理论基础 -- 实际上直到今天,人工智能仍然不能为自己找到坚实的数学基石,在很多时候,它的行为仍然是一个黑盒子 -- 这一点倒是很像大脑。

感知机是基于数据的机器学习模型。在感知机遭到重创之后,机器学习阵线不得不沉寂十多年之久。而在此期间,知识工程与专家系统则占据了风头。其中最有名的,可能是一个名为内科医生-I的程序,它的数据库中包含了500种疾病描述和3000种疾病表现。但是,由于现实世界太复杂,基于规则的模型能够很好地处理检索,但在推理上就显得呆板而肤浅,并且规则越多,就越难相容,于是很快也败下阵来。

专家系统是当年人类实现人工智能最后的希望。因此,专家系统的落败,也导致人工智能的发展陷入低谷,进入了冰冻的寒武纪。然而,正如地球经历寒武纪一样,在封冻的冰层之下,进化正在加速,一些革命性的突破,即将到来。

Geoffrey Hinton 2024年诺贝尔物理学奖

1986年,杰弗里·辛顿[^hinton](Geoffrey Hinton)、大卫·鲁梅尔哈特(David Rumelhart)和罗恩·威廉姆斯(Ron Williams)发表了反向传播算法。这是深度学习的奠基之作,它使得多层神经网络的训练成为可能,从理论上,我们向模拟有1000亿神经元的大脑迈出了至关重要的一步。但深度学习的时代并没有立刻到来,人工智能还被封印在厚厚的冰雪之下。

又过去了10多年。1998年,Yann LeCun(杨立昆)提出了LeNet,这是最早的卷积神经网络,也是人类历史上第一个有实际用途的神经网络。它被广泛应用于全美的自动提款机上,用来读取支票上的数字。Yann LeCun的成功再次掀起了人工智能浪潮,也最终把机器学习路线重新带回到人们的视野中来。

LeNet, by Yann LeCun

不过,Yann LeCun的成功还无法产生势如破竹、摧枯拉朽般的攻势。相反地,在短暂的热闹之后,人工智能的研究似乎再次进入休眠期。不过,这一次它只是小憩了4年,很快,它将被AlexNet唤醒,随后便如河出伏流,一泄千里。

而在这次复苏的背后,两位华人科学家--黄仁勋和李飞飞则是最重要的幕后英雄。前者以英伟达的显卡和Cuda引擎为深度学习提供了强大的算力,后者则以ImageNet数据集,为卷积神经网络提供了土壤和营养。

Info

李飞飞
尽管李飞飞荣获了美国工程院院士、医学科学院院士和艺术与科学学院院士,但世界可能仍然大大低估了她的主要贡献 -- ImageNet的重要性。如果没有第谷长达20余年的天文观测,就不会有开普勒三大定律,也就不会有牛顿第三定律。牛顿曾说自己是站在了巨人的肩膀上,他所说的巨人当然不是莱布尼茨,而是第谷和开普勒。
李飞飞正是当代的第谷。

AlexNet也是一个卷积神经网络,它由Alex Krizhevsky, Ilya Sutskever和Geoffrey Hinton提出,在2012年的ImageNet竞赛中,取得了85%的识别准确率,比上一届的冠军整整提升了10%!LeNet只能运用在一个很小的场景下,处理很小规模的数据集,而AlexNet则是在超过1000个分类的图片上进行的识别,它的成功显然更具现实意义和突破性。AlexNet让基于数据驱动的机器学习路线加冕成为王者。直到今天,尽管机器学习还存在种种不足,我们离通用人工智能还不知道有多长的距离,但是,似乎再也没有声音质疑机器学习,而要改用基于规则的专家系统了。

AlexNet

下一个突破则是机器视觉对人类的超越。根据研究,在ImageNet数据集上人类的分类能力极限是5.1%的错误率。一旦机器视觉的错误率低于这个指标,也就战胜了人类,意味着人工智能在视觉上的应用完全成熟。

这个决定性的胜利是由华人科学家何明恺在2016年取得的。他通过深度残差网络,将神经网络的层数增加到了惊人的(与当时的水平相比)152层,但通过巧妙的设计,允许输入数据在训练阶段绕过其中的某些层,从而部分解决了深层网络中的梯度消失问题。

ResNet-18

最终,resnet在识别错误率降到了4.5%,显著地超越了人类的极限!再也没有任何理由怀疑和拒绝人工智能的应用了!

到此为止,机器学习路线取得了压倒性的胜利,人工智能研究就进入加速时代。在短短的几年后,甚至自然语言理解也被突破,以transformer为代表的架构在自然语言理解、文生图、文生视频、编程等多个领域都取得了成功。

行百里者半九十。人类的终极愿景 -- 通用人工智能(AGI)何时能产生还未可知。人工智能在未来应该成为人类的伴侣,但此刻,人类的亚当还没能造出自己的夏娃。此外,尽管当代人工智能模型已经极为强大,但它内部的运行机制仍然没有坚实的理论基础,AI引起的伦理问题才刚刚暴露,这些都是未来有待我们攻克的一道道关卡。

在这一刻抄底,胜率高达95%

在4月8日,我们发表了一篇名为《1赔10,3月27日我抄底了》的文章,基于坚实的统计数据,说明了为什么当天应该抄底。时间过去了半年,中证1000又为我们提供了两个新的例证。这篇文章我们就来回顾一下。

原理和定义

我们先介绍一下原理。你可能观察到,当发生一段连续下跌时,那么下跌的幅度越大,则反弹的力度就越大,并且越可能发生反弹。这就像弹簧一样,或者像是蹦极 -- 在连续下跌到某个点之后,总会往回弹一点。

但我们需要求出连续下跌到什么程度时,反弹的概率会是多大。这样一来,我们的操作才有更有底气和依据。

首先需要定义什么是连续下跌。实际上,我们可以像前文所述,使用每日涨跌。但在这篇文章里,我们想尝试另一种方法,即通过连续阴线区间的跌幅来定义,这样可以过滤一些假信号。

具体的计算方法如下图:

我们先看图中序号1到序号2这一段。这是10月8号到10月11日的走势。10月8日这一天跳空高开,接着一路下跌直到10月11日,8号开盘买入的人,到11日收盘时,亏损达到15.8%。这个损失,就是我们所说的连续阴线区间的跌幅。

我们再看序号3到序号4这一段。这是11月14日到11月18日的情况,14日开盘买入者共亏损7.5%,连续阴线区间的跌幅就是7.5%。

如果我们使用连续下跌(而不是连续阴线的区间跌幅)来计算最大跌幅,将会是从11月12日起开始计算下跌,但提前一天(11月15日)就可能给出抄底信号,这个信号就有点过早。因为在这区间中,出现了一天的假阳线(13日,下跌但收阳线)。这一天筹码发生交换,假设前一日亏损者把筹码全部倒给了新入场者,那么活跃交易者的持仓成本是下降的,他们还能再扛一阵子。

这是我们这次改进中,使用连续阴线的区间跌幅的原因。但实际上两种定义各有优劣,你可以使用机器学习模型来决定何时使用哪一个。

代码实现

既然模型定义清楚,现在我们就开始实现。这段代码需要用到发现连续阴阳线的一个函数,我们把它定义为find_runs:

 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
def find_runs(x):
    """Find runs of consecutive items in an array.
    """

    # 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

为了让大家都能复现代码,我们使用了akshare来提供数据。这是一个免费、开源的行情数据源。

 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
import akshare as ak
now = datetime.datetime.now().date()
start = now - datetime.timedelta(days=365*4)

start_date = start.strftime("%Y%m%d")
end_date = now.strftime("%Y%m%d")

# 通过akshare获取中证1000日线数据(近1000天)
bars = ak.index_zh_a_hist(symbol="000852", start_date=start_date, end_date=end_date)

bars.rename(columns = {
    "日期": "date",
    "开盘": "open",
    "最高": "high",
    "最低": "low",
    "收盘": "close",
    "成交量":"volume"
}, inplace=True)

bars["date"] = pd.to_datetime(bars["date"])
bars.set_index("date", inplace=True)

bars["flag"] = np.select([bars["close"] > bars["open"], 
                          bars["close"] < bars["open"]], 
                          [1, -1], 
                          0)
v, s, l = find_runs(bars["flag"] == -1)

cum_neg_returns = []
for vi, si, li in zip(v, s, l):
    if vi and li > 1:
        cum_neg_returns.append((bars.index[si], 
                                bars.index[si + li-1], 
                                bars.close[si + li -1 ]/bars.open[si] - 1))

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

从生成的直方图来看,连续跌幅达到7.5%以后的次数就很少了,也就是,连续下跌超过7.5%,还能继续下跌是小概率事件。

我们看一下连续下跌最厉害的10次,分别是什么情况:

1
r.nsmallest(10, "cnr").sort_values("end", ascending=False)
start end cnr
107 2024-11-14 2024-11-18 -0.075433
106 2024-10-08 2024-10-11 -0.158511
89 2024-03-21 2024-03-27 -0.071948
88 2024-01-26 2024-02-05 -0.190592
87 2024-01-19 2024-01-22 -0.067846
78 2023-10-16 2023-10-23 -0.074109
45 2022-09-15 2022-09-19 -0.066983
37 2022-04-20 2022-04-26 -0.170335
34 2022-03-14 2022-03-15 -0.066983
33 2022-03-03 2022-03-09 -0.086669

我们看到2024年就发生了5次,分别是1月22日、2月5日、3月27日、10月11日和最近的11月18日。其中2月5日和10月11日这两次下跌幅度很大,后面的反弹也就很大,果然是风浪越大鱼越贵!

1月22日这次能出现在表中非常意外,毕竟这一次只下跌了两天。但是,随后也确实出现了一波持续3天的小反弹,涨幅超过6.1%,还比较可观。

底该怎么抄?

在11月8日收盘时,阴线连续下跌幅度为7.54%,盘中跌幅更大,所以,在盘中就出现了连续跌幅达到7.54%的情况。如果此时你决定抄底,成功的概率有多大?

有一个奇怪的pandas函数可以帮我们计算出来:

1
2
decline_ratio = -0.075433
r.cnr.le(decline_ratio).mean()

它的奇妙之处在于,le用来找出小于等于decline_ratio的数据,把它们标记为true,其它的标记为false,然后mean用在bool变量上,会求出真值的比例,也就是我们要找的概率!

这个概率是4.63%。如果你在此时抄底,那么还有4.63%的概率,你需要忍受继续下跌,这就是发生在10月9日的情况。按照概率的提示,你大概会在10月9日的盘中杀进来,然后要忍受此后两天的继续下跌,这个跌幅跟你之前看到的差不多(也是7.5%左右)。

不过,好消息是,如果你在10月9日杀进来,你有在10月10日选择小幅盈利出局的权利。如果你在这一天选择出局,把筹码倒给了新入场的人,他们还可以再抗7.5%的跌幅!这就是为什么股谚说多头不死,下跌不止。

如果你不会编程,可以通过下面的表格速查抄底成功概率:

1
2
3
4
5
6
data = []
for loss in np.linspace(-0.06, -0.076, 15):
    data.append((loss, 1- r.cnr.le(loss).mean()))

df = pd.DataFrame(data, columns=['最大亏损', '抄底胜率'])
df.style.format("{:.1%}")
  最大亏损 抄底胜率
0 -6.0% 87.0%
1 -6.1% 87.0%
2 -6.2% 87.0%
3 -6.3% 87.0%
4 -6.5% 88.0%
5 -6.6% 89.8%
6 -6.7% 90.7%
7 -6.8% 93.5%
8 -6.9% 93.5%
9 -7.0% 93.5%
10 -7.1% 93.5%
11 -7.3% 94.4%
12 -7.4% 94.4%
13 -7.5% 95.4%
14 -7.6% 96.3%

再往后胜率不变(因为数据量少),所以就没有列出了。在实际操作中,可以从-6%之后开始,使用马丁格尔交易法。

Tip

我在这里没有使用最高价和最低价。这两个价格的稳定性不如收盘价与开盘价(即成交量少)。但你也可以试试。

你还可以计算出抄底之后的可能获利。你可以这样定义:从连续下跌之后出现的连续阳线涨幅,即为抄底之后的盈利。这个计算比较简单,你可以先过滤出连续跌幅大的,再通过循环来计算此后的平均收益。

百闻不如一练。我讨厌读那些看上去很美好,但无法验证的文章。很多时候,读这些文章只是在浪费时间,因为你都不知道哪句是真的,反正都无法验证。

同往常一样,这篇文章同样提供可运行的代码。你只要登录Quantide Research平台,就可以运行本文的代码验证我们作出的结论,然后选择下载本文代码,持续跟踪连续下跌引起的反弹信号。

加入星球,就可以拿到门票。在星球(及Quantide Research平台)里,我们已经发布了可alpha101因子库(可运行)、5个年化超过15%的因子,还有三角形整理检测等代码。未来将继续以更新公众号的频率,持续同步发布笔记相关代码。

如果你不明白这里概率计算的原理,或者想为自己打下坚实的量化基础,可以考虑选修《量化24课》或者《因子分析与机器学习策略》。

捕捉主力-最大成交量因子

因为黑夜,更能看见满天星光 | ©️ Nathan Jennings

今天跟一位朋友聊天,他想换到量化行业,有点担心是否有点为时已晚,又或者大环境不好。于是我用这句话鼓励他:

因为黑夜,更能看见满天星光。

低谷之中,必有转机,熬过黑暗,前方自有光明。

其实在我身边,已经有好几位『普通人』成功地转向了量化。我之前有一位策略研究员,前几天跟我通了个电话,告诉我已经有私募接洽他,希望买他的策略。他已经注册了自己的公司。他是一个真正的传奇,我会在恰当的时候,讲述他的故事,带给更多人启迪。

做你热爱的事情,不用管其它人给现在的你打上的标签,不要被他人定义。

You gotta be brave。

只要你还愿意追逐梦想,你就不是平庸的。

言归正转。

在第十二课,我承诺给大家讲如何发现新的因子。这活儿光说不练不行,我得真真正正拿出一个还没有广泛传播的因子(策略)出来。今天就来兑现这个承诺。我们探索因子,无非就是量价时空四大维度。这里面关于成交量的因子最少,所以,今天就介绍一个我自己探索的因子。

如有雷同,纯属巧合(读书太少)。这个因子是基于成交量,运用羊群效应的原理构建的。

因子还没被挖完吗?

在说书之前,先回答一个问题。因子难道还没有被挖掘完吗?毕竟,全世界有这么多做量化的人。

事实是,这世界算力不够,所以,还存在大量的关系没有发掘出来。小市值因子之父,Rolf Banz探索小市值因子的故事,也许能很好说明这一点。

1980年前后,Rolf Banz发表了一篇论文《The relationship between return and market value of common stocks》,向世界介绍了小市值因子。

Banz 的这篇论文并不复杂,只有 16 页纸。论文中并没有使用高深的数学,只是使用了基础的统计科学,GLS 和 OLS。如果当时有计算机可用的话,这个推导过程会显得格外简单。

甚至,我们可以仅凭他论文中的这张图来理解小市值因子:

收益与市值分导关系

在这张图中,Banz把资产按市值大小分成了5组,第一组是市值最小的一组,第5组是市值最大的一组。很显然,其它几组的收益都与市值大小有着线性关系,但第一组,即小市值组有更大的月度收益,不能被线性回归。

就是这样简单的一个事实,帮助Banz获得了小市值之父的称号。如果说威廉.夏普发现了第一个因子的话,Banz就是发现第二个因子的人。

Banz 用到的数据来自 1926 年到 1975 年。这些数据就在芝大的 CRSP 数据库里。在漫长的 50 多年时间里,一直在静静地等待有人发现她的价值。芝大经济学人才辈出,期间不知道有多少人接触过这份数据。如此低垂和芳香的果实最终却被 Banz 采摘。

我想,这足以说明,在任何时代,在任何圈子,都会有一些低垂的果实,等待有心人去采摘

但是,更加惊人的是,另一颗低垂的果实,就出现在Banz自己的论文里。在上图中,资产实际上是被分成了25组。在按市值划分的每个组内,Banz又按波动率将资产分成了5组(在图上纵向分布)。第一组是波动率最大的一组,第5组则是波动率最小的一组。

很显然,在每一个组合中,低波动率的资产收益都大于高波动率的资产。

实际上,这里出现了另外一个因子,就是低波动因子。我们回测过,日线低波动因子可以达到16.4%的年化Alpha。月线可以达到6.3%左右的年化,因子IC是0.04,相当可观。

然后,直到10年之后,Haugen和Baker才发现和命名了低波动因子。Banz做完了几乎所有工作,但却与这项发明失之交臂。

低垂的果实永远都会有的

最大成交量因子

这个因子也可以叫做主力因子。它的构造原理是,价格的方向是由资金里的主力决定的。因为当个体处于群体中时,其行为和决策受到群体影响,往往会失去理性,变得更加情绪化和冲动。个体往往会受到他人引导,最终会跟随头羊的方向前进,这就是羊群效应。

主力才能决定方向,决定方向的力量也就是主力。因此,主力方向是可以建模的。

 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
import pandas as pd

def max_volume_direction(df, win=40):
    old_index = df.index.copy()
    df = df.reset_index().set_index(np.arange(len(df)))
    df["flag"] = np.select([
        df["close"] > df["open"],
        df["close"] < df["open"]
    ], [1, -1], 0)

    df["move_vol_avg"] = df["volume"].rolling(window=win, min_periods=win).mean().shift(1)
    df["argmax"] = df['volume'].rolling(win, min_periods=win).apply(lambda x: x.idxmax())
    df.fillna(0, inplace=True)
    df["move_vol_max"] = df.apply(lambda row: df.loc[row['argmax'], 'volume'], axis=1)
    df['vr'] = df['volume'] / df['move_vol_avg'] * df["flag"]
    df["span"] = df.index - df["argmax"]

    def calc_rolling_net_balance(df):
        pos = df["argmax"].iloc[-1] + 1
        sub = df.loc[pos:,]
        return (sub["flag"] * sub["volume"]).sum() / df["move_vol_max"].iloc[-1]

    balances = []
    for sub in df.rolling(win):
        b = calc_rolling_net_balance(sub)
        balances.append(b)

    df["move_balance"] = balances

    return df[["vr", "move_balance", "span"]].set_index(old_index)

这段代码的逻辑是,如果某个bar的成交量相对于过去一段时间的平均成交量异常放大(用该bar的成交量除以平均成交量,记为vr),那么它就可能是主力的操作。我们认为,该根bar的方向,有较大概率是此后主力的运作方向。

如果这根bar是阳线,它反应的是买入操作;如果这根bar是阴线,它反应的是卖出操作(记为flag)。

主力为了测试对手盘和跟风盘,在试盘之后,有可能中断操作一段时间,借此观察盘面变化。因此,我们要考察该bar之后一段时间的交易情况,把这部分成交量换算成净余成交量(记为move_balance),再除以主力操作的那个bar的成交量(『归一化』)。如果净余成交量与主力bar是同向的,说明对手盘很小(或者没有成为对手的意愿);如果净余成交量与主力bar相反且较大,则主力的意图有可能难以实现,主力可能暂时放弃操作。

我们拿一个样本测试一下:

 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
import akshare as ak
def test(bars, thresh=5):
    bars.index = np.arange(len(bars))
    df = max_volume_direction(bars, 40)

    bars.rename(columns={"day": "date"}, inplace=True)
    cs = Candlestick(bars, height=750)
    # add up markers
    df["close"] = bars["close"]
    x = df[np.isfinite(df.vr) & (df.vr > thresh)].index
    y = df[np.isfinite(df.vr) & (df.vr > thresh)]["close"] * 1.05
    cs.add_marks(x, y, name="up", marker="triangle-up")

    # add down markers
    x = df[np.isfinite(df.vr) & (df.vr < -thresh)].index
    y = df[np.isfinite(df.vr) & (df.vr < -thresh)]["close"] * 0.95
    cs.add_marks(x, y, name="down", marker="triangle-down", color="green")

    cs.plot()

code = "sz002466"
bars = ak.stock_zh_a_minute(symbol=code, period="30", adjust="qfq")
bars = bars[-150:].copy()

bars["volume"] = bars.volume.astype(int)

test(bars)

Attention

由于akshare无法按时间段获取30分钟线,并且只能获取固定长度的30分钟线(更早的会丢弃)k线,所以,这段代码运行的结果将会与下图不同。

在测试中,我们设置vr的阈值为5。在一些比较激进的个股上,设置为8以上可能效果会更好。

在示例中,我们看到三个向上箭头。其中前两个出现后,随后股价下跌,但反向成交量很小,表明没有对手抛盘。于是主力随后又进行了拉升。第三个箭头出现后,此后出现了比较明显的抛压信号(墓碑线),随后股价下跌。

当然,我们还需要对其进行大规模的测试。这些正是《因子分析与机器学习策略》要介绍的内容。在因子创新上,我们从量、价、时、空四个维度,都给出了创新的思路和方向,保持关注,持续为你更新!

当交易员用上火箭科学!波和导数检测出艾略特浪、双顶及及因子构建

这篇文章的部分思想来自于 John Ehlers。他曾是雷神的工程师,当年是为NASA造火箭的。他有深厚的数字信号处理(DSP)技术背景,为石油钻探发明了最大熵频谱分析(MESA)。这种分析方法能为短暂的地震回波提供高分辩率的显示。

随后,他把这种方法应用到金融领域,并开创了名为MESA的软件公司,为交易者提供相关分析软件和教育服务。

他和JM Hurst是在证券的周期分析上贡献最大的几人之一。John Ehlers还发表了许多专著,包括《交易者的火箭科学》等。

不过,直到1989年Python才被发明,直到千禧年左右才广为人知,所以,John Ehlers的许多思想,是使用一种所谓的Easy Language表达的。我们尝试使用Python来传达他的一些思想,并加入了自己的理解与拓展。最后,我们将介绍基于这种思想,发现的一个因子。

毫无疑问,证券价格是一种变形的周期信号。它的基本走势由公司的价值决定,叠加交易产生的波动。

加速成长的公司,比如,正在浪头上的科技股,它们的价值曲线可能是指数函数,比如90年代的思科、2000年前后的微软,后来的苹果和现在的英伟达。基础服务类的公司,它们的价值曲线应该是参照GDP增长的直线。

Info

说到苹果,最近发布的 Mac Mini 4是相当不错。正在抢购中。几年前入手的 Macbook M1压缩mp4视频能达到6:1的加速比,Mac Mini 4估计加速比能到30:1甚至更高了,也就是1小时的影片,应该不到2分钟完成编码压缩完成。

波动则由不同风格、不同资金管理周期(其倒数即为频率)的投资者驱动。这两类曲线合成了最终的走势,并且重大事件将改变周期规律。

下面这个函数将生成一个震荡向上的价格序列。它由一条向上的直线和一个sine曲线合成。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def gen_wave_price(size, waves, slope, amp = 0.3, phase=0):
    time = np.arange(size)
    amp_factor = time * amp
    freq = 2 * np.pi * waves / size

    y = amp_factor * np.sin(freq * time + phase) + time * slope
    return y

y = gen_wave_price(100, 3, 0.5, 0.25)
plt.plot(np.arange(100), y)

这将生成以下图形:

图1. 震荡向上走势

实际上,我们已经合成了一个艾略特驱动浪:

艾略特上升5浪

合成双顶和头肩顶

关于双顶和头肩顶,我有很好的检测算法。不过,如果你对波更感兴趣的话,我可以用sine函数来捏一个。

1
2
3
4
x = np.linspace(1, 10, 100)
y = np.sin(x) + .33 * np.sin(3*x)

plt.plot(x, y)
图2 双头

头肩顶只需要再加一个sine波:

1
2
3
4
x = np.linspace(1, 10, 100)
y = np.sin(x) + .1* np.sin(3*x) + .2 * np.sin(5*x)

plt.plot(x, y)
图3 头肩顶

如果要检测股价序列中,是否存在双头,我们可以使用scipy中的curve_fit函数。既然\(np.sin(x) + .33 * np.sin(3*x)\)能拟合出双头,那么,如果我们能从价格序列中,通过curve_fit找出类似的函数,再判断参数和估计误差,就能判断是否存在双头。

这个函数泛化后,应该表示为:

 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
from scipy.optimize import curve_fit, differential_evolution

# 待推断函数
def model(x, a, b, c, d):
    return a * np.sin(b * x) + c * np.sin(d * x)

# 目标函数
def objective(params):
    a, b, c, d = params
    y_fit = model(x, a, b, c, d)
    error = np.sum((y - y_fit) ** 2)
    return error

# 推断和绘图
def inference_and_plot(x, y):
    bounds = [(0, np.max(y)), (0, len(x)), (0, 1), (0, len(x))]
    result = differential_evolution(objective, bounds)

    initial_guess = result.x
    params, params_covariance = curve_fit(model, x, y, p0=initial_guess)

    plt.plot(x, y)
    plt.plot(x, model(x, *params), 
             label='拟合曲线', 
             color='red', 
             linewidth=2)
    return params, params_covariance


# 增加一点噪声
x = np.linspace(1, 10, 100)
y = np.sin(x) + .33* np.sin(3*x)
y += np.random.normal(0.05, 0.1, size=100)
inference_and_plot(x[60:], y[60:])
图4 双头推断

这里使用了差分进化算法来自动发现参数。

我们把蓝色部分当成是真实的股价序列,红色曲线就是通过差分进化算法推断出来的拟合曲线。

波和导数

到目前为止,我们还只进行了铺垫和前戏,只是为了说明股价序列确实具有波的特性。既然如此,我们把波的一些特性给用起来。

正弦波有一个有意思的特性,就是它的导数是一个余弦波,即:

\(\frac{d}{dt}sin(\omega t)=(\frac{1}{\omega})\times cos(\omega t)\)

求导之后,变成频率相同的余弦波,而余弦波是相位提前的正弦波。我们来验证一下。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
t = np.linspace(1, 10, 100)

# 原函数
y = np.sin(t)

# 导函数
dy = np.diff(y)
dy = np.insert(dy, 0, np.nan)

fig, ax = plt.subplots(figsize=(10,5))
line1, = ax.plot(y, label="原函数")

ax2 = ax.twinx()
ax2.grid(False)
line2, = ax2.plot(dy, '-', color='orange', label="导数")

lines = [line1, line2]
labels = [line.get_label() for line in lines]
plt.legend(lines, labels, loc="upper right")
plt.show()
图5 导函数与原函数关系

蓝色的线是原来的股价,黄色的则是它的导数。从图中可以看出,求导之后,频率不变,振幅变小了,相位提前,并且黄色线的每一个波峰和波谷,后面都对应着蓝色线的波峰和波谷。这意味着什么呢?

如果原函数是一个波函数,现在,我们就能提前预测波峰和波谷了。上图清楚地显示了这一点。

几乎所有的技术指标都是落后指标,但是,导函数居然帮我们预言了波峰与波谷的到来!

Tip

不要感到震惊!其实很多量化人都已经在一种浑然不知的状态下使用这个特性了。比如,你几乎肯定用过三周期的np.diff再加np.mean。只要你用了np.diff数据,就在某种程度上使用了导数。但是,清楚地知道这一特性,我们才知道何时使用、何时放弃。

让我们把话说得再明白一点:

如果原函数是一个波函数,那么,通过寻找导函数的波峰与波谷(这是利用已经发生的数据),就能提前1/4周期知道原函数何时到波峰与波谷 -- 惟一的前提是,规律在这么短的时间里,不发生改变 -- 不会总是这样,但总有一些时间、一些品种上,规律确实会保持,而你要做的,就是运用强大算力,尽快发现它们。

我们将在《因子分析与机器学习策略》课程中,将这个特性转换成一个特征,加入到机器学习模型中,以提高其对顶和底的预测能力。课程还在进行中,现在加入,会是未来几个月中,价格最低的时候。

课程助理小姐姐在这里等你!

不过,在构建机器学习模型之前,我们可以先用这个特性构建一个因子。

点击查看源码

我使用2018年到2023年间随机抽取的2000支个股,进行了因子验证,得到以下结果:

1D 5D 10D
Ann. alpha 0.138 0.096 0.078
beta 0.036 0.064 0.058
Mean Period Wise Return Top Quantile (bps) -1.180 -1.613 -1.580
Mean Period Wise Return Bottom Quantile (bps) -15.671 -11.045 -9.155
Mean Period Wise Spread (bps) 14.491 9.531 7.650

6年间的累计年化收益图如下:

图6 累计收益图

这个收益还没有进行优化。实际上,从下面的分层收益均值图来看,是可以优化的。

图7 分层收益均值图

我们可以在因子检验之前,过滤掉第10层的因子。这样处理之后,我们得到的年化Alpha将达到20.08%,6年累计收益接近3倍。

即使是纯多策略,该因子的年化Alpha也达到了14%。

因子构建及验证代码加入星球后即可获得。

Recurring Phase of Cycle Analysis

John Ehler在这篇文章里,提出了寻找周期的方法。

图8 John Ehlers's Recurring Phase Analysis

代码使用的是Easy Languange,不过这门语言对我来说一点也不easy。我不知道它是如何实现FFT的,并且还有无处不在的魔术数字。

不过,我完成了一个类似的研究。这个研究旨在揭示fft变换之后的直流分量的意义。

点击查看源码

对图1中的时间序列进行fft变换后,去掉直流分量,再逆变换回来,将两者进行对比,我们会得到这样的一个图:

图9 去掉直流分量的对比

非常有意思。

第一点,橙色的线是去掉直流分量后的序列(的实部)。它与原序列之间的差值是一个常数,这个常数竟然是原序列的均值!

1
assert np.mean(y) == np.abs(y - y2)

这就是直流分量的真实含义。从数学上讲是一个均值,从交易上讲,它是公司的定价,一切波动都在围绕它发生。

第二点,y与y2的模的差分有上下界。当股价上涨到一定程度之后,一部分能量被浪费在虚部的方向上,该方向是与实部正交的方向,从而导致这个差分有上下界。这似乎是图8中,John Ehler要揭示的信息。

如果一个函数有上下界,它对交易的帮助就太大了。

[1103] QuanTide Weekly

本周要闻

  • 英伟达和宣伟公司纳入道指
  • 制造业PMI时隔5个月重返景气区间
  • 三季报收官,8成上市公司实现盈利

下周看点

  • 周二:美国大选投票日(美东时间)
  • 周二:财新发布10月服务业PMI
  • 4日-8日,人常会:增量政策工具或将揭晓
  • 周六:统计局发布10月PPI/CPI

本周精选

  1. 一门三杰!一年翻十倍的男人发明了 UO 指标
  2. 世界就是一个波函数:直流分量差分因子获得15.%年化

  • 道琼斯指数发布公告,将英伟达和全球涂料供应商宣伟公司纳入道琼斯工业平均指数。英伟达将取代英特尔,宣伟将取代陶氏化学。
  • 统计局发布,10月份制造业PMI 50.1%,环比上升0.3。这是制造业PMI连续5个月运行在临界点以下后重新回到景气区间。
  • 统计数据显示,近八成上市公司前三季度实现盈利,近五成实现净利润正增长。消费品行业呈现明显修复态势,高技术制造业经营具有韧性,农林牧渔、非银金融、电子、社会服务等行业净利润增幅居前,同比增速达507%、42%、37%、30%。

  • 人大常会会11月4日至8日在北京举行,前期财政部提及的一次性新增债务额度和“不仅于此”的增量政策工具,或者将在本次会议上揭晓答案。

根据财联社、东方财富、证券时报等资讯汇编


一年十倍男发明了UO

Larry Williams,1987 年世界期货交易大赛冠军

指标 Ultimate Oscillator(终极振荡器)是由 Larry Williams 在 1976 年发表的技术分析因子。

Larry 是个牛人,不打嘴炮的那种。他发明了 William's R(即 WR)和 ultimate ocsillator 这样两个指标。著有《我如何在去年的期货交易中赢得百万美元》一书。他还是 1987 年世界期货交易大赛的冠军。在这场比赛中,他以 11.37 倍回报获得冠军。

更牛的是,在交易上,他们家可谓是一门三杰。


Michelle Williams

这是他女儿,michelle williams。她是知名女演员,出演过《断臂山》等名片,前后拿了 4 个奥斯卡最佳女配提名。更厉害的是,她在 1997 年也获得了世界期货交易大赛的冠军,同样斩获了 10 倍收益。在这个大赛的历史上,有这样收益的,总共只有三人,他们家占了俩。

这件事说明,老 williams 的一些交易技巧,历经 10 年仍然非常有效。

Larry Williams 的儿子是位心理医生,著有《交易中的心理优势》一书。近水楼台先得月,身边有两位世界冠军,确实不愁写作素材。


这是指标的计算公式。

\[ \text{True Low} = \min(\text{Low}, \text{Previous Close}) \\ \text{True High} = \max(\text{High}, \text{Previous Close}) \\ \text{BP} = \text{Close} - \text{True Low} \\ \text{True Range} = \text{True High} - \text{True Low} \\ \text{Average BP}_n = \frac{\sum_{i=1}^{n} BP_i}{\sum_{i=1}^nTR_i} \\ ULTOSC_t=\frac{4Avg_t(7) + 2Avg_t(14) + Avg_t(28)}{4+2+1} \times 100 \]

它旨在通过结合不同时间周期的买入压力来减少虚假信号,从而提供更可靠的超买和超卖信号。Ultimate Oscillator 考虑了三个不同的时间周期,通常为 7 天、14 天和 28 天,以捕捉短期、中期和长期的市场动量。

这个公式计算步骤比较多,主要有 true low, true high 和 true ange, bull power 等概念。

用这个图来解释会更清楚。


所谓的 true range,就是把前收也考虑进行,与当天的最高价、最低价一起,来求一个最大振幅。然后计算从 true low 到现价的一个涨幅,作为看涨力道(Bull Power)。

最后,用看涨力道除以真实波幅,再在一定窗口期内做平均,这样就得到了归一化的看涨力道均值。

最后,它结合长中短三个周期平均,生成最终的指标。

从构造方法来讲,它与 RSI 最重要的区别是,加入了 high 和 low 两个序列的数据。

做过交易的人知道,关键时刻最高价和最低价,都是多空博弈出来的,它是隐含了重要信息的。如果实时盯过盘口的人,可能感受更深。

像最高点,它是主力一口气向上吃掉多少筹码才拿到的这个最高点。上面的筹码吃不掉,最高价就定在这个地方。吃不掉的筹码是更大的资金的成本或者其它什么心理价位,就是未来的压力位

因此,ultimate oscillator 与 RSI 相比,是包含了更多的信息量的。希望这部分解读,能对大家今后探索因子起到一定的启迪作用。

这个图演示了实际中的 uo 指标,看起来是什么样的。从视觉上看起来,它跟 RSI 差不多,都是在一定区间震荡的。


这个因子在回测中的表现如何?在回测中,从 2018 年到 2023 年的 6 年中,它的 alpha 年化达到了 13.7%,表现还是很优秀的。

不过因子收益主要由做空贡献。大家看这张分层收益图,收益主要由第 1 层做空时贡献。在纯多的情况下,alpha 并不高,只有 1.6%,收益主要由 beta 贡献,所以组合收益的波动比较大。


所以,这个指标在期货上会更好使。

在多空组合下,6 年的收益达到了 2.2 倍。

最后我们看一下因子密度分布图。看上去很符合正态分布,尽显对称之美。

从分层均值收益图来看,我们在交易中还可以做一点小小的优化,就是淘汰第8层之上的因子。这样调优之后,在2018年到2022年间,年化Alpha达到了24%,5年累计收益达到了2.75倍。


我们保留了2023年的数据作为带外数据供测试。在这一年的回测中,年化Alpha达到了13%,表明并没有出现过拟合。2023年的累计收益曲线如下:

同期沪指是以下跌为主。8月底开启的上涨,在时间上与DMA策略上涨巧合了。


世界就是一个波函数

从直觉上看,使用波谱分析的方法来构建因子非常自然。因为经济是有周期的,交易更是有周期的。不过在量化交易中运用波谱分析,有它的难度。

以人声的波谱分析来说,它的频率有着固定的范围,能量也有固定的范围,换句话说,它们是平稳序列。但证券价格不是。我们多次讲过这个观点,股票价格是震荡向上的随机序列,只要国家经济还在发展,因此它不是平稳的。

但我们总能找到一种方法来分析问题。

波谱变换

我们先简单地介绍波谱变换。

1
2
3
4
5
6
7
8
fft_result = np.fft.fft(close)
freqs = np.fft.fftfreq(len(close))

# 逆傅里叶变换
filtered = fft_result.copy()
filtered[20:] = 0
inverse_fft = np.fft.ifft(filtered)
inversed = pd.Series(inverse_fft.real, index=close.index)

1
2
3
4
5
# 绘制原始信号和分解后的信号
plt.figure(figsize=(14, 7))
plt.plot(close, label='Original Close')
plt.plot(inversed, label='Reconstructed from Sine Waves')
plt.legend()

第一行代码是将时间序列变换成频谱,也就是所谓的时频变换。变换后的结果是一个复数数组,其中实部是频谱,虚部是频谱的偏移。

该数组是按频率由小大到排列的,也就是数组的开始部分是低频信号,结尾部分是高频信号。元素的取值是该信号的能量。一般我们把高频信号当成时噪声。 在这个数组当中零号元素有特殊的含义,它的频率是零赫兹,也就是它是一种直流分量。

第一行是生成频率的代码。注意它只与时间序列本身的长度有关系。也就是一个序列如果长度为30个时间单位,那么我们认为它的最高频率是30次。至于该频率实际上有没有信号,要看前一个数组对应位置的数值,如果是非零,就认为该频率的波存在。

第6~第8行是对转换后的频率信号进行简单处理。我们将20号以后的数组元素置为零。这样就实现了滤波。

然后我们通过ifft将处理后的信号逆变换回来,再重建时间序列。


我们看到图像更平滑了。所以这也是一种均线平滑的方法。好,关于FFT我们就介绍到这里。

直流分量的解释

现在我们思考一个问题,将价格序列进行时频变换后,得到的直流分量,意味着什么?

这里有一个猜想,如果我们把一次振动看成一次交易 -- 买入时导致股价上升,卖出时导致股价下跌回到起点 -- 这就是一种振动,对吧?

那么,高频振动就对应着高频交易,低频振动就对应着低频交易。如果在该窗口期没有做任何交易的资金,它们就是长线资金,是信号中的直流分量。直流分量的能量越大,高频振动的能量越小,股价就越稳定。

现在,我们再进一步思考,如果在t0期直流分量的能量为e0,在t1期的能量变为e1,那么,两者的差值意味着什么?


这就意味着有新的长线资金(超过窗口期)进来了。那么,股价就应该看涨。

直流分量差分因子

这个因子的原理是把股价当成一种波动,对它按30天为滑动窗口,进行波谱分析,提取直流分量(即频率为0的分量)的差分作为因子。

1
2
3
4
5
6
7
def calc_wave_energy(df, win):
    close = df.close / df.close[0]
    dc = close.rolling(win).apply(lambda x: np.fft.fft(x)[0])
    return-1 * dc.diff()

np.random.seed(78)
_ = alphatest(2000, start, end, calc_factor=calc_wave_energy, args=(30,), top=9)

这是年化Alpha,很意外我们就得到了17%的年化:

1D 5D 10D
Ann. alpha 0.170 0.144 0.114
beta 0.022 0.030 0.040
Mean Period Wise Return Top Quantile (bps) 2.742 2.512 2.042
Mean Period Wise Return Bottom Quantile (bps) -9.614 -8.516 -7.270
Mean Period Wise Spread (bps) 12.355 11.178 9.473

我们再来看分层收益均值图。我们从未得到过如此完美的图形。它简直就像是合成出来的。


近20年累计收益17.5倍。

在《因子分析与机器学习》课程中,我们批露了更多高效率因子,并且深入浅出地讲解了因子分析和机器学习构建量化交易策略的原理,快来一起学习吧。