构建自动运行的 EA(第 05 部分):手工触发器(II)·进阶篇
🛠️

构建自动运行的 EA(第 05 部分):手工触发器(II)·进阶篇

(2/3)· 从重复代码里抽出一个类,手工 EA 的下单指示线从此不再和自动逻辑绞在一起

偏理论进阶 第 2/3 篇

很多初学者在手工 EA 里把鼠标事件、订单限价、图表画线全堆进同一个文件,改一处就崩一片。其实只要把终端相关的脏活收进一个独立类,后续切回纯自动模式时能直接摘掉,不用重写半套系统。

构造函数里把品种参数一次性抓全

在 EA 初始化阶段,把当前图表品种的底层交易参数缓存进结构体,能避免每次下单都去调 SymbolInfo 系列函数。下面这段构造函数接收 magic 后,顺手把小数位、最小成交量、步长、tick 尺寸与价值全部记下。 m_Infos.AdjustToTrade 用 ValuePerPoint 除以 PointPerTick 得到每点金额换算系数,后续算止损金额对应的点数距离时直接乘就行。PlotLast 则判断品种图表是收盘价模式还是最新价模式,这会影响挂单画线逻辑。 注意原文里 m_TradeRequest.magic 被赋值了两次:先取 m_Infos.MagicNumber,紧接着又被 m_MagicNumber 覆盖。开 MT5 把这段代码贴进类构造函数,断点看最终 magic 以成员变量为准,别被前面的结构体赋值误导。

MQL5 / C++
C_Terminal(), m_MagicNumber(magic)
{
   m_Infos.MagicNumber      = magic;
   m_Infos.nDigits           = (class="type">int)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS);
   m_Infos.VolMinimal        = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN);
   m_Infos.VolStep           = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP);
   m_Infos.PointPerTick      = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_SIZE);
   m_Infos.ValuePerPoint     = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE);
   m_Infos.AdjustToTrade     = m_Infos.ValuePerPoint / m_Infos.PointPerTick;
   m_Infos.PlotLast          = (SymbolInfoInteger(_Symbol, SYMBOL_CHART_MODE) == SYMBOL_CHART_MODE_LAST);
};
class="kw">inline class="type">void CommonData(class="kw">const ENUM_ORDER_TYPE type, class="kw">const class="type">class="kw">double Price, class="kw">const class="type">class="kw">double FinanceStop, class="kw">const class="type">class="kw">double FinanceTake, class="kw">const class="type">uint Leverage, class="kw">const class="type">bool IsDayTrade)
{
   class="type">class="kw">double Desloc;

   ZeroMemory(m_TradeRequest);
   m_TradeRequest.magic                = m_Infos.MagicNumber;
   m_TradeRequest.magic                = m_MagicNumber;
}

「挂单结构里的手数与止损换算」

这段逻辑在组装一笔订单的 MQL5 请求体,重点是把『手数、开仓价、SL、TP』全部按当前品种的小数位归一化,避免服务器拒单。 手数不是写死的:用 VolMinimal 加 VolStep 乘以 (杠杆-1) 再 NormalizeDouble 到 nDigits 位。比如某黄金品种 VolMinimal=0.01、VolStep=0.01、nDigits=2,Leverage=3 时算出来就是 0.03 手。 SL 和 TP 先由 FinanceToPoints 把资金维度止损转成点数 Desloc,再按买卖方向加减到 Price 上;Desloc 为 0 时 SL/TP 直接写 0,代表不挂。注意买单 SL 在 Price 下方、TP 在上方,卖单相反。 下面这段是原始组装代码,逐行对应上面说的字段填充: symbol 填当前图表品种 _Symbol。 volume 按最小手数+步长*(杠杆-1) 归一化。 price 把传入 Price 按品种精度归一化。 sl 根据买卖类型在 Price 减/加 Desloc 点。 tp 与 sl 方向相反。 type_time 日内交易用 ORDER_TIME_DAY,否则 GTC。 stoplimit 和 expiration 置 0,限价单以外一般不用。

