MVC 设计范式及其可能的应用·综合运用
🧩

MVC 设计范式及其可能的应用·综合运用

(3/3)· 前两层铺垫完概念与单组件写法,这篇用完整 EA 把三者真正接起来跑通

进阶 第 3/3 篇
把信号计算、画线提示和按键响应全写进同一个 OnTick,是多数 EA 维护崩盘的开始。改一个平仓逻辑,结果图表对象跟着出问题,这种耦合在加功能时会成倍放大。用 MVC 把职责切干净,不是优雅癖,是给自己留后路。

◍ 挂单簇的止损对称与台账回收

这段逻辑先把交易对象设为当前品种的成交填充方式,再用上一根 K 线的高低点外扩 delta 点距,分别挂出 BuyStop 与 SellStop。买挂单触发价取前一根高点加 delta*point,卖挂单取前一根低点减 delta*point,两者互为对方的止损位——也就是突破反向即砍,属于典型的通道外破挂单。 止损跨度按 (dEnterBuy - dEnterSell) * tAkeProfitKoeff 算,再均摊到两端做止盈。若 tAkeProfitKoeff 设为 1.5,则止盈距离是挂单间距的 1.5 倍,盈亏比倾向大于 1,但外汇与贵金属杠杆高,滑点扩大时实际触发价可能偏离。 ClosePositions 用倒序循环清掉所有持仓,CloseOrder 则按交易对名精确删挂单并 break,避免重复扫。OnTrade 里用 bTicket/sTicket 数组对账,某一侧挂单失效就删掉对侧挂单并减 iOrders,防止半边仓裸奔。 开 MT5 把这段塞进 EA 的 CView 类,先只跑一个符号、把 iCount 设 1,观察挂单间距与止损对称是否符合你的点值预期再扩品种。

MQL5 / C++
  trade.SetMarginMode();
  trade.SetDeviationInPoints(iSlippage);

  class="type">class="kw">double dEnterBuy, dEnterSell;
  class="type">class="kw">double dTpBuy,    dTpSell;
  class="type">class="kw">double dSlBuy,    dSlSell;
  class="type">class="kw">double dSlSize;

  for (class="type">int i = class="num">0; i < init.iCount; i++) {
    dEnterBuy  = NormalizeDouble(iHigh(init.names[i],class="num">0,class="num">1) + delta * init.points[i], _Digits);
    dEnterSell = NormalizeDouble(iLow(init.names[i],class="num">0,class="num">1)  - delta * init.points[i], _Digits);
    dSlSell    = dEnterBuy;
    dSlBuy     = dEnterSell;
    dSlSize    = (dEnterBuy - dEnterSell) * tAkeProfitKoeff;
    dTpBuy     = NormalizeDouble(dEnterBuy + dSlSize, _Digits);
    dTpSell    = NormalizeDouble(dEnterSell - dSlSize, _Digits);

    trade.SetTypeFillingBySymbol(init.names[i]);

    trade.BuyStop(volumes,  dEnterBuy,  init.names[i], dSlBuy,  dTpBuy);
    trade.SellStop(volumes, dEnterSell, init.names[i], dSlSell, dTpSell);
  }
}
class="type">void ClosePositions() {
  CTrade trade;

  for (class="type">int i = PositionsTotal() - class="num">1; i >= class="num">0; i--) {
    trade.PositionClose(PositionGetTicket(i) );
  }
}
class="type">void CloseOrder(class="type">class="kw">string pair) {
  CTrade trade;

  class="type">class="kw">ulong ticket;
  for (class="type">int i = OrdersTotal() - class="num">1; i >= class="num">0; i--) {
    ticket = OrderGetTicket(i);
    if (StringCompare(OrderGetString(ORDER_SYMBOL), pair) == class="num">0) {
      trade.OrderDelete(ticket);
      break;
    }
  }
}
class="macro">#include <Trade\Trade.mqh>
class CView {
class="kw">public:
  class="type">void CView() {}
  class="type">void Initialize();
  class="type">void Enter();
  class="type">void ClosePositions();
  class="type">void CloseAllOrder();
  class="type">void OnTrade();
class="kw">private:
  class="type">void InitTicketArray() {
    ArrayInitialize(bTicket, class="num">0);
    ArrayInitialize(sTicket, class="num">0);
    iOrders = class="num">0;
  }
  CTrade trade;
  class="type">int     iOrders;
  class="type">class="kw">ulong   bTicket[], sTicket[];
};
class="type">void CView::OnTrade() {
  if (OrdersTotal() == iOrders) class="kw">return;

