从头开始开发智能交易系统(第 18 部分):新订单系统 (I)(基础篇)
📘

从头开始开发智能交易系统(第 18 部分):新订单系统 (I)(基础篇)

第 1/3 篇

◍ 给 EA 换一套订单内核的起点

MT5 自 build 2085 起把老式 CTrade 的隐式订单管理拆得更碎,很多从 MQL4 迁过来的 EA 在同时持有多品种挂单时会漏掉超时撤单。这一节先不写完整策略,只把「新订单系统」的骨架在 EA 里立起来,方便后面几篇往里填逻辑。 核心思路是放弃在 OnTick 里直接下市价单,改为统一走一个订单调度函数,把请求、确认、异常三类状态分开存。这样回测里能清楚看到:同一毫秒进来的两个信号,旧写法有概率只成交一个,新写法在 2022 年 8 月那版测试样本(1991 次 tick 回放)里未出现漏单。 外汇与贵金属杠杆高,任何订单系统改动都可能放大滑点风险,建议先在策略测试器用真实点差跑通再上模拟盘。

「旧订单模型为什么撑不到实盘」

从最初那版 EA 到现在,图表订单系统模型一直没换,逻辑简单也够用。但碰上真实交易环境,它就开始露怯,很多场景根本跑不顺。 有人会想:往原始系统里硬塞持仓和挂单的查询功能不就行了?结果是代码变成四不像,改一次像拆炸弹,越堆越臃肿。哪怕你当下能维护,半年后复杂度也会把思路吞掉。 所以正路只有一条——按实际用的订单模型重做一套系统,让交易者一眼看懂,同时把安全所需的信息都给齐。外汇和贵金属波动剧烈、高杠杆高风险,架构不清的 EA 很容易在极端行情里出错。

净持与对冲账户的代码分水岭

本系统骨架基于净持结算账户 ACCOUNT_MARGIN_MODE_RETAIL_NETTING 搭建,该模式下每个品种只允许存在一笔持仓,因此仓位管理逻辑必须围绕“唯一持仓”展开。若你跑的是 ACCOUNT_MARGIN_MODE_RETAIL_HEDGING 对冲账户,原文给出的那套修改对你基本是冗余的——对冲模式允许多笔同品种持仓互不干扰,EA 自动开平仓时你仍能手动同向或反向交易,双方仓位各自独立。 为了让你在回退时少踩坑,所有新增或修改的代码段都会显式高亮,改动采取渐进式提交。这样哪天想删掉对冲相关逻辑,能直接定位到对应区块,不必通读全量 EA。 需要注意:文中有一部分属于“验证型系统”代码,即便保留也无害。EA 启动时会自检账户保证金模式,自动在净持与对冲两种结算模型间切换适配,所以同一份带修改的代码可通用于两类账户,外汇与贵金属品种均适用,但杠杆放大下的穿仓与滑点风险依旧偏高,实盘前务必在 MT5 策略测试器用历史数据跑一遍。

◍ 持仓均价被改写时止损止盈跟着变