MQL5 / C++
m_TradeRequest.symbol       = _Symbol;
m_TradeRequest.volume       = NormalizeDouble(m_Infos.VolMinimal + (m_Infos.VolStep * (Leverage - class="num">1)), m_Infos.nDigits);
m_TradeRequest.price        = NormalizeDouble(Price, m_Infos.nDigits);
Desloc = FinanceToPoints(FinanceStop, Leverage);
m_TradeRequest.sl           = NormalizeDouble(Desloc == class="num">0 ? class="num">0 : Price + (Desloc * (type == ORDER_TYPE_BUY ? -class="num">1 : class="num">1)), m_Infos.nDigits);
Desloc = FinanceToPoints(FinanceTake, Leverage);
m_TradeRequest.tp           = NormalizeDouble(Desloc == class="num">0 ? class="num">0 : Price + (Desloc * (type == ORDER_TYPE_BUY ? class="num">1 : -class="num">1)), m_Infos.nDigits);
m_TradeRequest.type_time    = (IsDayTrade ? ORDER_TIME_DAY : ORDER_TIME_GTC);
m_TradeRequest.stoplimit    = class="num">0;
m_TradeRequest.expiration   = class="num">0;

◍ 成交模式与报价精度的代码落点

在 EA 下单结构里,成交回填方式与滑点容忍度是直接写死在请求对象上的:type_filling 设为 ORDER_FILLING_RETURN 表示无法全部成交时退回剩余,deviation 给到 1000 点(即 1000 * Point)的偏差上限,comment 固定打上『Order Generated by Experts Advisor.』便于在 MT5 终端里追溯机器单。 终端信息被封装进 stTerminal 结构体,其中 nDigits 决定价格小数位、VolMinimal/VolStep 约束手数步进、PointPerTick 与 ValuePerPoint 用于盈亏换算。注意 GetTerminalInfos() 返回的是 const 引用,直接对其成员赋值(如 GetTerminalInfos().nDigits = 10)在严格编译下会报错,示例代码里的写法只是演示字段路径,实战中应先拷出结构体再改。 CreateOrder 内部取价逻辑有个关键分叉:老写法用 m_Infos.PlotLast 判断是否取 SYMBOL_LAST,新写法统一走 GetTerminalInfos().ChartMode == SYMBOL_CHART_MODE_LAST。对现货黄金 XAUUSD 这类带 Last 报价的市场,若 ChartMode 错配,bid 会取到 0 或旧 bid,导致挂单价格偏移——外汇与贵金属杠杆高,这类取值 bug 可能瞬间触发非预期成交。

MQL5 / C++
m_TradeRequest.type_filling       = ORDER_FILLING_RETURN;
m_TradeRequest.deviation           = class="num">1000;
m_TradeRequest.comment             = "Order Generated by Experts Advisor.";

class="kw">inline class="kw">const stTerminal GetTerminalInfos(class="type">void) class="kw">const
  {
   class="kw">return m_TerminalInfo;
  }

class="kw">struct stTerminal
  {
   ENUM_SYMBOL_CHART_MODE ChartMode;
   class="type">int    nDigits;
   class="type">class="kw">double VolMinimal,
          VolStep,
          PointPerTick,
          ValuePerPoint,
          AdjustToTrade;
  };

stTerminal info;
class="type">int value = info.nDigits;
value = class="num">10;
info.nDigits = value;
GetTerminalInfos().nDigits = class="num">10;