  for (class="type">int i = class="num">0; i < init.iCount; i++) {
    if (bTicket[i] != class="num">0 && !OrderSelect(bTicket[i]) ) {
      bTicket[i] = class="num">0; iOrders--;
      if (sTicket[i] != class="num">0) {
        trade.OrderDelete(sTicket[i]);
        sTicket[i] = class="num">0; iOrders--;
      }
      class="kw">continue;
    }

「挂单与参数怎么落进代码里」

这套多品种日元交叉盘策略,核心是把上一根 K 线的高低点外扩一定点数后挂 BuyStop / SellStop,靠止损互换和倍数止盈来框定盈亏比。下面这段是实际挂在 MT5 上能跑的挂单与初始化逻辑,输入参数直接决定了触发位置和仓位。 先看外部输入:delta=200 表示在 iHigh(...,0,1) 之上 200 点挂买、iLow(...,0,1) 之下 200 点挂卖;tAkeProfitKoeff=2 意味着止盈距离是买卖挂单价差的 2 倍;volumes=0.03 是每单手数,六个品种同时挂双向单,一开就是 12 张挂单。外汇与贵金属杠杆高,0.03 手在 AUDJPY 这类高波动交叉盘上也可能在单日吐掉远超本金比例的点数,需自测承受度。 Enter() 里把 dSlBuy 设为 dEnterSell、dSlSell 设为 dEnterBuy,相当于多空互为止损;dSlSize 算出价差后乘 2 给到 TP。Initialize() 里 trade.SetDeviationInPoints(15) 把滑点容差锁在 15 点,ArrayResize 按 init.iCount 开好票号数组。 如果 OrderSelect 失败会把对应挂单票号清零并减计数,同时尝试删掉反向挂单,避免僵尸单堆着吃保证金。ClosePositions() 和 CloseAllOrder() 分别按持仓和挂单倒序清场,最后 InitTicketArray() 复位。

MQL5 / C++
if (sTicket[i] != class="num">0 && !OrderSelect(sTicket[i]) ) {
      sTicket[i] = class="num">0; iOrders--;
      if (bTicket[i] != class="num">0) {
         trade.OrderDelete(bTicket[i]);
         bTicket[i] = class="num">0; iOrders--;
      }
   }
}
class="type">void CView::Initialize() {
   trade.SetExpertMagicNumber(Magic);
   trade.SetMarginMode();
   trade.SetDeviationInPoints(iSlippage);

   ArrayResize(bTicket, init.iCount);
   ArrayResize(sTicket, init.iCount);

   InitTicketArray();
}
class="type">void CView::Enter() {

   class="type">class="kw">double dEnterBuy, dEnterSell;
   class="type">class="kw">double dTpBuy,    dTpSell;
   class="type">class="kw">double dSlBuy,    dSlSell;
   class="type">class="kw">double dSlSize;

   for (class="type">int i = class="num">0; i < init.iCount; i++) {
      dEnterBuy   = NormalizeDouble(iHigh(init.names[i],class="num">0,class="num">1) + delta * init.points[i], _Digits);
      dEnterSell = NormalizeDouble(iLow(init.names[i],class="num">0,class="num">1)  - delta * init.points[i], _Digits);
      dSlSell     = dEnterBuy;
      dSlBuy      = dEnterSell;
      dSlSize     = (dEnterBuy - dEnterSell) * tAkeProfitKoeff;
      dTpBuy      = NormalizeDouble(dEnterBuy + dSlSize, _Digits);
      dTpSell     = NormalizeDouble(dEnterSell - dSlSize, _Digits);

      trade.SetTypeFillingBySymbol(init.names[i]);

      trade.BuyStop(volumes,  dEnterBuy,  init.names[i], dSlBuy,  dTpBuy);
      bTicket[i] = trade.ResultOrder();

      trade.SellStop(volumes, dEnterSell, init.names[i], dSlSell, dTpSell);
      sTicket[i] = trade.ResultOrder();

      iOrders +=class="num">2;
   }
}
class="type">void CView::ClosePositions() {

   for (class="type">int i = PositionsTotal() - class="num">1; i >= class="num">0; i--) {
      trade.PositionClose(PositionGetTicket(i) );
   }

   InitTicketArray();
}
class="type">void CView::CloseAllOrder() {

