在量化因子挖掘中的完整实践:从点云到持续同调)
拓扑数据分析TDA在行情结构因子挖掘中提供了一个与常规量价因子不同的技术视角它把一段行情看成几何对象通过形状变化提取可量化特征。趋势、震荡、放量突破、区间反复这类交易员常用语言很难用单一均线或波动率指标稳定表达。TDA 的思路是先构造点云或单纯复形再计算持续同调用“出生-死亡”区间记录形状特征在不同尺度下的表现。下面围绕这一思路完整实现从行情数据准备、点云构造、持久同调计算到结构因子生成的流程并补充因子验证和常见问题适合有一定 Python 基础、正在做量价因子研究的读者。量化因子研究中的一个现实问题是传统技术指标往往基于固定函数形式例如移动平均捕捉趋势、布林带捕捉波动但它们对价格路径的“形状”不敏感。TDA 从拓扑不变量出发能够把点云中的连通分量、环、空腔等结构转化成数值摘要。把这种摘要放到滚动窗口上就能得到一条结构因子序列。它不能保证预测收益但可以作为已有因子体系之外的低相关补充信号。1. TDA 为什么能刻画行情结构从“形状”到因子1.1 拓扑数据分析到底在分析什么拓扑数据分析研究的是点云或空间对象的“形状性质”。一个点云可以是一组二维坐标点也可以是高维空间中的样本集合。传统统计关注均值、方差、相关关系拓扑则关心点云中有多少个独立的连通分量是否存在环状结构是否存在高维空腔这些结构在多大尺度下出现又在多大尺度下消失。持续同调是 TDA 的核心工具。它通过不断改变一个尺度参数例如把点云中的每个点扩展成半径不断增大的球观察点之间形成的边、三角形、四面体如何出现和连通。每一个拓扑特征都会有一个“出生”尺度和一个“死亡”尺度。出生早、死亡晚的特征通常被认为是真实结构而那些出生后很快消失的特征通常被视为噪声。在数学上零维同调H0对应连通分量一维同调H1对应环或空洞二维同调H2对应空腔。对行情研究来说H0 可以理解为行情被切分成多少段H1 可以理解为价格路径是否存在“绕了一圈再回来”的结构。把这种抽象描述落到代码中就是计算点云上的 Vietoris-Rips 复形并输出持续同调图。1.2 行情结构如何转换成拓扑对象K 线价格序列本身是一条随时间变化的曲线不是点云。要让 TDA 起作用需要先把一段行情转换成点云。常见做法是对收益率序列做延迟嵌入也就是 Takens 嵌入。假设一个窗口内有 60 根K线可以得到 59 个对数收益率。若选择嵌入维度为 2就把相邻两个收益率组成一个二维点第 1 个点第 1 个收益率、第 2 个收益率第 2 个点第 2 个收益率、第 3 个收益率依此类推。这样得到的点云虽然不是价格走势本身却保留了价格路径的局部动态关系。当行情反复震荡时连续收益率点会在某个区域内来回移动容易形成环状结构当行情单边运行时点云会沿某个方向延伸连通分量的变化会更简单。需要强调的是这种“形状含义”只是构造因子的直觉依据。真正能不能作为有效因子要看后续的 IC 分析、分层回测和样本外验证不能直接通过看图得出因果关系。1.3 TDA 因子与常规量价因子的差异把 TDA 因子放进现有因子库之前先要清楚它与常规因子在计算逻辑上的区别。维度常规量价因子TDA 结构因子输入形式均值、方差、动量等统计量点云或复形的拓扑摘要非线性多数因子线性表达能力有限能捕捉环、连通分量等非线性结构参数窗口、阈值、权重窗口、嵌入维度、同调维度、尺度范围计算成本低通常 O(n)相对高与点云规模和维度有关解释性较强如“动量越高越强”中等需要结合持久性图解释主要风险参数过拟合计算慢、参数敏感、容易过拟合这个对比并不是说 TDA 优于传统因子而是说明它有独立的数学来源。把两类因子放在一起使用时相关度往往不高这是它的主要价值。2. 搭建 TDA 因子挖掘实验环境依赖与数据准备2.1 实验环境清单TDA 因子挖掘的最小环境包括 Python 科学计算栈和至少一个持续同调计算库。依赖包用途安装建议numpy数值计算、数组操作pip install numpypandas行情数据读取、滚动窗口、因子表处理pip install pandasscipy秩相关系数、统计检验pip install scipyripser计算 Vietoris-Rips 持续同调pip install ripserpersim持久性图对比、可视化辅助pip install persimgudhi更完整的 TDA 计算库可选conda install -c conda-forge gudhi推荐使用 Python 3.8 到 3.11。不同版本的 ripser 和 gudhi 对 Python 版本支持不同安装前可以先确认当前环境版本。如果是在公司内网或离线环境需要提前准备 wheel 包或镜像源。2.2 安装 TDA 计算库实际项目中最常用的是 ripser。它接口简单适合快速计算点云的 H0 和 H1。pip install numpy pandas scipy ripser persim如果后续要使用更丰富的拓扑工具可以安装 gudhiconda install -c conda-forge gudhi或者尝试 pip 安装pip install gudhi安装完成后用一条命令验证依赖是否可导入python -c import ripser, persim; print(tda libs ok)如果看到tda libs ok说明核心库可用。gudhi 导入会比较慢属于正常现象。2.3 准备行情数据TDA 计算只需要收盘价就可以演示但生产级因子研究建议保留完整 OHLCV 数据。下面假设数据文件是 CSV包含datetime, open, high, low, close, volume六列。import pandas as pd df pd.read_csv(market.csv, parse_dates[datetime]) df df.sort_values(datetime).reset_index(dropTrue) close df[close].astype(float) print(df.head()) print(缺失值数量, df.isnull().sum().sum())这里有几个关键点日期必须排序否则滚动窗口的语义会错乱价格必须是浮点数避免字符串参与计算如果数据来自前复权接口要确认复权方式与回测口径一致如果数据包含退市或停牌缺失要先决定填充或删除策略。在原始数据缺失的情况下直接 dropna 可能造成窗口断裂。更好的做法是先检查缺失位置再决定是用前值填充还是剔除对应交易日。2.4 滚动窗口设计TDA 因子本质上是对每个历史窗口计算一个拓扑摘要因此窗口参数决定了因子的时间尺度。参数推荐范围影响窗口长度 window20 到 120 根K线窗口越短因子越灵敏噪声越大窗口越长结构越稳定信号越滞后嵌入维度 embed_dim2 到 5维度越高点云越复杂计算越慢但能捕捉更多非线性结构同调维度 maxdim1 或 2计算 H1 通常够用计算 H2 会显著增加耗时滚动步长 step1 或 5步长越小因子序列越密集步长越大计算量越小学习环境可以直接用步长 1 让因子序列连续。生产环境如果要降低计算量可以在批处理中使用步长 5再用插值或重采样对齐到目标频率。3. 用滑动窗口把行情序列变成点云并计算持久同调3.1 从价格序列到收益率序列直接用价格构造点云有两个问题一是价格序列通常非平稳不同时间段的绝对价格区间相差很大二是不同股票的价格量纲不一致比如百元股和两元股直接用原始价格会让点云距离完全被价格水平主导。推荐先计算对数收益率再按窗口做标准化import numpy as np import pandas as pd close pd.Series([100, 101, 99, 102, 105, 107, 106, 108, 109, 110]) log_ret np.log(close).diff().dropna() print(log_ret)对数收益率的好处是近似可加能够把连续复利收益变成线性可计算的形式。实际计算时每个窗口内部还会再做一次标准化消除短期均值和波动差异的影响。3.2 延迟嵌入构建点云对一个窗口内的收益率序列seg构造嵌入维度为embed_dim的点云def make_point_cloud(segment, embed_dim2): segment np.asarray(segment, dtypefloat) if len(segment) embed_dim 1: raise ValueError(segment length must be larger than embed_dim) columns [segment[i: len(segment) - embed_dim i] for i in range(embed_dim)] return np.column_stack(columns)例如embed_dim2时点云形状为(len(segment)-21, 2)也就是把连续两个收益率组成二维坐标点。这样做保留了收益率序列的局部顺序关系比单独把一个窗口内的每个点作为一维点云更有结构含义。3.3 计算持久同调得到点云后调用 ripser 计算持续同调from ripser import ripser point_cloud np.array([ [0.0, 0.0], [1.0, 0.0], [0.5, 0.8], [2.0, 0.0], [1.5, 1.2], ]) diagrams ripser(point_cloud, maxdim1)[dgms] print(H0 diagram:\n, diagrams[0]) print(H1 diagram:\n, diagrams[1])diagrams[0]是零维同调的出生-死亡矩阵每一行是[birth, death]。无穷大的死亡值在 ripser 中通常表示为inf需要在使用前过滤掉。一个常见的误区是直接对diagrams[1]求和忽略了inf行。零维同调中通常有一个点的死亡是inf它对应整个点云最终连接成一个连通分量这个无穷特征需要单独处理或直接过滤。3.4 把持久性图压缩成结构化因子为了把每个窗口的拓扑结果变成因子值需要把持久性图压缩成数值摘要。下面给出一个完整的特征提取函数def extract_tda_features(close, window60, embed_dim2, maxdim1): log_ret np.log(close).astype(float).diff().dropna() factor_rows [] index_list [] for i in range(window, len(log_ret) 1): seg log_ret.iloc[i - window:i].values seg (seg - seg.mean()) / (seg.std() 1e-9) if len(seg) embed_dim 1: continue cloud make_point_cloud(seg, embed_dim) diagrams ripser(cloud, maxdimmaxdim)[dgms] h0 diagrams[0] h1 diagrams[1] if len(diagrams) 1 else np.empty((0, 2)) finite_h0 h0[np.isfinite(h0[:, 1])] finite_h1 h1[np.isfinite(h1[:, 1])] h0_pers float(np.sum(finite_h0[:, 1] - finite_h0[:, 0])) h1_pers float(np.sum(finite_h1[:, 1] - finite_h1[:, 0])) h1_count len(finite_h1) h1_mean float(np.mean(finite_h1[:, 1] - finite_h1[:, 0])) if h1_count else 0.0 h1_max float(np.max(finite_h1[:, 1] - finite_h1[:, 0])) if h1_count else 0.0 factor_rows.append([h0_pers, h1_pers, h1_count, h1_mean, h1_max]) index_list.append(log_ret.index[i - 1]) columns [h0_pers, h1_pers, h1_count, h1_mean, h1_max] return pd.DataFrame(factor_rows, indexindex_list, columnscolumns)运行下面的代码查看结果sample_close np.exp(np.cumsum(np.random.normal(0, 0.01, 200))) * 100 sample_close pd.Series(sample_close) factor_df extract_tda_features(sample_close, window40, embed_dim2, maxdim1) print(factor_df.head())输出中每一行代表一个时间点上的拓扑结构摘要。h1_count表示该窗口内发现的环数量h1_pers表示所有环的持久性总和。这个结果就是后续因子研究的基础。4. 把拓扑特征设计成结构化因子因子定义与参数分析4.1 常见拓扑因子定义从持久性图中可以衍生出很多因子下面列出实际研究中常用的几个。因子名计算方式直觉含义需要注意h0_persH0 非无穷点持久性总和连通分量出现与合并的总复杂程度可能对噪声敏感h1_countH1 点数量窗口中环状结构的数量窗口太短时经常为 0h1_persH1 点持久性总和环状结构的总强度与 h1_count 高度相关h1_meanH1 点持久性均值平均每个环结构的稳定性环数量为 0 时按 0 处理h1_maxH1 点持久性最大值最强环状结构的持续尺度容易受单个异常值影响h1_pers / h0_pers一维与零维持久性比值环结构相对连通结构的重要性分母为 0 时要做保护在后续建模中如果同时使用多个拓扑因子要注意它们之间可能存在高相关性。例如h1_count和h1_pers通常会一起变大直接同时放入线性模型可能造成共线性问题。可以先做相关矩阵检查再决定是否保留。4.2 参数选择对因子的影响TDA 因子的参数敏感性比传统均线因子更高主要体现在三个方面窗口长度改变后同一时间点的因子值可能完全不同嵌入维度从 2 变成 3可能让 H1 环的数量大幅增加是否对窗口内部做标准化会直接影响点云点之间的距离分布。建议在参数选择上采用固定网格加敏感性验证而不是只挑一组最佳回测参数。下图参数表可以作为初始参考参数小值表现大值表现推荐初始值window20信号多、噪声大、H1 经常缺失信号平滑、滞后明显60embed_dim2计算快、结构简单结构复杂、计算慢、维度灾难2 或 3maxdim1只关注连通分量和环额外计算空腔1是否标准化不标准化受价格水平影响大标准化后不同股票可比每个窗口内标准化要注意的是这些推荐值不是固定结论。研究阶段应该在多只股票、多个时间区间上测试参数稳定性。如果一组参数只在某一只股票上有效在其他股票上完全失效那么它更可能是在拟合噪声。4.3 因子预处理去极值、标准化与中性化TDA 原始特征经常出现极端值尤其是h1_max和h1_pers。在做相关分析前建议先做去极值和标准化。def winsorize_series(s, lower0.025, upper0.975): q_low s.quantile(lower) q_high s.quantile(upper) return s.clip(q_low, q_high) def zscore_series(s): return (s - s.mean()) / (s.std() 1e-9) factor_df[h1_pers_w] zscore_series(winsorize_series(factor_df[h1_pers]))中性化是更复杂的处理通常需要回归剔除市值、行业等因素的影响。对纯 TDA 结构因子来说不一定要中性化但如果是把因子放入多因子模型中性化可以降低与已知风格因子的重叠。5. 因子有效性验证IC、分层回测与过拟合控制5.1 构造未来收益标签TDA 因子关注结构本质上是预测未来收益的候选信号。需要先定义预测目标。常见标签是未来 N 日收益率N 5 future_ret close.shift(-N) / close - 1future_ret在时间 t 的值表示从 t 时刻持有到 tN 时刻的收益率。这个标签需要与因子做严格对齐否则会出现未来函数。对齐方式如下aligned pd.DataFrame({ factor: factor_df[h1_pers], future_ret: future_ret }).dropna() print(aligned.head())这里的关键是shift(-N)必须基于完全相同的交易日索引。如果出现过停牌日期或数据拼接时间轴对不上会导致因子和标签错位最终算出错误的 IC。5.2 计算 IC 与 Rank ICIC 是因子值与未来收益的相关系数。常见做法是计算 Spearman 秩相关系数因为它对异常值更稳健。from scipy.stats import spearmanr def rank_ic(factor_series, ret_series, horizon5): df pd.DataFrame({ factor: factor_series, ret: ret_series }).dropna() if len(df) 10: return float(nan), float(nan) corr, p_value spearmanr(df[factor], df[ret]) return corr, p_value ic, p rank_ic(aligned[factor], aligned[future_ret], horizon5) print(Rank IC , ic, p-value , p)如果 IC 在较长历史区间内稳定且显著说明因子可能包含有效信息。如果 IC 忽正忽负则说明因子不稳定。研究阶段还可以按年度或月度分别计算 IC观察因子有效性是否随时间衰减。5.3 分层回测与分组收益除了 IC还可以把因子值分成五层或十层统计每层的未来平均收益。如果最高因子层的平均收益显著高于最低层并且中间层有单调趋势则因子的区分度更好。def layered_returns(factor_series, ret_series, q5): df pd.DataFrame({factor: factor_series, ret: ret_series}).dropna() df[layer] pd.qcut(df[factor], q, labelsFalse, duplicatesdrop) grouped df.groupby(layer)[ret].mean() return grouped print(layered_returns(aligned[factor], aligned[future_ret], q5))注意pd.qcut在因子值大量重复时可能报错需要加duplicatesdrop。当因子值有很多 0 时比如窗口内没有环结构分层会集中在同一层。分层回测只是辅助验证不是完整策略。它忽略了交易成本、滑点、持仓周期和组合约束不能作为上线依据。5.4 过拟合风险与多重检验TDA 因子参数多容易在回测中“找到”一组表现很好的参数但这种表现经常来自过拟合。控制方法包括固定训练集和测试集不在测试集上反复调参使用滚动窗口样本外验证对所有参数组合记录 IC 分布不能只看最优值多股票、多周期、多资产类别测试与随机噪声序列做对比测试判断因子表现是否显著优于随机输入。环节学习环境生产环境数据单只股票少量历史数据多股票、多市场、长期历史参数手动试验参数冻结、版本管理验证单个回测样本外滚动、压力测试计算Jupyter 脚本批处理任务、监控告警上线不需要需要灰度、日志、回滚6. 常见问题与排查清单6.1 常见问题概览问题现象常见原因检查方式处理建议安装 ripser 失败Python 版本过高或缺少编译环境检查 Python 版本和 pip 源使用 conda 安装或切换到 3.9/3.10H1 特征全部为 0窗口太短、嵌入维度低、数据太平稳打印 h1_count 分布增大窗口、提高嵌入维度特征出现 NaN窗口内标准差为 0检查原始价格是否长期不变用固定小 epsilon 保护或剔除无效窗口因子与未来收益错位shift 方向或索引对齐错误打印因子和标签尾部日期使用统一 DataFrame 对齐人工抽查索引计算非常慢窗口内点云太多、maxdim 过高检查点云规模和耗时限制 maxdim1或降采样点云不同机器结果不一致浮点精度、距离矩阵计算差异固定随机种子和版本记录依赖版本固定测试数据6.2 为什么 H1 环经常为空这是学习 TDA 因子时最容易遇到的问题。一个只有 20 根K线的窗口标准化后收益率点在二维平面上可能非常集中Rips 复形在很小半径内就把所有点连成一片环结构来不及形成。解决方案包括增大窗口例如从 20 改为 60增大嵌入维度例如从 2 改为 3 或 4使用非标准化的距离度量但要先确认不会引入价格水平偏差对多个窗口结果做滚动聚合而不是只依赖单窗口。6.3 为什么 IC 不稳定即使因子计算正确IC 也可能不稳定。常见原因在于行情结构因子本身具有时变性某个阶段的结构特征可能有效另一阶段可能无效。另一个原因是未来收益窗口与因子窗口不匹配例如用 60 日窗口计算结构因子却预测未来 1 日收益两者的时间尺度差别太大。排查路径建议从输入到输出逐层检查数据是否按交易日严格排序收益率标准化是否在每个窗口内完成点云维度是否足够形成环结构因子与未来收益是否严格对齐是否对极端值做了处理是否区分了样本内和样本外。6.4 内存和耗时问题的处理TDA 的计算复杂度会随着点云数量增加而快速上升。一个包含 60 个点的二维点云计算 H1 很快但如果一个窗口有 500 个点并计算 H2耗时可能增长几十倍。实际项目中可以将窗口内点云数量限制在 200 个点以内只计算 H0 和 H1对 tick 级数据先重采样到分钟级把历史计算做成离线缓存不在盘中重复计算用更快的库或 C 实现做生产部署。7. 生产落地从研究脚本到可复用因子工程7.1 离线批处理与缓存研究阶段的 Jupyter 脚本适合快速验证但生产环境需要一个可重复调度、可监控、可回滚的流程。推荐做法是把 TDA 因子提取封装成独立函数输入原始行情表输出因子表然后由定时任务调度。python extract_tda_factor.py --start 2020-01-01 --end 2024-12-31 --window 60每次运行结果写入带版本号的目录例如factor_data/v20250101/h1_pers.parquet。这样后续任何指标异常都能快速定位是哪一版特征、哪一次运行产生的问题。7.2 特征存储与版本管理因子表建议至少包含以下字段字段名说明datetime因子时间戳asset资产标识window窗口参数embed_dim嵌入维度factor_name因子名如 h1_persfactor_value因子值version因子版本号使用 Parquet 格式存储可以保持类型和索引比 CSV 更稳定。每次调整参数或代码后都应该生成新版本而不是覆盖旧文件。7.3 监控与数据质量生产因子需要监控以下几点原始行情是否缺失或停牌因子缺失比例是否超过阈值因子分布是否发生明显漂移计算耗时是否异常增加输入输出行数是否匹配。一旦发现异常应有告警和开关能够快速停止因子更新并回退到上一版本。7.4 与机器学习模型结合TDA 因子可以作为机器学习模型的特征输入但要注意训练集和测试集必须按时间切分不能随机打乱因子预处理参数例如去极值分位数只能在训练集上计算多因子之间的相关性要提前检查最终模型上线前要用独立的样本外区间验证。如果模型需要实时计算建议把每个窗口的点云缓存起来避免重复构造距离矩阵。TDA 计算通常是耗时大户不适合直接放在高并发接口内。7.5 生产环境注意事项清单因子代码版本和依赖版本都记录在案原始数据自动更新后重新计算因子要有幂等性回测与实盘使用同一套因子计算逻辑因子上线前至少经过一个完整样本外区间检验保留参数搜索记录避免“看到结果再选参数”污染回测关注因子衰减定期重新评估 IC。8. 扩展方向与学习路径8.1 从持续同调向更多拓扑工具扩展除 ripser 外可以尝试 gudhi 的 Alpha 复形和 Witness 复形它们在高维点云上的计算稳定性和效率不同。persim 库可以计算持久性图之间的距离例如瓦瑟斯坦距离这可以用于比较不同时间窗口结构是否相似。更进阶的方向是持久熵和持续图像。持久熵把持久性图压缩成一个标量计算速度更快持续图像则把持久性图转换成一个向量可以直接作为机器学习输入。8.2 在更多行情结构上验证TDA 因子不能只在一只股票上验证。建议在以下维度扩展不同板块和市值区间不同市场如股票、期货、加密货币不同周期从分钟级到周线不同行情阶段如趋势市、震荡市、高波动期。只有跨资产、跨周期保持相对稳定TDA 因子才有实际使用价值。8.3 与基本面因子和传统量价因子合成TDA 结构因子的一个重要价值是低相关。它和传统动量、波动率因子的相关性如果较低可以放入多因子模型做合成。合成前需要对所有因子做标准化和正交化否则会出现重复暴露。比较实用的做法是先计算相关矩阵保留与现有因子相关性低于 0.5 的 TDA 特征再进入模型训练。8.4 学习路径建议如果从零开始学习 TDA建议按下面顺序推进先理解点云、距离矩阵、单纯复形的基本概念用 ripser 对随机点云计算 H0 和 H1观察持久性图用模拟行情验证不同窗口参数下的拓扑特征变化把拓扑特征当成普通因子做 IC 和分层回测再学习 gudhi 和持久熵等高级工具。这个路径不需要深厚的纯数学基础重点在能够解释“为什么这个结构可能代表行情状态”并把结果变成可检验的数字。对量化研究而言TDA 的真正价值不是替代现有因子而是提供一种不同来源的低相关信号并用严格的统计流程验证它是否能稳定存在。动手实验时建议先用模拟数据跑通完整链路再切换真实行情这样能更快定位是数学问题、代码问题还是数据问题。