☰
缠论K线包含处理:合并规则、浮点陷阱与DLL插件增量缓存实践
2026/11/5 2:09:53 网站建设 项目流程

简介:面向通达信公式开发与缠论量化研究者的DLL插件源码包,聚焦相邻K线包含关系的自动判断与合并,为笔、线段、中枢等结构构建提供基础数据。资源共24个文件,以h头文件、cpp源文件为主,辅以obj、pdb、ilk等编译中间文件及dsp/dsw工程配置,并附dll、plg成品与使用说明,压缩包约693KB。已有5767人学习下载,说明其在通达信插件开发与缠论实现方向具有一定参考价值。源码围绕K线数据读取、包含条件判定、合并后序列更新、插件计算函数接口对接及结果回传图表等环节展开,读者可借此理解缠论包含处理从规则到DLL落地的组织方式,也能借鉴VC++工程配置与调试产物的对应关系;适合具备C++基础、希望复现并扩展笔段中枢逻辑的开发者研读。对学习缠论规则如何转化为可编译插件、排查合并边界条件与验证结构输出均有实际帮助。

1. 缠论K线包含处理:为什么合并K线是分型成立的前提

缠论K线包含处理经常被当成一个纯几何的小工具:两根K线谁套住谁,取个极值就算完事。可一旦把这段逻辑装进本地行情终端的插件里,对着真实日线数据跑,就会撞见一个反直觉的结果——同一段行情,合并顺序换一下,最后得到的标准K线序列能差出十几根,顶底分型的位置跟着整体位移。缠论后续的笔、线段、中枢,全部建立在这根被合并过的K线上,合并错了,后面全是假的。这就是为什么翻缠论源码,最值得逐行读的不是分型函数,而是前面那段看起来最笨的合并函数:它决定了后面所有结论的真假,也是整个插件里唯一没有“肉眼可验证”的环节。

2. 包含关系判定:从几何定义到一行比较函数

2.1 包含的严格定义与四种边界情形

把一根K线抽象成区间,第 i 根写作 [L_i, H_i],L 是最低价、H 是最高价。包含关系的几何定义只有一句话:如果一根K线的高低点完全落在另一根的高低点区间之内,两者构成包含。写成不等式就是 H_a ≥ H_b 且 L_a ≤ L_b,此时 a 包含 b。

拆开看有四种情形,其中两种容易被忽略。第一种是严格包含,区间真包含,没有争议。第二种是反向包含,b 包住 a,判定式反过来成立。第三种是完全相等,两根K线的高低点一模一样,这在涨跌停一字板或者极度缩量的横盘里并不罕见,按上面的不等式,它同时满足 a 包含 b 和 b 包含 a,方向就没法唯一确定。第四种是“半包含”:只有高点相等而低点更高,或者只有低点相等而高点更低。按非严格不等式的定义,这也算包含,因为一方的区间是另一方的超集。

这第四种情形是分歧的源头。有的实现只在严格包含时才合并,半包含直接放过;有的实现按 ≥ 和 ≤ 一律合并。两种约定在高位横盘、连续小星线的段落里会给出不同的K线数量,进而让分型的确认位置前后差一两根。这里没有绝对的对错,缠论原文也没把这件事钉死,真正的红线是整个系统必须自洽——分型、笔、中枢用的一定是同一份合并结果,不能分型用严格版、画图用宽松版。

// 包含关系判定:返回两根K线的关系 enum class Relation { NONE, // 无包含关系 A_CONTAINS_B, // 前一根包含后一根 B_CONTAINS_A, // 后一根包含前一根 EQUAL // 高低点完全相同(互相包含) }; inline Relation judgeRelation(const Bar& a, const Bar& b) { const bool aCoversB = (a.high >= b.high) && (a.low <= b.low); const bool bCoversA = (b.high >= a.high) && (b.low <= a.low); if (aCoversB && bCoversA) return Relation::EQUAL; // 全等,需靠方向裁决 if (aCoversB) return Relation::A_CONTAINS_B; if (bCoversA) return Relation::B_CONTAINS_A; return Relation::NONE; }

