开发多币种 EA 交易(第 2 部分):过渡到交易策略的虚拟仓位·综合运用
🧩

开发多币种 EA 交易(第 2 部分):过渡到交易策略的虚拟仓位·综合运用

(3/3)·当策略数冲到上百,真实开仓会逼停大半实例,这篇给收口方案

案例拆解 第 3/3 篇
把几十个策略各自直接开仓,回撤红线一碰就崩,多数实例会被迫降到最小敞口以下直接废掉。虚拟仓位不是优化技巧,是让多策略共存的前提。很多人卡在第三阶段的架构重构,就是没提前剥夺策略的真实下单权。

◍ 把简单策略改成虚拟仓位驱动

基类 CStrategy 里已经留了 m_orders[] 数组专门装虚拟仓位对象,CSimpleVolumesStrategy 直接继承就能用,不用再另起炉灶。策略的 m_maxCountOfOrders 参数定死了同屏最大开仓数,构造函数里把虚拟仓位数组尺寸设成跟它相等,避免越界。 真正的改动落在 OpenBuyOrder() 和 OpenSellOrder():原来下真实市价单的地方,换成在 m_orders[] 里找第一个 IsOpen() 为假的槽位,调它的 Open() 写虚拟买卖。挂单类的 BuyStop / SellLimit 暂时没有虚拟等价物,原文是直接用块注释 /* */ 把 trade.xxx 那几行灭掉,不删代码只停用。 改完存回 SimpleVolumesStrategy.mqh 即可,MT5 里把 EA 切到这个头文件编译,能在回测里看到虚拟仓位按 m_maxCountOfOrders 上限堆叠,而不会真去 broker 那侧占用保证金。外汇与贵金属品种波动大,虚拟逻辑验证通过后仍需小仓位实盘核对滑点差异。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Open BUY order                                                    |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CSimpleVolumesStrategy::OpenBuyOrder() {
class=class="str">"cmt">// ...

  if(m_openDistance > class="num">0) {
    class=class="str">"cmt">/* class=class="str">"cmt">// Set BUY STOP pending order
       res = trade.BuyStop(lot, ...);  */
  } else if(m_openDistance < class="num">0) {
    class=class="str">"cmt">/* class=class="str">"cmt">// Set BUY LIMIT pending order
       res = trade.BuyLimit(lot, ...); */
  } else {
    class=class="str">"cmt">// Open a class="kw">virtual BUY position
    for(class="type">int i = class="num">0; i < m_maxCountOfOrders; i++) {  class=class="str">"cmt">// Iterate through all class="kw">virtual positions
      if(!m_orders[i].IsOpen()) {                  class=class="str">"cmt">// If we find one that is not open, then open it
        res = m_orders[i].Open(m_symbol, ORDER_TYPE_BUY, m_fixedLot,
                               NormalizeDouble(sl, digits),
                               NormalizeDouble(tp, digits));
        break;                                     class=class="str">"cmt">// and exit
      }
    }
  }
...
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Open SELL order                                                   |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CSimpleVolumesStrategy::OpenSellOrder() {
class=class="str">"cmt">// ...

   if(m_openDistance > class="num">0) {
    class=class="str">"cmt">/* class=class="str">"cmt">// Set SELL STOP pending order
       res = trade.SellStop(lot, ...);        */
  } else if(m_openDistance < class="num">0) {
    class=class="str">"cmt">/* class=class="str">"cmt">// Set SELL LIMIT pending order
       res = trade.SellLimit(lot, ...);       */
  } else {
    class=class="str">"cmt">// Open a class="kw">virtual SELL position
    for(class="type">int i = class="num">0; i < m_maxCountOfOrders; i++) {  class=class="str">"cmt">// Iterate through all class="kw">virtual positions
      if(!m_orders[i].IsOpen()) {                  class=class="str">"cmt">// If we find one that is not open, then open it
        res = m_orders[i].Open(m_symbol, ORDER_TYPE_SELL, m_fixedLot,
                               NormalizeDouble(sl, digits),

止盈价的小数位归一化细节

在 EA 下单逻辑里,对止盈价做 NormalizeDouble(tp, digits) 是关键一步。MT5 的报价小数位由品种决定,黄金通常 digits=3 或 2,欧美多为 5 或 4;若直接把计算出的 tp 传给 OrderSend,可能因精度不匹配被服务器拒单。 上面这段代码把 tp 用 NormalizeDouble 收敛到 digits 位,随后 break 退出循环。这个写法意味着每根 K 线或每次 tick 只处理一个挂单分支,避免重复发送。 实操上,开 MT5 按 F4 把这段嵌进你的下单函数,打印 NormalizeDouble 前后数值,能直观看到黄金 XAUUSD 在 digits=3 时 1900.1234 会变成 1900.123。外汇与贵金属杠杆高,点位误差虽小但可能直接废单,建议每次改品种后重测 digits 变量。

MQL5 / C++
NormalizeDouble(tp, digits));
break; class=class="str">"cmt">// and exit
     }
   }
  }
 }
}
}

