开发多币种 EA 交易(第 4 部分):虚拟挂单和保存状态(基础篇)
📘

开发多币种 EA 交易(第 4 部分):虚拟挂单和保存状态(基础篇)

第 1/3 篇

多币种 EA 的虚拟挂单与状态留存思路

在 MT5 里跑多币种 EA,实盘直接对每个品种下真单会被经纪商限制挂单总数,也拖慢终端。常见做法是先在 EA 内部用数组维护「虚拟挂单」,只在价格触达预设位时才发真实 OrderSend。 所谓保存状态,是指 EA 重启或图表周期切换后,能从全局变量或文件把未成交虚拟单、已开仓的魔法码与止损位读回来,避免重复下单或丢单。2024-10-07 发布的该系列第 4 部分原文提到,这种结构在同时监视 10 个以上品种时,终端 CPU 占用比直接挂真单低约 30%。 外汇与贵金属杠杆高、滑点突发行情多,虚拟单触发逻辑必须加成交超时与重试验证,否则跳空时可能漏单。开 MT5 新建 EA,先写个二维结构体存 symbol、vprice、magic,比边写边想清晰得多。

◍ 从多币种架构走向虚拟挂单

前一篇已经把代码架构翻了一遍,做出能跑多种并行策略的多币种 EA。到目前位置只接了最基础的功能,即便如此改动量也不小。 现在底子算够厚了,往后加功能尽量不伤已写的骨架,非动不可就只动最小一处。 下一步先补虚拟挂单:买入止损、卖出止损、买入限价、卖出限价,而不只是当下的虚拟市价仓位(买、卖)。 顺带要做的还有两件事:给虚拟单和仓位做轻量可视化,方便在回测里肉眼核对开仓规则有没有被执行;让 EA 把运行状态落盘,终端重启或换设备后能接着中断点跑。 本篇先从最轻的虚拟挂单下手,后面再碰可视化和断点恢复。

「把挂单塞进同一个虚拟订单类」

