news 2026/9/24 22:56:01

股票序列相似性分析:DTW/LCSS/滑动Pearson实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
股票序列相似性分析:DTW/LCSS/滑动Pearson实战指南

简介:本资源是一份面向金融数据分析初学者与课程设计实践者的Python实战项目,聚焦股票价格时间序列的相似性度量问题,适用于量化分析入门、数据科学课程作业及算法可视化教学场景。压缩包共12个文件,含2个核心Python脚本(主程序与数据处理)、1个Word课程报告、1个SQLite数据库(存储股价样本)、1个依赖说明txt及7张结果截图png,直观呈现DTW动态时间弯曲算法在多支股票价格曲线比对中的应用效果,包体仅2.13MB,轻量易部署。已有612人学习下载,资源结构清晰:源码模块化、数据内嵌、报告详述原理与实现步骤,并附完整运行环境配置(requirements.txt)。读者可直接复现从数据加载、DTW距离计算到折线图可视化分析的全流程,掌握时序相似性分析的关键技术路径与工程落地细节。

1. 为什么用欧氏距离算股票价格相似性会集体翻车?——Python序列相似性分析不是“把两列数字丢进scipy就行”

你手上有300只股票的日收盘价序列,想找出和贵州茅台走势最像的10只标的。如果直接用scipy.spatial.distance.euclidean算两两之间的欧氏距离,结果大概率会让你怀疑人生:明明形态高度一致的两只ETF,距离值却比一只暴涨股和一只ST股还大;更玄学的是,把所有价格统一除以首日开盘价做归一化后,排名又全乱了。这不是代码写错了,而是序列相似性 ≠ 数值距离——股票价格是强趋势、高噪声、非平稳的时间序列,它的“相似”本质是局部模式、斜率变化、波动节奏的匹配,而非全局数值对齐。本方案不依赖任何第三方量化平台或付费API,纯Python实现,覆盖DTW(动态时间规整)、LCSS(最长公共子序列)、Pearson+滑动窗口三套工业级可用方案,每种方法都配可复现的参数调优逻辑、真实A股数据验证脚本、以及我踩过的5个血泪坑。适合刚跑通yfinance爬虫的新手,也适合需要嵌入策略回测框架的熟手——核心不是“怎么算”,而是“为什么这个参数值在沪深300成分股上有效”。


2. 用DTW在本地跑通股票序列相似性:最小命令+三个必调参数

DTW(Dynamic Time Warping)是处理股票价格这类非线性对齐问题的黄金标准。它允许时间轴发生弹性伸缩,比如把一只股票“慢涨3天+急跌1天”的模式,匹配到另一只“慢涨2天+横盘1天+急跌1天”的模式。但直接调fastdtw库默认参数,在日线级别数据上会严重过拟合——因为DTW本质是在找最优路径,而股价噪声会让算法拼命拟合毛刺。

2.1 安装与数据准备:避开Windows下OpenMP编译地狱

提示:不要用pip install dtw!它自带的Cython实现对Windows支持极差,编译失败率超70%。改用纯Python实现且维护活跃的dtaidistance库。

# 优先用清华源加速安装(国内用户必备) pip install -i https://pypi.tuna.tsinghua.edu.cn/simple/ dtaidistance

数据准备必须做两件事:去趋势 + 标准化。直接用原始价格序列跑DTW,算法会把90%计算量花在拟合整体上涨斜率上,完全忽略波动细节。我们用scipy.signal.detrend移除线性趋势,再用Z-score标准化:

import numpy as np from scipy import signal from dtaidistance import dtw def preprocess_stock_series(prices: np.ndarray) -> np.ndarray: """对股票价格序列做去趋势+标准化预处理""" # 移除线性趋势(保留波动结构) detrended = signal.detrend(prices, type='linear') # Z-score标准化:均值为0,标准差为1 normalized = (detrended - np.mean(detrended)) / (np.std(detrended) + 1e-8) return normalized # 示例:加载两只股票的300日收盘价 stock_a = np.array([...]) # 比如贵州茅台2023年日线 stock_b = np.array([...]) # 比如五粮液同期日线 seq_a = preprocess_stock_series(stock_a) seq_b = preprocess_stock_series(stock_b)

这段代码的关键在于signal.detrendtype='linear'——它只移除一次多项式趋势,保留二次项(如加速上涨/减速下跌)和所有波动峰谷。若用type='constant'只减均值,DTW仍会被整体涨幅主导;若用savitzky_golay滤波,反而会平滑掉关键转折点。

2.2 DTW距离计算:为什么必须设max_distwindow

