简介:通达信缠论K线包含处理插件源码面向通达信插件开发者与缠论量化分析学习者,核心解决相邻K线包含关系的自动判断与合并问题,为后续笔、线段和中枢构建提供规范化K线序列。缠论包含处理是笔、线段与中枢识别的基础环节。压缩包共24个文件,约693KB,以头文件与cpp源码、dll动态库、plg插件、dsp/dsw工程文件及lib/obj/pdb等编译调试中间文件为主,既可参考成品验证效果,也能按模块研读实现。已有5767人学习,说明其在通达信缠论插件方向具有一定参考价值。源码覆盖K线数据读取、包含判断、合并更新及结果回显等环节,并展示插件接口与计算函数集的对接方式;读者可借此理解VC++环境下缠论包含处理的工程组织、MFC或ATL插件编写、调试思路和通达信插件开发流程,适合作为缠论指标插件开发与源码剖析的实践样本。
1. 一段被错误合并的K线打乱的一笔
先说个具体的。某开发者把一套缠论画笔指标挂到通达信上,日线看着还挺像回事,切到 5 分钟就全乱了——笔连成一串,分型一个接一个,本该一笔的地方冒出三笔。他以为是递归逻辑写错,折腾两天最后发现真正的问题在最前面:K线包含关系根本没有合并,所有分型和笔都建立在带噪声的原始K线上。
这份缠论K线包含处理插件源码要解决的,就是这件最底层、也最容易被跳过的事:在通达信环境里按缠论规则把原始K线做包含合并,输出合并后的高低点序列,为分型、笔、线段打好基础。它适合两类人:写过通达信指标公式、想在公式层做递归却做不下去的;以及想用 C++ 写 DLL 插件扩展公式能力、缺一个完整可编译样例的。算法本身不复杂,难的是状态机怎样处理前复权、停牌、新股这些不规整的数据时不出岔子。
2. 架构选型:为什么包含处理不能纯公式写
2.1 公式引擎只处理序列,不保留可变状态
通达信公式语言的执行模型是“对整条序列逐根求值”,REF(X,1)只能回看固定根数,HHV、LLV这类函数也是整段滑动窗口。这种模型写均线、MACD 很舒服,写包含处理就会遇到根本问题。
包含合并的本质是:合并后的第 n 根,未必对应原始的第 n 根。两个原始K线合成一根之后,后面的索引整体前移,你没法在公式里维护一个“当前处理到哪根原始K线、输出了几根合并K线”的游标。更麻烦的是合并方向要依赖“上一段无包含区间的方向”,这是个需要跨多根回溯的可变状态,公式里的变量每根都重新求值,存不住。
所以常见做法是拆成两层:C++ 那一层负责跑真正的顺序状态机,公式层只负责取数据和画图。这不是为了炫技,是执行模型决定的。我在别的指标上也踩过同样的坑——凡是“结果长度和输入长度不一致”的逻辑,只要硬塞进公式层,最后都会变成一堆REF套REF的玄学表达式,改一次错一次。
2.2 源码目录与各文件职责
拿到包之后先看结构,不用急着编译。这类插件工程的目录组织有很强的惯例性,职责边界清楚,照着认一遍基本就能判断改动该落在哪:
| 路径 | 职责 | 改动频率 |
|---|---|---|
src/ChanKline.cpp | 包含合并状态机、分型识别 | 高,算法主体 |
src/TdxExport.cpp | 导出函数、注册表、入参校验 | 低,接口固定 |
include/ChanKline.h | 合并K线结构体、函数声明 | 中,加字段时改 |
include/TdxFunc.h | DLL 导出约定、注册结构定义 | 极低,跟随终端版本 |
formula/chan_merge.txt | 指标公式,调用 DLL 并绘图 | 中,调参时改 |
build/build.bat | 编译脚本,指定位数和输出目录 | 低 |
test/sample.csv | 固定样本,做回归验证 | 中,加案例时改 |
ChanKline.cpp和TdxExport.cpp分开是有意为之。算法部分不引入任何终端相关的头文件,纯 C++,能单独抽出来单元测试;导出层只做三件事——校验指针和长度、调算法、把结果按输入长度回填。这样你换一套接口(比如换成别的行情软件的函数签名),算法代码一行都不用动。
2.3 一条数据从行情到屏幕的完整链路
把链路走一遍,后面看代码就有位置感了。终端加载指标公式,公式里的TDXDLL1(1, HIGH, LOW)触发一次 DLL 调用,终端把当前图表的 HIGH、LOW 序列以float数组传进来,同时告诉你长度nDataLen和一个预分配的输出数组pfOUT。DLL 在内部完成合并,然后必须往pfOUT里写满nDataLen个值——注意是写满,不能少写,少写的位置会保留上一帧的残留数据,图表上就会出现一段莫名其妙的横线。
输出对齐是这套架构里最关键的设计点。合并后的K线数量少于原始K线,但终端的绘图缓冲区长度是固定的。解决办法是在合并结构体里存一个srcIndex,记录这根合并K线是由原始第几根生成的,输出时按srcIndex把每一根原始K线映射到它所属的合并K线:
原始K线序号 0 1 2 3 4 → 合并后 0 0 0 1 1
这样第 3、4 根原始K线都输出合并K线 1 的高点,画出来的图就是阶梯状的,肉眼能直接看到哪几根被并掉了。这个映射表还能反过来用:想画原始K线做对比时,按同一套索引回查即可。
由于一个导出函数只能输出一组float序列,合并后的高点、低点、方向标记需要拆成多个函数注册,用序号区分。这套做法在通达信 DLL 插件里很常见,代价是公式层要写多行调用,好处是每路输出都能单独拿去参与别的计算。
3. 核心算法拆解:包含判定、方向合并与分型
3.1 包含关系的判定条件与两个边界
包含关系的数学定义很干脆:两根K线,一根的高点不高于另一根、低点不低于另一根,就是包含。写成代码是一行:
// 前包含后,或后包含前,两种情况都算包含关系 bool bContain = (h <= prev.high && l >= prev.low) || (h >= prev.high && l <= prev.low);看着简单,实际实现时有三个边界要处理清楚。
第一是相等。高点相等但低点不同,严格说不构成包含,因为区间没有被完全覆盖。但如果用<=和>=,高点相等就会被判成包含,把一根正常的K线并掉。缠论里对相等的处理有争议,多数实现选择“相等也算包含”,理由是行情软件复权后经常出现小数位相同的K线,不并会留下大量伪分型。这份源码采用的是算包含,代码里那两行<=、>=就是为这个开的口子。要改成严格不等,把等号去掉即可,但后面的分型结果会明显变多。
第二是第一根K线。第一根没有前一根可比,不能判包含,直接入栈,方向标记置 0 表示未定。这个 0 很关键,它会影响第一次遇到包含时怎么合并。
第三是长度。nLen为 0、指针为空、nLen超过内部数组容量,这三个都得在进入循环前挡掉。后面排查章节会讲为什么这个检查不能省。
3.2 合并方向:一个必须回看的变量
合并的规则是“向上取高高,向下取低低”。向上合并时新高取两根里更高的高点,新低取更高的低点;向下合并反过来,新高取更低的、新低取更低的。判断“向上还是向下”,看的是最近一段没有包含关系的走势方向,而不是当前这两根谁高谁低。
这个变量必须在循环外声明、循环内更新:
int nDir = 0; // 1 向上,-1 向下,0 未定 for (int i = 1; i < nLen; i++) { float h = pHigh[i]; float l = pLow[i]; MergedBar& prev = pOut[nOut - 1]; bool bContain = (h <= prev.high && l >= prev.low) || (h >= prev.high && l <= prev.low); if (!bContain) { // 无包含:正常入栈,同时用高低点关系刷新方向 if (h > prev.high && l > prev.low) nDir = 1; else if (h < prev.high && l < prev.low) nDir = -1; pOut[nOut].high = h; pOut[nOut].low = l; pOut[nOut].srcIndex = i; pOut[nOut].dir = nDir; nOut++; } else { // 有包含:按已有方向合并,nOut 不推进,srcIndex 保持不变 if (nDir >= 0) // 向上或方向未定,按向上处理 { if (h > prev.high) prev.high = h; if (l > prev.low) prev.low = l; } else // 向下 { if (h < prev.high) prev.high = h; if (l < prev.low) prev.low = l; } prev.dir = nDir; } }nDir >= 0这里把“方向未定”也划到向上分支,是个权衡。理论上方向未定时应该保留两种可能,但那样会让状态机变成二叉树,复杂度翻几倍。实践中第一根之后立刻遇到包含的概率很低,即便遇到,按向上合并也只是让开头的形态偏强一点,不影响后续笔的成立。我一般会在这里加一行日志,跑样本时统计一下方向未定就合并的次数,如果占比超过 5%,说明数据起点选得不好,往前多取 100 根K线一般能解决。
还有一点容易忽略:合并之后不需要回头再检查一遍。因为合并是沿着一个固定方向收敛的,新生成的区间一定包含于原区间,不会和被再前一根重新构成包含。有人为了保险加了回退重判,结果是在窄幅震荡区间里来回合并,输出的合并K线数量比预期少一半。
3.3 从合并K线到分型:三条硬条件
分型建立在合并K线之上,判定要用合并后的高低点,这是所有后续结果的前提。顶分型的条件是中间那根高点最高、低点也最高;底分型反过来,高点最低、低点也最低。只判高点不判低点是常见的错误写法,会把一些区间重叠的K线组合误判成分型。
// 顶分型:中间K线的高点、低点同时高于左右两根 bool IsTopFractal(const MergedBar* b, int i) { return b[i].high > b[i-1].high && b[i].high > b[i+1].high && b[i].low > b[i-1].low && b[i].low > b[i+1].low; } // 底分型:中间K线的高点、低点同时低于左右两根 bool IsBottomFractal(const MergedBar* b, int i) { return b[i].high < b[i-1].high && b[i].high < b[i+1].high && b[i].low < b[i-1].low && b[i].low < b[i+1].low; }注意这里的下标用的是合并后的序号,不是原始序号。要在图上画线,得用srcIndex换算回原始位置,否则线条会整体偏移几根——偏移量随包含合并的数量累积,越往后越明显,看起来像是“指标算错了”。这个换算是把分型结果输出给公式层时最容易出问题的地方。
分型出来之后,笔的成立还要加两个约束:两个相邻的顶底分型之间至少隔一根独立K线;顶分型的高点必须高于底分型的低点,否则这一笔不成立,需要继续往后找。这两条在源码里是独立函数,按顺序过滤,不做嵌套判断,出问题时容易定位到是哪条卡住的。
4. 接口对接:导出函数与公式层的约定
4.1 导出函数的统一签名
DLL 那一侧的接口形态是终端定死的,改不了。常见的形式是四个输入序列加一个输出序列,返回值表示成功与否:
#include "TdxFunc.h" // 统一签名:长度 + 输出数组 + 输入序列 // 不同终端版本参数个数可能不同,以随包的 TdxFunc.h 为准 int WINAPI CalcMergedHigh(int nDataLen, float* pfOUT, float* pfIN1, float* pfIN2, float* pfIN3) { if (nDataLen <= 0 || nDataLen > MAX_BARS) return 0; if (pfOUT == NULL || pfIN1 == NULL || pfIN2 == NULL) return 0; // 静态缓冲,避免每帧 new,图表切换时高频调用 static MergedBar s_bars[MAX_BARS]; int n = MergeKline(pfIN1, pfIN2, nDataLen, s_bars); if (n <= 0) return 0; // 关键:输出长度必须与输入一致,按 srcIndex 回填 int k = 0; for (int i = 0; i < nDataLen; i++) { while (k + 1 < n && s_bars[k + 1].srcIndex <= i) k++; pfOUT[i] = s_bars[k].high; } return 1; }static缓冲这行值得单独说。图表刷新、周期切换、指标叠加,都会触发重新计算,如果每次进来都new一片几百 K 的数组,长时间挂着终端会看到内存曲线一路往上。静态缓冲的代价是不可重入,多线程同时调用会打架,但终端调用是串行的,这个约束可以接受。真要上多线程,改成线程局部存储。
回填那段的while循环是这套代码的核心技巧。s_bars[k+1].srcIndex <= i判断当前原始K线是否已经跨过了下一根合并K线的起点,跨了就前进。k+1 < n是防越界,最后一根合并K线会被所有剩余的原始K线共享。
4.2 注册表:序号就是公式层的调用凭据
注册函数的写法各家实现差异比较大,但结构一致:把函数名、参数个数、函数地址塞进一个数组,序号从 1 开始,与公式里的第一个参数一一对应。
void RegisterTdxFunc(PDLLFUNCARRAY pArray) { // 序号 1 对应公式里的 TDXDLL1(1, HIGH, LOW) pArray->Add("CHAN_MH", 2, (PVOID)CalcMergedHigh); pArray->Add("CHAN_ML", 2, (PVOID)CalcMergedLow); pArray->Add("CHAN_DIR", 2, (PVOID)CalcMergeDir); pArray->Add("CHAN_FX", 2, (PVOID)CalcFractal); }参数量这个字段要跟实际签名对上,写 2 表示公式层传两个序列进来。写多了终端会多传空指针,写少了后面两个指针是野值,两种都会崩,而且崩在终端进程里,看不到堆栈日志,只能靠猜。这是 DLL 插件最难受的一点。
四个函数分别输出合并高点、合并低点、方向标记、分型类型(1 顶、-1 底、0 无)。拆成四个是为了让公式层能自由组合,比如只想看方向就不必算分型。
4.3 公式层:调用、绘图与参数
公式层不干重活,只做三件事:调 DLL、画K线、把分型标出来。
{缠论包含处理:合并后高低点 + 分型标记} MH := TDXDLL1(1, HIGH, LOW); ML := TDXDLL1(2, HIGH, LOW); DIR := TDXDLL1(3, HIGH, LOW); FX := TDXDLL1(4, HIGH, LOW); {用合并后的高低点重画K线,阶梯状代表被合并} STICKLINE(MH >= ML, MH, ML, 8, 0), COLORYELLOW; {顶分型画绿箭头,底分型画红箭头} DRAWICON(FX = 1, MH * 1.002, 2); DRAWICON(FX = -1, ML * 0.998, 1);DRAWICON的第二个参数是位置,乘个 1.002 是为了让箭头浮在K线上方,不遮挡。这个系数按品种价格量级调,几十块的股票用 1.002 合适,上千的就得改小,不然箭头飘太远。
三行TDXDLL1调用的第一个参数是注册序号,第二个和第三个是输入序列。千万别把HIGH、LOW的顺序写反,反了之后包含判定会用高点和低点交叉比较,结果是一堆看起来很像但完全不对的合并K线,而且不会报错,只能靠肉眼比对发现。
公式文件保存成.txt后由终端导入,或者直接放进指标目录。DLL 文件的放置路径要跟终端版本对上,放错位置的表现是公式保存成功、加载后线全是 0,不报错。这个坑后面单独讲。
5. 避坑排查:五条踩过的坑
5.1 崩溃与数值异常
现象:指标加载后公式编辑器提示函数不存在,或者整条线输出全 0,终端没有任何报错。原因:位数不匹配或路径不对。64 位终端加载 32 位 DLL 会静默失败,DLL 放在安装目录根下而不是约定的插件目录同样如此。解决:先确认终端进程位数,编译时对齐;DLL 放到插件目录后完全退出终端再启动,不要只关窗口。验证办法是注册一个只返回常数的测试函数,能画出直线说明加载成功,再去查算法。
现象:当前屏幕正常,往前翻历史K线,翻到某一页终端直接退出。原因:内部静态数组越界。历史数据往前翻时nDataLen会突然变大,超过MAX_BARS,而入口检查只判了<= 0。解决:入口加上限判断并直接返回 0,宁可这一帧不画也不能崩。MAX_BARS按终端能提供的最大K线数留 30% 余量,我一般设 20000。
现象:合并后的价格出现 0.01 级别的偏差,日线上看不出来,分钟线上累积得很明显。原因:全程用float运算,合并时反复取大取小,误差累积。价格本身的有效位数不多,但复权因子带小数,乘完之后float的尾数就不够用了。解决:内部结构和计算全用double,只在写pfOUT的最后一步转float。改动量不大,但能消掉大部分精度类怪现象。
5.2 图形与信号类问题
现象:一笔画出来横跨了停牌区间,复牌后的第一根K线被并进了停牌前那根里。原因:停牌期间终端返回的K线高低点重复,连续几十根完全相同,包含判定会把整段压成一根,复牌跳空又被这根吞掉。解决:在合并前做切段:检测到连续 N 根高低点完全相同,或者日期跳变超过阈值,就把状态机重置,方向标记归 0,从下一根重新开始。N 取 3 到 5 都行,太小会误切一字板。
现象:5 分钟周期上笔特别多,日线上看着挺干净,同一只票两个周期对不上。原因:包含处理对噪声天然敏感,周期越小,无意义的包含越多,合并后的形态和高级别本来就不是缩放关系。解决:别在 1 分钟、5 分钟上直接画笔。常见做法是在日线或 30 分钟上定笔,低周期只用来找买卖点位置。指标参数里可以加一个“最小合并K线数”开关,小于这个数直接不输出分型,能过滤掉一大半碎笔。
6. 二次开发:把包含处理接进自己的笔段体系
改这套代码,第一个该动的是方向初始化的策略,不是算法主体。nDir = 0那段把“未定”归到向上,是个能用但不精确的取舍。如果你做的品种趋势性强,可以改成用前 5 根K线的收盘价斜率定初始方向;震荡品种则相反,宁可让第一段多合并几次。改这里只影响开头几十根,回测时对比一下分型数量的变化就能判断好坏。
第二个值得加的是合并映射表输出。现在只输出合并后的高低点,公式层拿不到“哪几根原始K线被并了”。加一个导出函数,输出每根原始K线所属的合并序号,公式层就能画出原始K线和合并K线的对照,调参时特别直观:
// 输出合并序号映射:pfOUT[i] = 原始第 i 根所属的合并K线序号 int WINAPI CalcMergeMap(int nDataLen, float* pfOUT, float* pfIN1, float* pfIN2, float* pfIN3) { if (nDataLen <= 0 || nDataLen > MAX_BARS) return 0; static MergedBar s_bars[MAX_BARS]; int n = MergeKline(pfIN1, pfIN2, nDataLen, s_bars); if (n <= 0) return 0; int k = 0; for (int i = 0; i < nDataLen; i++) { while (k + 1 < n && s_bars[k + 1].srcIndex <= i) k++; pfOUT[i] = (float)k; // 序号直接用来做颜色分组 } return 1; }验证方法比改代码更重要。test/sample.csv里放几段手工算过的样本最有用:一段单边上涨、一段窄幅震荡、一段停牌跳空、一段新股上市头 60 根。每次改完算法跑一遍,把合并K线数量和分型位置跟预期对一遍。手工算不需要算全程,每个样本掐头去尾取 30 根就够,重点是确认边界行为没变。没有这套回归样本,改一个<=变成<,表面上只是分型多了几个,实际可能把整段笔的划分全推翻了,而你完全不知道是哪次改动引入的。
最后提一句口径问题:这套东西输出的只是结构,不是买卖信号。顶底分型画出来之后怎么用,取决于你自己的过滤条件,别把它当成现成的交易系统。
我早几年改这类指标,习惯直接上终端看图,觉得对就行。后来有一次把一个float改成double,图上完全看不出差别,但笔的数量少了 7%,而我是一周后翻旧截图才发现的。从那以后我每次动算法,都强制先跑一遍样本回归,看完数字再看图。希望帮到你。
本文还有配套的精品资源,点击获取