Python股票价格序列相似性分析:DTW距离与聚类实战

📅 发布时间:2026/9/28 7:39:34
Python股票价格序列相似性分析:DTW距离与聚类实战
简介面向股票价格序列相似性分析的Python课程设计资源包以动态时间弯曲算法为核心适合金融数据处理与时间序列分析方向的高校学生、开发者借鉴。压缩包内共12个文件涵盖2个可直接运行的Python源码、1份课程设计报告文档、1个SQLite数据库、1个环境依赖说明文本及7张分析结果截图整体大小约2.13MB。代码与配套数据完整下载后即可运行复现分析流程通过折线图直观展示不同股票价格序列的相似性对比结果。报告文档详细阐述了动态时间弯曲算法的原理、参数设置与实验设计思路数据库和截图则便于核对中间数据、验证输出效果。目前已有613人浏览学习对于需要完成课程设计或接触时间序列相似度计算方法的读者这份资料能够帮助快速掌握从数据处理到可视化呈现的完整链路节省大量资料查阅与排错时间。1. 股票价格序列相似性分析为什么直接比收盘价是错的“这两只股票走势很像。”这句话在行情软件里经常听人讲但落到Python里多数人的第一反应是拉出收盘价逐点相减算一个“欧氏距离”数字越小代表越像。这个做法在股票价格序列相似性分析里基本是错的股价绝对水平不同行情启动时间又有错位逐点相减的结果会被高价位股票完全主导。这个方向真正要解决的是“形态相似”而不是“点位接近”——用合适的度量方式找出历史行情中走势最接近的标的再往下做聚类、选股、配对交易或板块轮动分析。对于正准备用python数据分析与可视化做量化研究的人这篇文章把度量选型、数据预处理、DTW实现、参数取舍和常见翻车点串成一条能直接复现的流程跑得通也能解释结果。2. 相似性度量怎么选欧氏距离、相关系数与动态时间规整的边界2.1 三种主流度量的适用边界先把欧氏距离为什么错讲透。给定两条等长收盘价序列欧氏距离把每个时间点的差值平方再求和这个公式本身没错但用在原始价格上有两个硬伤。其一是价格水平不同贵州茅台1700元一只银行股5元任何逐点差值都被茅台一方的量级吞掉银行股的波动在距离里几乎不占权重。其二是时间错位同样的V型反转A股在第10天见底B股在第15天见底逐点相减出来的差异巨大但画在图上肉眼一看就是同一形态。所以欧氏距离只适合“已经对齐、已经去量纲”的数据直接算原始价格得到的就是数字很小但毫无业务解释的伪相似。这一步我见过太多新人踩进去。皮尔逊相关系数比欧氏距离聪明一些它先做了中心化对绝对价格水平不敏感衡量的是两条序列的线性共变方向。问题在于它默认两个序列在时间轴上逐点对齐一旦行情错位相关性会被显著拉低而且它只看方向不看幅度涨1%和涨10%的序列相关系数都可能落在0.9以上。在股票价格序列相似性分析里相关系数适合用来判断“同一段时间里是否同步联动”不适合做“形态是否接近”的匹配。如果你只是想知道两个标的是不是同一板块的共振关系用它没问题但你要做“找出历史上最像我的标的”它不够用。动态时间规整DTW是这类问题的默认答案。它的核心是允许时间轴伸缩通过动态规划找一条累积成本最小的对齐路径把序列A的第i个点和序列B的第j个点匹配起来。只要两条序列形状相似哪怕一个启动早、一个启动晚算出来的距离也会很小。这对股票的意义非常直接——一轮板块行情里龙头先动、跟风票后动是常态时间错位是数据的固有属性不是你清洗不到位。DTW把这个属性显式建模所以它比逐点对齐的度量方式更贴近真实行情。度量方式对价格水平敏感对时间错位敏感适合场景欧氏距离强强等长、已对齐的标准化序列皮尔逊相关系数弱强同步联动判断DTW中归一化后弱弱错位形态匹配、走势聚类余弦相似度中强收益率向量方向比较2.2 数据预处理比距离公式更决定结果距离公式选得再对数据不做预处理结果依然是废的。在股票价格序列相似性分析里我认为预处理的重要程度高于距离公式本身。第一步是转成收益率序列而不是直接用收盘价价格是带趋势的非平稳序列两只股票看起来同涨同跌可能只是因为在同一段行情里都被大盘拖着走转成收益率之后绝对水平被消掉留下的才是每天的涨跌形态。第二步是标准化让不同波动率的股票站到同一尺度上比较一般用z-score先减均值再除标准差。import pandas as pd import numpy as np # 读取长表约定至少包含 date、symbol、close 三列 df pd.read_csv(daily_prices.csv, parse_dates[date]) # 转成 日期 x 股票代码 的宽表方便按股票逐列计算 wide df.pivot_table(indexdate, columnssymbol, valuesclose).sort_index() # 停牌日沿用前收盘价然后丢弃上市时间不足的列 wide wide.ffill().dropna(axis1, howany) # 对数收益比普通涨跌幅更平滑数值上接近连续复利 log_ret np.log(wide / wide.shift(1)).dropna(howall) # z-score每只股票用自己的均值、标准差归一量纲统一 ret_z (log_ret - log_ret.mean()) / log_ret.std()几个参数习惯值得说明。pivot_table的三个参数index、columns、values分别对应日期、标的、价格字段任何一列放错宽表结构就乱了后续所有计算都会跟着错。ffill处理停牌是行业惯例但代价是制造了假的零收益——短停牌可以忍长期停牌后建议把整列标的删掉。log_ret用np.log(wide / wide.shift(1))而不是pct_change是因为对数收益在做时间聚合时误差更小也更贴近金融资产收益率的分布形态。如果只做形态匹配ret_z可以直接用如果后续要算价差做配对交易建议保留原始对数收益那一列不要z-score因为价差策略关心真实幅度。提示手写DTW只用来理解原理实际项目请使用dtaidistance这一类经过C扩展加速的库速度差距在一个数量级以上。3. 用Python算价格序列相似度从CSV到DTW距离矩阵再到聚类3.1 数据清洗把日线数据整理成可计算的矩阵接上一章的预处理结果ret_z这个DataFrame已经是行等于日期、列等于股票代码的标准宽表这是相似性分析的原料。开始计算之前要检查两件事列数是否等于股票池数量行数是否覆盖了完整的交易日历。如果中途有股票退市或新上市宽表会自然出现NaN之前的dropna(axis1, howany)会把“数据先天不足”的标的清掉。这个取舍建议宁可少算几只也别让缺失值顺着滚动窗口传染到整条序列。一个常见的数据来源思路是用爬虫或行情API把日线抓成本地CSV。A股可以用akshare或tushare美股可以用yfinance抓下来的字段大同小异最终都要整理成date/symbol/close这种长表。接口返回格式和限频规则在不同环境里差异很大所以我更愿意在本地先把CSV落盘并做一次肉眼检查再交给上面的代码处理。抓取本身是另一个话题相似性分析从本地文件开始就完全足够。3.2 实现DTW距离从手写动态规划到dtaidistance核心计算是距离矩阵。先看DTW的原理实现它帮助你理解后面要调的window参数到底约束了什么。DTW用一个n行m列的矩阵D记录累积距离D[i][j]表示序列A前i个点与序列B前j个点的最小累积成本转移来自左上、上、左三个方向取最小值后加上当前点的代价。import numpy as np def dtw_distance(s1, s2, windowNone): n, m len(s1), len(s2) D np.full((n 1, m 1), np.inf) D[0, 0] 0.0 for i in range(1, n 1): for j in range(1, m 1): # window 限制对齐路径不能偏移太远None 表示允许任意弯曲 if window is not None and abs(i - j) window: continue cost abs(s1[i - 1] - s2[j - 1]) D[i, j] cost min(D[i - 1, j], D[i, j - 1], D[i - 1, j - 1]) return D[n, m]D矩阵为什么初始化成inf而不是0把边界之外的位置设为无穷大动态规划就永远走不到那些格子这和Sakoe-Chiba窗口是同一个思想——限制不合理的对齐。windowNone时不设约束任何点都可以和任何点对齐设了window10则只有|i - j| 10的位置才会被填充。手写这个版本的时间复杂度是O(n*m)n和m是序列长度。250个交易日两两比较单次算一次是6万次左右的操作直接跑还能接受一旦股票数量到几百只全量两两比较就是几十亿次操作手写版本会慢到让人失去耐心。所以实际工程里更推荐用dtaidistance库它有C扩展和OpenMP加速比纯Python手写快一到两个数量级而且内置了window参数。from dtaidistance import dtw # 传入已经 z-score 的 numpy 数组window 限制最大错位 10 个交易日 dist dtw.distance(ret_z[600000].values, ret_z[000001].values, window10)两个参数要注意。第一ret_z[600000]是pandas Series传给库函数之前要取.values否则部分版本会报警告甚至发生类型转换失败第二window10不是10%而是“允许时间轴上最多偏移10个交易日”。按业务周期来设做短线形态匹配window取5做中期板块行情匹配window取10到15做年度趋势匹配window可以放大到30。算完整只股票池的距离矩阵代码是这样symbols ret_z.columns.tolist() n len(symbols) dist_matrix np.zeros((n, n)) for i in range(n): for j in range(i 1, n): d dtw.distance(ret_z.iloc[:, i].values, ret_z.iloc[:, j].values, window10) dist_matrix[i, j] d dist_matrix[j, i] d矩阵是上三角对角对称结构i到j和j到i的距离一样所以内层只算一次存两次。全量两两计算在股票池放大后非常耗时正确的优化顺序是先加window约束再换并行版本最后用fastdtw近似替代。距离矩阵本身只告诉你“谁跟谁像”要继续用聚类把相似标的圈成组或者直接在矩阵里找最近邻。3.3 层次聚类与相似标的推荐一点数据分析与可视化的落地拿到对称距离矩阵scipy的层次聚类是现成工具。层次聚类的输入要压缩矩阵格式squareform负责把对称矩阵压缩成只保留上三角的向量linkage用ward方法合并簇ward会让每个簇的组内方差尽量小与“相似走势归一组”的目标一致。from scipy.cluster.hierarchy import linkage, fcluster from scipy.spatial.distance import squareform condensed squareform(dist_matrix) Z linkage(condensed, methodward) labels fcluster(Z, t5, criterionmaxclust) for s, label in zip(symbols, labels): print(f{s}: group {label})fcluster的t和criterion组合是聚类的两个关键参数。criterionmaxclust表示t的含义是“最多分几类”t5就是让树在某个高度切开最多产生5个簇。这个“最多”有陷阱如果数据本身只适合分成两类t5也可能只输出3类打印结果时一定要检查实际类数。换成criteriondistance则代表按距离阈值切树适合你心里已经有一个“多近算一组”的业务间隔。如果把两条序列的对齐路径画出来就能直观验证聚类结果是否符合预期import matplotlib.pyplot as plt from dtaidistance import dtw as dtw_lib path dtw_lib.warping_path(ret_z_s1.values, ret_z_s2.values, window10) plt.figure(figsize(8, 4)) plt.plot([p[0] for p in path], [p[1] for p in path], linewidth1) plt.xlabel(series A index) plt.ylabel(series B index) plt.title(DTW alignment path) plt.show()看到一条接近45度的对角线说明两条序列基本逐点对齐距离主要由幅度差决定看到明显的平台或跳跃段说明算法把一段做了“多对一”匹配通常是一个序列短期暴涨暴跌、另一个序列平缓过渡这是真实行情里很常见的情况也是DTW相对欧氏距离的优势所在。到这里一条完整流程就走通了清洗数据、算距离、跑聚类、画路径。接下来真正影响结果质量的是几个参数怎么取。4. 参数调优的三个决策点时间窗口、路径约束与聚类数4.1 时间窗口不同聚类含义完全不同DTW只对喂进来的序列负责你输入多长它就匹配多长所以时间窗口是你表达“我想要什么相似”的主要方式。我习惯把窗口分成三档20个交易日以内捕捉短线情绪形态噪声极大随机成分主导60到120个交易日对应板块行情和中期趋势做行业轮动和配对交易的人最常用250个交易日接近年度视角聚类结果更多体现行业属性而不是行情阶段。一个反直觉的点是窗口越长DTW距离往往越大因为累积的差异项更多。这会使得“短期相似”和“长期相似”之间没有可比性。解决办法是归一化距离把最终距离除以序列长度得到“平均每个交易日的差异程度”def normalized_dtw(s1, s2, windowNone): dist dtw.distance(s1, s2, windowwindow) return dist / min(len(s1), len(s2))除以min(n, m)是工程上稳定简单的做法更严谨的做法是拿到warping_path之后按实际路径点数归一化但多数场景下两者差异不大。归一化之后60日窗口的平均距离和250日窗口的平均距离才放到同一个水平上比较。如果没有这一行你可能会误判“长期相似对”比“短期相似对”差得多其实只是序列长度带来的尺度差。4.2 Sakoe-Chiba窗口给DTW装一个合理的活动范围DTW允许任意弯曲这是优点也是风险源。两个点之间没有任何时间约束时算法可以把一段完全不相关的行情强行拉在一起得到一个很小的距离但这种小距离是过拟合出来的没有业务解释。Sakoe-Chiba窗口就是给它加一条边界|i - j| window超过这个错位的匹配直接禁止。window取多少合适里面有一点玄学成分但有可参考的经验取0或1时DTW退化成逐点对齐和欧氏距离没有区别取序列长度的10%到20%是比较常见的起点。250个交易日的数据window25能覆盖大多数同板块内“龙头先涨一周、跟风后涨一周”的错位场景。调window的可靠方法是拿一组你亲耳标注过的相似对去测比如你知道某两只银行股在某段行情里走势几乎一致让window从5扫到30观察距离是否稳定在一个低水平。如果出现明显拐点拐点附近就是当前股票池的合理参数区间。注意这个参数对不同的股票池要重新标定不要沿用一次结果就跑全市场。4.3 聚类数怎么定轮廓系数比肉眼靠谱相似性分析里聚类数没有标准答案因为“类”的定义取决于业务目标。但有一点要避免靠肉眼扫热力图决定分几类。热力图的颜色渐变本质是连续分布人眼会把渐变色硬看成离散簇这是典型的视觉误导。可落地的做法是让聚类数k从2到8遍历用轮廓系数衡量簇内聚度和簇间分离度分数越高说明这个k下的分组边界越清晰。from sklearn.metrics import silhouette_score for k in range(2, 9): labels_k fcluster(Z, tk, criterionmaxclust) score silhouette_score(dist_matrix, labels_k, metricprecomputed) print(fk{k}, silhouette{score:.4f})silhouette_score的metricprecomputed是关键参数它直接吃DTW距离矩阵不再用欧氏距离重算一遍样本间相似度。输出解读不要只看峰值如果k4到k6都是0.62、0.63、0.62说明这个区间都合理取中间值k5如果k8又突然冲到0.8那多半是过分割个别股票的极端走势被单拎成了簇。做一轮滚动验证也是必要的把同样的分析在三个不同的时间窗口上跑一遍如果三次聚类结果的标的组合重叠率低于50%说明这个分组对时间点过度敏感不适合直接进策略。写python量化交易策略代码时我一般会把聚类稳定性写成一个函数输出重叠率而不只是输出类别标签这一步能挡住大部分伪规律。5. 避坑指南股票价格序列相似性分析的五个翻车现场以下五条是从真实使用过程里沉淀下来的踩坑记录每一条都按现象、原因、解决的顺序说清楚。5.1 现象相似度排第一的股票走势图是镜像的原因输入没有做中心化或归一化原始收盘价里既有绝对价格水平又有长期趋势这两个高权重成分会主导整个距离把真正的形态差异完全盖住。另一种常见情况是数据没有处理前复权填权缺口制造了一个假的跳空导致两条本来相似的序列在缺口处被强行拉开。解决统一用对数收益率z-score后的序列作为输入除权除息数据务必确认已经做前复权处理否则同类股票在分红日附近全部失真。注意除权除息会造成价格跳空预处理前务必确认数据是前复权。忽略这一条聚类结果会把“近期除权”误判成“走势不同”。5.2 现象DTW在两三百只股票池上跑不完原因全量两两比较的复杂度是O(N^2 * L^2)N是股票数L是序列长度。300只股票、250个交易日、不加window约束单对计算6万次乘以44850对总量接近27亿次纯Python版本要跑几十分钟。解决先加window10的约束把单对计算量降下来再换dtaidistance的并行版本或fastdtw近似算法最后做两阶段剪枝先用皮尔逊相关系数这一便宜度量粗筛出Top 50再在这个子集上算精确DTW。两阶段剪枝在大池子里几乎不损失精度但速度提升非常明显。5.3 现象换个时间窗口聚类结果完全变样原因价格序列是非平稳的不同窗口里主导行情的板块不同聚类本身就是对“当前这一段”的归纳窗口一换归纳的对象就变了结果重排才是正常现象不代表代码有bug。解决不要用一次性聚类结论直接下单。固定两个窗口比如60日和120日分别聚类只保留两个结果里仍然同组的标的作为“稳健相似对”。如果两个窗口对某对标的归属矛盾很大说明它们只是短期共振不是中期关联。把“跨窗口重叠率”打印出来比单独看任何一次聚类的轮廓系数都更有决策价值。5.4 现象序列长度参差不齐运行时报数组维度错误原因股票停牌导致部分日期没有行情记录直接整行dropna会让不同标的的序列长度不一致进入DTW后矩阵维度对不上。解决在宽表阶段统一对齐交易日历对单日小缺口用ffill对长时间停牌直接剔除标的。代码上加一行过滤即可# 统计每只股票的缺失天数缺失超过 20 个交易日的标的直接剔除 missing wide.isna().sum() valid_cols missing[missing 20].index wide wide.loc[:, valid_cols]这个阈值的含义是“容忍最多20个交易日的无交易状态”超过就认为标的在活跃性上不具备可比性。阈值本身可以调但一定要有一个否则后面所有矩阵运算都在不同长度的数组上做报错位置还会出现在距离计算内部排查起来很费劲。5.5 现象z-score之后涨5%和涨1%的序列变得几乎“一样”原因z-score把每只股票的波动率都压到1幅度信息被刻意抹掉这是标准化本身的代价但在必须区分真实涨幅的场景里这个代价不可忽略。解决分清你要找的是“形态相似”还是“幅度相似”。目标只是找形态z-score没有问题目标是找“涨得一样多”的标的就要保留原始对数收益作为特征或者改用收益率百分位。这个选择没有标准答案属于业务目标的分叉口不是算法漏洞。动手前问清楚一个问题你要的是“形状像”还是“涨得像”再决定预处理管线用哪一套。6. 进阶把相似性分析封装成模板再做一次滚动验证把整个流程封装成一个类是让这个技术方向从“跑通一次”变成“可重复使用”的关键一步。模板要解决两个问题距离矩阵结果复用以及相似对查找接口统一。缓存字典是这套方案的核心同样的历史数据反复分析时距离不用重算class PriceSimilarityAnalyzer: def __init__(self, window10): self.window window self._cache {} def fit(self, ret_z): self.symbols ret_z.columns.tolist() self.ret_z ret_z self._compute_dist_matrix() return self def _compute_dist_matrix(self): n len(self.symbols) self.dist np.zeros((n, n)) for i in range(n): for j in range(i 1, n): key (self.symbols[i], self.symbols[j]) if key not in self._cache: self._cache[key] dtw.distance( self.ret_z.iloc[:, i].values, self.ret_z.iloc[:, j].values, windowself.window, ) self.dist[i, j] self.dist[j, i] self._cache[key] def topk_similar(self, symbol, k10): idx self.symbols.index(symbol) order np.argsort(self.dist[idx]) return [self.symbols[i] for i in order[1:k 1]]fit负责预处理和距离矩阵计算topk_similar返回与指定标的距离最小的前k个标的整个类的接口控制在两个方法以内不管是写脚本还是接进策略引擎都顺手。缓存按(symbol_i, symbol_j)作key天然支持重复分析如果数据更新了一个月想重算全部距离直接清空_cache即可。模板之上最值得做的一次验证是滚动样本外检验取一段历史窗口算出Top K相似标的停在那里看之后一段时间的相对收益是否仍然同步。这个检验能直接暴露前几章讲的过拟合问题——如果“历史上极其相似”的两只股票在未来一个月里走势背离那这个相似度只对回测有意义对前瞻没有意义。我自己的习惯是固定三个窗口分别跑一遍算出重叠率和样本外相关性只有两项都通过的结果才敢往策略里放。这个方向做久了最大的血泪经验是相似性分析的输出从来不是“答案”只是一组假设。它告诉你历史上谁和谁像但不会告诉你下一个交易日这种关系还会不会成立。我因为偷懒跳过滚动验证、直接拿一次聚类结果去做配对交易被一只看起来完美匹配的周期股坑过一回从那以后所有相似度结论都要过第二个时间窗口验证一遍。希望帮到你。本文还有配套的精品资源点击获取