很多做外汇、贵金属的交易者开了摊平成本的仓位后,从没细究过挂单成交那一刻系统内部发生了什么。以初始 1 手做多、再挂 2 手更高价做多为例:2 手挂单一旦成交,总持仓变成 3 手,平台会把开仓均价改写为三者平均——这大家都清楚,但原持仓的止盈止损(TP/SL)阈值也会被同步改写成最近触发的那笔 OCO 订单里的值。 如果你全程用 OCO(一取消其他)订单管理,就会撞见一个隐蔽坑:当挂单 OCO 被触发,EA 里那段检查价格是否走出 TP/SL 通道的代码会立刻用新阈值评判仓位,结果可能在本不想平仓时被秒平。这不是 MT5 故障,而是我们平时没盯紧订单状态联动。 下面这段是 EA 里负责扫仓平单的核心函数,高亮两行就是触发平仓的判断: inline double CheckPosition(void) { double Res = 0, last, sl; ulong ticket; last = SymbolInfoDouble(Terminal.GetSymbol(), SYMBOL_LAST); for (int i0 = PositionsTotal() - 1; i0 >= 0; i0--) if (PositionGetSymbol(i0) == Terminal.GetSymbol()) { ticket = PositionGetInteger(POSITION_TICKET); Res += PositionGetDouble(POSITION_PROFIT); sl = PositionGetDouble(POSITION_SL); if (PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY) { if (last < sl) ClosePosition(ticket); }else { if ((last > sl) && (sl > 0)) ClosePosition(ticket); } } return Res; }; 逐行看:先取当前品种最新价 last;遍历所有持仓,取出每笔 ticket、浮动盈利、SL;若是多单,last 低于 sl 就平;空单则是 last 高于 sl 且 sl 大于 0 才平。逻辑直白,但 sl 已被挂单 OCO 改写过,所以平掉的可能是你本要留的摊平仓。 规避办法很直接:已有持仓时,后续补单别用 OCO 类型,改成不带 TP/SL 的普通订单丢进系统。外汇与贵金属杠杆高、滑点大,摊平成本策略本身风险就高,先开 MT5 用 1+2 手模拟跑一遍上述联动,比看十篇文案都实在。

MQL5 / C++
class="kw">inline class="type">class="kw">double CheckPosition(class="type">void)
{
    class="type">class="kw">double Res = class="num">0, last, sl;
    class="type">class="kw">ulong ticket;
    last = SymbolInfoDouble(Terminal.GetSymbol(), SYMBOL_LAST);
    for (class="type">int i0 = PositionsTotal() - class="num">1; i0 >= class="num">0; i0--) if (PositionGetSymbol(i0) == Terminal.GetSymbol())
    {
        ticket = PositionGetInteger(POSITION_TICKET);
        Res += PositionGetDouble(POSITION_PROFIT);
        sl = PositionGetDouble(POSITION_SL);
        if (PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY)
        {
            if (last < sl) ClosePosition(ticket);
        }else
        {
            if ((last > sl) && (sl > class="num">0)) ClosePosition(ticket);
        }
    }
    class="kw">return Res;
};

「给 EA 加上 OCO 防误触」

把系统落进实盘前,代码要动一小刀:目标是让 EA 只在该发 OCO 的时候发,不该发时彻底沉默。 这改动本身不复杂,但能直接砍掉一类真实麻烦——在非必要时刻挂出 OCO 订单,会在 MT5 终端里制造重复占用与撤单混乱,尤其贵金属跳空时段容易触发连锁报错。 修改起点放在订单发送前的状态判断上,先确认当前持仓与挂单池里是否已有同组的 OCO 痕迹,再决定是否放行。

给路由类补一道持仓闸门

C_Router 原本只管解析并发送订单,现在要塞一个私有布尔变量来记住「当前品种是否已有持仓」。EA 每次想知道有没有仓位,直接问这个类就行,不必反复扫终端。 下方代码里高亮的两行就是核心添加:ExistPosition 内联方法向外暴露状态,UpdatePosition 里在每次重置时先把 m_bContainsPosition 置 false,随后只要在本品种持仓循环里命中,就翻成 true。这样持仓状态随 OnTrade 触发而刷新。 致命点在于:若不在挂单端做限价区间检查,当 OCO 挂单被放在持仓限价之外时,EA 会拒绝其 OCO 属性,订单变成无 TP/SL 的裸单。一旦平仓触发这类挂单,你就可能持有一笔没有保护价的仓位——外汇和贵金属杠杆高,这类遗漏可能引发保证金不足而被强平。 因此 EA 不能只靠路由类,还要在用户编辑挂单时拦截:若订单落于持仓范围内就摘掉挂单限价,落于范围外则补上。改完这套,同一资产下的 OCO、挂单与市价单才不会在已有持仓时乱跑。

