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. 关于这套系统的真实体会与风险提示
最后说点跟技术无关但很重要的东西。这类图形比对系统本质上是一种技术分析辅助工具,它描述的是“过去发生了什么类似的走势”,至于“未来会怎样”需要用户自行判断。我在系统里明确写了一行提示:所有相似度得分和形态标签不构成任何投资建议,只作为研究辅助参考。
从工程角度看,这套系统的核心价值不在于算法有多先进,而在于把模糊的、非结构化的“图形感觉”拆解成了可配置、可回溯、可评估的计算流程。从一开始手工比图到后续的自动归档和实时分类,每一步的演进都是靠用户反馈和数据复盘来验证的——某个标签是否合理,某条规则是否需要调整,都要有对应的数据支撑,而不能靠拍脑袋。
如果你也想做类似的系统,我的建议很直接:先小范围验证,把两三个核心形态的比对做通,再考虑大规模归档和实时化。饿死在半路的项目,往往不是算法不行,而是工程复杂度超出了团队当下的承受能力。