处理虚拟挂单和处理虚拟仓位,属性大头是重合的,唯独挂单多了一个到期时间字段,以及由此带来的“到期关闭”这种新结束原因。每来一个分时报价,就得额外判断未关闭的虚拟挂单是不是到点废了,而不能像仓位那样只盯止损止盈。 触发逻辑上,价格碰到挂单的开盘价,挂单就该翻成已开的虚拟仓位。如果当初分两个类写,触发时要新建仓位对象再删挂单对象,纯属多余体力活;合成一个 CVirtualOrder 类,只要把 m_type 从 BUY_LIMIT/SELL_STOP 之类改成对应的 BUY/SELL 仓位类型就完事。 具体落地时给类加了 m_expiration(到期时间)和 m_isExpired(是否到期标志),以及 CheckTrigger() 方法。CheckTrigger() 里按方向取当前 Bid 或 Ask,达到开盘价就把 m_type 换成仓位类型并通知策略。Open() 方法也多了开启价和到期时间两个入参——仓位的话开启价直接填当前市场开盘价。 在 CSimpleVolumesStrategy 里,OpenBuyOrder() / OpenSellOrder() 留了口子:用当前价加减 m_openDistance 点算出一个挂单开启价再调 Open()。改完只动了 VirtualOrder.mqh 和 SimpleVolumesStrategy.mqh 两个文件,EA 设 openDistance_ 不等于 0 时就会下虚拟挂单而非直接开仓。 外汇和贵金属杠杆高、滑点跳空频繁,虚拟挂单的触发价在实盘里可能因跳空直接越过,回测和实盘表现会有偏差,验证时务必留意。编译后图表上看不到挂单本身,只有日志提示,等它转成虚拟仓位才上图——想直接可视化挂单得等后面另说。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Class of class="kw">virtual orders and positions                            |
class=class="str">"cmt">//+------------------------------------------------------------------+
class CVirtualOrder {
class="kw">private:
  ...
class=class="str">"cmt">//--- Order(position) properties
  ...
  class="type">class="kw">datetime          m_expiration;    class=class="str">"cmt">// Expiration time
  ...
  class="type">bool              m_isExpired;     class=class="str">"cmt">// Expiration flag
  ...
class=class="str">"cmt">//--- Private methods
  ...
  class="type">bool              CheckTrigger();  class=class="str">"cmt">// Check for pending order trigger
class="kw">public:
  ...
class=class="str">"cmt">//--- Methods for checking the position(order) status
  ...
  class="type">bool              IsPendingOrder() {class=class="str">"cmt">// Is it a pending order?
    class="kw">return IsOpen() && (m_type == ORDER_TYPE_BUY_LIMIT
                      || m_type == ORDER_TYPE_BUY_STOP
                      || m_type == ORDER_TYPE_SELL_LIMIT
                      || m_type == ORDER_TYPE_SELL_STOP);
  }
  class="type">bool              IsBuyOrder() {   class=class="str">"cmt">// Is it an open BUY position?
    class="kw">return IsOpen() && (m_type == ORDER_TYPE_BUY
                      || m_type == ORDER_TYPE_BUY_LIMIT
                      || m_type == ORDER_TYPE_BUY_STOP);
  }
  class="type">bool              IsSellOrder() {  class=class="str">"cmt">// Is it an open SELL position?
    class="kw">return IsOpen() && (m_type == ORDER_TYPE_SELL
                      || m_type == ORDER_TYPE_SELL_LIMIT
                      || m_type == ORDER_TYPE_SELL_STOP);
  }
  class="type">bool              IsStopOrder() {  class=class="str">"cmt">// Is it a pending STOP order?

挂单类型判定与虚拟开仓接口

在自研订单容器里,先用两个布尔方法把挂单性质切分清楚:IsStopOrder 只在订单处于未成交状态且类型为 BUY_STOP 或 SELL_STOP 时返回真;IsLimitOrder 则对应 BUY_LIMIT 与 SELL_LIMIT。这样后续策略逻辑不必反复写类型判断,直接调方法即可。 CVirtualOrder::Open 是统一的虚拟开仓入口,参数覆盖 symbol、ENUM_ORDER_TYPE、lot、price、sl、tp、comment、expiration 以及 inPoints 开关。其中 inPoints 为 false 时,sl/tp 按绝对价格解释;置 true 则按点数计算,写 EA 时容易忽略这点导致止损距离错算。 注意到 Open 声明里 expiration 默认值是 0,代表挂单不设到期;若给具体 datetime,MT5 会在该时刻自动撤销未触发的挂单。外汇与贵金属杠杆高,虚拟订单仅用于回测或信号模拟,实盘映射仍要走真实交易接口并自担风险。

MQL5 / C++
class="kw">return IsOpen() && (m_type == ORDER_TYPE_BUY_STOP || m_type == ORDER_TYPE_SELL_STOP);
}
class="type">bool IsLimitOrder() { class=class="str">"cmt">// is it a pending LIMIT order?
  class="kw">return IsOpen() && (m_type == ORDER_TYPE_BUY_LIMIT || m_type == ORDER_TYPE_SELL_LIMIT);
}
...
class=class="str">"cmt">//--- Methods for handling positions(orders)
class="type">bool CVirtualOrder::Open(class="type">class="kw">string symbol,
                         ENUM_ORDER_TYPE type,
                         class="type">class="kw">double lot,
                         class="type">class="kw">double price,
                         class="type">class="kw">double sl = class="num">0,
                         class="type">class="kw">double tp = class="num">0,
                         class="type">class="kw">string comment = "",
                         class="type">class="kw">datetime expiration = class="num">0,
                         class="type">bool inPoints = false); class=class="str">"cmt">// Opening a position(order)
...
};
class="type">bool CVirtualOrder::Open(class="type">class="kw">string symbol,       class=class="str">"cmt">// Symbol
                         ENUM_ORDER_TYPE type, class=class="str">"cmt">// Type(BUY or SELL)
                         class="type">class="kw">double lot,           class=class="str">"cmt">// Volume
                         class="type">class="kw">double price = class="num">0,     class=class="str">"cmt">// Open price
                         class="type">class="kw">double sl = class="num">0,        class=class="str">"cmt">// StopLoss level(price or points)
                         class="type">class="kw">double tp = class="num">0,        class=class="str">"cmt">// TakeProfit level(price or points)
                         class="type">class="kw">string comment = "",  class=class="str">"cmt">// Comment
                         class="type">class="kw">datetime expiration = class="num">0 class=class="str">"cmt">// Expiration time

◍ 挂单触发与市价转持仓的逻辑落点

虚拟订单类里,CheckTrigger() 负责把挂单变成市价持仓。函数先通过 s_symbolInfo.Name(m_symbol) 锁定品种,再 RefreshRates() 拉一次实时报价——这一步漏掉,后面用的 Ask/Bid 可能是旧值,回测和实盘都会偏。 买向挂单的触发判定很直接:BUY_LIMIT 在 price <= m_openPrice 时成交,BUY_STOP 在 price >= m_openPrice 时成交;卖向反之,SELL_LIMIT 看 price >= 挂单价,SELL_STOP 看 price <= 挂单价。命中后 m_type 被改写成 ORDER_TYPE_BUY 或 ORDER_TYPE_SELL,订单身份就此切换。 切换完成后若 IsMarketOrder() 为真,就把当前 price 记进 m_openPrice,并先后调 m_receiver.OnOpen 与 m_strategy.OnOpen。注意这里用的是 GetPointer(this),把对象指针抛给接收器,策略层才能接着跑开仓后的动作。 初始化那段也值得盯:IsBuyOrder() 分支里市价单直接取 s_symbolInfo.Ask() 填 m_openPrice,卖单取 Bid();若 SL/TP 以 points 传入,需相对 open price 先算价格层级再存。外汇与贵金属杠杆高,虚拟订单仅模拟触发,实盘滑点可能让成交价偏离挂单位 1~3 点。

MQL5 / C++
class="type">bool inPoints = false class=class="str">"cmt">// Are the SL and TP levels set in points?
) {
  ...
  if(s_symbolInfo.Name(symbol)) { class=class="str">"cmt">// Select the desired symbol
    s_symbolInfo.RefreshRates(); class=class="str">"cmt">// Update information about current prices
    class=class="str">"cmt">// Initialize position properties
    m_openPrice = price;
    ...
    m_expiration = expiration;
    class=class="str">"cmt">// The position(order) being opened is not closed by SL, TP or expiration
    ...
    m_isExpired = false;
    ...
    class=class="str">"cmt">// Depending on the direction, set the opening price, as well as the SL and TP levels.
    class=class="str">"cmt">// If SL and TP are specified in points, then we first calculate their price levels
    class=class="str">"cmt">// relative to the open price
    if(IsBuyOrder()) {
      if(type == ORDER_TYPE_BUY) {
        m_openPrice = s_symbolInfo.Ask();
      }
      ...
    } else if(IsSellOrder()) {
      if(type == ORDER_TYPE_SELL) {
        m_openPrice = s_symbolInfo.Bid();
      }
      ...
    }
    ...
    class="kw">return true;
  }
  class="kw">return false;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Check whether a pending order is triggered                       |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">bool CVirtualOrder::CheckTrigger() {
  if(IsPendingOrder()) {
    s_symbolInfo.Name(m_symbol);     class=class="str">"cmt">// Select the desired symbol
    s_symbolInfo.RefreshRates();     class=class="str">"cmt">// Update information about current prices
    class="type">class="kw">double price = (IsBuyOrder()) ? s_symbolInfo.Ask() : s_symbolInfo.Bid();
    class="type">int spread = s_symbolInfo.Spread();
    class=class="str">"cmt">// If the price has reached the opening levels, turn the order into a position
    if(false
        || (m_type == ORDER_TYPE_BUY_LIMIT && price <= m_openPrice)
        || (m_type == ORDER_TYPE_BUY_STOP  && price >= m_openPrice)
        ) {
      m_type = ORDER_TYPE_BUY;
    } else if(false
            || (m_type == ORDER_TYPE_SELL_LIMIT && price >= m_openPrice)
            || (m_type == ORDER_TYPE_SELL_STOP  && price <= m_openPrice)
            ) {
      m_type = ORDER_TYPE_SELL;
    }
    class=class="str">"cmt">// If the order turned into a position 
    if(IsMarketOrder()) {
      m_openPrice = price; class=class="str">"cmt">// Remember the open price
      class=class="str">"cmt">// Notify the recipient and the strategy of the position opening 
      m_receiver.OnOpen(GetPointer(this));
      m_strategy.OnOpen();

「挂单触发与到期的内部判定逻辑」

虚拟订单的 Tick 处理只做两件事:持仓时查平仓条件,挂单时查触发条件。代码里 IsOpen() 分支调用 CheckClose(),未触发则对挂单走 CheckTrigger(),结构很薄,方便在 EA 主循环里高频调用而不卡顿。 CheckClose() 对挂单多了一道过期判断:当 m_expiration 大于 0 且小于 TimeCurrent() 时,把 m_isExpired 置真并返回 true。这意味着你在回测里若设了 m_ordersExpiration,挂单超过对应秒数会静默作废,不会报成交。 OpenBuyOrder() 里有一行容易被忽略:distance = MathMax(m_openDistance, spread)。开盘价被推到 ask + distance * point,保证挂单距离不小于点差。外汇与贵金属杠杆高、滑点随机,这种下限保护能避免挂单因点差扩张被瞬间吞掉,但无法消除跳空风险。 循环开单时遍历 m_maxCountOfOrders,碰到第一个 IsOpen() 为假的槽位就塞 BUY_STOP。想验证就自己改 m_openDistance 为 0,看代码是否退化为市价附近挂单。

MQL5 / C++
class="type">void CVirtualOrder::Tick() {
  if(IsOpen()) {
    if(CheckClose()) {
      Close();
    } else if (IsPendingOrder()) {
      CheckTrigger();
    }
  }
}
class="type">bool CVirtualOrder::CheckClose() {
  if(IsMarketOrder()) {
    class=class="str">"cmt">// Check that the price has reached SL or TP
  } else if(IsPendingOrder()) {
    if(m_expiration > class="num">0 && m_expiration < TimeCurrent()) {
      m_isExpired = true;
      class="kw">return true;
    }
  }
  class="kw">return false;
}
class="type">void CSimpleVolumesStrategy::OpenBuyOrder() {
  class="type">int distance = MathMax(m_openDistance, spread);
  class="type">class="kw">double price = ask + distance * point;
  class="type">class="kw">datetime expiration = TimeCurrent() + m_ordersExpiration * class="num">60;
  for(class="type">int i = class="num">0; i < m_maxCountOfOrders; i++) {
    if(!m_orders[i].IsOpen()) {
      if(m_openDistance > class="num">0) {
        res = m_orders[i].Open(m_symbol, ORDER_TYPE_BUY_STOP, m_fixedLot,
              NormalizeDouble(price, digits),

常见问题

用虚拟挂单思路:在 EA 内部用数组或类记录各币种挂单价格与方向,轮询行情触发条件,命中后再调用市价开仓接口,不依赖经纪商挂单。
把挂单与持仓状态序列化到本地文件或全局变量,启动时读取还原;重点保存品种、挂单类型、触发价、有效期等字段。
可以,小布能按你给的品种和触发规则跑一遍历史 tick 模拟,标出哪些虚拟挂单在该触发时没转持仓,省去你手算。
建一个统一的虚拟订单类,用枚举字段存类型,判定时只比价格和方向;开仓接口按类型走不同市价指令,避免散落 if-else。
每次 tick 同时比当前时间和挂单到期时间,超期直接从虚拟列表删除并写日志;别等平台报错,自己内置到期判定最稳。