在量化研究里经常遇到这样一类问题:日常使用的动量、波动率、均线偏离等因子,本质上是价格序列的一阶或二阶统计量,一旦行情进入复杂的震荡、趋势切换和量价背离阶段,这些统计特征容易钝化。近几年,不少量化团队开始尝试用拓扑数据分析(TDA)从“形状”和“连通性”的角度去刻画行情结构,并把提取到的持续性特征作为新的候选因子。本文会围绕这个方向做一次完整的实战拆解,从拓扑数据分析的基本概念讲起,逐步完成 Python 环境配置、时间序列点云构建、持久图计算、拓扑特征提取以及因子 IC 和分组收益检验,适合已经有 Python 基础、想了解 TDA 因子挖掘方法的量化研究者和数据科学从业者。
这里先说明一点:本文不是论文复现,也不是宣称 TDA 因子一定能提升收益,而是给出一套可以照着运行、照着扩展的最小闭环方案。读完这篇文章,你能够自己把任意一段行情数据转换成拓扑因子,并接入已有的多因子分析流程。
1. 为什么用拓扑数据分析做行情结构因子挖掘
1.1 传统行情因子的局限
传统因子大多建立在价格、成交量、持仓量等数据的统计特征上,例如:
- 动量因子:过去 20 日收益率。
- 波动率因子:过去 20 日收益率的滚动标准差。
- 均值回归因子:当前价格偏离移动平均线的程度。
- 量价相关性因子:成交量与价格的相关系数。
这类因子有一个共同特点:它们描述的是数据的“数值分布”或“时间相关性”,很难描述数据作为一种几何对象时的“结构”。比如两个标的过去 20 天的收益率均值、标准差完全一致,但一个呈现缓慢爬升趋势,另一个呈现反复震荡格局,传统统计因子可能把它们看成同一类状态。拓扑数据分析的作用,就是把这种“形状差异”变成可计算的数值特征。
另一个常见问题是:金融时间序列噪声大、非平稳,传统特征对异常值敏感。拓扑不变量对连续形变具有不变性,这是 TDA 在噪声环境下仍有价值的重要原因。
1.2 拓扑数据分析能做什么
拓扑数据分析(Topological Data Analysis)是一种基于代数拓扑和计算几何的数据分析方法。它不直接关心数据的坐标,也不关心数据降维到几个主成分,而是关注数据点云在多个尺度下的连通分支、环状结构、空洞等拓扑特征。
在行情结构因子挖掘中,TDA 通常被用来做两件事:
- 第一,把一维行情序列通过“滑窗嵌入”构造为高维点云,再用 TDA 描述这个点云内部的结构。
- 第二,计算持久图,从图的点分布中提取数值特征,例如持久环的数量、寿命、总持久性等,这些数值可以作为候选因子进入多因子模型。
与 PCA、t-SNE 等降维方法相比,TDA 更适合回答“数据里是否存在某种持续存在的结构”这一类问题,而不是“数据如何排列在一个平面上”。这一点恰好契合行情结构研究中对“趋势持续性”“震荡循环”“状态切换”等概念的描述需求。
1.3 拓扑因子与传统因子的边界
拓扑因子并不是要完全替代传统因子。它在实际研究中的角色更像是一种“增量视角”:
- 传统因子擅长捕捉线性趋势和波动强度。
- 拓扑因子擅长捕捉循环结构、分离结构和多尺度下的持续性特征。
因此,一个比较合理的做法是:先独立计算拓扑因子,再与动量、波动率、流动性等传统因子放在同一个正交化框架里比较,观察它是否提供了额外的预测信息。如果拓扑因子与已有因子高度相关,那么它的贡献有限;如果相关性低且 IC 稳定,就需要进一步研究其背后的行情解释。
2. 拓扑数据分析的核心概念
2.1 点云:从行情序列到高维空间
拓扑数据分析处理的对象通常是“点云”,也就是一组点的集合。行情数据是时间序列,不能直接输入 TDA 管线,需要先转换成点云。最常见的转换方式是“滑窗嵌入”。
假设我们有过去 60 个交易日的收益率序列:
r_1, r_2, ..., r_60用长度为 10 的窗口、步长为 2 去切分这段序列,就能得到一系列子向量:
[r_1, r_2, ..., r_10] [r_3, r_4, ..., r_12] [r_5, r_6, ..., r_14] ...每个子向量可以看作是 10 维空间中的一个点,所有子向量的集合就是一个点云。这样,一个时间窗口内的行情状态,就被映射成为高维空间中的一块点云。后面的持久同调计算,都是在这块点云上进行的。
这里需要注意两种滑窗概念的区别:
- 第一种是“行情样本的滑窗嵌入”,用于把一个序列转成点云,本文使用这种方式。
- 第二种是“因子计算的滚动窗口”,每隔若干根 K 线滑动一次,用于生成时间序列上的因子值。
在实战代码中,两者会同时出现,需要区分清楚。
2.2 单纯复形与过滤
得到了点云之后,TDA 需要在点云上构造一种叫做“单纯复形”的组合结构。简单理解,单纯复形是由点、边、三角形、四面体等基本几何单元按规则拼成的结构。
最常用的是 Vietoris-Rips 复形,简称 Rips 复形。它的构造规则如下:
- 当两个点之间的距离小于阈值 ε 时,这两个点之间连一条边。
- 当三个点两两之间的距离都小于 ε 时,这三个点构成一个三角形。
- 当四个点两两之间的距离都小于 ε 时,这四个点构成一个四面体。
随着 ε 从 0 逐渐增大,点云中会有越来越多的边、三角形和更高维的单纯形出现,整个过程称为“过滤”。这个动态扩张的过程,是后续计算拓扑特征的核心基础。
Rips 复形之所以被广泛使用,是因为它只需要点云的距离矩阵,计算方式直接,适合处理各类点云数据。虽然它的单纯形数量增长很快,但配合持久同调的加速算法,在中小规模点云上仍然很高效。
2.3 持久同调与持久图
在过滤过程中,点云里的拓扑结构会不断出生和消失:
- 0 维拓扑特征代表“连通分量”,即相互连接在一起的点的集合。
- 1 维拓扑特征代表“环”或“孔洞”,即点云中形成的闭合圈。
- 2 维拓扑特征代表“空腔”,类似三维空间中的气泡。
每个拓扑结构都有一个“出生尺度”和一个“死亡尺度”。如果某个环在很大尺度范围内始终存在,说明它不是一个偶然的噪声结构,而可能对应数据中比较显著的循环或周期模式。
持久图是记录这些出生和死亡信息的二维图。图中每个点对应一个拓扑特征:
- 横坐标是出生尺度。
- 纵坐标是死亡尺度。
- 点离对角线越远,说明该结构存在的时间尺度越长,越重要。
例如,H0 持久图上的点,通常有一个点是无穷大,对应最大的连通分量;H1 持久图上的点,反映的是点云中显著环的数量和寿命。
持久图的价值在于:它完整记录了数据在多尺度下的拓扑指纹。接下来提取因子,本质上就是把持久图压缩成一个个可用于截面排序的数值指标。
2.4 从持久图到拓扑因子
持久图本身是二维分布,不能直接作为单因子使用,还需要进一步特征化。常用特征包括:
- 持久环数量:有限寿命拓扑特征的数量。
- 平均寿命:所有非无穷点的寿命均值。
- 最大寿命:寿命最长的特征对应的尺度差。
- 总持久性:所有有限寿命之和。
- 各维特征的组合:把 H0 和 H1 的特征拼接起来,或做组合运算。
这些特征可以在每个滚动窗口上计算,形成一条因子时间序列。之后再和未来收益做相关分析,就完成了“拓扑结构”到“候选因子”的映射。
如果希望比较两段行情之间的拓扑差异,还可以使用持久图之间的 Bottleneck 距离或 Wasserstein 距离。这会进入更细粒度的行情分类和状态识别场景,本文先不做展开。
3. 环境准备与依赖安装
3.1 运行环境
本文示例使用 Python 3.8 及以上版本即可,推荐使用 Python 3.9 或 3.10。操作系统可以是 Windows、macOS 或 Linux。建议先在项目目录下创建独立的虚拟环境,避免依赖冲突。
示例中主要使用以下库:
- numpy:数值计算。
- pandas:行情数据和因子处理。
- scipy:Spearman 相关系数计算。
- ripser:持久同调计算,核心计算库。
- matplotlib(可选):持久图可视化。
其中 ripser 是 Topological Data Analysis 中最常用的 Python 包之一,底层使用 C++ 实现,计算速度较快。另一个常用库是 giotto-tda(gtda),它提供了更完整的管线封装,但依赖相对较重。为了减少环境复杂度和保持代码可读性,本文使用 ripser。
3.2 安装依赖
在项目根目录打开终端,使用以下命令安装:
pip install ripser persim numpy pandas scipy matplotlib如果你的机器上已经配置了 Jupyter Notebook,也可以直接在 notebook 中运行以上命令。安装完成后,可以用一行代码验证:
from ripser import Rips import numpy as np print("RipsER version OK")如果安装过程中遇到编译失败问题,通常是因为 pip 版本过低、缺少编译器或者系统 Python 版本过旧。建议先把 pip 升级到最新版本,再安装:
pip install --upgrade pip不建议在系统全局环境里直接安装这些科学计算库,尤其当系统 Python 被其他项目依赖时,很容易出现版本冲突。
3.3 一个最小可运行示例
安装完依赖后,可以先运行下面这个最小示例,确认持久同调计算链路正常:
from ripser import Rips import numpy as np np.random.seed(0) # 生成 80 个三维点 points = np.random.normal(size=(80, 3)) rips = Rips(maxdim=1) dgms = rips.fit_transform(points) print("H0 点数:", len(dgms[0])) print("H1 点数:", len(dgms[1]))如果能够正常输出 H0 和 H1 的点数,说明 ripser 安装成功,后续的因子挖掘实验可以在此基础上进行。
4. 完整实战:从行情序列到拓扑因子
为了把前面的概念串起来,这一节实现一个完整的因子挖掘流程:使用一段模拟行情数据,滑窗构建点云,计算持久图特征,构造拓扑因子,并做初步的 IC 和分组收益检验。
4.1 实验设计
整体流程可以拆成以下五个步骤:
- 构造演示行情数据:用几何布朗运动生成一段日线价格和收益率序列。
- 对每个分析窗口中的收益率序列做滑窗嵌入,构建高维点云。
- 使用 RipsER 计算点云的 H0 和 H1 持久图。
- 从持久图中提取各维度的总持久性、平均寿命、最大寿命等特征。
- 将拓扑特征作为候选因子,与未来 20 日收益计算 Spearman IC,并查看分组收益。
这里使用模拟数据的主要原因是保证代码可复现。换成真实行情数据时,只需要把df替换成包含close和ret列的数据框即可。
4.2 构建行情数据与滑窗点云
先看数据部分。我们生成 1500 个交易日的模拟收盘价和收益率:
import numpy as np import pandas as pd np.random.seed(42) n_days = 1500 rets = np.random.normal(0.0003, 0.01, n_days) close = 100 * np.cumprod(1 + rets) idx = pd.date_range("2021-01-04", periods=n_days, freq="B") df = pd.DataFrame({"close": close}, index=idx) df["ret"] = df["close"].pct_change().fillna(0) df.head()这里使用的pct_change()会计算日收益率,后面的滑窗嵌入基于收益率而不是收盘价。原因是收益率序列通常更平稳,而价格序列带有明显的趋势和量纲,直接用价格做距离计算容易被绝对水平主导。
接下来定义滑窗嵌入函数,把一个等长序列切分成多个高维子向量:
def build_point_cloud(series, embed_dim=10, embed_step=2): """ 将一维序列转换为高维点云。 参数说明: series: 一维收益率序列 embed_dim: 子窗口长度,决定点云维度 embed_step: 子窗口采样步长 """ n = len(series) points = [ series[i:i + embed_dim] for i in range(0, n - embed_dim + 1, embed_step) ] # 如果序列长度不够,至少保留一个点 if not points: points.append(series[-embed_dim:]) return np.array(points)这里的核心思路是:在同一个分析窗口内部,把连续的embed_dim个收益率视为一个点,然后让窗口在序列上滑动,得到一系列点。这些点在embed_dim维空间中形成一块点云,后续的拓扑结构就从这块点云中计算。
4.3 计算持久图并提取拓扑特征
下面定义拓扑特征提取函数。它对每个点云调用 RipsER 计算 H0、H1 两维的持久图,并输出四个常见特征:拓扑特征数量、平均寿命、最大寿命和总持久性。
from ripser import Rips # 复用同一个 Rips 对象,避免反复初始化 rips = Rips(maxdim=1, verbose=False) def compute_topological_features(series, embed_dim=10, embed_step=2): """ 输入:一段收益率序列 输出:H0 和 H1 的拓扑特征字典 """ pc = build_point_cloud(series, embed_dim, embed_step) # 对每个维度做标准化,减弱量纲差异的影响 pc = (pc - pc.mean(axis=0)) / (pc.std(axis=0) + 1e-9) dgms = rips.fit_transform(pc) feats = {} for dim in range(2): dgm = dgms[dim] # 去掉死亡时间为无穷大的点 finite = dgm[np.isfinite(dgm[:, 1])] if len(finite) == 0: lifetimes = np.array([0.0]) else: lifetimes = finite[:, 1] - finite[:, 0] feats[f"h{dim}_num"] = len(finite) feats[f"h{dim}_mean_life"] = lifetimes.mean() feats[f"h{dim}_max_life"] = lifetimes.max() feats[f"h{dim}_total_pers"] = lifetimes.sum() return feats这里有几个实现细节需要说明:
Rips(maxdim=1)会同时计算 H0 和 H1,H2 及以上维数计算成本更高,在行情结构研究中通常先从 H1 开始验证。- 持久图中包含死亡时间为正无穷的点,它们代表始终存在的连通分量或环,统计寿命时通常剔除,否则会出现无穷大值。
- 对点云按列标准化后,持久图的绝对尺度会发生变化,但特征之间的相对大小仍可用于因子排序。如果你希望保留行情的原始振幅信息,可以去掉标准化步骤。
4.4 滚动计算因子
有了拓扑特征提取函数之后,就可以滚动生成因子时间序列。参数选择如下:
analysis_window = 60:每个因子样本回溯 60 个交易日。embed_dim = 10:子窗口长度。embed_step = 2:点云内部滑窗步长。sample_step = 5:每隔 5 个交易日计算一次因子,降低计算量。
代码如下:
analysis_window = 60 embed_dim = 10 embed_step = 2 sample_step = 5 records = [] for end in range(analysis_window, len(df), sample_step): window_ret = df["ret"].iloc[end - analysis_window:end].values feats = compute_topological_features(window_ret, embed_dim, embed_step) feats["date"] = df.index[end - 1] records.append(feats) factor_df = pd.DataFrame(records).set_index("date") print(factor_df.head())这个循环的本质是:在每一个因子生成日,只使用当天之前analysis_window个交易日的数据计算拓扑特征,不包含未来数据,这符合因子计算中“避免前视偏差”的基本要求。
输出会得到一个包含多列拓扑特征的因子表,例如:
h0_num:H0 连通分量数量,通常与分析窗口内点云的点数接近。h1_num:H1 环的数量。h1_total_pers:H1 总持久性,是后续最常用的拓扑因子。h1_max_life:H1 最大寿命,代表最显著环的持续尺度。
4.5 因子有效性初评:IC 与分组收益
因子构造完成后,需要评估它是否与未来收益存在相关性。这里使用未来 20 日收益作为预测目标:
from scipy.stats import spearmanr # 计算未来 20 日收益 factor_df["ret_future20"] = ( df["close"].shift(-20) / df["close"] - 1 ).reindex(factor_df.index) # 全局 Spearman IC for col in ["h0_total_pers", "h1_total_pers", "h1_max_life"]: ic, pval = spearmanr(factor_df[col], factor_df["ret_future20"]) print(f"{col}: Spearman IC = {ic:.4f}, p-value = {pval:.4f}")Spearman IC 衡量因子值和未来收益之间的秩相关性,取值范围在 -1 到 1 之间。绝对值越大,说明因子的单调预测能力越强。通常在实际因子研究中,我们会观察 IC 的均值、标准差和 IC > 0 的占比,而不能只看某一段时间的 IC。
分组收益检验可以让因子的单调性看得更直观:
# 按 H1 总持久性分成 5 组 factor_df["quantile"] = pd.qcut( factor_df["h1_total_pers"], 5, labels=False, duplicates="drop" ) group_ret = factor_df.groupby("quantile")["ret_future20"].mean() print(group_ret)如果因子的分组收益呈现明显的单调上升或下降,说明该因子对收益截面有较强的解释力;如果分组收益没有规律,则说明该拓扑特征在模拟数据上没有预测价值。
4.6 结果解读
需要强调,使用随机模拟数据运行上述脚本时,IC 通常会接近 0,分组收益也不会有明显单调性。这是正常现象,因为几何布朗运动产生的价格序列本身没有可预测的结构。更重要的是,这套流程可以完整地运行,并且已经展示了从行情序列到拓扑因子再到因子检验的全部环节。
当你把数据源换成真实行情数据后,需要注意两个判断维度:
- 第一,拓扑特征是否稳定。如果某个标的的 H1 特征时有时无,说明它的行情结构在不同时期差异较大,因子适用性有限。
- 第二,拓扑因子是否提供增量信息。即使拓扑因子本身 IC 较高,也需要和传统动量、波动率因子做正交化检验,确认它不是已有因子的重复表达。
5. 常见问题与排查思路
5.1 计算量太大、内存暴涨
Rips 持久同调需要计算点云两两之间的距离,因此复杂度随点数增加明显上升。如果点云数量超过几千个点,可能会遇到内存不足或计算时间过长的问题。
可以采取以下优化手段:
- 增大
embed_step,减少点云内的点数。 - 增大
sample_step,减少滚动因子的频次。 - 对点云进行随机降采样,但要注意不要破坏核心结构。
- 分时段计算,避免一次性处理整段超长历史数据。
在实际项目中,更推荐把持久图计算缓存下来。因为同样的行情数据,在参数不变时,拓扑特征不会变化,重复计算只会浪费计算资源。
5.2 持久图里大量噪声点
持久图中的点数量很多,并不代表行情结构丰富。真正的“有效结构”通常只占少数,大多数点离对角线非常近,可以被视为噪声。
处理思路是设置寿命阈值:
# 例如只保留寿命大于 0.1 的特征点 valid = dgm[np.isfinite(dgm[:, 1])] valid = valid[valid[:, 1] - valid[:, 0] > 0.1]但寿命阈值的设定需要谨慎,过大的阈值会丢失真实结构,过小的阈值又无法过滤噪声。建议先画出持久图观察分布,再决定阈值范围。
另外,数据标准化会显著影响寿命大小。如果对点云做了标准化,特征的寿命阈值需要重新校准,不能直接使用未标准化时的经验值。
5.3 H1 特征不稳定
H1 特征常常出现时有时无的情况,尤其在行情趋势非常强的时候,收益率点云可能呈一条倾斜的流形,内部没有明显的环状结构,因此 H1 总持久性可能为 0。
面对这种情况,可以考虑:
- 使用 H0 和 H1 的组合特征,而不是只依赖 H1。
- 在更长周期上观察,例如用周线数据代替日线。
- 引入成交量、振幅等多维特征,让点云包含更多结构信息,而不是只使用收益率。
5.4 参数选择与过拟合
拓扑因子涉及的参数较多,例如analysis_window、embed_dim、embed_step和sample_step。参数不同,因子分布可能差异很大。如果在某组参数下 IC 很高,但参数稍微调整就失效,很可能是在过拟合历史数据。
推荐的做法是做一个参数敏感性矩阵:固定其他参数,对目标参数从小到大变化,观察 IC 的均值和稳定性。如果因子在较宽的参数范围内都表现稳定,那么它进入组合模型的可信度会更高。
常见问题汇总如下:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 安装 ripser 失败 | pip 版本过旧、缺少编译器 | 升级 pip、安装构建工具、使用虚拟环境 |
| 计算时间过长 | 点云点数过多 | 增大采样步长、降采样、缓存结果 |
| 持久图全是噪声 | 数据未标准化或窗口过短 | 调整嵌入维度、设置寿命阈值 |
| H1 特征经常为 0 | 行情本身缺乏稳定环状结构 | 使用 H0/H1 组合特征、改成周线数据 |
| IC 波动剧烈 | 参数过拟合 | 参数敏感性分析、样本外测试 |
| 因子与传统因子高度相关 | 拓扑特征和已知统计量重复 | 做因子正交化,计算增量 IC |
6. 工程实践与量化应用建议
6.1 数据清洗与预处理
拓扑特征对输入数据的量纲和尺度比较敏感。如果点云中同时包含收益率、成交量和振幅,不同特征的数值范围差异很大,距离度量会被数值较大的维度主导。建议在构造点云前统一做标准化,或者先对各列做 winsorize 处理,降低极端值的影响。
同时要注意,行情数据中的除权除息、停牌、异常大单都可能产生孤立点或异常距离,这会直接污染持久图。最好先做复权处理和停牌过滤,再进入 TDA 计算流程。
6.2 因子去极值与中性化
计算出的拓扑因子往往带有明显的截面分布偏度,直接参与回归或排序可能受到极端值影响。在进入多因子模型前,建议做以下处理:
- 去极值:例如用中位数加减 5 倍 MAD 截断。
- 标准化:将因子转换为 z-score。
- 中性化:如果因子与市值、行业、波动率大小存在显著相关,可以用线性回归把因子残差化,得到风险中性后的拓扑因子。
这一步对所有因子都适用,拓扑因子也不例外。否则后续的 IC 分析可能只是因为因子间接暴露在行业或市值因子上。
6.3 滚动窗口与计算缓存
在真实项目中,通常需要每天或每周更新因子,而不是只计算一次。拓扑特征的计算成本比普通统计特征高,建议把持久图结果按“标的 + 参数 + 截止日期”缓存