开发多币种 EA 交易(第 12 部分):开发自营交易级别风险管理器·进阶篇
🛡️

开发多币种 EA 交易(第 12 部分):开发自营交易级别风险管理器·进阶篇

(2/3)· 为什么历史回测里的10%回撤,实盘前向期说崩就崩?

偏理论进阶 第 2/3 篇
把回测里归一化出的10%回撤当安全垫,是多数多币种EA实盘爆仓的前奏。前向期换了个波动结构,昨天的止损线今天就拦不住亏损。用固定金额硬砍仓却不区分日级与总级,往往在该停时还在补,该活时已经死。

虚拟风控里的日损与总损上限怎么算

在 MT5 的虚拟风控模块里,日损和总损上限都支持三种计算模式:固定金额、基于基础余额的百分比、基于当日基准水平的百分比。DailyLoss() 与 OverallLoss() 两个方法结构对称,只是分别读取各自的限额参数与计算开关。 以 DailyLoss() 为例,当 m_calcDailyLossLimit 设为 RM_CALC_LIMIT_FIXED 时直接返回 m_maxDailyLossLimit;设为 FIXED_PERCENT 时返回 m_baseBalance * m_maxDailyLossLimit / 100;设为 PERCENT 时则返回 m_baseDailyLevel * m_maxDailyLossLimit / 100。总损 OverallLoss() 逻辑完全一致,把日损变量换成总损对应成员即可。 当日内亏损触达上限、状态被置为 RM_STATE_DAILY_LOSS 后,若后续条件恢复,代码会调用 CMoney::DepoPart(m_prevDepoPart) 把占用仓位比例退回,并将 m_state 改回 RM_STATE_OK,再通过 CVirtualReceiver::Instance().Changed() 通知接收端。PrintFormat 会打出 VirtualProfit、Profit、Daily Profit 及恢复后的 depoPart 数值,方便在日志里核对。 外汇与贵金属杠杆高,这类虚拟限额只是保护逻辑的一道闸,实盘仍可能因滑点穿过限值,验证时建议先用策略测试器跑日线级回测看触发频率。

MQL5 / C++
if(m_state == RM_STATE_DAILY_LOSS) {
class=class="str">"cmt">// Restore the status to normal:
CMoney::DepoPart(m_prevDepoPart);       class=class="str">"cmt">// Return the used portion of the total balance
m_state = RM_STATE_OK;                  class=class="str">"cmt">// Set the risk manager to normal
CVirtualReceiver::Instance().Changed(); class=class="str">"cmt">// Notify the recipient about changes
PrintFormat(__FUNCTION__" | VirtualProfit = %.2f | Profit = %.2f | Daily Profit = %.2f",
            m_virtualProfit, m_profit, m_dailyProfit);
PrintFormat(__FUNCTION__" | RESTORE: depoPart = %.2f",
            m_prevDepoPart);
}
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Maximum daily loss                                               |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">class="kw">double CVirtualRiskManager::DailyLoss() {
  if(m_calcDailyLossLimit == RM_CALC_LIMIT_FIXED) {
    class=class="str">"cmt">// To get a fixed value, just class="kw">return it 
    class="kw">return m_maxDailyLossLimit;
  } else if(m_calcDailyLossLimit == RM_CALC_LIMIT_FIXED_PERCENT) {
    class=class="str">"cmt">// To get a given percentage of the base balance, calculate it 
    class="kw">return m_baseBalance * m_maxDailyLossLimit / class="num">100;
  } else { class=class="str">"cmt">// if(m_calcDailyLossLimit == RM_CALC_LIMIT_PERCENT)
    class=class="str">"cmt">// To get a specified percentage of the daily level, calculate it
    class="kw">return m_baseDailyLevel * m_maxDailyLossLimit / class="num">100;
  }
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Maximum total loss                                               |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">class="kw">double CVirtualRiskManager::OverallLoss() {
  if(m_calcOverallLossLimit == RM_CALC_LIMIT_FIXED) {
    class=class="str">"cmt">// To get a fixed value, just class="kw">return it 
    class="kw">return m_maxOverallLossLimit;
  } else if(m_calcOverallLossLimit == RM_CALC_LIMIT_FIXED_PERCENT) {
    class=class="str">"cmt">// To get a given percentage of the base balance, calculate it 
    class="kw">return m_baseBalance * m_maxOverallLossLimit / class="num">100;
  } else { class=class="str">"cmt">// if(m_calcDailyLossLimit == RM_CALC_LIMIT_PERCENT)
    class=class="str">"cmt">// To get a specified percentage of the daily level, calculate it
    class="kw">return m_baseDailyLevel * m_maxOverallLossLimit / class="num">100;
  }
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Check loss limits                                                |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CVirtualRiskManager::CheckLimits() {
  CheckDailyLimit();      class=class="str">"cmt">// Check daily limit
  CheckOverallLimit();    class=class="str">"cmt">// Check total limit
}
class=class="str">"cmt">//+------------------------------------------------------------------+

