开发多币种 EA 交易(第 24 部分):添加新策略(二)·进阶篇
🧩

开发多币种 EA 交易(第 24 部分):添加新策略(二)·进阶篇

(2/3)·从SimpleVolumes切到SimpleCandles,库与项目解耦下的集成坑比想象深

含代码示例 第 2/3 篇
接上篇,我们继续深挖策略替换的落地细节。很多人以为换策略只是写个新类,真正卡住进度的是库部分不能依赖项目部分的那条线。本篇顺着CreateProject.mq5的改动,看怎么在保持隔离的前提下让自动优化输送机吃进新参数。

给策略挂上最大点差闸门

给 EA 加输入参数不是只动 extern 就完事。以「最大点差」为例:想在收到开仓信号时,若实时点差超过阈值就不下单,得同步改三处——输入声明、初始化字符串拼装、策略类内部属性读取。 输入侧用 input int maxSpread_ = 10 表示允许的最大点差(单位:点)。初始化函数里,StringFormat 的格式化串要从原 7 个参数扩成 8 个,尾部追 ,%d 并补 maxSpread_ 实参,否则类构造时读到的字符串会错位。 类内要新增 m_maxSpread 成员,在构造函数里从初始化串解析赋值。之后在开仓信号方法头部加一句判断:若 (int)SymbolInfoInteger(symbol,SYMBOL_SPREAD) > m_maxSpread 则直接 return,不进单。外汇与贵金属点差跳动剧烈,这类闸门能降低滑点吞噬概率,但无法消除高风险。 顺带一提,MQL5 的 input group "===" 仅影响 MT5 参数面板的视觉分组,不改逻辑;删参数时反向操作即可,但初始化串参数顺序必须和类构造签名严格对齐。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| class="num">4. Strategy inputs                                                |
class=class="str">"cmt">//+------------------------------------------------------------------+
sinput class="type">class="kw">string        symbol_            = "";      class=class="str">"cmt">// Symbol
sinput ENUM_TIMEFRAMES period_          = PERIOD_CURRENT;  class=class="str">"cmt">// Timeframe for candles
input group "===  Opening signal parameters"
input class="type">int            signalSeqLen_      = class="num">6;      class=class="str">"cmt">// Number of unidirectional candles
input class="type">int            periodATR_         = class="num">0;      class=class="str">"cmt">// ATR period(if class="num">0, then TP/SL in points)
input group "===  Pending order parameters"
input class="type">class="kw">double         stopLevel_         = class="num">25000;  class=class="str">"cmt">// Stop Loss(in ATR fraction or points)
input class="type">class="kw">double         takeLevel_         = class="num">3630;   class=class="str">"cmt">// Take Profit(in ATR fraction or points)
input group "===  Money management parameters"
input class="type">int            maxCountOfOrders_  = class="num">9;      class=class="str">"cmt">// Max number of simultaneously open orders
input class="type">int            maxSpread_         = class="num">10;     class=class="str">"cmt">// Max acceptable spread(in points)
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| class="num">5. Strategy initialization class="type">class="kw">string generation function             |
class=class="str">"cmt">//|    from the inputs                                                |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">class="kw">string GetStrategyParams() {
   class="kw">return StringFormat(
            "class CSimpleCandlesStrategy(\"%s\",%d,%d,%d,%.3f,%.3f,%d,%d)",
            (symbol_ == "" ? Symbol() : symbol_), period_,
            signalSeqLen_, periodATR_, stopLevel_, takeLevel_,
            maxCountOfOrders_, maxSpread_
          );
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Trading strategy using unidirectional candlesticks               |
class=class="str">"cmt">//+------------------------------------------------------------------+
class CSimpleCandlesStrategy : class="kw">public CVirtualStrategy {
class="kw">protected:
  class=class="str">"cmt">// ...
  class=class="str">"cmt">//---  Money management parameters
  class="type">int            m_maxCountOfOrders;   class=class="str">"cmt">// Max number of simultaneously open positions

「用连续阴线触发买信号的坑」

这段策略类把参数从初始化字符串里逐个读出来,其中 m_maxSpread 以 point 为单位记录最大可接受的点差,构造函数里用 ReadLong 强转后赋值。实盘里若点差超过这个值,后续挂单逻辑会被直接拦掉,避免在流动性差的时候吃滑点。 SignalForOpen 先 CopyRates 拉取 m_signalSeqLen+1 根 K 线:索引 0 是当前未平仓蜡烛,1 到 m_signalSeqLen 是已收盘的。默认给 signal=1(买),只要遍历中发现任意一根收盘阳线(open<close)就归零并跳出;若买信号被取消则翻转成 -1(卖),再查是否出现阴线取消。 这里有个反直觉点:连续 m_signalSeqLen 根全为阴线才给买信号,逻辑等价于“跌透反弹”假设,但原文未给任何胜率回测。外汇与贵金属波动受消息驱动,这种纯形态信号在历史数据上可能失效,建议先在 MT5 策略测试器用 2020—2023 年 XAUUSD 的 M15 跑一遍看最大回撤。

MQL5 / C++
class="type">int m_maxSpread; class=class="str">"cmt">// Max acceptable spread(in points)
class=class="str">"cmt">// ...
};
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Constructor                                                      |
class=class="str">"cmt">//+------------------------------------------------------------------+
CSimpleCandlesStrategy::CSimpleCandlesStrategy(class="type">class="kw">string p_params) {
class=class="str">"cmt">// Read the parameters from the initialization class="type">class="kw">string
  m_params = p_params;
  m_symbol = ReadString(p_params);
  m_timeframe = (ENUM_TIMEFRAMES) ReadLong(p_params);
  m_signalSeqLen = (class="type">int) ReadLong(p_params);
  m_periodATR = (class="type">int) ReadLong(p_params);
  m_stopLevel = ReadDouble(p_params);
  m_takeLevel = ReadDouble(p_params);
  m_maxCountOfOrders = (class="type">int) ReadLong(p_params);
  m_maxSpread = (class="type">int) ReadLong(p_params);
  class=class="str">"cmt">// ...
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Signal for opening pending orders                                |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">int CSimpleCandlesStrategy::SignalForOpen() {
class=class="str">"cmt">// By class="kw">default, there is no signal
  class="type">int signal = class="num">0;
  class="type">MqlRates rates[];
class=class="str">"cmt">// Copy the quote values(candles) to the destination array.
class=class="str">"cmt">// To check the signal we need m_signalSeqLen of closed candles and the current candle,
class=class="str">"cmt">// so in total m_signalSeqLen + class="num">1
  class="type">int res = CopyRates(m_symbol, m_timeframe, class="num">0, m_signalSeqLen + class="num">1, rates);
class=class="str">"cmt">// If the required number of candles has been copied
  if(res == m_signalSeqLen + class="num">1) {
    signal = class="num">1; class=class="str">"cmt">// buy signal
    class=class="str">"cmt">// Go through all closed candles
    for(class="type">int i = class="num">1; i <= m_signalSeqLen; i++) {
      class=class="str">"cmt">// If at least one upward candle occurs, cancel the signal
      if(rates[i].open < rates[i].close ) {
        signal = class="num">0;
        class="kw">break;
      }
    }
    if(signal == class="num">0) {
      signal = -class="num">1; class=class="str">"cmt">// otherwise, sell signal
      class=class="str">"cmt">// Go through all closed candles
      for(class="type">int i = class="num">1; i <= m_signalSeqLen; i++) {
        class=class="str">"cmt">// If at least one downward candle occurs, cancel the signal
        if(rates[i].open > rates[i].close ) {
          signal = class="num">0;
          class="kw">break;
        }
      }
    }
  }
class=class="str">"cmt">// If there is a signal, then
  if(signal != class="num">0) {
    class=class="str">"cmt">// If the current spread is greater than the maximum allowed, then
    if(rates[class="num">0].spread > m_maxSpread) {
代码逐行拆解:m_maxSpread 声明为整数型,存最大可接受点差(单位 point);构造函数里从 p_params 字符串按序 ReadLong 取该值并强转赋值。SignalForOpen 中 CopyRates 取 m_signalSeqLen+1 根速率数据,res 等于预期数量才进判断;signal 初值 1,遍历已收盘 K 线遇阳线即置 0 并 break,全阴则保留买信号,否则转 -1 卖信号并查阴线取消;末尾若 signal 非零,比较 rates[0].spread 与 m_maxSpread,超阈则拦截。

MQL5 / C++
class="type">int m_maxSpread; class=class="str">"cmt">// Max acceptable spread(in points)
class=class="str">"cmt">// ...
};
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Constructor                                                      |
class=class="str">"cmt">//+------------------------------------------------------------------+
CSimpleCandlesStrategy::CSimpleCandlesStrategy(class="type">class="kw">string p_params) {
class=class="str">"cmt">// Read the parameters from the initialization class="type">class="kw">string
  m_params = p_params;
  m_symbol = ReadString(p_params);
  m_timeframe = (ENUM_TIMEFRAMES) ReadLong(p_params);
  m_signalSeqLen = (class="type">int) ReadLong(p_params);
  m_periodATR = (class="type">int) ReadLong(p_params);
  m_stopLevel = ReadDouble(p_params);
  m_takeLevel = ReadDouble(p_params);
  m_maxCountOfOrders = (class="type">int) ReadLong(p_params);
  m_maxSpread = (class="type">int) ReadLong(p_params);
  class=class="str">"cmt">// ...
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Signal for opening pending orders                                |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">int CSimpleCandlesStrategy::SignalForOpen() {
class=class="str">"cmt">// By class="kw">default, there is no signal
  class="type">int signal = class="num">0;
  class="type">MqlRates rates[];
class=class="str">"cmt">// Copy the quote values(candles) to the destination array.
class=class="str">"cmt">// To check the signal we need m_signalSeqLen of closed candles and the current candle,
class=class="str">"cmt">// so in total m_signalSeqLen + class="num">1
  class="type">int res = CopyRates(m_symbol, m_timeframe, class="num">0, m_signalSeqLen + class="num">1, rates);
class=class="str">"cmt">// If the required number of candles has been copied
  if(res == m_signalSeqLen + class="num">1) {
    signal = class="num">1; class=class="str">"cmt">// buy signal
    class=class="str">"cmt">// Go through all closed candles
    for(class="type">int i = class="num">1; i <= m_signalSeqLen; i++) {
      class=class="str">"cmt">// If at least one upward candle occurs, cancel the signal
      if(rates[i].open < rates[i].close ) {
        signal = class="num">0;
        class="kw">break;
      }
    }
    if(signal == class="num">0) {
      signal = -class="num">1; class=class="str">"cmt">// otherwise, sell signal
      class=class="str">"cmt">// Go through all closed candles
      for(class="type">int i = class="num">1; i <= m_signalSeqLen; i++) {
        class=class="str">"cmt">// If at least one downward candle occurs, cancel the signal
        if(rates[i].open > rates[i].close ) {
          signal = class="num">0;
          class="kw">break;
        }
      }
    }
  }
class=class="str">"cmt">// If there is a signal, then
  if(signal != class="num">0) {
    class=class="str">"cmt">// If the current spread is greater than the maximum allowed, then
    if(rates[class="num">0].spread > m_maxSpread) {

◍ 用点差阈值卡掉失真信号

在 MT5 的 EA 逻辑里,点差一旦超过预设上限,继续交易信号就不可信。上面这段把 BUY/SELL 信号强制归零,等于把高滑点环境下的假突破过滤掉。 具体看代码:先用 PrintFormat 打出函数名、信号方向、当前点差和上限值,方便在专家日志里复盘哪一笔被丢弃;随后 signal=0 直接取消信号。m_maxSpread 是外部可调参数,做黄金这类平均点差 20~50 点的品种,建议先在策略测试器里跑一周 tick 数据,把阈值定在常态点差的 1.5 倍附近。 外汇与贵金属杠杆高、点差跳变频繁,这种硬过滤只能降低概率性亏损,不保证胜率。开 MT5 把这段接进你现有的信号函数,观察日志里 IGNORE 的频率,再反调 m_maxSpread。

MQL5 / C++
PrintFormat(__FUNCTION__" | IGNORE %s Signal, spread is too big(%d > %d)",
                     (signal > class="num">0 ? "BUY" : "SELL"),
                     rates[class="num">0].spread, m_maxSpread);
         signal = class="num">0; class=class="str">"cmt">// Cancel the signal
       }
   }
   class="kw">return signal;
}

把建项目 EA 拆成类才扛得住新策略

看 CreateProject.mq5 的 OnInit,旧写法把连库、建项目、建阶段、建作业、排队、关库全堆在一个函数里。CreateJobs() 尤其臃肿:它既要预处理输入、拼参数模板、写数据库,又顺手建了优化任务。这种「一个函数包打天下」的结构,换策略就得动深层代码,没法直接挪进库区复用。 原来的全局变量 paramsTemplate1 写死了第一阶段参数:signalPeriod_ 从 12 扫到 240、步长 40,stopLevel_ 从 200.0 到 20000.0,共 8 组参数带 Y/N 开关。旧策略所有第一阶段作业都用同一模板,但新策略把交易品种和周期也塞进参数,模板就出了可变部分——你改一次得钻进任务创建函数内部硬改,库区隔离直接破功。 另外,当前 EA 写死三阶段流水线。开发时试过加第 18、19 部分的额外阶段,实测对最终结果没显著改善,才固定成三阶段。可别家策略未必合适,一旦把代码搬进库还锁死阶段数,以后想加就难了。 与其以后返工,不如现在重构:把逻辑拆成类移进库,项目侧只管拼阶段和内容。初步目标先抽出 COptimizationProject 类,下面这段新 OnInit 就是方向——用对象方法链式搭项目,阶段和参数都能灵活插。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Expert initialization function                                     |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">int OnInit() {
class=class="str">"cmt">// Connect to the database
  DB::Connect(fileName_);
class=class="str">"cmt">// Create a project
  CreateProject(projectName_,
                projectVersion_,
                StringFormat("%s - %s",
                            TimeToString(fromDate_, TIME_DATE),
                            TimeToString(toDate_, TIME_DATE)
                            )
                );
class=class="str">"cmt">// Create project stages
  CreateStages();
class=class="str">"cmt">// Creating jobs and tasks
  CreateJobs();
class=class="str">"cmt">// Queueing the project for execution
  QueueProject();
class=class="str">"cmt">// Close the database
  DB::Close();
class=class="str">"cmt">// Successful initialization
  class="kw">return(INIT_SUCCEEDED);
}
class=class="str">"cmt">// Template of optimization parameters at the first stage
class="type">class="kw">string paramsTemplate1 =
  "; ===  Open signal parameters\n"
  "signalPeriod_=class="num">212||class="num">12||class="num">40||class="num">240||Y\n"
  "signalDeviation_=class="num">0.1||class="num">0.1||class="num">0.1||class="num">2.0||Y\n"
  "signaAddlDeviation_=class="num">0.8||class="num">0.1||class="num">0.1||class="num">2.0||Y\n"
  "; ===  Pending order parameters\n"
  "openDistance_=class="num">10||class="num">0||class="num">10||class="num">250||Y\n"
  "stopLevel_=class="num">16000||class="num">200.0||class="num">200.0||class="num">20000.0||Y\n"
  "takeLevel_=class="num">240||class="num">100||class="num">10||class="num">2000.0||Y\n"
  "ordersExpiration_=class="num">22000||class="num">1000||class="num">1000||class="num">60000||Y\n"
  "; ===  Capital management parameters\n"
  "maxCountOfOrders_=class="num">3||class="num">3||class="num">1||class="num">30||N\n";
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Expert initialization function                                     |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">int OnInit() {
class=class="str">"cmt">// Create an optimization project object for the given database
  COptimizationProject p;
class=class="str">"cmt">// Create a new project in the database
  p.Create(...);
class=class="str">"cmt">// Add the first stage
  p.AddStage(...);
class=class="str">"cmt">// Adding the first stage jobs
  p.AddJobs(...);
class=class="str">"cmt">// Add tasks for the first stage jobs
  p.AddTasks(...);
class=class="str">"cmt">// Add the second stage
  p.AddStage(...);
class=class="str">"cmt">// Add the second stage jobs
  p.AddJobs(...);
class=class="str">"cmt">// Add tasks for the second stage jobs
  p.AddTasks(...);
class=class="str">"cmt">// Add the third stage
  p.AddStage(...);

「把多阶段任务塞进队列并自毁 EA」

在 MQL5 的 CExpert 或自定义项目对象 p 上,三轮任务不是一次跑完,而是分阶段压进同一个执行管道。先 AddJobs 补上第三阶段的作业,再用 AddTasks 把该阶段的具体任务挂进去,此时前两层若已就绪,调度器会在后台按依赖顺序吞吐。 p.Queue() 这一步才是真正把整个项目推入终端的执行队列,漏掉它,前面所有的 Add 都只停留在内存对象里,MT5 不会主动跑。 任务递交后直接调 ExpertRemove() 让 EA 自我注销,常见于一次性批处理脚本——初始化函数返回 INIT_SUCCEEDED 仅表示挂载成功,不代表策略会继续驻留图表。开 MT5 把这段接在你自己的 p 配置后,能验证 EA 跑完即消失的现象。

MQL5 / C++
class=class="str">"cmt">// Add the third stage job
  p.AddJobs(...);
class=class="str">"cmt">// Add a task for the third stage job
  p.AddTasks(...);
class=class="str">"cmt">// Put the project in the execution queue
  p.Queue();
class=class="str">"cmt">// Delete the EA
  ExpertRemove();
class=class="str">"cmt">// Successful initialization
  class="kw">return(INIT_SUCCEEDED);
}