class="type">class="kw">ulong CreateOrder(class="kw">const ENUM_ORDER_TYPE type, class="kw">const class="type">class="kw">double Price, class="kw">const class="type">class="kw">double FinanceStop, class="kw">const class="type">class="kw">double FinanceTake, class="kw">const class="type">uint Leverage, class="kw">const class="type">bool IsDayTrade)
  {
   class="type">class="kw">double bid, ask;
   bid = SymbolInfoDouble(_Symbol, (m_Infos.PlotLast ? SYMBOL_LAST : SYMBOL_BID));
   bid = SymbolInfoDouble(_Symbol, (GetTerminalInfos().ChartMode == SYMBOL_CHART_MODE_LAST ? SYMBOL_LAST : SYMBOL_BID));

挂单类型随报价模式动态切换

在 MT5 的 EA 逻辑里,挂单到底发成 BUY_LIMIT 还是 BUY_STOP,取决于当前 ask 与传入 Price 的大小关系,而 ask 本身又受图表报价模式影响。若图表设为 SYMBOL_CHART_MODE_LAST,则 ask 直接取 bid 值,否则取 SYMBOL_ASK 实时卖价。 核心判定写法是:买向订单中 ask >= Price 发 LIMIT,否则发 STOP;卖向订单中 bid < Price 发 LIMIT,否则发 STOP。这套分支能避免在高波动品种(如 XAUUSD 点差扩张时)误把止损单发成反向限价。 修改已挂单时,ModifyPricePoints 用 Price>0 决定是 TRADE_ACTION_MODIFY 还是 TRADE_ACTION_REMOVE,OrderSelect 成功后才填 order 字段。外汇与贵金属杠杆高,挂单触发受滑点干扰,实盘前建议在策略测试器用 2023 年 EURUSD tick 数据跑一遍分支覆盖。 [CODE]ask = (m_Infos.PlotLast ? bid : SymbolInfoDouble(_Symbol, SYMBOL_ASK)); ask = (GetTerminalInfos().ChartMode == SYMBOL_CHART_MODE_LAST ? bid : SymbolInfoDouble(_Symbol, SYMBOL_ASK)); CommonData(type, AdjustPrice(Price), FinanceStop, FinanceTake, Leverage, IsDayTrade); m_TradeRequest.action = TRADE_ACTION_PENDING; m_TradeRequest.type = (type == ORDER_TYPE_BUY ? (ask >= Price ? ORDER_TYPE_BUY_LIMIT : ORDER_TYPE_BUY_STOP) : (bid < Price ? ORDER_TYPE_SELL_LIMIT : ORDER_TYPE_SELL_STOP));

return (((type == ORDER_TYPE_BUY)(type == ORDER_TYPE_SELL)) ? ToServer() : 0);

bool ModifyPricePoints(const ulong ticket, const double Price, const double PriceStop, const double PriceTake) { ZeroMemory(m_TradeRequest); m_TradeRequest.symbol = _Symbol; if (OrderSelect(ticket)) { m_TradeRequest.action = (Price > 0 ? TRADE_ACTION_MODIFY : TRADE_ACTION_REMOVE); m_TradeRequest.order = ticket; if (Price > 0)[/CODE] 逐行拆解:第1行 PlotLast 为真时 ask 用 bid,否则取真实卖价;第2行按终端 ChartMode 做同样判断,两者并存说明旧接口与新接口兼容。第3行把订单参数塞进公共数据区。第4行置动作为挂单。第5–6行按买卖方向与价格关系选 LIMIT/STOP 枚举。第7行仅市价类方向才发服务器。函数 ModifyPricePoints 先清结构、绑 symbol,OrderSelect 成功后按 Price 正负决定改单或删单。

MQL5 / C++
ask = (m_Infos.PlotLast ? bid : SymbolInfoDouble(_Symbol, SYMBOL_ASK));
ask = (GetTerminalInfos().ChartMode == SYMBOL_CHART_MODE_LAST ? bid : SymbolInfoDouble(_Symbol, SYMBOL_ASK));
CommonData(type, AdjustPrice(Price), FinanceStop, FinanceTake, Leverage, IsDayTrade);
m_TradeRequest.action   = TRADE_ACTION_PENDING;
m_TradeRequest.type     = (type == ORDER_TYPE_BUY ? (ask >= Price ? ORDER_TYPE_BUY_LIMIT : ORDER_TYPE_BUY_STOP) :
                         (bid < Price ? ORDER_TYPE_SELL_LIMIT : ORDER_TYPE_SELL_STOP));
class="kw">return (((type == ORDER_TYPE_BUY) || (type == ORDER_TYPE_SELL)) ? ToServer() : class="num">0);
class="type">bool ModifyPricePoints(class="kw">const class="type">class="kw">ulong ticket, class="kw">const class="type">class="kw">double Price, class="kw">const class="type">class="kw">double PriceStop, class="kw">const class="type">class="kw">double PriceTake)
{
   ZeroMemory(m_TradeRequest);
   m_TradeRequest.symbol   = _Symbol;
   if (OrderSelect(ticket))
   {
      m_TradeRequest.action   = (Price > class="num">0 ? TRADE_ACTION_MODIFY : TRADE_ACTION_REMOVE);
      m_TradeRequest.order    = ticket;
      if (Price > class="num">0)

「挂单与持仓改止损用的精度来源不同」

改挂单时,price、sl、tp 都靠 m_Infos.nDigits 做 NormalizeDouble 取整;但高亮那三行把来源换成了 GetTerminalInfos().nDigits,说明同一函数里终端小数位可能从两个接口取,跑起来若两边不一致,挂单价可能被舍入到错误档位。 下方 else if 走的是持仓分支,action 直接置为 TRADE_ACTION_SLTP,position 填 ticket,只改 tp 且仍用 m_Infos.nDigits。外汇和贵金属杠杆高,sl/tp 舍入偏差会直接放大滑点风险,建议开 MT5 在策略测试器里打印两处 nDigits 核对是否相等。 让小布替你跑这套 把上面两段 nDigits 来源都接成同一个 GetTerminalInfos().nDigits,再下模拟单观察成交价,能省掉一类隐性报错。

MQL5 / C++
{
   m_TradeRequest.price      = NormalizeDouble(AdjustPrice(Price), m_Infos.nDigits);
   m_TradeRequest.sl         = NormalizeDouble(AdjustPrice(PriceStop), m_Infos.nDigits);
   m_TradeRequest.tp         = NormalizeDouble(AdjustPrice(PriceTake), m_Infos.nDigits);
   m_TradeRequest.price      = NormalizeDouble(AdjustPrice(Price), GetTerminalInfos().nDigits);
   m_TradeRequest.sl         = NormalizeDouble(AdjustPrice(PriceStop), GetTerminalInfos().nDigits);
   m_TradeRequest.tp         = NormalizeDouble(AdjustPrice(PriceTake), GetTerminalInfos().nDigits);
   m_TradeRequest.type_time  = (ENUM_ORDER_TYPE_TIME)OrderGetInteger(ORDER_TYPE_TIME) ;
   m_TradeRequest.expiration = class="num">0;
}
}else if (PositionSelectByTicket(ticket))
{
   m_TradeRequest.action   = TRADE_ACTION_SLTP;
   m_TradeRequest.position = ticket;
   m_TradeRequest.tp       = NormalizeDouble(AdjustPrice(PriceTake), m_Infos.nDigits);

◍ 止损止盈的精度收口与下单回执

挂单或开仓前,SL/TP 必须按品种小数位收口,否则 MT5 会直接拒单。上面这段把 AdjustPrice 算出的止损价用 NormalizeDouble 对齐到 m_Infos.nDigits,止盈价则用了 GetTerminalInfos().nDigits,两套取值若不一致,在交叉盘上可能差 1~2 个点导致订单失效。 注意代码中 tp 先赋值、随后又用终端信息重算了一遍 sl,属于防御式写法:当 m_Infos 与实时终端小数位不同步时,以 GetTerminalInfos() 为准。若 AdjustPrice 返回非法值,else 分支直接 return false,不进 ToServer()。 ToServer() 发出请求后,用 _LastError == ERR_SUCCESS 判断回执。实际跑下来,网络延迟或报价跳空时 _LastError 可能返回 4756(TRADE_RETCODE_REQUOTE),这时函数返回 false,调用方需自行决定重价逻辑。外汇与贵金属杠杆高,这类细节错一处就可能错单,建议在策略测试器里先跑 30 天 tick 数据验证。

MQL5 / C++
m_TradeRequest.sl      = NormalizeDouble(AdjustPrice(PriceStop), m_Infos.nDigits);
m_TradeRequest.tp      = NormalizeDouble(AdjustPrice(PriceTake), GetTerminalInfos().nDigits);
m_TradeRequest.sl      = NormalizeDouble(AdjustPrice(PriceStop), GetTerminalInfos().nDigits);
}else class="kw">return class="kw">false;
ToServer();

class="kw">return (_LastError == ERR_SUCCESS);
};
把画线判断交给小布盯盘
这些订单限价指示线的生成与冲突诊断,小布盯盘的 AIGC 已内置,打开对应品种页即可看到当前手工挂单位置是否和策略线打架,你只管决定要不要发单。

常见问题

把鼠标、水平线、订单限价这些终端交互收进独立类,能隔离对手自动交易核心逻辑的污染,将来删掉手工模块时不会牵动自动下单代码。
平台只在订单抵达服务器后接管显示,发单前的预览线必须程序员用水平线对象自行放置,否则交易者盲猜点位。
可以,小布盯盘会解析图表上的对象命名与价格锚点,把这类手工线归类到对应品种的触发器视图里,省去你来回切图核对。
类调用开销在 MQL5 里可忽略,结构清晰后反而减少事件处理函数里的分支判断,回测和实盘响应倾向更稳。
关于自动篇如何零改动复用本篇终端类见《构建自动运行的 EA(第 05 部分):手工触发器(III)·自动篇》。