这段函数的全部价值在于把“全等”单独拎出来。如果不区分 EQUAL,直接让 aCoversB 优先返回,那么在连续一字板的行情里,合并方向会被固定成某一种,向上段落里本该取高高、却因为走了包含分支取了别的值,K线序列的性质就被破坏了。把这个分支显式暴露出来,逼迫调用方在 EQUAL 时去查方向,是后面所有逻辑能立住的前提。

2.2 全等K线、零振幅与除权后的浮点陷阱

真实数据里最会咬人的不是标准K线,而是那些退化的K线。一字涨停时 H == L,一根K线退化成一个点,它会被任何覆盖这个价位的K线包含,也会被同价位的前一根包含。如果连续五天一字板,五根K线在几何上是同一个点,合并逻辑必须能处理这种“多重全等”——每加一根都要和当前的合并结果比,而不是两两配对。

比一字板更隐蔽的是浮点误差。很多行情源内部用“分”为单位的整数存价格,读出后转成 double 参与计算。11.11 这个十进制小数在二进制里没有精确表示,转出来可能是 11.109999999999999。当一根K线的高点来自不同的计算路径(比如复权再还原、或者跨周期重采样),两个“应该相等”的价格在 double 层面会差一个 1e-14 量级的值。非严格不等式在遇到这种差异时,会把本该判为包含的两根K线判成不包含,合并结果和肉眼看到的图直接对不上。

复权是另一个放大器。前复权等于原始价乘一个随时间变化的因子,除权日附近的K线在复权后会整体平移,原本不构成包含的两根可能变成包含,原本包含的可能被推开。切换复权模式却不重算合并结果,图上就会出现前半段和后半段“接不上”的裂痕。

常见的处理思路有两条。一条是用相对容差做比较,把所有价格比较都套一层近似函数;另一条是干脆把价格整数化,乘以一个固定倍数(比如 10000)转成 int64 再比。后一条在行情终端这种价格精度固定的场景里更稳,因为它把“近似相等”变成了真正的相等,不存在传递性被破坏的问题。相对容差的问题在于,a≈b、b≈c 未必能推出 a≈c,一旦用在排序或聚类上就会出怪事。

2.3 比较函数怎么写:相对容差与整数化两种路线

如果走相对容差路线,核心是把绝对误差按量级缩放。3 元的股票和 300 元的股票,允许的误差不该是同一个数值,否则低价股会被大量误判为相等,高价股则几乎不会有相等判定。

// 相对容差比较:eps 按两者量级缩放 inline bool nearlyGE(double x, double y, double relEps = 1e-9) { const double scale = std::max({1.0, std::fabs(x), std::fabs(y)}); // x >= y 在容差意义下成立:允许 x 比 y 小一点点 return x >= y - relEps * scale; } inline bool nearlyLE(double x, double y, double relEps = 1e-9) { return nearlyGE(y, x, relEps); }

relEps 取 1e-9 是个偏保守的值,对应 300 元的股票上约 3e-7 的绝对误差,足以覆盖双精度转换噪声,又不至于把 0.0001 元的真实价差吞掉。scale 里带上 1.0 这个下界,是为了避开价格接近 0 时容差退化为 0 的情况——虽然股票价格不会到 0,但指数、期货连续合约的历史数据里偶尔会出现极端值。

整数化路线的写法更直接:读取时统一 round(price * 10000) 转 int64,之后所有判定都用整数比较,输出时再除回去。代价是精度被锁死在万分之一,遇到需要更高精度的品种(比如某些报价到小数点后四位的合约)就得改倍数。好处是彻底消灭了浮点比较的所有玄学,也让 EQUAL 判定变得可靠。

两条路线在同一个插件里最好只用一条,不要一半用容差、一半用整数,否则合并和分型两个模块对“相等”的理解会出现分歧。真实项目里我倾向于整数化,因为缠论的包含处理本质上是在做离散的区间比较,把它拉到浮点域里没有任何收益,只会引入不可复现的边界问题。

3. 合并方向与滚动:向上取高高、向下取低低,方向到底从哪来

3.1 方向由前两根已处理K线决定,不是由当前两根