◍ 日内亏损封顶后如何锁死仓位

虚拟风控里最实用的一刀,是当日内浮亏触线就直接把可用仓位比例压到零。上面这段 C++ 风格的方法,就是每天亏损超过 DailyLoss() 设定值、且账户还有持仓时,立刻切换状态并停手。 它先拿 m_dailyProfit 跟 -DailyLoss() 比,再确认 CMoney::DepoPart() 大于 0,两个条件同时满足才动作。此时会把当前占用比存进 m_prevDepoPart,随后把 DepoPart 设为 0,等于当天不再开新仓。 状态机切到 RM_STATE_DAILY_LOSS,并调 Instance().Changed() 通知接收端。PrintFormat 打出虚拟利润、实际利润和日内利润三组数,方便你在 MT5 Experts 日志里核对——比如某天 m_dailyProfit 读到 -152.30、DailyLoss 设 150,就会触发这条路径。 外汇和贵金属杠杆高,这类硬止损逻辑能避免情绪加仓把小亏拖成大坑,但触发后是否恢复交易仍需你手动重设参数。

MQL5 / C++
class=class="str">"cmt">//| Check daily loss limit                                                                         |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CVirtualRiskManager::CheckDailyLimit() {
class=class="str">"cmt">// If daily loss is reached and positions are still open
   if(m_dailyProfit < -DailyLoss() && CMoney::DepoPart() > class="num">0) {
   class=class="str">"cmt">// Switch the risk manager to the achieved daily loss state:
      m_prevDepoPart = CMoney::DepoPart();     class=class="str">"cmt">// Save the previous value of the used part of the total balance
      CMoney::DepoPart(class="num">0);                     class=class="str">"cmt">// Set the used portion of the total balance to class="num">0
      m_state = RM_STATE_DAILY_LOSS;           class=class="str">"cmt">// Set the risk manager to the achieved daily loss state
      CVirtualReceiver::Instance().Changed();  class=class="str">"cmt">// Notify the recipient about changes
      PrintFormat(__FUNCTION__" | VirtualProfit = %.2f | Profit = %.2f | Daily Profit = %.2f",
                  m_virtualProfit, m_profit, m_dailyProfit);
      PrintFormat(__FUNCTION__" | RESET: depoPart = %.2f",
                  CMoney::DepoPart());
   }
}

「给数组循环补一个 FORI 宏」

在 MT5 里写 EA 或指标时,数组遍历和追加是高频动作。原 Macros.mqh 里已有 APPEND、FIND、ADD、FOREACH 等宏,但缺一个「不依赖数组长度、纯按次数跑」的循环宏。 新加的 FORI(N, D) 就是干这个的:用 i 从 0 跑到 N-1,每次执行 D 表达式。它和 FOREACH 的区别在于不读 ArraySize,适合已知次数、或给数组预填固定长度缓冲的场景。 把改动存到当前工程目录的 Macros.mqh,下次 #include 就能直接用。下面这段是完整宏文件,已含 FORI(高亮行): 代码逐行拆:#ifndef __MACROS_INCLUDE__ 防止重复包含;APPEND 用 ArrayResize 扩一位并把 V 写进末位;FIND 倒序找值并让 I 存下标,找不到时 I 为 -1;ADD 调 FIND 后仅当缺失才 APPEND;FOREACH 按数组实际大小遍历;FORI 按 N 次硬循环;REMOVE_AT 前移元素并缩容;REMOVE 先找后删。 验证方式:开 MT5 新建脚本,#include 该文件,写 FORI(5, Print(i)); 编译运行,终端应打出 0 到 4 共 5 行。外汇与贵金属品种波动剧烈,自动化脚本须先在策略测试器跑历史数据,实盘存在滑点与断连风险。

