相似K线形态匹配:从原理到Python量化选股实战 做量化这些年我最大的感受是技术指标写起来容易真正难的是给指标一个“可信的逻辑”让它在历史数据里站得住脚。均线金叉、MACD背离这些经典套路用的人太多参数被反复优化近几年的有效性衰减得很厉害。后来我开始把精力放到一个相对冷门的方向——相似K线。思路很简单不去预测未来而是去历史里翻答案。当前这只股票的K线形态过去到底出现过多少次出现之后一般都怎么走这篇文章就以同花顺Supermind为环境把相似K线的形态验证和选股从原理到代码完整过一遍给想做技术面分析专题的朋友一个可以直接上手的参考。内容涉及Python量化交易策略代码、形态匹配算法、回测验证和选股落地属于技术面分析的第一个专题后续我会继续拆解其他因子方向。1. 为什么相似K线能用于选股——先搞清楚逻辑再写代码很多人一上来就问我代码怎么写我的建议是反过来先把逻辑想清楚。相似K线不是某个具体指标而是一套“以史为鉴”的形态匹配方法。它背后的假设、适用场景和失效边界直接决定了策略能不能长期跑下去。1.1 相似K线到底在寻找什么相似K线的核心思想是从全市场的历史行情里找到与当前股票最近一段走势“长得像”的历史片段然后统计这些片段之后的涨跌表现。如果历史相似形态出现后上涨的概率显著大于下跌那么当前这只股票就具备入选条件。这里说的“长得像”不是简单地看几根阳线几根阴线而是把一段K线的走势轨迹当作一条曲线用数学方法计算曲线之间的形状相似度。比如一只股票最近30个交易日的收盘价走势是一条先横盘、再缩量回踩、然后放量突破的曲线。我们就去历史数据里找同样符合“横盘—回踩—突破”节奏的片段看看它们之后5天、10天、20天分别怎么走。为什么这种方法值得研究行为金融学里有一个解释市场上的参与者面对相似的图形会产生相似的认知和操作反应。比如“放量突破平台”这个形态很多人会认为它是启动信号于是真金白银买入这种集体行为反过来又强化了形态的有效性。换句话说相似K线策略捕捉的是市场参与者的一致性行为惯性而不是什么玄学。另一个角度是筹码结构。两段走势相似往往意味着背后的筹码分布、成本结构也有一定相似性。比如都经历了长期横盘换手浮筹被清洗干净一旦启动上方抛压会比较小。这种筹码层面的逻辑用传统的单点指标比如某一天的RSI、某一天的布林带位置根本表达不出来但形态匹配可以。1.2 这个逻辑什么时候会失效我必须先泼一盆冷水相似K线不是圣杯它的逻辑成立有几个前提条件。第一条历史样本要足够多。A股市场有效的历史数据不过二三十年如果模板取120天全市场切片数量看似很多但真正“极端相似”的样本可能只有十几个统计意义很弱。所以模板长度不宜过长一般20到40个交易日是一个比较均衡的区间。第二条市场环境会变。注册制之前和之后市场生态完全不同2015年之前小市值因子横行2017年之后白马股抱团2021年之后又是量化主导的存量博弈。同一段K线形态在不同市场环境下出现后的走势可能有本质区别。所以做相似K线验证时一定要按时间段切分来看不能把十几年的数据混在一起一锅炖。第三条也是最重要的相似K线解决的是“择时”和“选股”的一部分问题它无法规避系统性风险。股灾、流动性危机这种极端行情下再漂亮的形态也会被泥沙俱下地砸穿。所以实盘策略必须叠加市场状态过滤比如大盘指数在20日线下方时暂停选股或者降低仓位。想清楚这三条你再看接下来的代码和回测就不会被表面的高胜率冲昏头脑。2. K线相似度的数学定义——把“看着像”变成“算得出”“像不像”是主观感受量化交易必须把它变成可计算的数值。这一步是整个专题的核心定义方式直接决定匹配效果。2.1 数据标准化不同价位的股票怎么放一起比贵州茅台2000块和农业银行3块钱收盘价曲线直接算距离没有任何意义绝对价格差异会完全淹没形态差异。所以第一步是标准化。我实践中推荐用“首日基准法”而不是常见的min-max归一化。原因很简单min-max归一化对极值敏感两段完全不同的形态只要最高点和最低点相同归一化后可能长得很像产生大量误匹配。首日基准法把每一天的收盘价除以第一天的收盘价再减1得到的就是“相对于起点涨跌幅序列”。比如模板第一天收盘10元第二天10.5元第三天9.8元标准化后就是0%、5%、-2%。这样不同价位的股票就站在同一个尺度上了。同理可以处理开盘价、最高价、最低价但我在实践里发现收盘价序列的效果最稳定因为收盘价是所有交易者博弈后的最终结果噪声相对小。2.2 三种相似度度量方法对比标准化之后需要一个数值来衡量两条曲线的接近程度。我实际用过三类方法各有优劣方法计算思路优点缺点适用场景欧氏距离逐日计算涨跌幅差值的平方和再开方直观、计算快、对整体形状敏感对“相位偏移”敏感早一天晚一天启动会被判为不相似形态稳定、节奏同步的匹配皮尔逊相关系数计算两条序列的线性相关程度对整体趋势方向敏感不关心绝对幅度只能衡量线性关系且对波动幅度不敏感上涨1%和上涨5%可能算出高相关性判断趋势方向是否一致形状距离对序列做一阶差分再计算差分序列的欧氏距离更关注“走势节奏”而非“绝对位置”对噪声更敏感需要先平滑节奏型形态如先横盘后突破的匹配我自己的经验是单独用任何一种都容易出问题。欧氏距离把幅度和形状混在一起相关系数忽略了幅度形状距离容易被单日噪声干扰。最稳定的做法是用加权综合打分——形状距离占50%欧氏距离占30%相关系数占20%三个分数分别排序后取加权排名。2.3 规范化K线相似度计算代码下面给出一套在Supermind上可以直接用的相似度计算函数我用的是平台内置的Python环境Pandas和NumPy都是自带的。这个函数直接传两个list进来就行返回一个0到1之间的相似度越小越像。import numpy as np import pandas as pd def normalize_series(series): 首日基准标准化 把绝对价格序列转换为相对第一天涨跌幅的序列 arr np.asarray(series, dtypenp.float64) if arr[0] 0: return np.zeros_like(arr) return (arr - arr[0]) / arr[0] def compute_similarity(hist_series, target_series): 计算两段K线序列的综合相似度 返回0到1之间的值越小越相似 # 统一长度 n min(len(hist_series), len(target_series)) hist normalize_series(hist_series[-n:]) target normalize_series(target_series[-n:]) # 1. 欧氏距离 euclidean_dist np.sqrt(np.sum((hist - target) ** 2)) # 2. 相关系数距离 corr np.corrcoef(hist, target)[0, 1] if np.isnan(corr): corr 0.0 corr_dist 1 - abs(corr) # 3. 形状距离对一阶差分做归一化再算欧氏距离 hist_diff np.diff(hist) target_diff np.diff(target) shape_dist np.sqrt(np.sum((hist_diff - target_diff) ** 2)) # 综合打分形状距离权重最高因为它最贴近“走势节奏” # 将三个距离映射到0~1区间后再加权 # 这里的缩放系数是根据经验设置的可以根据实际数据调整 score 0.5 * (shape_dist / (n 1e-9)) \ 0.3 * (euclidean_dist / (n 1e-9)) \ 0.2 * corr_dist return min(score, 1.0)这段代码有几个细节值得注意。第一np.corrcoef在两条序列完全一样时才能算出1.0但实际行情序列几乎不可能完全一样所以很少出现除零。但序列方差为0时相关系数会变成NaN比如股票连续一字涨停整个模板期内没有任何波动。这种极端情况需要用np.isnan兜底否则会污染整个排序结果。第二形状距离为什么权重最高因为技术分析的形态本质上说的是“节奏”。先横盘三天再突破和直接连涨三天欧氏距离可能差不多但意义完全不同。形状距离通过一阶差分把“先横盘”和“直接涨”区分开了。第三缩放系数里的n是模板长度。长度越长累计距离越大所以除以n做一个平均化处理让不同模板长度下的得分可以互相比较。这个细节我在第一次写的时候漏掉了导致30天模板和60天模板的分数完全不在一个量级上还排查了半天。3. 形态验证回测——怎么证明“相似形态之后真的会涨”有了相似度计算函数下一步就是验证找到的相似形态历史上出现之后到底涨不涨。这一步不能省不验证就直接上选股本质上就是在赌博。3.1 回测框架怎么设计形态验证回测和普通策略回测有一个重要区别普通回测验证的是一个规则在时间序列上连续运行的效果形态验证要做的则是“扫描历史、切片对比、统计后续表现”。我设计的框架分四步确定模板片段。可以来自你关注的某只股票最近N天的实际走势也可以人为设定一个通用形态比如连续5天小阳线放量突破。对全市场或某个股票池的历史数据做滑动窗口扫描。窗口长度与模板长度一致每次滑1天计算每个窗口与模板的相似度。过滤掉相似度不够高的窗口保留下来的就是“历史相似形态”。统计这些相似形态出现后未来5天/10天/20天的收益分布计算胜率、平均收益、最大亏损等指标。这里有一个统计陷阱必须提醒滑动窗口扫描会产生大量重叠样本。比如第100天和第101天的窗口有29天是重叠的两者对应的是几乎相同的形态后续收益也高度相关。如果把它们当成两个独立样本来统计会严重高估样本量导致胜率的置信度虚高。解决办法是在筛选相似形态时做“非极大值抑制”也就是两个相似形态出现的时间间隔小于模板长度的一半时只保留相似度最高且后续收益最稳定那一个。3.2 验证结果怎么解读回测跑完之后不要只看一个平均收益率就下结论至少要拆解下面几个维度指标作用我的判断标准样本数量确认统计意义少于30个样本的结果直接弃用胜率形态出现后上涨的比例至少高于55%且连续多年稳定平均收益率期望收益跑赢同期基准3%以上才考虑实盘最大单次亏损尾部风险超过8%需要警惕说明形态有“踩雷”概率年度分布逻辑是否逐年有效如果只有某一年特别赚钱大概率是风格巧合还要做一个关键对比相似形态之后的收益和“股票池全样本”之后的平均收益差值才是这个形态真正贡献的超额收益。如果相似形态之后平均涨了5%但全样本平均也涨了5%那这个形态没有任何信息量只是跟随大盘而已。3.3 一套可直接运行的形态验证代码下面这段代码可以在Supermind上跑通。我用的是平台自带的HistoryData接口获取行情如果你用别的数据源替换成对应接口即可。def validate_similar_pattern(stock_code, template_end_date, lookback30, hold_days10, threshold0.15): 验证某只股票当前形态与历史相似形态的后续表现 stock_code: 股票代码 template_end_date: 模板结束日期通常取最新交易日 lookback: 模板长度 hold_days: 持有天数 threshold: 相似度阈值低于该值视为相似 hist get_price(stock_code, start_date2015-01-01, end_datetemplate_end_date, fields[close], frequency1d) if hist is None or len(hist) lookback 50: return None close_arr hist[close].values template close_arr[-lookback:] match_indices [] # 滑动窗口扫描注意留出持有期空间避免用到未来数据 for i in range(0, len(close_arr) - lookback - hold_days): window close_arr[i:i lookback] sim compute_similarity(window, template) if sim threshold: match_indices.append(i) # 非极大值抑制剔除间隔过近的重复样本 filtered [] min_gap int(lookback / 2) for idx in sorted(match_indices): if not filtered or idx - filtered[-1] min_gap: filtered.append(idx) if len(filtered) 10: return { sample_count: len(filtered), warning: 样本数量不足10个统计结果仅供参考 } # 统计未来收益 future_rets [] for idx in filtered: entry_price close_arr[idx lookback - 1] exit_price close_arr[idx lookback - 1 hold_days] future_rets.append(exit_price / entry_price - 1.0) future_rets np.array(future_rets) win_rate np.mean(future_rets 0) return { sample_count: len(filtered), win_rate: round(win_rate, 4), avg_return: round(np.mean(future_rets), 4), max_loss: round(np.min(future_rets), 4), median_return: round(np.median(future_rets), 4) }这个函数返回的字典直接打印就能看到形态的历史表现。我在实际使用中会把template_end_date设为最新交易日把stock_code替换成关注的股票池批量跑一遍就能知道当前市场环境下哪些形态还活着、哪些形态已经死了。这里要特别强调一点模板取的是包含最后一日的完整形态扫描窗口的i循环结束条件是len(close_arr) - lookback - hold_days目的就是确保每个匹配样本都有完整的持有观察期。否则你匹配到了最近几天的相似形态还没来得及验证后续走势等于用未来数据骗自己。4. 从验证到实盘选股——把相似K线跑成一个完整的选股策略验证通过只是第一步真正落到选股上还需要一套工程化的流程。这里说的工程化不是为了炫技而是为了把人为干扰降到最低让策略在实盘中稳定执行。4.1 全市场选股的核心流程我设计的实盘选股流程分为五个环节每日收盘后拉取全市场日线数据。在Supermind上就是用get_price批量获取注意这里是全市场几千只股票数据量比较大建议只拉收盘价和成交量两个字段把内存占用控制住。统一形态模板。模板K线怎么定两种思路。一种是用某只你自己关注的“明星股票”当前形态作为模板寻找同形态的其他股票另一种是先手动指定一个“上涨前兆”的通用形态模板比如25天横盘5天缩量回踩放量启动。我更推荐第二种因为它的逻辑更清晰也更容易解释。计算全市场每只股票最近N日K线与模板的相似度。直接复用compute_similarity函数。对相似度排序取前10到20只作为候选池。叠加硬过滤条件剔除不能买、不敢买的股票。这里的核心是第五步很多新手容易忽略。候选池里很可能混着ST股、停牌股、次新股它们的K线数据不完整或者交易规则特殊直接买会出各种幺蛾子。4.2 硬过滤条件清单以下过滤条件我用的是“有一票否决权”的规则命中任何一条就剔除不管相似度多高剔除ST、*ST这类股票涨跌幅限制和流动性都和正常股票不同形态参考意义有限。剔除上市不足120个交易日的次新股K线样本太少而且次新股走势受筹码解锁、炒作情绪影响极大。剔除停牌中的股票停牌期间K线是平的会被误判为“横盘企稳”形态。剔除当日涨停且封单量很大的股票因为次日买入通常买不进需要等回调但回调后形态就变了。剔除日均成交额低于1亿的股票流动性不足你的买入冲击成本会直接吃掉形态带来的超额收益。这些条件可以用一段简单的Pandas代码批量处理。在Supermind上ST标记、上市日期、成交额这些字段都可以通过平台的数据接口拿到。我把这段代码写成函数每天收盘后一键执行。def filter_candidates(candidates_df, price_df, trade_days120, min_amount100000000): 对相似K线排序后的候选池做硬过滤 candidates_df: 包含股票代码、相似度分数的DataFrame price_df: 用于计算上市天数、停牌状态、成交额的数据 valid candidates_df.copy() # 剔除上市不足120天 valid valid[valid[list_days] trade_days] # 剔除ST valid valid[~valid[name].str.contains(ST, naFalse)] # 剔除停牌最近5日成交量都为0视为停牌 valid valid[valid[recent_5d_volume].sum(axis1) 0] # 剔除日均成交额过低 valid valid[valid[avg_amount_5d] min_amount] # 按相似度排序取前N只 valid valid.sort_values(similarity).head(10) return valid这个函数是我实际策略里的简化版。真实场景里我还会加入一个“距离涨停板位置”的过滤——如果股票已经涨了7个点以上再追进去的赔率已经不划算了相似度再高也要放弃。这个参数因人而异我做短线的话阈值设在5%。4.3 参数调优和过拟合防范每次讲到参数我都得强调一遍过拟合这个问题。相似K线策略的参数包括模板长度、相似度阈值、持有天数、候选池大小。这四个参数一旦组合起来光是全市场历史回测就能跑出几百种结果你想找到一个“历史最优参数”太容易了——但那个参数大概率是过拟合的产物。我的做法是“样本外验证”把历史数据分成三段2015到2018年做第一轮参数筛选2019到2021年做验证2022年之后只做最终确认不再调整任何参数。如果一个参数组合在前两段都表现稳定在第三段也没有明显失效我才会考虑小仓位实盘。这个方法笨但可靠能筛掉大部分“回测明星、实盘拉胯”的假策略。另外还要警惕参数敏感度。如果一个策略把模板长度从30改成31年化收益就从30%变成5%那这个策略基本不可用纯粹是在拟合噪音。真正稳定的策略参数小幅扰动时收益曲线应该是一条平滑的抛物线而不是一座尖锐的山峰。5. 在Supermind上实测踩坑记录——这些细节常规文档不会写最后这部分是我个人在Supermind上做相似K线专题时踩过的一些坑每一条都真实发生过分享出来帮大家省时间。5.1 复权方式不统一K线直接断裂第一个坑就是除权除息。股票分红送转之后如果不复权K线上会出现一个向下的跳空缺口但这个缺口不是市场行为而是财务操作造成的假象。如果你用不复权数据计算相似度一只走势平稳的股票在除权日会被误判成一个巨大的下跌形态既冤枉又危险。我习惯统一用前复权数据。但需要注意前复权数据在不同时间点拉取最新复权因子会变化导致历史价格被不断重新计算。如果你在2023年10月拉了一次历史数据又在2024年5月拉了一次同一段历史的收盘价可能对不上。所以策略里所有历史数据必须一次性拉取、落库保存不要每次回测都重新从接口取数否则回测结果根本不可复现。5.2 涨跌停板导致“买不到”和“卖不出”技术面形态匹配最容易匹配到刚启动的股票这类股票往往已经涨停或者接近涨停。涨停板上有巨大的封单普通小资金根本买不进。回测里算出来的收益再漂亮实盘买不进去等于零。我处理这个问题的方法在候选池里加入“当日涨幅”过滤涨幅超过5%的股票一律不追。同时回测时设置一个可成交的约束——如果选股日股票涨停直接用第二天的开盘价买入用这个更保守的价格来测试策略的真实收益。这个方法会牺牲一部分收益但换来了实盘可执行性值得。5.3 全市场扫描的性能瓶颈如果说策略逻辑是大脑那计算性能就是腿。相似K线全市场扫描模板长度30天全市场5000只股票每只要做几千次滑动窗口匹配计算量非常可观。我最开始在Supermind上跑全市场版本一次完整扫描要十几分钟完全没法做盘中决策。优化思路有三条一是向量化计算把单股票的循环改成NumPy矩阵运算速度能提升几十倍二是预计算特征把每段K线的一阶差分、归一化值提前算好存下来相似度计算时直接复用避免重复计算三是缩小扫描范围先用一个简单的波动率过滤把大量走势平淡、不可能匹配的股票排除掉只对剩下的候选做精确匹配。三条优化做完全市场扫描时间压到了1分钟以内已经完全够用。5.4 “看起来像”不等于“本质相同”——形态以外的信息不能丢这是我在做这个专题时最深刻的体会。两段K线可能走势曲线高度重合但背后的基本面、行业逻辑完全不同。一只股票的相似形态出现在业绩预增公告之后另一只出现在大股东减持公告之后后续走势能一样吗所以我现在会把相似K线当作“第一层筛选器”而不指望它独立完成整个策略。选出来的候选股我会再叠加一层逻辑过滤中报或年报有没有商誉暴雷风险、近期有没有限售股解禁、所属行业当前是不是热点方向。形态给出的是“路径概率”基本面排除的是“尾部风险”两者结合才算一个完整的可交易信号。5.5 幸存者偏差会比你想的更隐蔽最后说一个数据层面的坑。做全市场回测时如果用当前日期还在上市的股票池作为样本那么那些已经退市的股票天然被排除在外。但历史上存在大量退市股票它们在退市前往往走出一段瀑布式下跌这种形态恰恰是对“相似之后会涨”结论最有力的反向证据。把这些股票漏掉你的胜率会被系统性高估。解决方法是回测时必须使用“历史时点成分股”确保每一段历史的股票池都是当时真实存在的股票而不是今天的股票池。具体到Supermind需要拉取历史上市状态表把退市股也纳入相似度扫描样本。我第一次回测时没做这一步胜率虚高了将近8个百分点教训惨痛。这个专题到这里算是一个相对完整的闭环了从相似K线的定义到验证方法到实盘选股流程再到数据层面的避坑。做相似K线给我最大的一个体会是量化策略卖的不是高深的模型而是纪律性和对细节的敬畏。把每一个数据坑填平、把每一个统计陷阱避开策略的稳定性自然就出来了。下一期我打算接着写技术面分析专题里的另一个方向——成交量异动与资金行为识别那个方向和我今天讲的相似K线组合起来用效果会更有意思。