☰
通达信缠论源码:K线包含处理与DLL插件落地实战
2026/11/5 3:09:29 网站建设 项目流程

简介:这是一套面向通达信用户与量化分析开发者的缠论K线包含处理DLL插件源码,重点解决相邻K线存在包含关系时的自动判断与合并问题,为后续识别笔、线段和中枢提供规范化的K线序列。资源包共24个文件、约693KB,以C++源文件与头文件为核心,配合动态链接库、通达信插件文件及Visual Studio工程与编译中间文件,既能直接查看插件成品,也保留了完整的VC++构建结构。已有5767人学习下载,说明其在通达信缠论插件开发方向具有一定参考价值。读者可从中了解K线包含条件的遍历判断、合并后序列更新,以及笔、线段、中枢构造的工程实现思路,并观察插件接口如何与通达信平台交互、计算函数集如何组织;对希望把缠论规则落地为可执行工具,或研究MFC/ATL编写交易插件的中高级开发者,是一份较完整的源码样本。

1. 先处理包含关系:缠论源码落地绕不开的第一道坎

主图一挂上,线段像被风吹过一样来回改,十有八九不是笔公式写错,而是包含处理这一步偷了懒。这份通达信缠论源码把K线包含处理单独做成插件,核心目标很明确:把原始行情里的包含K线按方向合并成无包含序列,再交给笔、线段、中枢去递归。它解决的是缠论落地时最底层、也最容易被跳过的一环——合并方向、高低点取值、连续包含回溯,任何一处处理错,后面的结构全是歪的。适合正在写通达信公式、准备接DLL插件、或者想把缠论信号做成可复现流程的从业者。新手可以照着后面的代码把最小闭环跑通,熟手重点看方向状态机和索引映射,这两处决定了插件能不能在实时行情里稳住。

2. 拆开主链路:从原始K线数组到无包含序列的三步状态机

2.1 数据入口与K线结构对齐

插件源码的第一层不是算法,而是数据对齐。通达信公式侧传进来的通常是 HIGH、LOW、OPEN、CLOSE、VOL 这几组序列,插件内部必须先把它们按同一索引压成结构体,否则后面做包含判断时拿错周期或者错位一根,合并结果就会整体偏移。常见做法是保留原始索引index,合并后的每根K线记录firstIndex和lastIndex,这样线段起点、笔端点才能回溯到原始K线。下面的结构体是插件里最基础的数据契约,字段不多,但每一个都影响后续排查。