合并规则本身只有两句话:向上处理时,合并K线的高点取两者较高、低点也取两者较高;向下处理时,高点取两者较低、低点也取两者较低。真正难的是方向从哪来。当前正在比较的这两根K线本身就是包含关系,它们的相对位置没有方向信息,方向必须往外找。

标准做法是看已经处理完的序列的最后两根。设输出序列为 out,长度为 n,当新来一根原始K线与 out[n-1] 构成包含时,方向由 out[n-1] 与 out[n-2] 的关系决定:如果 out[n-1].high > out[n-2].high(同时 low 也更高),说明前一段是向上的,按向上合并;反之按向下合并。

这里有个初值问题。序列的最前面两根K线还没有“前两根”,没有方向可查。有的实现默认向上,有的实现把前两根直接原样放进输出序列、从第三根才开始判包含。后者更稳,因为前两根如果不构成包含,本来就不需要合并;如果构成包含,说明数据从第一根开始就是混沌的,此时按任一方向合并都会引入偏差,只能认了。

还有个容易写错的地方:方向判断要看的是已经处理完的序列,不是原始序列。原始序列里第 i-1 根和第 i-2 根可能构成包含,用它们判方向等于用一个尚未确定的量去确定另一个量。方向必须建立在“已经保证相邻不包含”的序列上,这个不变量是整个算法的地基。

// 用已处理序列的最后两根推断方向 // 返回 true 表示向上,false 表示向下 bool inferDirection(const std::vector<Bar>& out) { if (out.size() < 2) return true; // 初值:无依据时默认向上 const Bar& prev2 = out[out.size() - 2]; const Bar& prev1 = out[out.size() - 1]; // 已处理的相邻两根必不包含,因此只需比较高点即可判定走向 return prev1.high > prev2.high; }

注意最后一行只比了 high。因为 out 里的相邻两根已经保证不构成包含,那么它们的区间关系只有两种:一根整体在另一根上方,或者整体在下方。此时 high 的大小关系与 low 的大小关系必然一致,比一个就够。如果这里出现了 high 高但 low 低的情况,说明前面的合并流程已经出错了,属于不变量被破坏,应该在调试期用断言拦下来。

3.2 滚动合并的实现:合并结果必须回头再比

顺序处理是唯一正确的遍历方式:从左到右,每来一根就和输出序列的末尾比。如果构成包含,按方向合并进末尾;如果不构成包含,直接追加到输出序列。

问题出在“合并进末尾”之后。合并会改变末尾K线的高点和低点,改变后的区间有没有可能与 out[n-2] 又构成包含?理论上不会——合并只会沿方向扩展区间,向上合并时新低点变高、新高点变高,它相对 out[n-2] 的位置只会更靠上,不会退回去把 out[n-2] 包住。向下合并同理。所以单次合并之后不需要回头,这个性质保证了算法是线性的。

但工程实现里有两种写法会破坏它。一种是分治式:先把原始序列两两配对合并一轮,再对结果两两合并一轮。这种做法把顺序依赖彻底打乱了,因为某一对合并时用的方向可能是错的,错误会沿着层数指数放大。另一种是“先标记所有包含对,再统一消除”,听起来更高效,实际上一根K线可能同时被前后两根包含,标记完再消除会漏掉合并后的新包含关系。

还有一种情况需要额外保护:浮点容差介入之后,理论上不成立的“回头包含”有可能真的出现。合并后的新区间因为容差被判定为与 out[n-2] 构成包含,此时如果不处理,输出序列就会带着一个违反不变量的元素继续往下跑,后面所有的分型判断都建在流沙上。稳妥的做法是在合并后加一次回退检查,如果真的发生了就再合并一次,同时打日志——这类事件在生产环境里应该极其罕见,一旦频繁出现,说明容差参数或者数据源有问题。