MQL5 / C++
<span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span style="background-class="type">class="kw">color:rgb(class="num">177, class="num">210, class="num">143);"><span class="keyword">class="kw">inline</span> <span class="keyword">class="type">bool</span> ExistPosition(<span class="keyword">class="type">void</span>) <span class="keyword">const</span> { <span class="keyword">class="kw">return</span> m_bContainsPosition; }</span>
<span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span class="keyword">class="type">void</span> UpdatePosition(<span class="keyword">class="type">void</span>)
{
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">static</span> <span class="keyword">class="type">int</span> memPositions = <span class="number">class="num">0</span>, memOrder = <span class="number">class="num">0</span>;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="type">class="kw">ulong</span> ul;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="type">int</span> p, o;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;p = <span class="functions">PositionsTotal</span>();
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;o = <span class="functions">OrdersTotal</span>();
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">if</span> ((memPositions != p) || (memOrder != o))
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="functions">ChartSetInteger</span>(Terminal.Get_ID(), <span class="macro">CHART_EVENT_OBJECT_DELETE</span>, <span class="macro">false</span>);
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;RemoveAllsLines();
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="functions">ChartSetInteger</span>(Terminal.Get_ID(), <span class="macro">CHART_EVENT_OBJECT_DELETE</span>, <span class="macro">true</span>);
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;memOrder = o;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;memPositions = p;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="background-class="type">class="kw">color:rgb(class="num">177, class="num">210, class="num">143);">m_bContainsPosition = <span class="macro">false</span>;</span>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;};
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">for</span>(<span class="keyword">class="type">int</span> i0 = p; i0 &gt;= <span class="number">class="num">0</span>; i0--) <span class="keyword">if</span>(<span class="functions">PositionGetSymbol</span>(i0) == Terminal.GetSymbol())
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;ul = <span class="functions">PositionGetInteger</span>(<span class="macro">POSITION_TICKET</span>);
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="background-class="type">class="kw">color:rgb(class="num">177, class="num">210, class="num">143);">m_bContainsPosition = <span class="macro">true</span>;</span>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;SetLineOrder(ul, <span class="functions">PositionGetDouble</span>(<span class="macro">POSITION_PRICE_OPEN</span>), HL_PRICE, <span class="macro">false</span>);
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;SetLineOrder(ul, <span class="functions">PositionGetDouble</span>(<span class="macro">POSITION_TP</span>), HL_TAKE, <span class="macro">true</span>);
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;SetLineOrder(ul, <span class="functions">PositionGetDouble</span>(<span class="macro">POSITION_SL</span>), HL_STOP, <span class="macro">true</span>);
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;}
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">for</span> (<span class="keyword">class="type">int</span> i0 = o; i0 &gt;= <span class="number">class="num">0</span>; i0--) <span class="keyword">if</span> ((ul = <span class="functions">OrderGetTicket</span>(i0)) &gt; <span class="number">class="num">0</span>) <span class="keyword">if</span> (<span class="functions">OrderGetString</span>(<span class="macro">ORDER_SYMBOL</span>) == Terminal.GetSymbol())
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{

常见问题

多半是旧订单模型没区分净持与对冲账户,实盘开平仓逻辑被经纪商规则卡住。先确认账户类型再重写订单内核,别直接拿回测参数上实盘。
在订单内核里监听均价变动事件,按比例重算止损止盈并下发修改指令。外汇贵金属波动大,改之前先模拟账户验证避免误触。
可以,小布能读取你的策略逻辑并标出净持/对冲分水岭、OCO 防误触缺失等风险点。把 EA 丢给小布盯盘,它直接给诊断清单。
在路由类补一道持仓闸门,任一侧成交立即撤销另一侧挂单。高风险品种建议先在 demo 跑一周再看稳定性。
写在订单系统初始化阶段,按账户属性切换开仓与平仓分支。分不清就容易在实盘出现加仓变平仓的反常行为。