◍ 把优化工程封装成可持久化的项目类

做批量参数优化时,最忌讳把品种、周期、标准全塞进一个扁平结构。这里把优化数据库里的实体拆成三层:项目(COptimizationProject)、阶段作业(COptimizationJob)、具体任务(COptimizationTask),每一层对应库里一张表,类字段直接映射表字段,再补一点执行所需的方法。 目前图省事,这些类的属性方法全 public,每个类自带往库里插新记录的函数;改记录和读记录留到后面再做,因为建项目用不到。原先用的测试器参数模板,被换成按项目指针返回填充参数的独立函数,模板逻辑收进函数内部,项目对象能直接取数替换。 COptimizationProject 构造时立刻连库并开事务,析构时根据 CDatabase::s_res 决定提交还是回滚——有错就取消,没错才确认,同时释放动态对象。添加工作有两种入口:一种收逗号分隔的字符串(品种/周期/标准),内部转数组后调第二种数组版。 第三个参数是指向阶段EA优化参数生成函数的指针,类型 TJobsTemplateFunc 已在库里声明。下面这段就是项目类的骨架,开 MT5 建个空库对照 fields 就能验证表结构是否对得上。

MQL5 / C++
class=class="str">"cmt">// Create a new type - a pointer to a class="type">class="kw">string generation function
class=class="str">"cmt">// for optimization job parameters(job) accepting the pointer
class=class="str">"cmt">// to the optimization project object as an argument
typedef class="type">class="kw">string (*TJobsTemplateFunc)(COptimizationProject*);
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Optimization project class                                      |
class=class="str">"cmt">//+------------------------------------------------------------------+
class COptimizationProject {
class="kw">public:
  class="type">class="kw">string            m_fileName;      class=class="str">"cmt">// Database name
  class=class="str">"cmt">// Properties stored directly in the database
  class="type">ulong             id_project;      class=class="str">"cmt">// Project ID
  class="type">class="kw">string            name;            class=class="str">"cmt">// Name
  class="type">class="kw">string            version;         class=class="str">"cmt">// Version
  class="type">class="kw">string            description;     class=class="str">"cmt">// Description
  class="type">class="kw">string            status;          class=class="str">"cmt">// Status
  class=class="str">"cmt">// Arrays of all stages, jobs and tasks
  COptimizationStage* m_stages[];   class=class="str">"cmt">// Project stages
  COptimizationJob*   m_jobs[];      class=class="str">"cmt">// Jobs of all project stages
  COptimizationTask*  m_tasks[];     class=class="str">"cmt">// Tasks of all jobs of project stages
  class=class="str">"cmt">// Properties for the current state of the project creation
  class="type">class="kw">string            m_symbol;        class=class="str">"cmt">// Current symbol
  class="type">class="kw">string            m_timeframe;     class=class="str">"cmt">// Current timeframe
  COptimizationStage* m_stage;       class=class="str">"cmt">// Last created stage(current stage)
  COptimizationJob*   m_job;         class=class="str">"cmt">// Last created job(current job)
  COptimizationTask*  m_task;        class=class="str">"cmt">// Last created task(current task)
  class=class="str">"cmt">// Methods
                     COptimizationProject(class="type">class="kw">string p_fileName);   class=class="str">"cmt">// Constructor
                    ~COptimizationProject();                    class=class="str">"cmt">// Destructor
  class=class="str">"cmt">// Create a new project in the database
  COptimizationProject* COptimizationProject::Create(class="type">class="kw">string p_name,
       class="type">class="kw">string p_version = "", class="type">class="kw">string p_description = "", class="type">class="kw">string p_status = "Done");
  class="type">void              Insert();        class=class="str">"cmt">// Insert an entry into the database
让小布替你跑这套
这些多品种优化任务的参数编排和错误排查,小布盯盘的AIGC已内置常用模板,打开对应品种页即可直接套用,你只需盯决策点。

常见问题

保持库独立能让交易逻辑在不同项目间复用而不被具体品种或阶段绑死,破坏隔离后后续维护成本会指数上升,概率上更易出隐藏bug。
缩短到几个月间隔后单轮优化次数变密,多代理并发写库锁冲突显现,虽非直接因果但暴露了原先被长间隔掩盖的写入竞争。
可以,小布盯盘的品种页能留痕各策略优化任务与异常,省去手动建表的重复劳动,你专注看参数分布即可。
本篇第1节演示了向策略类追加参数字段的具体写法,主要涉及蜡烛形态阈值,改动量不大但需同步优化数据库结构。
分析章节指出相同或近似的帧收集与任务描述代码应下沉为库文件,项目部分仅留差异项,这样换策略时改动面最小。