void mergeBars(const std::vector<Bar>& in, std::vector<MergedBar>& out) { out.clear(); for (size_t i = 0; i < in.size(); ++i) { if (out.empty()) { out.push_back(MergedBar::from(in[i], i)); continue; } MergedBar& last = out.back(); Relation rel = judgeRelation(last.bar, in[i]); if (rel == Relation::NONE) { out.push_back(MergedBar::from(in[i], i)); // 无包含,直接追加 continue; } // 构成包含:方向由输出序列的最后两根决定 const bool up = inferDirection(barsOf(out)); if (up) { last.bar.high = std::max(last.bar.high, in[i].high); // 高高 last.bar.low = std::max(last.bar.low, in[i].low); // 低高 } else { last.bar.high = std::min(last.bar.high, in[i].high); // 高低 last.bar.low = std::min(last.bar.low, in[i].low); // 低低 } last.endIdx = i; // 索引区间右端点后移 last.bar.volume += in[i].volume; // 成交量累加 last.bar.close = in[i].close; // 收盘价跟最新 // 回溯保护:容差可能导致合并后又与倒数第二根构成包含 while (out.size() >= 2 && judgeRelation(out[out.size() - 2].bar, last.bar) != Relation::NONE) { // 合并倒数第二根与最后一根,方向仍由更前一根决定 mergeLastTwo(out); } } }

代码里最值得说的是 last.bar.close = in[i].close 这一行。合并K线的收盘价必须跟着最新那根走,因为收盘价代表的是这根合并K线右端点时刻的市场状态。如果保持第一根的收盘价,后续所有依赖收盘价的指标(比如 MACD 的面积计算)都会整体偏移。同理,high 和 low 用的是极值,open 通常保持第一根的开盘价不变,因为开盘代表的是这段区间的起点。

那个 while 循环是防御性的,正常数据下一次都不会进。但它的存在让整个函数在面对脏数据时不至于输出一个违反不变量的序列,代价是每次合并多一次比较,可以接受。

3.3 合并K线保留哪些字段:时间、成交量与索引映射

合并K线不是简单的高低点二元组,它需要携带足够的信息让下游模块能追溯回原始K线。至少要有四个字段:合并后的 high/low、代表时间、累计成交量、原始索引区间。

代表时间取哪一根,各家实现不同。取最后一根的时间(即 endIdx 对应的时间)更符合“这根K线代表到此刻为止”的语义,画图时也不会出现K线跑到未来位置的情况。取第一根的时间则更接近“这根K线从何时开始”,在计算持有时长时有意义。两种都能自洽,关键是全系统统一。

索引区间 [startIdx, endIdx] 是最容易被忽略、又最不能省的字段。分型确认之后要往前找对应的原始K线画标记,如果没有这个映射,就只能靠时间戳去原始序列里二分查找,遇到停牌、除权导致的时间不连续时会找错。有了区间,分型落在哪根合并K线上,就直接对应原始序列的一段,画图、统计、回溯全都是一次数组访问。

成交量要不要累加也有讲究。缠论原文讨论的是价格形态,不涉及量,所以从纯理论角度合并K线的成交量没有定义。但实战中几乎所有人都会在合并K线上看量能,累加是最符合直觉的选择——它保持了“合并前后总量守恒”这个可验证的性质,调试时可以拿它当校验位用。

字段取值规则用途
high向上取 max,向下取 min分型、笔的端点判定
low向上取 max,向下取 min同上
open保持区间第一根少数依赖开盘的指标
close跟随区间最后一根收盘价类指标
volume区间内累加量能分析、守恒校验
startIdx / endIdx原始序列下标区间回溯定位、画图

这张表里每一行都对应过一次真实的翻车。尤其是 close 和 volume 那两行,写反了不会崩溃、不会报错,只会让指标数值慢慢偏离,等到发现时已经找不到是哪一版改坏的。

4. 插件接入:DLL 如何在宿主进程里拿到K线并回写指标

4.1 宿主与插件的接口契约:函数表、结构体版本与导出名

本地行情终端的插件体系通常是一个进程内 DLL。宿主启动时扫描插件目录,LoadLibrary 加载,GetProcAddress 拿到约定的导出函数,之后每次需要计算指标就调用一次。插件自己不负责取数据,数据由宿主通过回调函数表传进来——这是一条单向依赖,插件不链接宿主的任何库,只依赖一张纯 C 的函数指针表。

这张表至少包含三组能力:查询K线数量、按索引取第 i 根K线、回写计算结果。取数据的接口签名一般是 int GetBar(int index, Bar* out),返回实际填充的字节数或者错误码。回写有两种模式,一种是按下标写数值(适合均线这类逐点输出),一种是注册自定义绘图对象(适合画线段、中枢矩形这类图元)。

结构体版本是最容易被跳过、出事又最难查的一环。宿主编译时的 Bar 结构和插件编译时的可能不一致,字段顺序、对齐、甚至字段数量都可能不同。常见的做法是在结构体头部放一个 size 字段,宿主填自己认为的大小,插件读出来校验,不匹配就直接拒绝加载并返回错误码。这比让插件去读一个偏移错位的字段、拿到一堆垃圾值再崩溃要体面得多。

// 宿主与插件之间的数据契约(纯 C,固定宽度类型) #pragma pack(push, 8) struct PluginBar { int32_t date; // YYYYMMDD,或与宿主约定的日期编码 int32_t time; // HHMM 或分钟数,日线填 0 double open; double high; double low; double close; double volume; double amount; }; #pragma pack(pop) // 宿主提供的回调表,插件只读不写 struct HostApi { int32_t structSize; // = sizeof(HostApi) int32_t apiVersion; // 宿主实现版本 int32_t (*getBarCount)(int32_t period); int32_t (*getBar)(int32_t period, int32_t index, PluginBar* out); int32_t (*setOutputValue)(int32_t index, double value); void* reserved[8]; // 预留,保证向后兼容 };

structSize 和 apiVersion 两个字段是有意冗余的。前者防的是编译期布局变化,后者防的是语义变化——比如某天宿主把 volume 的单位从“手”改成“股”,结构体大小不变,但数值差 100 倍。插件读到 apiVersion 不匹配时,至少知道该走兼容分支或者直接拒载。

reserved 数组是给未来的自己留的后路。第一次写插件的人几乎都会觉得预留没用,等到需要加一个“取复权因子”的接口时,才发现所有已发布的插件都是按老的函数表编译的,加字段会让老插件读到越界内存。预留八个指针的成本可以忽略,收益是接口能平滑演进好几年。

4.2 K线数据的内存布局:对齐、时间字段与 32/64 位混编

PluginBar 里全部用固定宽度类型,是为了避开 int 在不同编译器下宽度不一致的问题。日期用 int32 存 YYYYMMDD,比用字符串省空间,也比较方便做范围查询;但如果宿主内部用的是从某个基准日起的天数或者秒级时间戳,就必须在接口层统一,不能让插件去猜。

对齐是个隐蔽的坑。#pragma pack(8) 保证了 double 落在 8 字节边界上,访问效率最好;如果宿主那边用了 pack(1),两边算出来的 sizeof 就会不同,getBar 按错误的步长去读数组,从第二根开始全部错位,而第一根永远是对的——这种 bug 极难定位,因为打印第一根数据看起来完全正常。用 structSize 校验能第一时间拦住它。

32 位宿主加载 64 位 DLL 会直接失败,反过来也一样,这是操作系统层面的限制,不是代码问题。麻烦的是插件目录里同时放了两个版本、宿主按文件名模糊匹配加载了错的那个。稳妥的做法是导出函数里带一个 GetPluginBitness(),宿主拿到之后和自己的位宽比对,不匹配就在日志里写清楚,而不是让用户对着一个“加载成功但没输出”的空图发愣。

数据所有权也得说清楚。getBar 返回的指针如果指向宿主内部缓冲区,插件不能长期持有,下一次行情推送就可能被覆盖;如果要缓存,必须自己拷贝。反过来,setOutputValue 传的是值不是指针,生命周期问题就消失了。接口设计上凡是能传值的都别传指针,能传 const 引用的别传可写引用,这能消掉一大半跨模块的内存事故。

4.3 增量刷新:最后一根K线的回滚与重算

实时行情是持续推送的,最后一根K线在收盘前会不断变化:高点可能被刷新、低点可能被打破、成交量一直在涨。这意味着合并结果也在变——刚才还和倒数第二根构成包含,下一秒可能就不构成了。如果每次推送都全量重算,几千根K线的合并加上后面的分型、笔,在低端机器上会明显卡顿;如果只在末尾追加、不回头,最后一根K线的合并在方向翻转时会算错。

可行的做法是维护一个“确认边界”。收盘已确定的K线,其合并结果不会再变,把它们冻结起来;边界之后的 N 根原始K线保留原始数据,每次推送时全部丢弃重算。N 的取值要覆盖最坏情况下的连续包含链——理论上一条包含链可以很长,连续几十根小星线互相包含并不罕见。工程上取 50 到 100 根是常见区间,再大就退化成全量重算了。

struct MergeCache { std::vector<MergedBar> confirmed; // 已冻结的合并结果 size_t confirmedRawCount = 0; // 冻结时已消费的原始K线根数 bool direction = true; // 冻结时刻的方向状态,重算的起点 // 每次行情推送调用:丢弃 confirmedRawCount 之后的所有合并结果 void rollback(const std::vector<Bar>& raw) { const size_t keep = (confirmedRawCount > kTailWindow) ? confirmedRawCount - kTailWindow : 0; confirmed.resize(/* 与 keep 对应的合并根数 */); confirmedRawCount = keep; // 方向必须跟着回滚,否则重算会用错方向 direction = inferDirection(confirmed); } };

rollback 里最容易被漏掉的是 direction 的重置。方向是随序列推进不断更新的状态量,如果回滚时只截断了 confirmed 数组却没恢复方向,重算的那几十根K线会全部按回滚前的方向合并。表现出来就是行情跳一下、图上的K线形态突变一次,用户会觉得这软件“有鬼”。这类状态不一致属于典型的黑匣子问题,只能靠把状态和它依赖的数据绑在一起回滚来根治。

缓存失效的条件也要列全:切换周期、切换复权模式、切换品种、切换数据源、盘中除权。少列一个,就会出现“日线切换到 5 分钟再切回来,日线的合并结果变成了 5 分钟的形状”这种离谱现象。稳妥的写法是给缓存加一个 key,把上述所有维度都编码进去,key 变了就整个丢弃重建,不做局部复用。

5. 排查清单:包含处理最容易翻车的五个现场

5.1 数据侧:复权切换与停牌缺口

现象:前复权和不复权来回切换几次之后,图上的分型标记开始对不上,越靠左的历史K线偏移越大,同一个顶分型在不同复权模式下落在不同的日期上。

原因:合并结果是缓存的,切换复权模式时只重算了显示的K线数据,没有让合并缓存失效。插件里存着旧复权模式下的索引映射,新数据按老映射去定位,偏移量随历史长度累积。

解决:把复权模式编进缓存 key,切换时整块丢弃重建;同时在插件入口处记录一次当前复权模式,和缓存里的比对,不一致就强制走全量重算。这类问题的血泪经验是:任何影响价格的开关,都必须传播到最底层的预处理缓存。

现象:某只股票停牌半年后复牌,复牌前后的K线合并结果看起来正常,但一遇到复牌当天的巨幅跳空,方向判断就反了,紧接着的几根K线被并进了错误的方向。

原因:停牌期间没有K线,复牌首根与前一根之间的时间跨度是半年,但合并算法只看价格不看时间,把这两根当成连续交易日处理。跳空导致的高低点关系被误读为趋势反转。

解决:在原始数据层面就标注出交易日不连续的位置,合并时遇到超过阈值的时间间隔,强制重置方向、把当前K线作为新段落的第一根。阈值取多少个自然日要看品种,A股一般 10 个自然日以上就该考虑。

5.2 算法侧:方向判断与滚动终止

现象:合并后的序列里出现了相邻两根仍然构成包含的情况,分型函数在这种数据上会输出连续两个顶或者两个底,笔的划分直接乱掉。

原因:增量重算时只回溯了固定根数,而包含链的长度超过了这个窗口。回滚点落在一个仍然处于包含关系中的位置,重算从这里开始,方向初值是根据不完整的历史推出来的。

解决:不要用固定窗口拍脑袋,而是在回滚点处加一次校验——检查回滚点前后的两根是否构成包含,构成就再往前退一根,直到找到一段确定的非包含边界。这个循环的上限设一个硬阈值(比如 500 根),超过就放弃增量、转全量重算。

现象:连续一字板的票,合并后的K线数量比预期少很多,成交量被吞掉一大截,图上看起来像少了好几天的数据。

原因:全等K线(H == L 且与前一根完全相同)在 judgeRelation 里返回 EQUAL,合并逻辑按方向取极值,取完还是同一个价格,但 endIdx 往前推了,成交量累加进去了,而视觉上K线没有任何变化,看起来像“消失了”。

解决:这是正确行为,不是 bug,但必须在UI上体现出来——把合并K线的数量或者索引区间显示出来,或者在成交量柱上按区间宽度做视觉区分。真正需要修的是当 EQUAL 连续出现几十次时,索引区间会变得极大,画图时如果按区间宽度算K线宽度,会画出一根超宽的柱子,需要给宽度设上限。

5.3 工程侧:缓冲区越界与多周期串数据

现象:插件在日线下运行正常,切到 5 分钟线就崩溃,崩溃位置在合并函数的某个 vector::back() 调用上。

原因:5 分钟线的K线数量是日线的几十倍,插件内部有个固定大小的静态数组(比如 char buf[65536])按根数分配空间,日线够用,分钟线直接越界,覆写了相邻内存。

解决:所有和K线数量相关的容器一律用动态分配,禁止静态数组;在入口处先调 getBarCount 拿到实际根数,做一次上限检查(比如超过 200 万根直接返回错误),把“数据量超预期”变成一个明确的错误码而不是一次内存踩踏。

现象:同时打开两个窗口,一个看日线一个看 5 分钟线,两个窗口都加载同一个插件,结果 5 分钟窗口的图上出现了日线的分型标记。

原因:插件用了全局静态变量存合并结果和索引映射,两个窗口在同一个进程里,共享同一份静态存储。后计算的覆盖先计算的。

解决:所有状态必须绑定到一次调用上下文,不能用全局变量。如果宿主接口没提供上下文句柄,就在插件内部按周期和品种做一个 map,key 里带上周期、品种、复权模式,取不到就新建。同时要注意线程安全——行情线程和绘图层可能并发访问,map 外面得加锁或者用线程局部存储。

6. 设计取舍:全量重算、增量维护,还是两级缓存

把这套东西写完并且跑稳之后,最值得回头想的一件事是:合并这步到底该花多少工程成本。三种路线各有明确的适用面,没有一条能通吃。

路线实现成本单次耗时(5000根)状态一致性风险适用场景
每次全量重算最低,几十行毫秒级无,每次都是纯函数盘后分析、回测、K线数量少
增量 + 回滚窗口中等,需要设计回滚点微秒级中,状态多容易漏回滚实时行情、多窗口
两级缓存(含分型层)高,要把下游也算进去纳秒级命中高,失效条件成倍增加高频刷新、复杂指标叠加

我一般的判断标准是看“用户会不会感觉到”。盘后打开软件看图,全量重算几千根K线对现代CPU来说就是一次眨眼,为它设计增量缓存纯属自找麻烦,而且增量逻辑一旦有 bug,排查成本远高于省下的那点时间。只有实时推送的场景才值得上增量,因为每三秒一次的全量重算在低配机器上会累积成可感知的卡顿。

验证合并结果对不对,比优化它更重要。我固定会跑四组性质检查,任何一组挂了就直接回去看代码,不看输出图。

第一组是不变量:合并后的序列里,任意相邻两根都不能构成包含。这一条是算法的定义,挂了说明方向或滚动逻辑有问题。第二组是守恒:合并前后所有K线的最高价的最大值、最低价的最小值必须相等;成交量总和必须相等。这两条能抓住绝大部分字段更新遗漏。第三组是单调:合并K线的代表时间必须严格递增,endIdx 必须严格递增且覆盖全部原始下标。第四组是幂等:对合并结果再跑一次合并,输出应该完全不变——因为相邻已经不含包含关系,第二次跑应当原样返回。

这四组检查加起来不到一百行,但它拦下过我至少三次自以为没问题的改动。有一次是 close 字段跟错了K线,前两组全过、第三组也过,最后是靠第四组的幂等性发现的——第二次合并时 close 又跟了一次,值就变了。从那以后我每次改合并或者分型相关的代码,都强制走一遍这四组断言,不管改动多小。增量缓存省下的时间是给用户的,这几行断言省下的时间是给自己的。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询