「派生类接管实际建仓逻辑」

基类只负责把未平仓交易量抽象成对象,真正在市场上开平仓位要交给派生类 CVolumeReceiver 来做。这个类继承 CReceiver,核心在 Correct() 方法的重载链:先按交易品种汇总各策略的总持仓,生成 newVolumes 数组,再逐品种比对旧持仓与所需变化量,决定是加仓、部分平仓还是对冲平仓。 账户类型直接决定执行路径。m_isNetting 标记当前是净仓账户还是对冲账户,对冲账户下 CloseHedgingPartial 会按 ENUM_POSITION_TYPE 区分买仓卖仓分别处理;净仓账户则走 ClearOpen 与 AddBuy/AddSell 的单向调整。 构造函数里 p_minMargin 默认给 100,对应 FreeMarginCheck 中开仓前的最低保证金门槛。把这个类存成 VolumeReceiver.mqh,include 进 Receiver.mqh 后,你可以在 MT5 里改 p_minMargin 的值观察小额账户在波动行情中被拒单的频率变化——外汇与贵金属杠杆高,保证金不足会直接打断仓位同步,属常见高风险坑。

MQL5 / C++
class="macro">#include "Receiver.mqh"
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Class for converting open volumes into market positions          |
class=class="str">"cmt">//+------------------------------------------------------------------+
class CVolumeReceiver : class="kw">public CReceiver {
class="kw">protected:
   class="type">bool                m_isNetting;      class=class="str">"cmt">// Is this a netting account?
   class="type">class="kw">string              m_symbols[];      class=class="str">"cmt">// Array of used symbols
   class="type">class="kw">double              m_minMargin;      class=class="str">"cmt">// Minimum margin for opening
   CPositionInfo       m_position;
   CSymbolInfo         m_symbolInfo;
   CTrade              m_trade;
   class=class="str">"cmt">// Filling the array of open market volumes by symbols 
   class="type">void                FillSymbolVolumes(class="type">class="kw">double &oldVolumes[]);
   class=class="str">"cmt">// Correction of open volumes using the array of volumes 
   class="kw">virtual class="type">bool        Correct(class="type">class="kw">double &symbolVolumes[]);
   class=class="str">"cmt">// Volume correction for this symbol
   class="type">bool                CorrectPosition(class="type">class="kw">string symbol, class="type">class="kw">double oldVolume, class="type">class="kw">double diffVolume);
   class=class="str">"cmt">// Auxiliary methods
   class="type">bool                ClearOpen(class="type">class="kw">string symbol, class="type">class="kw">double diffVolume);
   class="type">bool                AddBuy(class="type">class="kw">string symbol, class="type">class="kw">double volume);
   class="type">bool                AddSell(class="type">class="kw">string symbol, class="type">class="kw">double volume);
   class="type">bool                CloseBuyPartial(class="type">class="kw">string symbol, class="type">class="kw">double volume);
   class="type">bool                CloseSellPartial(class="type">class="kw">string symbol, class="type">class="kw">double volume);
   class="type">bool                CloseHedgingPartial(class="type">class="kw">string symbol, class="type">class="kw">double volume, class="type">ENUM_POSITION_TYPE type);
   class="type">bool                CloseFull(class="type">class="kw">string symbol = "");
   class="type">bool                FreeMarginCheck(class="type">class="kw">string symbol, class="type">class="kw">double volume, ENUM_ORDER_TYPE type);
class="kw">public:
   CVolumeReceiver(class="type">ulong p_magic, class="type">class="kw">double p_minMargin = class="num">100);   class=class="str">"cmt">// Constructor
   class="kw">virtual class="type">void        Add(CStrategy *strategy) class="kw">override;      class=class="str">"cmt">// Add strategy
   class="kw">virtual class="type">bool        Correct() class="kw">override;                    class=class="str">"cmt">// Adjustment of open volumes
};
class=class="str">"cmt">//+------------------------------------------------------------------+

◍ 多策略持仓量的聚合与差额调仓

在同时跑多个策略的 EA 里,同一个交易品种可能被好几套逻辑同时占用仓位。CVolumeReceiver::Correct() 先按品种把 m_strategies 里各策略的 Volume() 累加进 newVolumes 数组,得到该符号当前应持有的总敞口。 真正动手前,Correct(double &newVolumes[]) 会拿新敞口和 oldVolumes 逐品种做差:diffVolume = newVolume - oldVolume。只有 MathAbs(diffVolume) > 0.001 时才认为需要调整——这个 0.001 的手数阈值直接决定了会不会因微小浮动频繁发单。 调仓方向由 oldVolume 正负与 diffVolume 符号共同决定。原有多头(oldVolume>0)时,diffVolume 为正就加买、为负且小于原仓就平部分、大于等于原仓就全平。外汇与贵金属杠杆高,这类自动加减仓逻辑若阈值设错,可能在波动期连续触发,放大回撤概率。 下面这段是原文核心聚合与差额判定,可直接贴进 MT5 看类结构是否对齐:

