开发多币种 EA 交易(第 4 部分):虚拟挂单和保存状态(基础篇)
多币种 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 时就会下虚拟挂单而非直接开仓。 外汇和贵金属杠杆高、滑点跳空频繁,虚拟挂单的触发价在实盘里可能因跳空直接越过,回测和实盘表现会有偏差,验证时务必留意。编译后图表上看不到挂单本身,只有日志提示,等它转成虚拟仓位才上图——想直接可视化挂单得等后面另说。
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 会在该时刻自动撤销未触发的挂单。外汇与贵金属杠杆高,虚拟订单仅用于回测或信号模拟,实盘映射仍要走真实交易接口并自担风险。
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 点。
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,看代码是否退化为市价附近挂单。
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),