1. 为什么需要一套独立的图形比对系统先说清楚我做这件事的起因。去年在某市场分析平台做技术顾问时团队里几位研究型用户每天最耗时的工作之一就是手动翻K线图找“历史上有没有出现过类似的走势结构”。他们的口径五花八门有的说“要找2020年那种先横盘后突破的”有的说“要找出连续三次缩量回踩后收阳的形态”有的想把当前某只走势图跟过去十年的全部历史片段做匹配看看后续统计分布长什么样。这种需求听起来像是一个简单的“模板匹配”但真正做起来之后才发现完全不是那么回事。市面上很多分析软件确实提供了“形态选股”或“相似K线”功能但基本都是针对固定模式——比如锤子线、启明星、头肩顶——写死的规则模板跟用户口中那种模糊但灵活的“感觉像”差距很大。再者这些工具几乎没有考虑归档和历史数据规模的问题当你把比对范围从近一年扩展到近十年从单一市场扩展到多个板块数据的存储、索引、实时更新都会成为瓶颈。所以这套系统从一开始就定了三个核心目标第一把“图形比对”从固定模板升级到多标准可配置第二把历史数据的归档和冷热分层做扎实保证十年的数据也能流畅查询第三在盘中实时场景下对新产生的走势片段进行自动分类和标注不让分析延迟超过秒级。下面我会按照我实际开发和迭代的过程中最重要的几个模块来拆解。这篇文章适合的读者是正准备做金融时间序列分析系统、做量化平台基础设施、或者在处理“历史图形匹配”这类非结构化时序需求的技术负责人。不会涉及复杂的调仓策略推导重点放在工程实现上。2. 多标准图形的数学表达先把K线变成可计算的序列2.1 数据清洗与基准归一化是第一步也是最容易糊弄的一步任何图形比对的第一步都不是算法而是把原始行情数据清洗成干净的、可比较的最小单元。我的做法是把每根K线都映射成一个结构体时间戳、开盘价、最高价、最低价、收盘价、成交量。原始数据从数据源拿下来之后首先要处理三件事——复权、异常值剔除、交易时段对齐。复权是个大坑。很多免费数据源的复权因子是滞后的直接用不复权数据做长期比对高价股和送转股之间的形态会完全失真。我最终选择用后复权价作为统一基准。这样做的原因是后复权价能真实反映持有期间的总收益变化且历史区间的图形不会因为后续除权事件而在回溯时改变这对“当前片段 vs 历史片段”的比对尤其重要。我实际测试过如果不做复权处理某些股票在分红除权日前后会出现一个跳空阴线这种“假形态”会被比对模块误判为高相似度干扰非常大。异常值剔除也不只是去掉停牌日那么简单。有些数据源会把集合竞价的异常价格也写进K线导致单根K线的最高价或最低价远超正常范围。我采用的策略是先计算每只标的在过去60个交易日内的ATR平均真实波幅任何单根K线的振幅超过4倍ATR且无法在次日得到连续行情验证的直接从样本中标记为可疑点。标记而不是删除是因为保留可疑点的位置信息对后续归因分析比直接抹掉更有价值。2.2 特征工程关键点切分与形态描述子清洗完之后遇到的第一个核心问题是一段60根K线的序列拿什么来做“图形的形状度量”我试过几种思路最终选用了“关键点 形态描述子”的组合方案。所谓关键点就是在价格序列上找局部极值点。我把60根K线的收盘价序列做一次多尺度极值检测先用3日窗口找出局部高点和低点再用5日和8日窗口各做一次三层结果做并集去重。这样做的目的很朴素——不同尺度的用户需求对“转折点”的定义不一样短窗口能保留急涨急跌的细节长窗口能抓住大的趋势骨架。最终每个样本片段都会被压缩成一段数量不等的关键点序列通常60根K线会得到8到16个关键点。光有关键点还不够因为两只股票可能在相同的位置有关键点但后续的力度和斜率完全不同。因此我为每个关键点附加了一组形态描述子该点到下一个关键点的累计涨跌幅、区间内的平均单根K线实体占比、区间最大回撤、成交量变化斜率。这些描述子会把一段“看起来一样”的走势变成一组数值向量后续比对才有数学上可操作的空间。形态描述子的选择也踩过一些坑。最开始我把“区间振幅”放进描述子后来发现振幅高的形态在加权距离计算中会把幅值小但骨架一致的形态全部判为不相似。后来我把所有描述子做了z-score归一化并且把幅度类特征单独放到一个通道里幅值相似度跟形状相似度分开计算效果立刻改善了很多。2.3 相似度计算DTW思路和多视角加权序列对齐是图形比对里绕不过去的问题。两段走势如果骨架一致但一个走得快一个走得慢逐点欧氏距离会大得离谱。这里我用了动态时间规整DTW的思路——允许两段序列在时间轴上做非线性的伸缩对齐找到代价最小的对齐路径。DTW本身并不新鲜处理几十个点的序列也很快但直接套用有两个问题。第一原始DTW对噪声敏感某个毛刺点会把对齐路径拉得东倒西歪。我的解决方案是先对关键点序列做一次加权移动平均平滑再进行DTW计算。第二DTW的代价矩阵如果直接使用欧氏距离会把不同维度涨跌幅、回撤、成交量斜率的差异简单相加量纲差异会造成隐性的权重倾斜。所以我在系统里把相似度拆成三个视角分别计算最后做加权融合骨架距离在DTW对齐路径上计算关键点之间涨跌幅的差异衡量大的趋势骨架是否一致。细节距离计算平滑后的K线实体序列在对应时间点上的形状差异衡量局部K线是否类似。量价配合度用成交量的变化斜率差作为惩罚项因为量价配合形态比如放量突破、缩量回踩在技术分析中的含义完全不同。三个视角的权重不是固定的系统里专门做了一张配置表用户可以按自己的研究偏好调权。比如偏重“抄底形态”的研究员会把骨架距离的权重调高细节距离调低偏重“日内动量”的用户则会反过来。这其实就是标题里“多标准比对”的关键架构决策——标准的含义不是写死一套规则而是让指标的权重组合可以灵活配置。3. 比对模块的索引设计与查询链路3.1 数据分片与增量索引图形比对的计算量比想象中大得多。假设库里有500只标的每只标的过去十年大约2400根日K线滑窗生成60根K线的片段大约有2350个片段全国总共超过100万级的历史片段。如果每一次比对都把这100万条片段全部做DTW单次查询的响应时间会到秒级以上完全无法支撑盘中实时交互。我的做法是建两级索引。第一级是粗过滤索引把每个片段的骨架特征离散化成标签序列。比如把趋势方向离散成“上涨段、下跌段、横盘段”三元标签横盘段的定义是区间累计涨跌幅绝对值小于5%再把每个关键点的斜率分成陡、中、缓三档。这样每个片段会得到一个由若干三元标签组成的字符串序列。在线查询时先把当前走势片段也做相同的离散化然后在索引里做子串匹配把相似度可能较高的候选集从100万缩到几千。第二级才是DTW精排只对候选集内的片段做完整的三个视角距离计算最终按加权得分排序返回TopN结果。这套“粗筛精排”的流程有点类似搜索引擎的召回和排序好处是工程实现简单坏处是粗筛的标签离散化会漏掉一部分形态那些骨架相同但标签序列因为阈值抖动而错位的片段会被淘汰掉。3.2 查询流程的细节与参数配置整体查询链路分成五步核心逻辑里有一个参数“候选集上限”特别值得关注。粗筛子串匹配出来的候选数量跟当前片段中“横盘段”的占比关系极大横盘段多的形态匹配范围会非常宽候选数量可能冲到两三万条趋势明确的形态则可能只有一两千条。我把上限设为8000条超出时按“横盘段数量少优先”截断这种处理实际上比直接调低相似度阈值更稳定。精排阶段还有一个细节DTW的窗口宽度。标准DTW允许全局任意位置对齐但图形比对上完全没有必要允许“倒置匹配”也没有必要允许一根K线对一个几十根K线的区间做极端压缩。我加了一个约束参数路径弯曲率不超过1.5倍也就是说对齐路径上任何一个点源序列和目标序列的索引比不能超过这个范围。这个约束把很多不合理的对齐直接排除掉了计算量也降低了不少。粗筛的标签序列和精排的DTW距离结果会一起写入查询日志。这个日志作用很大——如果后续发现某个样本因为标签序列粗筛被淘汰了可以通过回放日志确认是阈值问题还是离散化粒度问题然后针对性地调整参数。3.3 性能上做过的最关键优化处理100万条历史片段每段用Python跑完整DTW是不可能达到交互级别的。我选型时决定用Python做原型验证但核心精排模块用C重写通过Cython绑定的方式暴露给上层调用。在实际压测中8000条候选的精排计算大约需要180毫秒到350毫秒加上粗筛的子串匹配耗时和I/O开销单次查询的p95延迟在620毫秒左右。我觉得这个延迟对大多数线下分析场景已经够用但如果要做到盘中实时同步比对——也就是说每次K线收盘就自动更新查询——还需要配合索引的分片并行。我把历史片段按标的切分到不同的计算节点上每个节点维护自己负责的那部分片段索引查询时把所有节点的候选结果汇总到主节点再做统一精排。这样即使未来标的数量扩大十倍水平扩展也只需要加节点就行。4. 归档模块解决数据膨胀后的冷热分层问题4.1 冷热数据划分的内在逻辑图形比对系统跑起来之后最直接的问题是数据量爆炸式增长。每根K线本身不大但加上形态描述子、标签序列、DTW对齐路径缓存、查询日志、归档快照一份原始数据被派生了七八份中间数据。我做过统计500只标的十年日线数据原始大小不到300MB但派生数据加起来超过了12GB。这时候必须做冷热分离。我的划分规则是最近三个月的原始行情数据和最近一个月的派生数据属于热数据存放在高性能的时序数据库中保证盘中实时读写三个月之前的数据经过压缩后转入冷存储保留原始K线、复权因子和关键点序列其他中间结果全部丢弃或降采样。为什么选三个月作为分界因为图形比对最常用到的“近期形态参考”多数集中在过去一两个季度内超过了这个窗口历史片段的复用率会显著下降全部放在热存储里性价比太低。热存储我选用了某开源时序数据库的列式压缩模式。实测效果很理想1200万行K线数据占用的磁盘空间从约8GB压到了约2.1GB而查询性能几乎没有下降——因为冷热分层之后热查询几乎不会触碰冷数据列式压缩的随机读成本完全可控。4.2 归档的存储格式与生命周期管理冷数据不应当沿用热数据的存储格式。我在归档侧设计了一套更紧凑的二进制格式把标的信息、复权因子、关键点序列、形态描述子打包成按标的年度分块的文件每个文件内部按时间顺序排列并使用LZ4做二级压缩。这样设计的原因跟读取模式强相关归档数据的读取通常需要“按历史片段整体拉取”而不是按单行查询因此文件级的整块压缩比行级压缩更合适。文件的分块粒度是标的季度而不是标的年。原因是我在统计查询频次时发现有些研究报告需要跨年度的长周期走势对比但绝大多数历史比对请求的跨度不超过一个季度。按季度切分既能减少单文件体积方便并发拉取又不会因为分得太碎导致小文件过多算是权衡之后的选择。归档生命周期管理用的是定时任务加保留策略热数据保留91天后每天凌晨由归档任务扫描过期分片做数据转换和压缩写入冷存储冷数据按7年、15年、永久三档策略分别定义衰减逻辑。这里有个很不起眼但很关键的点冷数据也不能只留二进制必须把当时的复权因子和分红送转信息一并归档否则几年后回头做长周期回测时会因为复权基准漂移而无法对齐。4.3 归档后的恢复与一致性校验归档做得再好如果恢复流程不可靠等于白做。我把恢复分成两个场景按需恢复单只标的的一段历史区间以及全量恢复用于迁移或重建索引。按需恢复的流程是先查归档清单找到对应标的和季度的文件块解压并加载到内存再按时间戳切出所需区间返回。全量恢复则直接遍历所有文件重建两级索引。恢复流程里最容易出问题的是“部分恢复”导致索引不一致。比如某标的的3月份归档文件损坏了恢复时只恢复了1月和2月的数据而索引里还残留着3月的片段条目查询时就会返回残缺结果。我的解决办法是在每次归档落盘时写一条校验记录内容包括文件大小、数据块数量、时间段戳和校验和恢复任务运行前先扫描校验记录任何不完整的归档文件都直接标记为缺失不进入索引同时在监控看板上暴露一条警告。我自己的经验是归档模块最好在系统上线第一天就做哪怕是简化版。不要天真地以为前期数据量小可以先不处理等数据多了再补。数据一旦多起来历史数据的清洗和复权处理会消耗数倍于当时的时间成本迁移过程还会打断线上服务非常折腾。5. 实时分类引擎把分析从盘后变成盘中5.1 流式处理框架的比较与选型盘中实时分类是这套系统最“智能体”的部分。它要做的事情是每当最新一根K线收盘系统自动把最近的60根K线片段做一次特征提取和分类然后打上形态标签比如“放量突破前高”“缩量回踩均线”“低位横盘整理”“高位放量滞涨”等把这些标签写回实时数据库供其他模块查询。选流式处理框架时我认真对比了几家。最终没有选择重量级的流计算平台而是用了轻量级的流式处理库直接把事件流接入一个常驻内存的处理器。理由是单市场的K线事件频率并不高全市场盘中平均每秒也就是几十个收盘事件重量级框架引入的分布式协调成本和运维成本完全没必要。处理器的核心架构是一个有状态的滑动窗口管理器。每只标的维护一个长度为60的K线环形缓冲区新事件到达时把最旧的一根踢出重新计算当前片段的特征和标签。特征计算复用离线版的形态描述子逻辑但做了一次重构把计算量降低到单片段平均小于8毫秒保证在事件峰值时不会反压。5.2 双通道架构规则引擎加轻量模型的互补设计实时分类我采用了双通道并行判定——规则引擎和轻量分类模型同时跑两者的结果做交叉验证不一致时以规则引擎为准并记入待复核队列。为什么这么设计因为两类方法各有短板。规则引擎对“放量突破前高”这类定义明确的形态判定精准、可解释性强但对那些介于两类之间的模糊形态缺乏泛化能力。轻量分类模型正好相反它对模糊形态的判断更贴合人的直觉但在训练样本覆盖不足的类别上可能出现离谱的误判。双通道互补后规则引擎负责守住确定性强的判定模型负责覆盖长尾形态交叉复核机制能持续积累错判样本供模型迭代。规则引擎目前的规则集有30多条全部写成可配置的形式。我举两条有代表性的规则例子放量突破前高当前收盘价大于过去20日内最高收盘价且当日成交量大于过去20日均量的1.8倍同时RSI值在55以上80以下排除极端超买。缩量回踩均线当前价格回踩20日均线收盘价距均线不超过0.5%且当日成交量小于过去20日均量的0.6倍。轻量分类模型则是我用一个标注样本集微调出来的梯度提升树模型特征输入是前述关键点序列的统计特征加少量实时技术指标输出是六大类形态标签的概率分布。这个模型在标注数据上的交叉验证准确率约83%对实时场景够用训练成本也比深度模型低得多。5.3 实时性指标和降级策略实时分类对延迟要求比较高但关键是延迟要稳定不能出现偶发的大毛刺。我实测过一次全市场1700只标的同时触发收盘事件的处理总耗时规则引擎处理完所有标的大约1.2秒模型通道大约3.8秒总耗时中位数在2.4秒左右p99大概在5.1秒。考虑到行情推送本身也有几十到几百毫秒的延迟这个性能在盘中对大多数分析场景是够用的。降级策略一定要提前考虑。流式处理最怕上游行情源抖动——信号断流时处理器的窗口数据会变得越来越陈旧如果继续用旧数据计算标签结果不仅没用还会误导。我加了一条规则如果某标的超过15分钟没有新K线事件就自动置为“数据停滞”状态所有实时标签清空并标记为过期直到新的有效事件到达才恢复。对于盘中临时停牌的标的这个机制同样能避免误判。另外实时分类的标签要和离线比对模块打通。实盘中用户很可能想从“当前刚出现的放量突破形态”一键出发跳转到历史上有相似结构的片段。这条通路的方法是把当前片段的形态描述子复制一份交到离线比对的精排模块里做一次异步查询结果直接推送到前端。异步很关键不要让实时分类的确定性任务被这种“锦上添花”的查询拖慢。6. 从部署到迭代实测数据和踩坑记录6.1 一套完整部署的资源消耗和性能数据我把整套系统跑在一台64核128GB内存的服务器上另外加了两台16核32GB的节点做归档和离线比对任务。时序数据库和归档存储都部署在同一台机器上通过内存缓存把热查询尽量留在内存里。运行一个月后的统计数据显示热库存量约28GB冷归档约110GB全天平均CPU使用率在35%左右内存占用峰值约86GB。实时分类和比对查询的并发负载并没有明显冲突主要得益于分片部署。性能方面我最关注三个指标比对查询的p95延迟、实时标签的端到端延迟、归档恢复的成功率。实测值分别为620毫秒、4.3秒、99.7%——这个恢复成功率看着不低但每个月都会出现两三次归档文件损坏或恢复中断的情况所以校验记录和监控告警是绝对必要的不能省。6.2 最容易翻车的三个细节第一个是数据对齐问题。不同数据源的交易时段切片差异会导致同一只标的在同一个时间点的K线不完全一致尤其在处理前收、后收或集合竞价数据时。我最终在清洗层统一采用“按分钟切片对齐再聚合”的策略从源头上消除对齐差异但这个过程花了两周时间调试才稳定下来是我在项目里最耗时的一部分。第二个是DTW平滑窗口的尺寸选择。窗口太小保留的噪声多窗口太大又会让关键点偏移。我用一组经过人工标注的形态样本做了交叉测试最终确定了5日加权窗口。但这个值不是通用的——换成周线或分钟线数据时需要重新标定。第三个是阈值过严导致召回率过低。最开始我把骨架距离的相似度阈值设得比较严格结果每天返回的匹配结果数量很少用户感觉“什么都查不到”。后来改成返回TopN模式按相似度得分排序直接返回前20条相似度得分只作为排序依据不做过硬过滤用户反馈立刻变好了很多。这个改动本质上是把“系统替用户做决定”变成了“系统给用户提供参考”更符合分析场景的使用习惯。6.3 后续规划从单个市场到多市场、从分类到归因目前这套系统已经稳定运行了几个月。下一步我计划做两件事第一把日线级别的比对拓展到周线和分钟线级别因为很多用户的形态定义其实是基于多周期共振的第二在实时分类的标签基础上做“归因分析”也就是说当一个实时形态被识别出来之后系统自动列出历史上最相似的前20个片段并统计这些片段之后20个交易日的平均表现、胜率和最大回撤。这两件事都会给“图形比对”注入更多的预测分析属性但也对系统提出了新的挑战。周线级别的关键点数量更少DTW对齐的可靠性需要重新验证分钟线级别的数据量是日线的数百倍索引和归档策略几乎要重新设计。目前我倾向于分钟线只支持热数据的小范围回溯不做全量归档等到实际需求数据支撑之后再决定是否扩展开销。7. 关于这套系统的真实体会与风险提示最后说点跟技术无关但很重要的东西。这类图形比对系统本质上是一种技术分析辅助工具它描述的是“过去发生了什么类似的走势”至于“未来会怎样”需要用户自行判断。我在系统里明确写了一行提示所有相似度得分和形态标签不构成任何投资建议只作为研究辅助参考。从工程角度看这套系统的核心价值不在于算法有多先进而在于把模糊的、非结构化的“图形感觉”拆解成了可配置、可回溯、可评估的计算流程。从一开始手工比图到后续的自动归档和实时分类每一步的演进都是靠用户反馈和数据复盘来验证的——某个标签是否合理某条规则是否需要调整都要有对应的数据支撑而不能靠拍脑袋。如果你也想做类似的系统我的建议很直接先小范围验证把两三个核心形态的比对做通再考虑大规模归档和实时化。饿死在半路的项目往往不是算法不行而是工程复杂度超出了团队当下的承受能力。
阅读完成 · 觉得有帮助?