一次调用18个模型一次输出信号,DLL批量计算的设计与实践

一次调用18个模型一次输出信号,DLL批量计算的设计与实践

一次调用18个模型一次输出信号,DLL批量计算的设计与实践

当18次重复计算变成1次共享,DLL性能优化的真实落地

「量化实战手记」— 记录从想法到落地的真实开发历程

引言:一张图看懂所有信号

想象一下:通达信主图上,所有 18 个选股模型的买卖信号一次性标注在K线上,每个信号值里自带模型编号——看到 5107 就知道是 S0005 的买点,看到 -1403 就知道是 S0014 的卖点。一张图,所有模型的信号一目了然。

这就是批量计算要实现的效果。在这之前,fqcopilot 的 18 个选股模型(S0000 ~ S0017)各自独立计算,每次都要从头做一轮完整的缠论分析——识别笔、线段、中枢,再算 MA5、MACD、ATR。18 个模型做的是同一套缠论分析,但每个都从头算一遍。

这不是算法问题,是架构问题。解法很清晰:把缠论分析的结果提取出来,让 18 个模型共享。一次分析,全部信号输出。这一次的升级更新,解决的就是这个问题。

第一章:问题分析

先看原来的计算流程。每个模型的 Calculator 类内部有个 initialize() 方法,负责做缠论分析和指标计算:

initialize() → 缠论分析 → 指标计算 → S0000信号 recognise_swing / recognise_bi / recognise_duan / MA / MACD / ATR initialize() → 缠论分析 → 指标计算 → S0017信号 recognise_swing / recognise_bi / recognise_duan / MA / MACD / ATR 18 × 缠论分析 = 18 倍冗余 同一组K线数据,笔/线段/中枢的结果完全相同

缠论分析的输入是 K 线的 OHLCV 数据和参数选项。只要输入相同,recognise_swingrecognise_birecognise_duanrecognise_trend 这些函数的输出就完全一致。MA5、MACD、ATR 这些指标同理。

这意味着 18 次计算中有 17 次是纯浪费。对于一根日 K 线有几千根的标的,这种冗余在实盘中会拖慢信号刷新速度。

原则:当多个消费者处理相同数据时,先识别不变量,再设计共享机制。冗余计算的本质是架构缺失。

第二章:方案设计

核心思路:把缠论分析的结果提取成一个独立的数据包,所有模型共享这个数据包

有两种实现方式。第一种是让每个模型直接接收外部计算好的数据,第二种是在内部建一个批量计算器,一次做完所有分析再分发给各模型。

OHLCV ChanContext swing_sigs wave_sigs stretch_sigs trend_sigs ma5 / macd / atrs std_bars / length S0000~S0017 18 × 信号 分析 共享 计算 OHLCV → ChanContext → 18模型共享 → 信号输出

选择了内部批量计算器的方案。原因是不破坏原有接口——单个模型的导出函数(Func3SXXXXFQ_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_swingrecognise_bi 等函数接受非 const 引用(vector<float>&),但构造函数接收的是 const 引用。所以需要创建局部可变拷贝 hlc。这不是多余,是类型系统的保护——调用方的数据不该被内部函数意外修改。

双构造函数:新老并存

每个 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() 逻辑完全不动。新构造函数只是换了个数据来源。

BatchCalculator 一次缠论分析 → ChanContext S0000 ~ S0017 Calculator 共享 ctx,各自计算信号 Func4 / SALL FQ_CLXS_ALL 原有函数不动

导出函数:各平台的输出适配

不同平台对 DLL 导出函数的接口要求不同:

平台导出函数输出方式
通达信Func4单 buffer,每根 K 线取第一个非零信号
交易师/大智慧SALL同上,通过 CALCINFO 接口
MT5/PythonFQ_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_sigsstretch_sigs,用作 recognise_birecognise_duan 的返回值暂存,再赋给 ctx

实际上完全不需要——直接赋值到 ctx.wave_sigsctx.stretch_sigs,下游函数直接读取 ctx 成员即可。减少两个变量,逻辑更紧凑。

原则:编译错误有时不是语法问题,而是架构边界问题。把类藏在 .cpp 里,只暴露函数签名,是控制可见性的有效手段。

第五章:最终架构

改动完成后,fqcopilot 的信号计算架构变成了两条并行路径:

原有路径 新增路径 Func3 / SXXXX / FQ_CLXS Calculator::initialize() 单模型信号输出 Func4 / SALL / CLXS_ALL BatchCalculator 18模型信号一次输出 18 次调用 × 各自分析 1 次分析 × 共享结果 原有函数完全不动,新旧路径并存

这种设计保证了零迁移成本。通达信用户如果只想用 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 通过局部可变拷贝适配。

浙ICP备2026022231号-1      浙公网安备33011002019439号