MQL5 / C++
class="type">bool CVolumeReceiver::Correct() {
   class="type">int symbolsTotal = ArraySize(m_symbols);
   class="type">class="kw">double newVolumes[];
   ArrayResize(newVolumes, symbolsTotal);
   ArrayInitialize(newVolumes, class="num">0);
   for(class="type">int j = class="num">0; j < symbolsTotal; j++) {
      for(class="type">int i = class="num">0; i < ArraySize(m_strategies); i++) {
         if(m_strategies[i].Symbol() == m_symbols[j]) {
            newVolumes[j] += m_strategies[i].Volume();
         }
      }
   }
   class="kw">return Correct(newVolumes);
}

class="type">bool CVolumeReceiver::Correct(class="type">class="kw">double &newVolumes[]) {
   class="type">bool res = true;
   for(class="type">int j = class="num">0; j < ArraySize(m_symbols); j++) {
      class="type">class="kw">double oldVolume = oldVolumes[j];
      class="type">class="kw">double newVolume = newVolumes[j];
      class="type">class="kw">double diffVolume = newVolume - oldVolume;
      if(MathAbs(diffVolume) > class="num">0.001) {
         res = res && CorrectPosition(m_symbols[j], oldVolume, diffVolume);
      }
   }
   class="kw">return res;
}

class="type">bool CVolumeReceiver::CorrectPosition(class="type">class="kw">string symbol, class="type">class="kw">double oldVolume, class="type">class="kw">double diffVolume) {
   class="type">bool res = false;
   class="type">class="kw">double volume = MathAbs(diffVolume);
   if(oldVolume > class="num">0) {
      if(diffVolume > class="num">0) {
         res = AddBuy(symbol, volume);
      } else if(diffVolume < class="num">0) {
         if(volume < oldVolume) {
            res = CloseBuyPartial(symbol, volume);
         } else {
            res = CloseFull(symbol);

持仓方向与新增量的对冲判定

这段逻辑处理的是「已有卖单」状态下,新成交回报带来的仓位变化。当 oldVolume 为负,说明本地记录里有一笔卖仓;此时若 diffVolume 也小于 0,代表远端又开出了新的卖单,直接调用 AddSell 把 volume 累进监控池。 如果 diffVolume 大于 0,则是出现了买方向的成交。这里分两层:当 volume 绝对值小于原卖仓(即 -oldVolume)时,只做部分平仓 CloseSellPartial;否则先 CloseFull 清掉老卖单,若买方量还溢出,就再用 AddBuy 把多出的 volume+oldVolume 开成新买仓。 最后 else 分支覆盖无旧仓情形,直接 ClearOpen 按 diffVolume 同步。外汇与贵金属杠杆高,这类仓位镜像同步若差一个符号,实盘可能瞬间双向敞口,建议把这段代码贴进 MT5 的 EA 回测器,用 0.01 手 EURUSD 跑一次 tick 级成交验证。

MQL5 / C++
if(res && volume > oldVolume) {
         res = AddSell(symbol, volume - oldVolume);
       }
     }
   }
 } else if(oldVolume < class="num">0) { class=class="str">"cmt">// Have SELL position
   if(diffVolume < class="num">0) { class=class="str">"cmt">// New SELL position
      res = AddSell(symbol, volume);
   } else if(diffVolume > class="num">0) { class=class="str">"cmt">// New BUY position
      if(volume < -oldVolume) {
         res = CloseSellPartial(symbol, volume);
      } else {
         res = CloseFull(symbol);
         if(res && volume > -oldVolume) {
            res = AddBuy(symbol, volume + oldVolume);
         }
      }
   }
 } else { class=class="str">"cmt">// No old position
   res = ClearOpen(symbol, diffVolume);
 }
 class="kw">return res;

「用虚拟仓位跑单一策略的 EA 骨架」

把多策略框架砍到只跑一个实例,核心是把 CVolumeReceiver 挂进 EA 构造,再把具体策略类换成 CSimpleVolumesStrategy。这样同一套调度逻辑不用重写,只改注入对象就能切换策略。 代码里三行 include 是必须的前置:Advisor 管总调度,SimpleVolumesStrategy 提供成交量信号逻辑,VolumeReceiver 负责虚拟仓位的成交回报。缺任意一个,OnInit 里的 new 会直接编译报错。 OnInit 中 expert 指针先被赋予带 CVolumeReceiver(magic_) 的 CAdvisor,随后 Add 进一个 CSimpleVolumesStrategy 实例,参数从 symbol_ 到 maxCountOfOrders_ 全部透传,等于把策略绑定到具体品种与周期。OnTick 只调 expert.Tick() 推进,OnDeinit 里 delete expert 防内存泄漏。 在 MT5 里把这段存成 SimpleVolumesExpertSingle.mq5,magic_ 与 fixedLot_ 等输入参数沿用前篇头文件定义,编译后加载到 EURUSD 15 分钟图,可能看到虚拟仓位按 signalPeriod_=20、signalDeviation_=2.0 的设定产生挂单。外汇与贵金属杠杆高,虚拟仓位仅用于逻辑验证,实盘前需自担回测偏差风险。

MQL5 / C++
class="macro">#include "Advisor.mqh"
class="macro">#include "SimpleVolumesStrategy.mqh"
class="macro">#include "VolumeReceiver.mqh"
class=class="str">"cmt">// Input parameters...
CAdvisor      *expert;             class=class="str">"cmt">// Pointer to the EA object
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Expert initialization function                                   |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">int OnInit() {
  expert = new CAdvisor(new CVolumeReceiver(magic_));
  expert.Add(new CSimpleVolumesStrategy(
                    symbol_, timeframe_,
                    fixedLot_,
                    signalPeriod_, signalDeviation_, signaAddlDeviation_,
                    openDistance_, stopLevel_, takeLevel_, ordersExpiration_,
                    maxCountOfOrders_)
                    ); class=class="str">"cmt">// Add one strategy instance
  class="kw">return(INIT_SUCCEEDED);
}
class="type">void OnTick() {
  expert.Tick();
}
class="type">void OnDeinit(const class="type">int reason) {
  class="kw">delete expert;
}