dtaidistance.dtw.distance默认不做任何约束,对300点序列会计算90000次距离比较,内存爆炸且结果不可控。必须启用两个硬约束:

distance = dtw.distance( seq_a, seq_b, window=30, # 约束:只允许在对角线±30步内匹配 max_dist=2.0, # 约束:路径累计距离超过2.0立即剪枝 use_pruning=True # 启用剪枝加速(必须配合max_dist) )
  • window=30:物理意义是“不允许一只股票的第100天价格去匹配另一只的第150天价格”,因为日线数据中超过30天的错位已失去交易意义。实测A股数据中,window=20~50区间效果最稳,小于15会漏掉典型周线级模式,大于80则退化为无约束DTW。
  • max_dist=2.0:这是Z-score标准化后的阈值。由于标准化后序列标准差≈1,2.0意味着允许单点最大偏差2个标准差——刚好覆盖95%的正常波动,但过滤掉极端跳空缺口。若用原始价格,此值需放大100倍以上,完全失去约束意义。
  • use_pruning=True:不加这个参数,max_dist无效!这是dtaidistance的隐藏开关,文档里藏得很深。

2.3 批量计算优化:用矩阵代替循环,提速17倍

对300只股票两两计算DTW,O(n²)复杂度下循环调用dtw.distance会卡死。dtaidistance提供向量化接口:

from dtaidistance import dtw_visualisation as dtwvis # 构建序列矩阵:(n_stocks, seq_length) all_sequences = np.stack([preprocess_stock_series(s) for s in stock_list]) # 批量计算距离矩阵(自动利用多核) dist_matrix = dtw.distance_matrix_fast( all_sequences, window=30, max_dist=2.0, use_pruning=True, parallel=True # 关键!启用多进程 )

注意distance_matrix_fast要求所有序列等长。若股票上市日期不同,需用np.pad在短序列前补np.nan,再用dtw.distance_matrix_fast(..., use_nogaps=True)跳过NaN——但实测补零比补NaN更稳定,因DTW对零敏感度低于NaN。


3. LCSS:当你要找“形态骨架”而非“完整匹配”时的救命方案

DTW擅长匹配完整波动周期,但对“只关心上涨段相似性”的场景力不从心。比如你想找和宁德时代2023年Q3那波主升浪(60个交易日)走势最像的股票,但其他股票同期有横盘或下跌段——DTW会被这些无关段拖累得分。此时LCSS(Longest Common Subsequence)成为更精准的工具:它只统计两个序列中符号变化方向一致的最长子序列长度,完全无视幅度和时间偏移。

3.1 为什么LCSS比Pearson更适合股票形态识别?

Pearson相关系数要求序列线性相关,而股价涨跌本质是非线性的。举个反例:股票A连续涨3天(+1%, +2%, +1%),股票B先跌1天(-1%)再涨3天(+1%, +2%, +1%)。Pearson会因首日负相关大幅拉低分数,但LCSS能识别出后3天完全一致的上涨骨架。LCSS的核心是符号序列转换

def to_direction_series(prices: np.ndarray, threshold: float = 0.001) -> np.ndarray: """将价格序列转为方向序列:1=涨,-1=跌,0=横盘(涨跌幅<threshold)""" returns = np.diff(prices) / prices[:-1] # 日收益率 directions = np.zeros_like(returns) directions[returns > threshold] = 1 directions[returns < -threshold] = -1 return directions # 示例:宁德时代Q3主升浪方向序列 ndt_q3 = to_direction_series(ndt_prices[180:240]) # 60日 # 输出:[1, 1, 1, 0, 1, 1, -1, 1, ...] —— 只保留方向骨架

threshold=0.001(0.1%)是A股实测最优值:太小(0.0001)会把噪声当信号,太大(0.01)会漏掉关键小波段。这个阈值必须和你的数据频率绑定——分钟线要用0.0001,周线可用0.005。

3.2 LCSS距离定义:用“最长公共子序列长度”做相似性度量

LCSS本身输出的是长度值,需转换为0~1范围的相似度:

def lcss_similarity(seq1: np.ndarray, seq2: np.ndarray, eps: float = 1.0) -> float: """计算两方向序列的LCSS相似度(0~1)""" # 动态规划求LCSS长度 n, m = len(seq1), len(seq2) dp = [[0] * (m + 1) for _ in range(n + 1)] for i in range(1, n + 1): for j in range(1, m + 1): if abs(seq1[i-1] - seq2[j-1]) < eps: # 方向相同即匹配 dp[i][j] = dp[i-1][j-1] + 1 else: dp[i][j] = max(dp[i-1][j], dp[i][j-1]) lcss_len = dp[n][m] # 归一化:除以较短序列长度(避免长序列天然占优) return lcss_len / min(n, m) # 计算宁德时代Q3 vs 比亚迪Q3方向序列相似度 similarity = lcss_similarity(ndt_q3, byd_q3) # 返回0.72

这里eps=1.0是关键——因为方向序列只有-1,0,1三个值,abs(a-b)<1.0等价于a==b。若误设为eps=0.5,则-1和0也会被判定为匹配,彻底失效。

3.3 LCSS实战陷阱:如何避免“假高相似度”?

LCSS对横盘段(0)极度敏感。若两只股票都长期横盘,方向序列全是0,LCSS相似度会虚高到0.99,但实际毫无交易价值。解决方案是加权LCSS,给涨跌信号更高权重:

def weighted_lcss_similarity(seq1, seq2): # 为涨跌信号赋予权重2.0,横盘权重0.1 weights = np.where((seq1 != 0) & (seq2 != 0), 2.0, np.where((seq1 == 0) & (seq2 == 0), 0.1, 0.0)) # 在DP中按权重累加 ...

但更实用的做法是预过滤:直接剔除横盘占比>70%的序列,或强制要求LCSS匹配段中涨跌信号占比>50%。这步在批量计算前必须做,否则结果不可信。


4. Pearson+滑动窗口:当你要兼顾统计严谨性与局部敏感性

DTW和LCSS都是纯模式匹配,但金融领域常需回答:“这两只股票在最近60天的相关性是否显著高于历史均值?”这时Pearson相关系数不可替代——它有成熟的统计检验框架(p-value),且计算极快。但直接用全周期Pearson会淹没局部突变。解决方案是滑动窗口Pearson,并叠加统计显著性过滤。

4.1 滑动窗口Pearson:用numpy.lib.stride_tricks.sliding_window_view提速50倍

传统for循环滑窗计算Pearson,对300只股票×300日数据要跑数万次scipy.stats.pearsonr,慢得无法接受。用NumPy原生滑窗:

from numpy.lib.stride_tricks import sliding_window_view def rolling_pearson(seq1: np.ndarray, seq2: np.ndarray, window: int = 60) -> np.ndarray: """返回滚动窗口Pearson相关系数数组""" # 生成滑动窗口视图(不复制内存) win1 = sliding_window_view(seq1, window) win2 = sliding_window_view(seq2, window) # 向量化计算:每行窗口独立计算Pearson # 公式:cov(x,y)/(std(x)*std(y)) cov = np.mean(win1 * win2, axis=1) - np.mean(win1, axis=1) * np.mean(win2, axis=1) std1 = np.std(win1, axis=1, ddof=1) std2 = np.std(win2, axis=1, ddof=1) pearson = cov / (std1 * std2 + 1e-8) # 防除零 return pearson # 计算贵州茅台vs五粮液60日滚动相关系数 rolling_corr = rolling_pearson(maotai, wuliangye, window=60) # 输出:长度为241的数组(300-60+1),每个值是对应60日窗口的相关系数

sliding_window_view是NumPy 1.20+特性,比pandas.DataFrame.rolling.corr()快50倍且内存占用低90%。关键点:ddof=1(样本标准差)必须设置,否则与scipy.stats.pearsonr结果不一致。

4.2 统计显著性过滤:用Fisher Z变换算p-value

Pearson系数本身不能直接判断显著性。必须通过Fisher Z变换将r值转为近似正态分布,再计算p-value:

from scipy import stats def pearson_pvalue(r: float, n: int) -> float: """计算Pearson相关系数的双侧p-value""" if abs(r) >= 1.0: return 0.0 # Fisher Z变换 z = 0.5 * np.log((1 + r) / (1 - r)) # Z统计量标准误 se = 1 / np.sqrt(n - 3) # 转为标准正态分布p-value p = 2 * (1 - stats.norm.cdf(abs(z) / se)) return p # 对滚动相关系数数组计算p-value p_values = np.array([pearson_pvalue(r, 60) for r in rolling_corr]) # 筛选显著相关窗口(p<0.05) significant_windows = rolling_corr[p_values < 0.05]

注意:n=60是窗口长度,不是总序列长度!这是新手最常犯的错误——用总长度300去算p-value,导致所有结果都显著。

4.3 相似性综合打分:把DTW、LCSS、Pearson结果融合成单一指标

单一方法总有盲区。我们设计一个加权融合公式,经沪深300成分股回测验证:

def fused_similarity(dtw_dist: float, lcss_sim: float, pearson_r: float, dtw_weight=0.4, lcss_weight=0.3, pearson_weight=0.3) -> float: """融合三种相似性指标(DTW距离需转为相似度)""" # DTW距离转相似度:越小越相似,用指数衰减 dtw_sim = np.exp(-dtw_dist / 2.0) # 分母2.0来自max_dist=2.0的设定 # LCSS和Pearson已是0~1相似度 return dtw_weight * dtw_sim + lcss_weight * lcss_sim + pearson_weight * abs(pearson_r) # 示例:对某只候选股计算融合分 score = fused_similarity( dtw_distance, # 来自2.2节 lcss_similarity, # 来自3.2节 rolling_corr[-1], # 最新窗口Pearson值 )

权重分配依据实测:DTW对形态匹配贡献最大(0.4),LCSS对方向骨架识别关键(0.3),Pearson提供统计置信度(0.3)。若你的场景侧重短期交易,可将Pearson权重提到0.5;若做行业轮动研究,则LCSS权重应加大。


5. 避坑:股票序列相似性分析的5个血泪经验(现象→原因→解决)

5.1 现象:DTW距离值在不同股票对之间差异极小(全在1.98~2.01),无法排序

原因:未做去趋势预处理,DTW被整体上涨斜率主导,所有序列都呈现“缓慢上升”模式,导致距离趋同。
解决:严格使用scipy.signal.detrend(prices, type='linear')移除线性趋势,再标准化。验证方法:画出detrended序列图,确认无明显斜率。

5.2 现象:LCSS相似度高达0.95,但两只股票K线图看起来毫无关联

原因:方向序列中横盘段(0)占比过高,LCSS大量匹配0-0对。
解决:计算前过滤横盘段占比>60%的序列;或改用加权LCSS,给涨跌信号权重2.0,横盘权重0.1。

5.3 现象:滚动Pearson计算结果出现大量nan

原因:窗口内序列标准差为0(如连续涨停/跌停),导致除零。
解决:在rolling_pearson函数中添加std1 = np.clip(std1, 1e-8, None),强制标准差不低于1e-8。

5.4 现象:用dtaidistance批量计算时内存溢出(OOM)

原因distance_matrix_fast默认加载全部序列到内存,300只股票×300点×8字节≈72MB,但算法中间矩阵达GB级。
解决:分块计算——将300只股票分成10组(每组30只),用itertools.combinations只计算组内距离,再拼接矩阵。

5.5 现象:融合得分最高的股票,人工看图发现形态完全不匹配

原因:Pearson权重过高,且未过滤p-value不显著的窗口。例如某窗口r=0.8但p=0.12,被错误计入融合分。
解决:融合前强制要求pearson_pvalue(r, window) < 0.05,否则该窗口Pearson贡献置0。


6. 进阶技巧:用“形态指纹”替代原始序列,让相似性分析真正可解释

所有前述方法都基于数值序列,但交易员真正需要的是可解释的形态标签——比如“头肩顶”、“W底”、“平台突破”。我们用Symbolic Aggregate approXimation(SAX)将价格序列压缩为字符串指纹,再用字符串编辑距离(Levenshtein)计算相似性。这步让结果从“数学上相似”升级为“人眼可验证相似”。

6.1 SAX转换:把300日价格变成10字符的形态密码

SAX将序列分段、标准化、离散化为字母。对股票价格,我们定制化参数:

def sax_transform(prices: np.ndarray, n_segments: int = 10, n_symbols: int = 3) -> str: """将价格序列转为SAX字符串(如'abcabccbac')""" # 1. 去趋势+标准化(同2.1节) normed = preprocess_stock_series(prices) # 2. 分段:每段30日(300/10) segments = np.array_split(normed, n_segments) # 3. 每段取均值 segment_means = np.array([np.mean(seg) for seg in segments]) # 4. 根据正态分布分位数映射为符号 # 3符号:a(<-0.43σ), b(-0.43~0.43σ), c(>0.43σ)——覆盖95%波动 breakpoints = stats.norm.ppf(np.linspace(0, 1, n_symbols + 1)[1:-1]) symbols = [] for mean_val in segment_means: symbol_idx = np.searchsorted(breakpoints, mean_val) symbols.append(chr(ord('a') + symbol_idx)) return ''.join(symbols) # 示例:贵州茅台2023年SAX指纹 maotai_sax = sax_transform(maotai_prices) # 输出:'cbbcccbcaa'

n_segments=10确保每段代表约1个月行情,n_symbols=3用a/b/c分别表示“强势/中性/弱势”,比传统4符号(a/b/c/d)更符合A股波动特征——实测显示3符号在沪深300上形态区分度最高。

6.2 字符串相似性:用Levenshtein距离替代数值距离

SAX字符串的编辑距离直接反映形态差异:

import Levenshtein def sax_similarity(sax1: str, sax2: str) -> float: """SAX字符串相似度:1 - 编辑距离/最大长度""" dist = Levenshtein.distance(sax1, sax2) return 1.0 - dist / max(len(sax1), len(sax2)) # 计算茅台vs五粮液SAX相似度 sax_sim = sax_similarity(maotai_sax, wuliangye_sax) # 如0.6

关键优势:可解释性。若maotai_sax='cbbcccbcaa'wuliangye_sax='cbbcccbcaa',直接看出两者都是“强-中-中-强-强-强-中-中-强-强”模式;若byd_sax='abbaaccbca',则一眼识别出“弱-中-中-弱-弱-强-强-中-弱-强”,与茅台模式差异巨大。

6.3 形态指纹实战:构建可回溯的相似性报告

最终输出不是冷冰冰的分数,而是带截图的形态对比报告:

def generate_similarity_report(target_sax: str, candidate_sax: str, target_prices, candidate_prices): """生成含可视化对比的相似性报告""" # 1. 计算SAX相似度 sim_score = sax_similarity(target_sax, candidate_sax) # 2. 找出差异位置(编辑操作) ops = Levenshtein.editops(target_sax, candidate_sax) # 3. 可视化:画出两序列+标注差异段 plt.figure(figsize=(12, 4)) plt.plot(target_prices, label='Target', alpha=0.7) plt.plot(candidate_prices, label='Candidate', alpha=0.7) # 在差异SAX段下方画红色背景 for op in ops[:3]: # 只标前3个差异 if op[0] == 'replace': start = op[1] * 30 # 每段30日 plt.axvspan(start, start+30, alpha=0.3, color='red') plt.title(f'SAX Similarity: {sim_score:.2f} | Diff Ops: {len(ops)}') plt.legend() plt.savefig('similarity_report.png') return sim_score # 生成茅台vs五粮液报告 score = generate_similarity_report(maotai_sax, wuliangye_sax, maotai, wuliangye)

这张图能让基金经理3秒内判断:“哦,它们在Q2那段平台整理期确实走势一致,但Q3茅台突破时五粮液没跟上”——这才是业务真正需要的“相似性”。

我坚持用SAX而非纯深度学习做形态编码,是因为它无需训练、可解释、零GPU依赖,且在A股数据上泛化性远超LSTM-autoencoder。过去三年,我所有策略回测都用这套SAX+DTW+LCSS三层验证,从未出现过“数学相似但图形迥异”的翻车。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/24 22:54:59

大一必看:绩点、时间管理、社交与信息差的避坑指南

站在大四的门槛上往回看&#xff0c;大一那个拖着行李箱、站在校门口茫然四顾的自己&#xff0c;真的很想拍拍他的肩膀说几句话。这篇博文不是一份完美的"人生规划指南"&#xff0c;而是一个过来人用四年的学费换来的真心建议——关于绩点、时间、社交、迷茫和信息差…

作者头像 李华
网站建设 2026/9/24 22:54:42

基于大模型的泵阀品控溯源系统设计与实践

泵阀行业的品控和追溯&#xff0c;在没上系统之前到底有多痛&#xff1f;我举个真实场景&#xff1a;一批出厂球阀发到客户现场&#xff0c;对方打开了某个看似不起眼的阀体&#xff0c;要求提供这只阀从毛坯熔炼炉号到最终壳体打压记录的全部质量档案。传统做法是安排专人翻纸…

作者头像 李华
网站建设 2026/9/24 22:54:41

2026年七款视频转换器深度横评:从编码原理到批量实战

1. 视频转换这件事&#xff0c;为什么2026年还值得认真聊做视频内容这行十来年&#xff0c;我电脑里换过的转换工具少说也有二三十款。从早期做字幕组压片&#xff0c;到后来帮客户做多平台分发&#xff0c;再到现在自己剪片子、录课程、整理素材库&#xff0c;视频格式转换几乎…

作者头像 李华
网站建设 2026/9/24 22:53:08

关停2年云主机迁移WorkBuddy:任务型负载的降本实践

1. 从一台跑了 2 年的云主机说起两年前&#xff0c;我在一台云主机上部署了一整套个人自动化工具链。当时的需求很朴素&#xff1a;定时抓取一些公开数据、跑几个脚本做格式转换、偶尔远程连上去改改配置。为了这套东西&#xff0c;我买了一台入门级云主机&#xff0c;装的是 L…

作者头像 李华