一次调用18个模型一次输出信号,DLL批量计算的设计与实践
一次调用18个模型一次输出信号,DLL批量计算的设计与实践
当18次重复计算变成1次共享,DLL性能优化的真实落地
「量化实战手记」— 记录从想法到落地的真实开发历程
引言:一张图看懂所有信号
想象一下:通达信主图上,所有 18 个选股模型的买卖信号一次性标注在K线上,每个信号值里自带模型编号——看到 5107 就知道是 S0005 的买点,看到 -1403 就知道是 S0014 的卖点。一张图,所有模型的信号一目了然。
这就是批量计算要实现的效果。在这之前,fqcopilot 的 18 个选股模型(S0000 ~ S0017)各自独立计算,每次都要从头做一轮完整的缠论分析——识别笔、线段、中枢,再算 MA5、MACD、ATR。18 个模型做的是同一套缠论分析,但每个都从头算一遍。
这不是算法问题,是架构问题。解法很清晰:把缠论分析的结果提取出来,让 18 个模型共享。一次分析,全部信号输出。这一次的升级更新,解决的就是这个问题。
第一章:问题分析
先看原来的计算流程。每个模型的 Calculator 类内部有个 initialize() 方法,负责做缠论分析和指标计算:
缠论分析的输入是 K 线的 OHLCV 数据和参数选项。只要输入相同,recognise_swing、recognise_bi、recognise_duan、recognise_trend 这些函数的输出就完全一致。MA5、MACD、ATR 这些指标同理。
这意味着 18 次计算中有 17 次是纯浪费。对于一根日 K 线有几千根的标的,这种冗余在实盘中会拖慢信号刷新速度。
原则:当多个消费者处理相同数据时,先识别不变量,再设计共享机制。冗余计算的本质是架构缺失。
第二章:方案设计
核心思路:把缠论分析的结果提取成一个独立的数据包,所有模型共享这个数据包。
有两种实现方式。第一种是让每个模型直接接收外部计算好的数据,第二种是在内部建一个批量计算器,一次做完所有分析再分发给各模型。
选择了内部批量计算器的方案。原因是不破坏原有接口——单个模型的导出函数(Func3、SXXXX、FQ_CLXS)完全不动,只在旁边加新的批量导出函数。
具体设计分三层:
第一层:ChanContext 结构体,把缠论分析的所有输出打包在一起。
第二层:BatchCalculator 类,构造时做一次缠论分析填充 ChanContext,然后创建 18 个 Calculator 共享这份数据。
第三层:各平台导出函数,消费 BatchCalculator 的结果,按平台特性输出。
原则:向后兼容不是给旧代码加补丁,而是在旁边铺新路。老路不动,新路更快。
第三章:核心实现
ChanContext:缠论分析的快照
从 BaseCalculator::initialize() 提取共享数据,定义一个纯粹的数据结构:
struct ChanContext {
std::vector<float> swing_sigs; // 分型信号
std::vector<float> wave_sigs; // 笔信号
std::vector<float> stretch_sigs; // 线段信号
std::vector<float> trend_sigs; // 趋势信号
std::vector<StdBar> std_bars; // 标准化K线
std::vector<float> ma5; // MA5
std::vector<float> dif, dea, macd; // MACD
std::vector<float> atrs; // ATR
int length = 0;
};
这个结构体没有行为,只有数据。它的职责很清晰:一次缠论分析的完整快照。18 个模型拿到同一份快照,各自做信号判断。
BatchCalculator:一次分析,分发所有
构造函数复刻了原来 initialize() 的逻辑,但把结果存到 ctx 而不是 Calculator 自己的成员变量里:
BatchCalculator::BatchCalculator(
const vector<float> &high, ..., const ChanOptions &options)
{
int length = static_cast<int>(high.size());
ctx.length = length;
// recognise_* 接受非 const 引用,需要局部可变拷贝
vector<float> h(high), l(low), c(close);
ChanOptions mut_options(options);
ctx.swing_sigs = recognise_swing(length, h, l);
ctx.wave_sigs = recognise_bi(length, h, l, mut_options);
ctx.stretch_sigs = recognise_duan(length, ctx.wave_sigs, h, l);
ctx.trend_sigs = recognise_trend(length, ctx.stretch_sigs, h, l);
ctx.std_bars = recognise_std_bars(length, h, l);
ctx.ma5 = MA(c, 5);
tie(ctx.dif, ctx.dea, ctx.macd) = MACD(c, 12, 26, 9);
ctx.atrs = ATR(h, l, c, 20);
}
注意一个小细节:recognise_swing、recognise_bi 等函数接受非 const 引用(vector<float>&),但构造函数接收的是 const 引用。所以需要创建局部可变拷贝 h、l、c。这不是多余,是类型系统的保护——调用方的数据不该被内部函数意外修改。
双构造函数:新老并存
每个 S00XX_Calculator 增加一个新的构造函数,接收 ChanContext:
// 原有构造函数 —— 单模型调用,自己算缠论
S0005_Calculator(high, low, open, close, vol, switch_opt, options);
// 新增构造函数 —— 批量调用,复用共享数据
S0005_Calculator(high, low, open, close, vol, switch_opt, options, ctx);
原有的 calculate() 逻辑完全不动。新构造函数只是换了个数据来源。
导出函数:各平台的输出适配
不同平台对 DLL 导出函数的接口要求不同:
| 平台 | 导出函数 | 输出方式 |
|---|---|---|
| 通达信 | Func4 | 单 buffer,每根 K 线取第一个非零信号 |
| 交易师/大智慧 | SALL | 同上,通过 CALCINFO 接口 |
| MT5/Python | FQ_CLXS_ALL | 完整 18 模型结果,flat double 数组 |
通达信和交易师只有一根输出线,所以每根 K 线只能输出一个信号值。策略是遍历 18 个模型,取第一个非零信号。信号值里已经编码了模型 ID(如 5107 = S0005),下游可以区分。
MT5 和 Python 能传大数组,所以返回完整的 count × 18 结果。
原则:同一个计算核心,通过不同的导出层适配不同平台。不要为每个平台重写逻辑。
第四章:实战踩坑
S0013/S0014 的依赖问题
S0013(二买信号)和 S0014(线段中枢上方信号)内部依赖 S0008(背驰信号)的计算结果。原来的单模型模式下,它们直接创建 S0008_Calculator 来获取 S0008 信号。
但在批量模式下,S0008_Calculator 的类定义在 S0008.cpp 内部,其他编译单元看不到。直接用会报 未定义标识符 错误。
解法是暴露函数,不暴露类。每个 S00XX.cpp 新增一个非成员函数 F_S00XX_ctx,内部创建 Calculator 并返回结果:
// S0008.cpp 中新增
vector<int> F_S0008_ctx(
const vector<float> &high, ..., const ChanContext &ctx)
{
return S0008_Calculator(high, ..., ctx).result();
}
// S0013.cpp 中使用
s0008_cache = F_S0008_ctx(high, low, open, close, vol, 0, options, ctx);
S0013 在构造时先通过 F_S0008_ctx 拿到 S0008 的结果并缓存,然后在 calculate() 中使用缓存而不是重新计算。
中间变量清理
最初的实现中,BatchCalculator 构造函数有两个局部变量 bi_sigs 和 stretch_sigs,用作 recognise_bi 和 recognise_duan 的返回值暂存,再赋给 ctx。
实际上完全不需要——直接赋值到 ctx.wave_sigs 和 ctx.stretch_sigs,下游函数直接读取 ctx 成员即可。减少两个变量,逻辑更紧凑。
原则:编译错误有时不是语法问题,而是架构边界问题。把类藏在 .cpp 里,只暴露函数签名,是控制可见性的有效手段。
第五章:最终架构
改动完成后,fqcopilot 的信号计算架构变成了两条并行路径:
这种设计保证了零迁移成本。通达信用户如果只想用 S0005,继续用 S0005 函数;如果想一次性看所有模型信号,改用 Func4 即可。两条路径互不干扰。
总结
整个改动涉及 25 个文件(3 个新增 + 22 个修改),净增约 750 行代码。核心变更:
ChanContext结构体:缠论分析数据的独立快照BatchCalculator:一次分析 + 18 模型复用- 每个 Calculator 增加双构造函数(复用 ctx)
- 新增
Func4(通达信)、SALL(交易师)、FQ_CLXS_ALL(MT5/Python)
原有导出函数完全不动,DLL 的 API 是纯增量的。
核心原则:性能优化的第一步不是算法优化,而是消除冗余。当 N 个流程在做同一件事,先让它们共享结果,再考虑加速单次计算。共享数据结构的引入,往往比任何算法优化都更有效。
附录:技术速查表
给技术读者的关键信息:
信号编码格式:direction × (model_id × 1000 + occurrence × 100 + entrypoint)
BatchCalculator 调用链:
OHLCV 数据
→ BatchCalculator 构造
→ ChanContext(一次缠论分析)
→ 18 个 F_S00XX_ctx 函数
→ 18 × vector<int> 信号结果
→ 导出函数按平台格式输出
S0013/S0014 依赖处理:构造时预缓存 F_S0008_ctx 结果,避免跨编译单元引用 Calculator 类。
const 引用兼容:recognise_* 系列函数接受非 const 引用,BatchCalculator 通过局部可变拷贝适配。