struct RawBar { double open; double high; double low; double close; double volume; long long time; int index; // 原始K线在输入数组里的下标 }; struct MergedBar { double open; double high; double low; double close; double volume; long long time; int firstIndex; // 合并区间起始原始下标 int lastIndex; // 合并区间结束原始下标 int direction; // 1向上,-1向下,0未定 };

这里最容易被忽略的是firstIndex和lastIndex。只输出合并后的高低点,不输出索引区间,主图看起来正常,但一旦接笔或者线段,端点会落到合并K线的“最后一根”上,而不是合并区间的边界。血泪经验是:包含处理插件可以少输出字段,但索引映射不能省。time字段也要跟着合并区间走,回放或者跨周期校验时,时间错位比价格错位更难查。

2.2 包含关系判定与方向状态机

包含关系的判定本身不复杂:一根K线的高点不低于另一根、低点不高于另一根,两者就构成包含。真正难的是方向。缠论里向上处理时高点取高、低点取高;向下处理时高点取低、低点取低。方向不是由当前这根K线单独决定,而是由前一根非包含K线和当前K线的相对位置决定。插件源码里一般用一个状态机维护direction:新K线与最后一根合并K线不包含时,比较两者高低点更新方向;发生包含时,沿用最后一根合并K线的方向,合并后继续向前回溯,直到不再包含。

bool isContained(const MergedBar& a, const MergedBar& b) { return (a.high >= b.high && a.low <= b.low) || (b.high >= a.high && b.low <= a.low); } MergedBar toMerged(const RawBar& r, int dir) { MergedBar m; m.open = r.open; m.high = r.high; m.low = r.low; m.close = r.close; m.volume = r.volume; m.time = r.time; m.firstIndex = r.index; m.lastIndex = r.index; m.direction = dir; return m; } MergedBar mergeTwo(const MergedBar& a, const MergedBar& b, int dir) { MergedBar r = a; if (dir > 0) { r.high = std::max(a.high, b.high); r.low = std::max(a.low, b.low); } else { r.high = std::min(a.high, b.high); r.low = std::min(a.low, b.low); } r.close = b.close; r.volume += b.volume; r.time = b.time; r.lastIndex = b.lastIndex; r.direction = dir; return r; }

mergeTwo里没有直接改open,因为合并K线的开盘价在多数缠论实现里取区间第一根的开盘价,这样主图上的合并K线不会跳空得太突兀。volume做累加,方便后续看量能配合。direction在合并后保留传入方向,不能因为当前K线收阴就翻成向下,这是很多新手写反的地方。

2.3 合并公式的四种分支与连续包含回溯

把方向拆开看,合并只有四种分支:向上合并取“高取高、低取高”,向下合并取“高取低、低取低”。但实际代码里不能只写一次合并,因为合并后的新K线可能和再前一根继续包含。比如三根K线里,第一根包含第二根,合并后又被第三根包含,或者合并结果反过来包含前一根。插件主循环必须用while回溯,不能只做一次if。下面的processBars是整份源码的主链路,短,但每一步都对应一个边界。

std::vector<MergedBar> processBars(const std::vector<RawBar>& raw) { std::vector<MergedBar> out; if (raw.empty()) return out; out.push_back(toMerged(raw[0], 1)); // 首根默认向上,后面可配置 for (size_t i = 1; i < raw.size(); ++i) { MergedBar cur = toMerged(raw[i], 0); if (!isContained(out.back(), cur)) { if (cur.high > out.back().high && cur.low > out.back().low) { cur.direction = 1; } else if (cur.high < out.back().high && cur.low < out.back().low) { cur.direction = -1; } else { cur.direction = out.back().direction; } out.push_back(cur); continue; } int dir = out.back().direction; MergedBar last = out.back(); out.pop_back(); last = mergeTwo(last, cur, dir); while (!out.empty() && isContained(out.back(), last)) { MergedBar prev = out.back(); out.pop_back(); last = mergeTwo(prev, last, prev.direction); } out.push_back(last); } return out; }

逻辑说明:不包含时直接入列,并用当前K线与最后一根合并K线的高低点比较来定方向;包含时先取出最后一根,按它的方向合并当前K线,然后检查合并结果是否又和前一根包含,如果包含就继续取前一根合并,方向用前一根的方向。参数说明:raw是按时间正序排列的原始K线;返回值是无包含序列;direction为 1 表示向上合并,-1 表示向下合并。这里最容易翻车的是“合并后回溯”的循环条件,写成if只能处理两层包含,遇到三根以上连续包含会留下残余包含关系,主图上看不出来,但笔会多出一段。

3. 编译与挂接:把DLL塞进通达信公式的最小闭环

3.1 本地编译与导出接口

插件源码要落地,第一步不是挂公式,而是先编译出一个能被外部调用的 DLL。导出函数建议用extern "C"加__declspec(dllexport),避免 C++ 名字修饰导致公式编辑器找不到函数名。参数不要传结构体数组,直接传基础类型指针最稳:输入高低开收、成交量、时间、数量,输出合并后的各字段和索引区间。调用方按原始K线数量分配输出缓冲区,最坏情况一根都不合并,返回数量不会超过输入数量。

extern "C" __declspec(dllexport) int ProcessContains( const double* high, const double* low, const double* open, const double* close, const double* volume, const long long* time, int count, double* outHigh, double* outLow, double* outOpen, double* outClose, double* outVolume, long long* outTime, int* outFirstIndex, int* outLastIndex, int* outDirection, int outCapacity) { std::vector<RawBar> raw(count); for (int i = 0; i < count; ++i) { raw[i] = {open[i], high[i], low[i], close[i], volume[i], time[i], i}; } std::vector<MergedBar> merged = processBars(raw); int n = static_cast<int>(merged.size()); if (n > outCapacity) n = outCapacity; // 容量不足时截断,调用方负责给足 for (int i = 0; i < n; ++i) { outHigh[i] = merged[i].high; outLow[i] = merged[i].low; outOpen[i] = merged[i].open; outClose[i] = merged[i].close; outVolume[i] = merged[i].volume; outTime[i] = merged[i].time; outFirstIndex[i] = merged[i].firstIndex; outLastIndex[i] = merged[i].lastIndex; outDirection[i] = merged[i].direction; } return n; }

参数说明:count是输入K线数量,outCapacity是输出缓冲区能容纳的K线数量,建议直接按count分配,因为最坏情况就是不合并;返回值是实际写入的合并K线数量。编译时注意客户端位数,DLL 位数和通达信客户端必须一致,32 位客户端配 32 位 DLL,64 位同理。运行库建议静态链接,避免目标机器缺运行库导致加载失败。下面这条命令用于检查导出表,确认函数名没有被修饰。

dumpbin /exports your_plugin.dll

逻辑说明:在开发者命令行里执行,看输出里是否有ProcessContains这个名字。如果看到的是?ProcessContains@@...这类修饰名,说明extern "C"没生效或者头文件里声明方式不一致。参数说明:/exports只看导出表,不执行 DLL;your_plugin.dll换成实际编译产物。这个检查比直接挂公式快,能提前排除一半“加载后无输出”的问题。

3.2 通达信侧挂接与参数约定

公式侧挂接的核心是“参数顺序不能错”。插件导出函数有几个输入数组,公式里就要按同样顺序传 HIGH、LOW、OPEN、CLOSE、VOL 和数量。不同客户端版本的外部函数声明格式有差异,按实际公式编辑器的要求填写,函数名用源码导出名。下面这张表是挂接时最该对齐的字段,先对表再动手,比反复改公式省时间。

插件参数公式侧传入说明
high / lowHIGH / LOW原始K线高低点,必须同周期
open / closeOPEN / CLOSE合并K线开盘收盘取区间首尾
volumeVOL合并时累加
count有效K线数量不要传全历史,先截窗口
outFirstIndex输出数组回溯原始K线下标
outDirection输出数组1向上,-1向下

挂接时先只接outHigh和outLow两条线,确认主图能画出合并后的高低点,再接索引和方向。不要一上来就把笔、线段、中枢全接上,出问题时根本分不清是包含处理错还是上层递归错。常见做法是:公式里先限制一个固定历史窗口,比如只算最近 500 根K线,等输出稳定后再放大窗口。

3.3 用构造K线做回归验证

插件编译通过不代表算法对。最省事的验证方式是用 Python 复刻同一套合并逻辑,构造几组包含链,把 C++ 输出和 Python 输出逐根对比。下面这段代码可以直接跑,覆盖“向上合并”“向下合并”“连续包含”三种情况。

def is_contained(a, b): return (a['h'] >= b['h'] and a['l'] <= b['l']) or \ (b['h'] >= a['h'] and b['l'] <= a['l']) def merge_two(a, b, direction): r = dict(a) if direction > 0: r['h'] = max(a['h'], b['h']) r['l'] = max(a['l'], b['l']) else: r['h'] = min(a['h'], b['h']) r['l'] = min(a['l'], b['l']) r['c'] = b['c'] r['v'] = a['v'] + b['v'] r['last'] = b['last'] r['dir'] = direction return r def process(bars): if not bars: return [] out = [dict(bars[0], first=0, last=0, dir=1)] for i, raw in enumerate(bars[1:], start=1): cur = dict(raw, first=i, last=i, dir=0) if not is_contained(out[-1], cur): if cur['h'] > out[-1]['h'] and cur['l'] > out[-1]['l']: cur['dir'] = 1 elif cur['h'] < out[-1]['h'] and cur['l'] < out[-1]['l']: cur['dir'] = -1 else: cur['dir'] = out[-1]['dir'] out.append(cur) continue direction = out[-1]['dir'] last = out.pop() last = merge_two(last, cur, direction) while out and is_contained(out[-1], last): prev = out.pop() last = merge_two(prev, last, prev['dir']) out.append(last) return out bars = [ {'o': 10, 'h': 12, 'l': 9, 'c': 11, 'v': 100}, {'o': 11, 'h': 11.5, 'l': 9.5, 'c': 10.5, 'v': 120}, {'o': 10.5, 'h': 13, 'l': 10, 'c': 12.5, 'v': 90}, ] for x in process(bars): print(x)

逻辑说明:第一根和第二根构成包含,方向默认向上,合并后高点取 12、低点取 9.5;第三根高点 13、低点 10,与合并K线不包含,直接入列。期望输出两根合并K线。参数说明:first和last是原始索引区间,dir是合并方向。把这段输出和 DLL 输出逐根比对,重点看h、l、first、last四个字段,任何一个不一致,先查方向初始化,再查相等判定容差。

4. 参数与边界:方向初始化、相等容差和索引映射怎么定

4.1 合并方向如何初始化

方向初始化看起来只是第一根K线的事,实际会影响前几根合并结果,尤其是历史数据开头有停牌、跳空或者集合竞价的时候。常见做法有三种:默认向上、默认向下、按前两根非包含K线推断。插件源码里一般把默认方向做成配置项,因为不同品种、不同周期的开头形态差别很大。默认向上适合上涨趋势明显的品种,默认向下适合下跌趋势,但更稳的做法是先用前两根K线比较高低点,推不出方向时再落回默认值。

struct ContainConfig { int initDirection = 1; // 1向上,-1向下 double eps = 1e-8; // 高低点相等判定容差 bool mergeVolume = true; bool keepFirstOpen = true; }; int inferInitialDirection(const RawBar& a, const RawBar& b, int fallback) { if (b.high > a.high && b.low > a.low) return 1; if (b.high < a.high && b.low < a.low) return -1; return fallback; }

参数说明:initDirection只在无法从数据推断时使用;eps用于浮点比较,避免12.000000001和12.0被当成不相等;mergeVolume控制成交量是否累加;keepFirstOpen控制合并K线开盘价取区间第一根还是最后一根。方向初始化错了,最明显的现象是历史开头几根合并K线和手工画的不一致,越往后影响越小,但如果你做的是短线,开头几根可能就是信号起点。

4.2 高低点取值规则与“相等”处理

向上合并时高点取高、低点取高,向下合并时高点取低、低点取低,这条规则不能混。真正容易出错的是“相等”算不算包含。比如两根K线高点相同、低点也相同,按>=和<=会判成包含,合并后高低点不变,但索引区间会拉长。如果业务上不希望把完全相等的K线合并,可以把判定改成严格大于和严格小于,但这样又会漏掉一部分实际包含关系。我的建议是:默认按非严格比较,保留eps容差,同时在输出里保留firstIndex和lastIndex,让上层自己决定要不要把相等K线当成独立结构。

场景高点取值低点取值注意
向上合并max(高1, 高2)max(低1, 低2)低点不能取低
向下合并min(高1, 高2)min(低1, 低2)高点不能取高
高低点相等不变不变索引区间仍要合并
成交量累加累加不累加会丢量能
时间取区间末根取区间末根回放时注意对齐

这张表可以直接当代码审查清单用。每次改合并函数,先对着表把四个分支走一遍,比在主图上反复猜快得多。

4.3 递归层级与线段起点

包含处理只是第一层,笔和线段还要在合并K线基础上继续递归。插件输出如果只给合并后的高低点,不给原始索引区间,上层递归就没法回溯到原始K线,线段起点会落在合并K线的“最后一根”上,看起来总是差一根。正确做法是每根合并K线都带firstIndex和lastIndex,上层取端点时用firstIndex或lastIndex去原始数组里定位。下面这个映射结构建议直接放进输出契约。

struct MergeSpan { int mergedIndex; // 合并后下标 int firstRawIndex; // 原始区间起点 int lastRawIndex; // 原始区间终点 int direction; // 合并方向 }; std::vector<MergeSpan> buildSpan(const std::vector<MergedBar>& merged) { std::vector<MergeSpan> spans; spans.reserve(merged.size()); for (int i = 0; i < static_cast<int>(merged.size()); ++i) { spans.push_back({i, merged[i].firstIndex, merged[i].lastIndex, merged[i].direction}); } return spans; }

参数说明:mergedIndex是合并序列下标,firstRawIndex和lastRawIndex是闭区间。递归层级不要设太深,包含处理之后一般只做笔和线段两层,中枢再往上就要考虑性能。实时行情里最后一根K线会反复更新,插件最好只重算最后 N 根,前面的合并结果缓存起来,否则每来一个 tick 都从历史第一根重算,主图会卡,结果也会因为浮点累加出现细微漂移。

5. 避坑与排查:包含处理最常见的五类翻车现场

5.1 合并后K线数量对不上

现象:主图合并后的K线数量比手工数的少很多,或者某些明显包含的K线没有被合并。原因通常有三个:包含判定用了开盘收盘而不是高低点;相等判定容差太小,浮点误差导致该合并的没合并;连续包含只做了一次if,没有回溯。解决方式是把每次合并前后的firstIndex、lastIndex、high、low打进日志,找第一根数量偏离的位置。如果日志里出现“合并后索引区间没有覆盖当前K线”,说明lastIndex更新漏了;如果出现“方向为 0”,说明方向状态机在某个分支没有赋值。

5.2 上涨趋势里出现向下合并

现象:一段明显上涨的行情里,合并K线突然按向下规则取低点,导致低点被压低,笔的端点跟着变形。原因多半是方向状态机被单根反向K线重置了。正确逻辑是:只有新K线与最后一根合并K线不包含时,才根据两者高低点更新方向;发生包含时,必须沿用最后一根合并K线的方向,不能拿当前K线的阴阳或者涨跌去翻方向。排查时在mergeTwo入口打印dir,看包含分支传进来的方向是不是被上层改成了 0 或者相反值。

5.3 插件加载后主图无输出

现象:公式挂上去,主图没有任何线,客户端也不报错。原因通常是 DLL 位数不匹配、导出函数名被修饰、调用约定不一致,或者数组越界导致客户端直接丢弃返回值。解决顺序:先用dumpbin /exports确认导出名;再用一个只返回固定值的空函数测试挂接链路;最后检查输入数组长度和输出缓冲区容量。如果空函数能出线,说明挂接没问题,问题在算法内部越界或者返回数量为 0。注意不要在主图公式里直接传全历史数据,先截 300 到 500 根,排除容量和性能干扰。

5.4 最后一根K线反复重算

现象:历史合并结果稳定,但最后一根K线随着实时行情跳动,合并K线的高低价和索引区间来回变,甚至把前一根已经合并好的K线又拆开。原因是插件每次都用全量数据从零计算,实时行情更新时最后一根K线的高低点变化,导致包含关系重新判定。解决方式是把合并状态缓存下来,只对最后 N 根做增量重算,并且给最后一根K线设一个“稳定阈值”,比如高低点变化小于eps时不触发重合并。如果做回放,固定历史窗口,不要用实时窗口去回测。

5.5 跨周期数据错位

现象:日线合并正常,切到 30 分钟或者 5 分钟,合并K线的索引区间和原始K线对不上,笔的端点整体偏移。原因通常是时间戳没有对齐,或者复权处理不一致,停牌和集合竞价K线被当成普通K线参与包含。解决方式是按日期时间对齐输入数组,先统一复权方式,再过滤无效K线。跨周期校验时不要只看价格,重点看firstIndex和lastIndex是否落在同一交易时段。下面这条排查命令用于确认 DLL 是否被正确加载,配合日志一起看。

tasklist /m your_plugin.dll

逻辑说明:列出加载了指定 DLL 的进程。如果通达信进程在列表里,说明 DLL 已经被加载;如果不在,先查路径和位数。参数说明:/m后面跟 DLL 文件名,不是完整路径。这个命令只能确认加载,不能确认函数是否被调用,函数调用情况还要靠插件内部日志或者公式输出。

6. 反向重构验证:用索引区间反查第一次合并偏离

正向跑通之后,我一般会再做一次反向验证:拿合并后的每根K线,回原始数组里取它覆盖的[firstIndex, lastIndex]区间,检查合并K线的高点是否不低于区间内所有原始K线的高点,低点是否不高于区间内所有原始K线的低点。这个检查不依赖方向,也不依赖笔和线段,能直接暴露合并函数把高低点取反、索引区间漏更新、连续包含回溯不完整的问题。

def assert_merge_invariants(raw, merged, eps=1e-9): for m in merged: seg = raw[m['first']:m['last'] + 1] assert seg, "索引区间为空" assert m['h'] >= max(x['h'] for x in seg) - eps, "高点没有覆盖区间" assert m['l'] <= min(x['l'] for x in seg) + eps, "低点没有覆盖区间" assert m['first'] <= m['last'], "索引区间倒置" print("merge invariants ok")

这段代码的参数只有raw、merged和容差eps。raw是原始K线列表,merged是插件输出,字段名按你实际导出的名字改。跑不过的时候,把失败的那根合并K线的区间打印出来,再和原始K线逐根对照,通常第一次偏离就出现在连续包含的第三根或者第四根上。我的习惯是每次接新行情源、改合并方向、换周期,都先把这段反向检查跑一遍,再去看笔和线段。从那以后我每次接新行情源,都先把包含处理回归跑一遍,再谈笔和线段。希望帮到你。

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

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

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

立即咨询