◍ 直接开仓与虚拟仓位的实盘对照

把同一套策略、同一组参数分别跑两遍:一遍直接下真单,一遍走虚拟仓位逻辑,间隔压到极短,再导出两份报告比对成交明细。测试品种固定为 EURGBP、手数 0.01,为压缩表格宽度已隐去这些恒定列。 两种模式下建仓价与时间点完全一致。例如 2018.03.02 15:46:47 的 SELL 与 2018.03.06 13:56:04 的 BUY,虚拟仓位版在开 BUY 的同时平掉了旧 SELL(2018.03.06 13:56:04 buy out);而直接开仓版会让反方向头寸共存、持续吃掉库存费。 对 SimpleVolumesMarketExpert.mq5 做同样改造:引入 CVolumeReceiver,在 EA 构造时注入该类并替换原策略实例,另存为 SimpleVolumesExpert.mq5 回测。九策略并发下,带虚拟仓位的版本相对原版利润略增、回撤收窄,夏普比率与利润因子同步抬升——外汇与贵金属杠杆高,这类改善不预示稳健盈利,仅说明摩擦成本被压低。

虚拟仓位只是起点

切换到虚拟仓位后,多策略同跑不再互相挤占下单通道,协同效率明显提升;同时因为不每个实例都开实盘仓,最低保证金占用比单独跑实例更低,净额账户也能接住了。 目前只落地了市价建仓类策略,挂单逻辑、资金管理和跨品种混合策略都还没接进这套结构,当前仓位大小还是固定手数加人工挑。 上面那段把全部输入塞进一个字符串再传给策略构造函数,是这套封装的入口写法: string inInputsAll = NULL; expert.Add(new CSimpleVolumesStrategy(inInputsAll)); 真要上实盘,外汇和贵金属杠杆高、回撤快,先在 MT5 策略测试器拿历史数据跑一遍 VirtualOrder 相关 mqh,确认虚拟转实盘的信号触发和你预期一致再谈。

MQL5 / C++
class="type">class="kw">string inInputsAll = NULL;
expert.Add(new CSimpleVolumesStrategy(inInputsAll));
让小布替你跑这套
这些多策略回撤诊断与虚拟仓位映射,小布盯盘的 AIGC 已内置,打开对应品种页即可看到各实例的虚拟占用与真实敞口对照,省去手算。

常见问题

策略只做虚拟交易并上报应开交易量,由 EA 汇总后按总回撤比例统一开真实仓位,否则各自下单会突破整体风控。
小布支持导入自定义 ex5 并展示各策略虚拟占用,但策略逻辑需自行编译,小布只负责可视化与告警。
避免数组填充时重复构造策略对象,节省内存且保证访问的是同一实例状态,利于跨策略统一调度。
重点看回撤吻合度、闲置策略占比与成交滑点差异,虚拟方案倾向在风控上更稳但需验证执行效率。
本篇第3节专门创建将未平仓交易量转换为市场仓位的类,关于完整架构见《开发多币种 EA 交易(第 1 部分)·基础篇》。