   for (class="type">int i = OrdersTotal() - class="num">1; i >= class="num">0; i--) {
      trade.OrderDelete(OrderGetTicket(i));
   }
}
input class="type">class="kw">string smb            = "AUDJPY, CADJPY, EURJPY, NZDJPY, GBPJPY, CHFJPY";
input class="type">class="kw">double delta          = class="num">200;
input class="type">class="kw">double volumes        = class="num">0.03;
input class="type">class="kw">double tAkeProfitKoeff = class="num">2;
input class="type">int   iHourOpen       = class="num">5;
input class="type">int   iHourClose      = class="num">22;
input class="type">int   iSlippage      = class="num">15;
input class="type">int   Magic           = class="num">12345;

把开仓平仓逻辑拆进三件套

这段 MT5 EA 骨架把初始化、模型判断和界面动作分成了三个头文件引用:EA_Init.mqh 管全局参数,View 管下单与持仓界面,Model 管时间窗判断。实际跑起来,OnInit 只做两件事——把品种符号 smb 传给 init,再把 view 初始化好,返回 INIT_SUCCEEDED 即代表加载成功。 OnTick 是盯盘核心:先问 model.TimeToOpen() 是否到开仓时间窗,是就调 view.Enter() 并直接 return,不再往下走;否则检查 model.TimeToClose(),触发则一次性平掉所有挂单和持仓。注意这里平仓分 CloseAllOrder 和 ClosePositions 两步,漏掉任一都会留尾巴。 OnTrade 里只转发给 view.OnTrade(),说明成交回执的刷新完全交给视图层。想在 MT5 验证,把这三个 mqh 补齐后加载到 EURUSD 的 M1,观察日志里 Initialize 是否只跑一次、Tick 触发频率是否跟 K 线对齐。外汇与贵金属杠杆高,实盘前务必用策略测试器跑至少 3 个月历史。

MQL5 / C++
class="macro">#include "EA_Init.mqh"
class="macro">#include "View\View.mqh"
class="macro">#include "Model\Model.mqh"
CInit  init;
CModel model;
CView  view;
class="type">int OnInit()
{
   init.Initialize(smb);
   view.Initialize();
   class="kw">return INIT_SUCCEEDED;
}
class="type">void OnTick() {
   if (model.TimeToOpen() ) {
      view.Enter();
      class="kw">return;
   }
   if (model.TimeToClose() ) {
      view.CloseAllOrder();
      view.ClosePositions();
   }
}
class="type">void OnTrade() {
   view.OnTrade();
}

◍ 视图与控制器的高频耦合隐患

当前这套简易 EA 里,视图对控制器方法的直接调用达到 11 次,全部走全局对象中转。在只挂一个对话框加一个交易面板的轻量结构下还没显出问题,但交互完全依赖 OnTick 里的控制器兜转,模型与视图彼此不直接通信。 外汇与贵金属行情跳动频繁,EA 逻辑一旦扩张,这类跨组件调用很可能翻几倍,把 MVC 原本解耦的优势磨平。想验证的话,开 MT5 在策略测试器里跑扩展前的版本,数一下日志里 CInint 相关入口就能复现这 11 次。 绕开全局对象、改用引用传参访问组件,是更干净的做法,类似 MFC 里文档与视图的挂接方式。范式本身没硬性规定组件怎么互叫,所以这条路留给你按实盘复杂度自己取舍。

「多模型下的模块拆分思路」

当指标或 EA 最初套用 MVC 范式后,若再引入两个模型、一个视图,控制器逻辑会变重。守住 MVC 的关键不是堆代码,而是把每个组件拆成独立模块,各自暴露访问接口,控制器只做调度。 原文给出的六个附件可作验证样本:MVC_primitive_1.zip 仅 0.9 KB 是最粗糙的单文件指标;MVC_EA_primitive.zip 1.47 KB 已是 MVC 骨架的伪 EA;EA_Real.zip 达 71.7 KB,是含模块管理的实盘级 EA。打开 MT5 加载这几个文件,能直观比对范式演进带来的结构差异。 模块级交互在那篇专门讲模块管理的文章里有具体写法。你在自己的 EA 里试把信号模型、仓位模型、绘图视图分别丢进不同 mqh,会比全塞进一个 mq5 更好维护,但也意味着参数传递要更克制,别把 Magik 这类参数散得到处都是。

让小布替你盯组件边界
小布盯盘的 AIGC 已内置结构诊断,打开对应品种页即可看到模型与视图是否越界调用。把重复劳动交给小布,你专注决策。

常见问题

倾向不要。控制器应只负责转发用户或定时器指令给模型,平仓属于模型的数据操作,塞进控制器会让它超重且难测。
可以。小布盯盘的品种页有结构视图,能标出跨层调用痕迹,便于你在重构时快速定位耦合点。
模型只暴露接口,视图通过订阅或轮询拉取;只要模型状态更新后通知机制到位,多视图概率上能保持一致。
小型脚本可能没必要,但涉及多周期信号、面板和后台管理的 EA,拆分后修改成本倾向明显降低。
先抽模型层隔离数据访问,再把绘图挪到视图,最后用控制器接输入,比一次性重写更稳。