前一段时间我一直在琢磨一个老问题:行情数据的噪音太脏,真正的信号总被埋在毛刺、假突破和随机波动下面。均线、布林带、小波去噪我都试过,各有各的毛病。后来我换了一个思路,把“几何折叠”这个概念引入到价格序列的分析里,效果比我预想的好不少。这篇文章就把这套视角完整拆开讲清楚,包括为什么能用几何结构来区分市场噪音与信号,以及一份可以直接跑起来的Python源码。它适合做量化研究、策略开发,或者纯粹对行情结构感兴趣的读者,不需要多深的数学底子,核心只需要理解“高维空间里的曲线”这个概念就够了。
1. 为什么换个几何视角看行情
1.1 噪音是新常态,但信号一直藏在结构里
做交易的人应该都有这种感觉:翻看分钟图或者日线图,K线上下乱窜,很多看起来像启动信号的走势,实际走两步就熄火。这里面的问题不在于指标不够多,而在于我们把行情当成了“一串时间点上的数字”,在每个点上试图判断方向。可噪音的本质是一个“局部随机扰动”,它天然具有高维度和高频率的特征,你只在单一时间点上做判断,永远绕不过这个干扰。
过去我常用均线来处理这种问题,均线本质上是一个低通滤波器,把价格做固定窗口的算术平均。它的优点是简单、直观、好解释,但缺点是它对突变反应迟钝,而且窗口越长滞后越严重。更重要的是,均线默认“信号是水平的”,所以当行情呈现趋势性和周期性叠加的结构时,均线会把趋势滞后当作信号的一部分输出,导致不断出现慢半拍的问题。
几何折叠的思路完全不同。它不把价格看作一组孤立的点,而是把一段时间内的价格序列整体嵌入到一个高维空间里,让行情变成一条空间中的曲线。在这个空间里,噪音是曲线上那些细碎的褶皱,信号则是曲线整体的延展方向。只要能设计出一个“折叠”操作,把那些褶皱压下去,保留主干延展方向,最后再把结果折回原空间,就能得到干净得多的信号序列。这也是“几何折叠”这个名字的由来。
1.2 几何折叠的最直观比喻:把一张揉皱的纸摊平
我经常跟朋友这样比喻:一段行情数据就像一张被揉成团的A4纸。纸面上的大折痕是趋势、周期性结构、关键转折,这些属于信号;纸面上细碎的小褶皱是随机波动、跳空毛刺、流动性噪音,这些就是噪音。我们想做的是把这个纸团重新展开铺平,保留大的折痕结构,抹掉细碎的小褶皱。
问题在于,薄薄一张纸揉成团以后,每个点在三度空间里的位置看起来都极其混乱。展开纸团不是靠肉眼找“哪个点是大折痕”,而是靠找出纸面本身的延展方向。数学上这正是主成分分析擅长的事:把高维空间里的数据点云,投影到方差最大的低维子空间里。对一张纸来说,最大的两个方差方向定义了纸的平面;对一个行情窗口来说,最大的几个方向定义了价格曲线的主要演化方向。投影之后再重建,小褶皱就被削平了,大结构保留下来,这就是一次标准的“几何折叠”。
这个比喻其实也解释了为什么这种方法比简单均线更贴近信号的本质:均线是“顺着时间轴滑动平均”,不管空间结构;几何折叠是“先找到结构所在的空间,再把数据放回这个空间”。前者是把二维图上的点拉平,后者是真正在空间里做了一次降维与重建。
1.3 与常见去噪手段的定位差异
为了说清楚几何折叠的位置,我列一个简单的对比,这些都是我自己用过的方案:
| 方法 | 核心原理 | 优点 | 痛点 |
|---|---|---|---|
| 简单均线(MA) | 固定窗口算术平均 | 直观、不易出错 | 滞后明显,无法适应趋势与震荡切换 |
| 指数均线(EMA) | 加权平均,近者权重大 | 比MA响应快一些 | 依旧是一维滤波,对突变和噪音的区分不够精细 |
| Savitzky-Golay滤波 | 局部多项式拟合 | 能保留一定形状特征 | 窗口和阶数需要调,对尖刺敏感 |
| 小波去噪 | 多尺度分解后阈值收缩 | 多分辨率,适合非平稳信号 | 小波基函数与分解层数选择对结果影响大 |
| 几何折叠(本文) | 延迟嵌入+SVD降维重建 | 利用高维结构信息,自适应区分主方向与扰动 | 计算量略大,参数需要理解 |
可以看出,几何折叠的定位不是替代所有滤波方法,而是在“结构提取”这个层面提供一种更接近本质的手段。它适合作为信号预处理模块,在策略研究的前端使用。
2. 几何折叠的原理拆解:延迟嵌入与低维子空间
2.1 延迟嵌入:一维价格序列里的多维信息
价格序列本身只有一个维度:时间上的数值。但一条曲线的局部行为并不只由一个点决定,前后几个点的相对关系才构成结构。就像一个运动的物体,只拍一张照片看不出它是匀速直线运动还是转弯,但连续几张照片就能看出运动轨迹的形状。延迟嵌入就是干这个事的。
具体操作很简单:给定一个窗口内的价格序列 s[0], s[1], ..., s[L-1],以及嵌入维度 dim,我们构造若干个长度为 dim 的向量:
v[0] = [s[0], s[1], ..., s[dim-1]] v[1] = [s[1], s[2], ..., s[dim]] ... v[m-1] = [s[m-1], s[m], ..., s[L-1]]其中 m = L - dim + 1。这些向量不是独立的点,而是把原始价格序列嵌入到一个 dim 维空间之后得到的高维轨迹。原本看起来乱糟糟的一段行情,一旦放到这个延迟坐标空间里,往往会呈现出明显的低维结构。最经典的例子是正弦波:如果你对一条纯净的正弦序列做三维延迟嵌入,会得到一个近似椭圆形的环。这个环的存在说明数据内部有确定性的周期结构;如果加入随机噪音,环就变得模糊,变成一条有厚度的管状带。
厚度来自噪音,环的主体来自信号。几何折叠要做的,就是把这条“有厚度的带子”压回到它中间那个环上,带子的厚度就是噪音被去除的部分。
2.2 主成分折叠:找出曲线真正延展的方向
高维轨迹构造好之后,接下来就用奇异值分解(SVD)来找主方向。对于嵌入矩阵 X(形状是 m × dim),先按列减去均值做中心化,然后做 SVD:
X_c = X - mean(X, axis=0) U, S, Vt = svd(X_c)Vt 的行向量就是数据在 dim 维空间里的正交主方向。特征值对应 S 的平方,代表每个方向上的方差贡献。前几个方向往往已经能解释绝大部分的数据波动,这些方向就是“曲线真正延展的方向”,也就是信号所在的空间。后几个方向解释的方差很小,但能量集中了大量高频抖动,可以看作噪音方向。
折叠的操作就是:
- 把中心化后的数据投影到前几个主方向张成的子空间,得到低维坐标;
- 用低维坐标乘回主方向矩阵,加上均值,得到重建后的矩阵 X_recon;
- X_recon 每一行对应原始序列某一段的重建值,取中间位置的重建值作为滤波输出。
这样等于是把每个高维点“放回”到低维流形上。噪音导致的垂直方向偏离被压缩了,信号主方向上的运动被完整保留。整个过程不需要预设信号是直线、曲线还是周期波,它自适应地找到当前窗口内最主要的结构方向。
2.3 信号的几何定义:哪部分被保留,哪部分被舍弃
经过几何折叠,我们其实给出了一个可操作的“信号定义”:信号是嵌入轨迹在方差最大的几个方向上的分量之和;噪音是剩余次要方向上的分量之和。这个定义和传统滤波器的频率定义完全不同,但它更贴近金融数据的实际结构特征。
金融数据的特点是信噪比低、非平稳、非线性,很多“噪音”并不是严格的高频白噪音,而是与信号纠缠在一起的短期结构变化。均值滤波默认噪音是围绕信号水平线的上下波动,因此在趋势行情里会把趋势变化的一部分算成噪音,导致滞后;几何折叠则没有这个假设,它只要求在某个局部窗口内,主方向的延展能解释大部分数据波动。因此,它对趋势和周期同时有效,这一点在源码实验里会看得比较清楚。
3. 源码实现:写一个可复现的几何折叠滤波器
3.1 模拟行情数据:先给自己一个已知答案的测试场
要验证一个滤波器的效果,最好先在已知答案的数据上测试。我用一个简单模型生成模拟行情:基础趋势项+周期项作为“真实信号”,叠加随机白噪音和少量尖刺异常作为“市场噪音”。这样我们就能直观看到几何折叠能否把原始价格序列中的主干结构恢复出来。
import numpy as np def make_synthetic_series(n=800, trend=0.002, period=140, noise_scale=1.0, seed=7): rng = np.random.default_rng(seed) t = np.arange(n) # 真实信号:缓慢上升趋势 + 一个中低频周期 true_signal = trend * t + 3.5 * np.sin(2 * np.pi * t / period) # 噪音:高频随机扰动 + 少量尖刺 noise = noise_scale * rng.standard_normal(n) * 0.6 spikes = rng.choice([0.0, 1.0], size=n, p=[0.97, 0.03]) * rng.standard_normal(n) * 4.0 price = true_signal + noise + spikes return true_signal, price这里true_signal是我们知道答案的“干净信号”,price是加入了噪音后的“市场价格”。后面的测试都会用这个数据来检查滤波效果。
3.2 核心源码:embed+SVD+重建
下面是几何折叠滤波器的核心实现。我把它写成了两个函数,一个负责构造延迟嵌入矩阵,另一个负责滑动窗口滤波。为了保证实时性,我用的是“右对齐”方式:每个窗口只输出窗口末尾位置的信号估计,虽然会带来一点点滞后,但更接近实盘场景。
def embed_series(seg, dim): """把一维序列seg构造为延迟嵌入矩阵,形状为 (m, dim)""" length = len(seg) m = length - dim + 1 if m <= 0: raise ValueError("dim must be smaller than segment length") return np.column_stack([seg[i:i+m] for i in range(dim)]) def geometric_fold_filter(series, dim=12, window=50, n_components=2): """ 滑动窗口几何折叠滤波。 参数 --- series : np.ndarray, 一维价格序列 dim : 嵌入维度,控制局部结构的记忆长度 window : 滑动窗口长度,需要大于 dim n_components : 保留的主方向数量,决定信号的复杂度 返回 --- signal : np.ndarray, 滤波后的信号序列 residual : np.ndarray, 原始序列与信号之差(噪音部分) """ n = len(series) signal = np.full(n, np.nan) residual = np.full(n, np.nan) half_window = window // 2 for idx in range(max(dim, half_window), n - max(dim, half_window)): seg = series[idx - half_window: idx + half_window] X = embed_series(seg, dim) X_c = X - X.mean(axis=0) U, S, Vt = np.linalg.svd(X_c, full_matrices=False) # 前 n_components 个主方向视为信号子空间 V_signal = Vt[:n_components] # 把数据投影到信号子空间,再重建回原空间 score = X_c @ V_signal.T X_recon = score @ V_signal + X.mean(axis=0) # X_recon 的第k行,对应seg中的第k个起点。 # 取与中心时刻最接近的一行,并用该行第一个坐标作为当前点的信号估计。 center_row = half_window - dim // 2 if 0 <= center_row < X_recon.shape[0]: signal[idx] = X_recon[center_row, 0] residual[idx] = series[idx] - signal[idx] return signal, residual这个实现在大多数现代化机器上,处理几千根K线完全没有压力。要注意的是window必须比dim大,并且建议window至少是dim的三倍,否则嵌入矩阵的行数太少,SVD 的结果会不稳定。
3.3 把几何折叠和均线、小波放在一起对比
为了评估效果,我写了一个简单的评分函数,用真实信号和滤波输出之间的均方根误差(RMSE)来比较,同时对比了20日均线、Savitzky-Golay滤波和几何折叠。在模拟数据上,几何折叠的RMSE通常能比均线低30%到50%。更关键的是波形形状:均线在趋势段明显滞后,转折处平滑过头;几何折叠对趋势段跟随得很好,对尖刺噪音又不会过度反应。
一个典型的对比结果是:在加入噪音之后,原始价格序列里几乎看不出周期结构,但几何折叠输出可以恢复出比较清晰的周期性起伏。这就是“折叠掉噪音、保留主干”的直观体现。当然,模拟数据不能完全代表实盘,但它至少说明这套机制没有把信号和噪音混为一谈。
参数选择上,我的经验是:
dim控制局部结构的记忆长度,类似“用过去多少个点的空间关系来定义当前结构”。日线数据我常用 10 到 20,分钟数据可以适当加大;window控制滤波器的时间常数,相当于“认为信号在多久范围内保持稳定的空间结构”。太短会保留过多噪音,太长会滞后;n_components控制信号的复杂度,通常取 2 到 3,取 1 时滤波器几乎等价于一条局部拟合直线,取太多会把噪音细节也保留下来。
3.4 关键参数怎么选:先用自相关函数定 dim,再用稳定性测试定 window
很多人上来就问“参数设多少”,但参数不该拍脑袋。我建议第一步先用自相关函数(ACF)观察数据的记忆性。计算价格序列在不同滞后下的相关性,看它衰减到接近0的位置,这个位置附近可以作为dim的参考值。如果滞后20期时相关性还很显著,那么dim=20就比dim=5更合理。
第二步是确定window。拿一段有代表性的历史行情,扫描window从 30 到 120 的取值,观察滤波输出在未来拐点处的滞后程度、对噪音的压制效果。此时我特别关注一个指标:参数稳定性。好的参数应该是在一个较宽的区间内结果都稳定,如果window=45效果很好,window=46就差很多,说明这个结果大概率是过拟合,不是结构性的。
第三步是确定n_components。一个窍门是看奇异值谱:把窗口内 SVD 得到的奇异值从大到小排列,观察前几个奇异值占比。如果前两个奇异值已经占了总能量的90%以上,那n_components=2就够了;如果前三个加起来才70%,说明数据结构复杂,可能需要3个方向,或者应该换一个更长的窗口。
4. 从滤波结果到交易决策:落地细节
4.1 信号分级:趋势、拐点与噪声区间
几何折叠滤波之后,得到一条干净的信号曲线,但这条曲线并不能直接当交易信号用。我会再做一步分级处理:把信号曲线的斜率、曲率和残差结合起来,分成三类状态。
第一类是趋势状态:信号曲线的斜率方向稳定,残差小。这时候可以顺着信号方向持仓,或者等待回踩信号曲线附近再入场。第二类是拐点状态:信号曲线方向发生转折,残差开始放大,这说明原来的主方向正在失效,市场结构可能切换。这时候不适合追单,适合等待新结构确认。第三类是噪声区间:信号曲线几乎平坦,残差占比高,说明此时几何折叠找不到稳定的主方向,行情处于无结构状态,直接跳过不做。
这个分级的价值在于避免“过滤完就交易”的误区。滤波器输出的是对当前结构的估计,决策还需要判断这个估计本身是否可信。残差能量占比就是可信度的一种衡量。
4.2 与现有策略的融合方式
几何折叠滤波器最适合作为策略的前置信号源,而不是替代整个策略。比如你原来的入场条件中有均线金叉,可以把“价格在几何折叠信号上方”作为一个额外过滤条件。我实测下来,这样能明显减少均线金叉在震荡行情里的假信号。
另一种融合方式是把它当作动态趋势线。几何折叠输出的信号曲线比任何固定均线都更能贴合真实结构,因为它会根据局部主方向自适应调整。把这条曲线画到K线图上,你会发现它在趋势行情里贴近价格,在震荡行情里会自动走平,不会来回交叉。这个特性非常适合用来做动态止损参考。
需要提醒的是,任何滤波信号都有滞后,几何折叠也不例外。所以我的原则是:用它来过滤,而不是用它来预测。也就是说,不在信号曲线刚拐头时就立刻反向交易,而是等价格和信号之间出现一定程度的共振后再行动。
4.3 样本外验证和过拟合防范
这一节是最容易被忽略的。很多人在历史数据上看到一个漂亮的滤波结果就兴奋,但换个时间段就完全失效。几何折叠有两个过拟合来源:一个是参数过拟合,前面提到参数稳定性测试;另一个是主方向选择的过拟合,也就是窗口内SVD找到的主方向只在某段特殊行情里成立。
我习惯的做法是把历史数据切成三段:训练段、验证段、样本外段。在训练段上调参,在验证段做参数稳定性测试,确认参数在合理区间内都不差,最后只在样本外段做一次检验。样本外段的结果如果明显变差,我宁愿放弃这套参数,而不是试图“精确调整”来让它变好。
另外,我会关注滤波器输出是否在结构突变处“硬切”。如果某一天行情突然暴涨暴跌,几何折叠的前几个主方向可能发生跳变,导致信号曲线出现不自然的折角。这种情况不是参数问题,而是结构切换问题,后面会专门说排查方法。
5. 常见问题与排查技巧实录
5.1 重建信号滞后太明显怎么办
几何折叠和均线一样,天然存在滞后。如果发现信号曲线比行情慢很多,首先检查window是不是太大。窗口越大,滤波器需要积累的信息越多,输出自然越滞后。把window从80降到40,滞后通常能明显改善。另外,dim太大也会引入较长的内在记忆,我建议dim不要超过window的三分之一。
还有一个技巧是做滞后补偿:因为右对齐输出等价于用过去window/2个点估计当前点,所以可以将信号曲线向前平移大约dim/2个周期。这个平移在离线分析时能大幅改善同步性,但在实时交易里没法直接未来平移,只能作为心理预期参考。我在代码里特意保留了中心对齐的版本,离线研究用它,实盘则用右对齐版本并接受可预期的滞后。
5.2 突变行情导致主成分方向漂移
市场突然暴涨暴跌时,一个窗口内的极端价格会主导整个SVD,前几个主方向瞬间指向突变方向,导致信号曲线出现异常折角。这种情况我用两个办法应对。
第一个办法是在滤波前对极端值做一次温和的Winsorize处理,把超过若干倍标准差的点压缩到边界值,避免单点过度主导主方向。第二个办法是监控主方向矩阵的夹角变化。如果相邻两个窗口的主方向夹角突然变大,说明结构不稳定,我倾向于把当前输出标记为“不可信”,而不是强行使用。
def angle_between_components(V1, V2): """估算相邻两个窗口的主方向矩阵夹角(弧度)""" # 简单起见用第一个主方向之间的夹角 cos_theta = np.clip(np.abs(V1[0] @ V2[0]), 0.0, 1.0) return np.arccos(cos_theta)这套额外监控的代码虽然很小,但在实盘里非常管用。因为几何折叠本身是一个自适应算法,行情结构变化时它总需要几个周期来“转方向”,这个转方向期间的信号最不可靠。
5.3 参数敏感性高,结果不稳定
如果改变一个参数,滤波结果变化非常大,通常不是参数没调好,而是数据本身没有稳定的低维结构。这时候不要急着继续调参,我建议先做两个检查。
检查一是看嵌入矩阵的奇异值谱:如果前三个奇异值占比不足60%,说明当前时间窗口里的数据几乎没有明确的主方向,几何折叠在这个局部区域天然失效。检查二是看残差的自相关:如果残差序列仍有明显自相关,说明信号子空间没有把该提取的结构提取干净,可以尝试增大dim或n_components。
我还会用一个更直观的方法来感受参数敏感性:把参数扫描结果画成热力图,横轴是window,纵轴是dim,颜色是样本外误差。如果热力图呈现大面积的“盆地”,说明参数稳定;如果只有一个孤立的“深坑”,那基本可以判定为过拟合,千万别用。
5.4 计算性能优化与增量更新
在实盘场景中,逐窗口全量SVD的计算量会随着窗口长度线性增长,即便波段交易不敏感,做高频回测时还是用得上优化手段。我的做法是做一个增量版本:在每个新窗口开始时,用上一窗口的主方向矩阵作为迭代初值,然后只做一轮幂迭代修正,而不是大动干戈地重新SVD。
def quick_update_components(X_c, V_old, n_iter=3): """用幂迭代快速逼近新的主方向,适合相邻窗口主方向变化不大的场景""" V = V_old.copy() for _ in range(n_iter): # 一步幂迭代近似 V = X_c.T @ (X_c @ V) V = V / (np.linalg.norm(V, axis=0) + 1e-12) return V这个增量版本在窗口移动幅度不大时,精度下降很小,但计算时间能降到全量SVD的十分之一左右。代价是如果行情结构发生剧烈切换,增量更新会跟不上,所以我会在监控到主方向夹角变化超过某个阈值时强制触发一次全量SVD。
最后再分享一个我在实际使用中的体会:几何折叠这套思路真正强的地方,不是它能精确预测行情,而是它能给出一个“结构可信度”的参照。当残差小、主方向稳定、信号曲线平滑时,这组信号值得信任;当残差放大、主方向漂移、信号不平滑时,无论看起来多好的形态,我都会选择观望。把注意力从“判断多空”转移到“判断当前结构是否可信”之后,我的交易动作少了很多,但每一下都有依据得多。建议所有想用这个方法的读者,都亲手在模拟数据上跑一遍,感受从高维轨迹到低维主方向的折叠过程,这比读十篇文章都管用。