MQL5 / C++
class=class="str">"cmt">// Useful macros for array operations
class="macro">#ifndef __MACROS_INCLUDE__
class="macro">#define APPEND(A, V)    A[ArrayResize(A, ArraySize(A) + class="num">1) - class="num">1] = V;
class="macro">#define FIND(A, V, I)  { for(I=ArraySize(A)-class="num">1;I>=class="num">0;I--) { if(A[I]==V) class="kw">break; } }
class="macro">#define ADD(A, V)      { class="type">int i; FIND(A, V, i) if(i==-class="num">1) { APPEND(A, V) } }
class="macro">#define FOREACH(A, D)  { for(class="type">int i=class="num">0, im=ArraySize(A);i<im;i++) {D;} }
class="macro">#define FORI(N, D)     { for(class="type">int i=class="num">0; i<N;i++) {D;} }
class="macro">#define REMOVE_AT(A, I) { class="type">int s=ArraySize(A);for(class="type">int i=I;i<s-class="num">1;i++) { A[i]=A[i+class="num">1]; } ArrayResize(A, s-class="num">1);}
class="macro">#define REMOVE(A, V)   { class="type">int i; FIND(A, V, i) if(i>=class="num">0) REMOVE_AT(A, i) }
class="macro">#define __MACROS_INCLUDE__
class="macro">#endif

把缩放因子拆出来单独算

做虚拟仓位资金管理时,真实账户要开多少量、能算多少利润,都依赖一个缩放系数:用「当前可交易总余额 × 占用比例」去除以「该虚拟仓位对应的归一化策略余额」。原来这段逻辑塞在 Volume() 里,现在把它抽成独立的 Coeff() 方法,Volume() 和 Profit() 就能共用同一套系数,避免重复代码。 抽离后两个方法实现几乎对称:从虚拟订单取出交易量或利润,直接乘 Coeff() 返回的系数即可。注意 Coeff() 里对 fittedBalance 为 0 做了保护,此时系数返回 1,相当于不缩放。 改完记得在 CMoney 类声明里补上 Coeff() 静态方法,并把 Volume()、Profit() 声明保留,最后存回 Money.mqh。外汇与贵金属杠杆高,这类系数若用固定余额 s_fixedBalance 测试,需清楚实盘 AccountInfoDouble(ACCOUNT_BALANCE) 波动会直接改变开仓量。 下面这段代码是抽离后的核心,逐行看逻辑:Coeff() 先取虚拟仓位的 FittedBalance(),为 0 则返回 1;否则取固定余额或账户实际余额,乘 s_depoPart 再除 fittedBalance 得系数。Volume() 和 Profit() 都是「虚拟值 × 系数」的一行返回。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Calculate the class="kw">virtual position volume scaling factor              |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">class="kw">double CMoney::Coeff(CVirtualOrder *p_order) {
  class=class="str">"cmt">// Request the normalized strategy balance for the class="kw">virtual position
  class="type">class="kw">double fittedBalance = p_order.FittedBalance();
  class=class="str">"cmt">// If it is class="num">0, then the scaling factor is class="num">1
  if(fittedBalance == class="num">0.0) {
    class="kw">return class="num">1;
  }
  class=class="str">"cmt">// Otherwise, find the value of the total balance for trading
  class="type">class="kw">double totalBalance = s_fixedBalance > class="num">0 ? s_fixedBalance : AccountInfoDouble(ACCOUNT_BALANCE);
  class=class="str">"cmt">// Return the volume scaling factor
  class="kw">return totalBalance * s_depoPart / fittedBalance;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Determine the calculated size of the class="kw">virtual position              |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">class="kw">double CMoney::Volume(CVirtualOrder *p_order) {
  class="kw">return p_order.Volume() * Coeff(p_order);
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Determining the calculated profit of a class="kw">virtual position            |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">class="kw">double CMoney::Profit(CVirtualOrder *p_order) {
  class="kw">return p_order.Profit() * Coeff(p_order);
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Basic money management class                                       |
class=class="str">"cmt">//+------------------------------------------------------------------+
class CMoney {
  ...
  
  class=class="str">"cmt">// Calculate the scaling factor of the class="kw">virtual position volume
  class="kw">static class="type">class="kw">double     Coeff(CVirtualOrder *p_order);
class="kw">public:
  CMoney() = class="kw">delete;             class=class="str">"cmt">// Disable the constructor
  
  class=class="str">"cmt">// Determine the calculated size of the class="kw">virtual position
  class="kw">static class="type">class="kw">double     Volume(CVirtualOrder *p_order);
  
  class=class="str">"cmt">// Determine the calculated profit of a class="kw">virtual position  
  class="kw">static class="type">class="kw">double     Profit(CVirtualOrder *p_order);  
  ...
};

◍ 给虚拟工厂补上风险管理的生产线

风险管理器继承自 CFactorable,要让系统能动态实例化它,就得在 CVirtualFactory 的静态 Create() 里开一条新分支。否则初始化字符串传进来,工厂不认这个类,返回的指针只能是 NULL。 具体改动很小:在原有 CVirtualAdvisor、CVirtualStrategyGroup、CSimpleVolumesStrategy 的 else if 链里,插入对 "CVirtualRiskManager" 的判断,命中就 new 一个出来并赋值给 object。改完把整份代码存到当前目录的 VirtualFactory.mqh 即可。 下面这段是工厂类的核心骨架,高亮部分就是本次新增的生产线入口: 工厂只做一件事——读初始化串里的类名,按名派发构造函数。你开 MT5 在 Include 目录建好这个 mqh,后续用小布盯盘跑多策略回测时,风险管理模块才可能随策略组一起被拉起。外汇与贵金属波动剧烈,这类架构仅降低耦合,不预示任何收益。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Object factory class                                                  |
class=class="str">"cmt">//+------------------------------------------------------------------+
class CVirtualFactory {
class="kw">public:
  class=class="str">"cmt">// Create an object from the initialization class="type">class="kw">string
  class="kw">static CFactorable* Create(class="type">class="kw">string p_params) {
    class=class="str">"cmt">// Read the object class name
    class="type">class="kw">string className = CFactorable::ReadClassName(p_params);
    
    class=class="str">"cmt">// Pointer to the object being created
    CFactorable* object = NULL;
    class=class="str">"cmt">// Call the corresponding constructor depending on the class name
    if(className == "CVirtualAdvisor") {
      object = new CVirtualAdvisor(p_params);
    } else if(className == "CVirtualRiskManager") {
      object = new CVirtualRiskManager(p_params);
    } else if(className == "CVirtualStrategyGroup") {
      object = new CVirtualStrategyGroup(p_params);
    } else if(className == "CSimpleVolumesStrategy") {
      object = new CSimpleVolumesStrategy(p_params);
    }
    
    ...
    class="kw">return object;
  }
};

「把风险管理器塞进虚拟顾问类」

给 CVirtualAdvisor 类补一个风险管理器属性后,EA 的初始化字符串结构要变:策略组初始化串之后紧跟风险管理器初始化串,构造函数里用 ReadObject(p_params) 单独读出来存进 riskManagerParams,再 NEW 出对象。 构造函数里 new 出来的东西,析构函数必须回收。m_riskManager 的 delete 要跟 m_receiver、m_interface 的释放写在一起,否则 MT5 跑久了内存会漏。 Tick() 里最关键的一点:风险管理器的处理函数要在调整市场交易量之前调用。这样同一根分时报价内,如果亏超限或者限额被改写,接收方还能当场把未平仓位手数改掉,不会拖到下一 tick。 改完记得存回当前目录的 VirtualAdvisor.mqh,直接重新编译 EA 就能验证对象生命周期对不对。外汇和贵金属杠杆高,限额逻辑写错可能瞬间放大回撤,上线前用策略测试器跑一遍历史分时。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Class of the EA handling class="kw">virtual positions(orders)              |
class=class="str">"cmt">//+------------------------------------------------------------------+
class CVirtualAdvisor : class="kw">public CAdvisor {
class="kw">protected:
  CVirtualReceiver      *m_receiver;       class=class="str">"cmt">// Receiver object that brings positions to the market
  CVirtualInterface     *m_interface;     class=class="str">"cmt">// Interface object to show the status to the user
  CVirtualRiskManager   *m_riskManager;   class=class="str">"cmt">// Risk manager object
  ...
};
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Constructor                                                      |
class=class="str">"cmt">//+------------------------------------------------------------------+
CVirtualAdvisor::CVirtualAdvisor(class="type">class="kw">string p_params) {
class=class="str">"cmt">// Save the initialization class="type">class="kw">string
  m_params = p_params;
class=class="str">"cmt">// Read the initialization class="type">class="kw">string of the strategy group object
  class="type">class="kw">string groupParams = ReadObject(p_params);
class=class="str">"cmt">// Read the initialization class="type">class="kw">string of the risk manager object
  class="type">class="kw">string riskManagerParams = ReadObject(p_params);
class=class="str">"cmt">// Read the magic number
  class="type">class="kw">ulong p_magic = ReadLong(p_params);
class=class="str">"cmt">// Read the EA name
  class="type">class="kw">string p_name = ReadString(p_params);
class=class="str">"cmt">// Read the work flag only at the bar opening
  m_useOnlyNewBar = (class="type">bool) ReadLong(p_params);
class=class="str">"cmt">// If there are no read errors,
  if(IsValid()) {
    ...
    class=class="str">"cmt">// Create the risk manager object 
    m_riskManager = NEW(riskManagerParams);
  }
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Destructor                                                       |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CVirtualAdvisor::~CVirtualAdvisor() {
  if(!!m_receiver)     class="kw">delete m_receiver;     class=class="str">"cmt">// Remove the recipient
  if(!!m_interface)    class="kw">delete m_interface;    class=class="str">"cmt">// Remove the interface
  if(!!m_riskManager)  class="kw">delete m_riskManager; class=class="str">"cmt">// Remove risk manager
  DestroyNewBar();                            class=class="str">"cmt">// Remove the new bar tracking objects 
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| OnTick event handler                                             |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CVirtualAdvisor::Tick(class="type">void) {
class=class="str">"cmt">// Define a new bar for all required symbols and timeframes
  class="type">bool isNewBar = UpdateNewBar();
class=class="str">"cmt">// If there is no new bar anywhere, and we only work on new bars, then exit
  if(!isNewBar && m_useOnlyNewBar) {
    class="kw">return;
  }
class=class="str">"cmt">// Receiver handles class="kw">virtual positions
  m_receiver.Tick();
把多账户回撤诊断交给小布
这些日级与总级回撤阈值的实时计算,小布盯盘的AIGC已内置,打开对应品种页即可看到当前偏离基础余额的比例,你只管定规则。

常见问题

基础余额是风险管理器初始化时锁定的参考点,每日基础余额随D1新柱重置,二者分离才能同时约束总损失与每日损失两层止损。
常见有固定存款货币、占基础余额百分比、占每日水平百分比三类,不同公司取点不同,管理器需预留这三种模式以免实盘错位。
可以,小布盯盘品种页内置AIGC回撤诊断,能按你设定的日级/总级阈值标出越界品种,省去自己写日志解析。
自营交易模型普遍只强制日级与总级,先跑通这两层再扩展周期,能降低首次实现的复杂度与隐藏bug概率。
按本篇模型,总损失触发为永久停止,不会像日级那样次日重启,需人工介入或重设基础余额才能再交易。