开发多币种 EA 交易系统(第 15 部分):为真实交易准备 EA·进阶篇
🔧

开发多币种 EA 交易系统(第 15 部分):为真实交易准备 EA·进阶篇

(2/3)·回测漂亮不等于能上真账户,品种改名、平仓模式与重启续跑三道坎卡掉大多数自制EA

实战向 第 2/3 篇

接上篇,我们继续深挖多币种EA从策略测试器走向真实账户的路径。很多人以为回测曲线满意就能直接挂实盘,结果经纪商把EURGBP写成EURGBP.x,EA当场失联。本文聚焦那些测试环境永远不会触发、但真账户必然出现的机制缺口。

◍ 旧版本EA的平仓退出机制怎么定

想给EA加一个「交易完成模式」,本意是:当你准备换参数跑新版本时,让旧版本尽快收摊。逻辑很直接——一旦账户浮动利润非负,就平掉所有仓、不再开新单;如果开模式的瞬间利润已经≥0,立刻全平,不拖。 但历史回测里能看到持续数月的回撤。若利润为负时死等回撤结束,可能等得非常久,反而耽误新版本上线。一种折衷是设一个最长等待天数,超时无论盈亏强制全平,让新版本早点接手。 实现上不用另写一套。把风险管理器的「目标利润」设成开启时的(当前余额-基础余额)之差,它就等价于退出逻辑;再补一个以具体日期时间为准的「最晚等待时限」参数,超时后 OverallProfit() 直接返回当前利润,触发全平。 代码里先在EA输入区加两个参数:useOnlyCloseMode_ 控制是否进关闭模式,onlyCloseDays_ 设最大等待天数(0表示不限制)。初始化字符串里把这两个值透传给风险管理器构造。 风险管理器侧新增 OverallProfit() 方法:若已到设定时间,返回当前利润(必然触发平仓);未到则返回按输入算出的预期利润。CheckOverallProfitLimit() 调用它决定是否清仓。改完存 SimpleVolumesExpert.mq5 与 VirtualRiskManager.mqh 即可在MT5里编译验证。外汇与贵金属杠杆高,强制平仓不代表回撤结束,实盘前请用策略测试器跑多周期确认。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Inputs                                                              |
class=class="str">"cmt">//+------------------------------------------------------------------+
...
input class="type">bool    useOnlyCloseMode_ = false;    class=class="str">"cmt">// - Enable close mode
input class="type">class="kw">double  onlyCloseDays_    = class="num">0;        class=class="str">"cmt">// - Maximum time of closing mode(days)
...
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Expert initialization function                                       |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">int OnInit() {
  ...
class=class="str">"cmt">// Prepare the initialization class="type">class="kw">string for an EA with a group of several strategies
  class="type">class="kw">string expertParams = StringFormat(
                    "class CVirtualAdvisor(\n"
                    "    class CVirtualStrategyGroup(\n"
                    "      [\n"
                    "        %s\n"
                    "      ],%f\n"
                    "    ),\n"
                    "    class CVirtualRiskManager(\n"
                    "      %d,%.2f,%d,%.2f,%.2f,%d,%.2f,%.2f,%d,%.2f,%.2f,%.2f"
                    "    )\n"
                    "    ,%d,%s,%d\n"

「风险管理器的参数装配细节」

这段初始化代码展示了一个虚拟持仓 EA 如何把风控参数打包进构造函数。注意 rmMaxOverallProfitLimit_ 默认值设为 1000000,代表以货币金额(MONEY_BB 方式)计算总盈利上限,实际跑盘时你应按账户规模改小,否则这条风控形同虚设。 rmMaxOverallProfitDate_ 默认是 0,含义是不限制等待总盈利达成的时间窗口;若设为具体 datetime,则超过该日期仍未摸到盈利上限便会触发对应平仓逻辑。外汇与贵金属杠杆高,这类全局盈利/亏损截断若参数错位,可能过早平掉趋势单或放过回撤。 下面这段是参数拼装的核心片段,建议直接粘进 MT5 的 MQ5 文件对照看变量映射:

MQL5 / C++
    class="type">class="kw">string expertParams = StringFormat(
                 "class CVirtualAdvisor(\n"
                 "   class CVirtualStrategyGroup(\n"
                 "      ,%d,%.2f\n"
                 ")",
                 strategiesParams, scale_,
                 rmIsActive_, rmStartBaseBalance_,
                 rmCalcDailyLossLimit_, rmMaxDailyLossLimit_, rmCloseDailyPart_,
                 rmCalcOverallLossLimit_, rmMaxOverallLossLimit_, rmCloseOverallPart_,
                 rmCalcOverallProfitLimit_, rmMaxOverallProfitLimit_,
                 rmMaxRestoreTime_, rmLastVirtualProfitFactor_,
                 magic_, "SimpleVolumes", useOnlyNewBars_,
                 useOnlyCloseMode_, onlyCloseDays_
       );
class=class="str">"cmt">// Create an EA handling class="kw">virtual positions
expert = NEW(expertParams);
...
class=class="str">"cmt">// Successful initialization
class="kw">return(INIT_SUCCEEDED);
}
input group ":::   Risk manager"
input ENUM_RM_CALC_OVERALL_PROFIT
rmCalcOverallProfitLimit_                 = RM_CALC_OVERALL_PROFIT_MONEY_BB;   class=class="str">"cmt">// - Method for calculating total profit
input class="type">class="kw">double      rmMaxOverallProfitLimit_   = class="num">1000000;                    class=class="str">"cmt">// - Overall profit
input class="type">class="kw">datetime    rmMaxOverallProfitDate_    = class="num">0;                          class=class="str">"cmt">// - Maximum time of waiting for the total profit(days)
class="type">int OnInit() {
   class="type">class="kw">string expertParams = StringFormat(
                 "class CVirtualAdvisor(\n"
                 "   class CVirtualStrategyGroup(\n"

把风控参数塞进虚拟持仓EA的构造串

这段初始化代码在做一件事:把策略参数和虚拟风控器的字段拼成一段格式化字符串,再传给 expert 构造器。注意 CVirtualRiskManager 的构造占位里有一个高亮字段 %d 对应 rmMaxOverallProfitDate_,它记录的是「总利润触顶时刻」的日期时间,不是利润数值本身。 从参数顺序看,rmMaxOverallProfitDate_ 排在 rmMaxOverallProfitLimit_ 之后、rmMaxRestoreTime_ 之前,说明利润上限判定和触顶时间戳是绑定的:当浮动总利润突破 m_maxOverallProfitLimit 时,m_maxOverallProfitDate 会被写入当时时间,后续恢复逻辑可能参考这个时间点。 类定义里 m_maxOverallProfitDate 类型为 datetime,注释写明是 Maximum time of reaching the total profit。在 MT5 里直接 Print 这个变量,能验证你的虚拟风控是否在预期K线封顶,而不是等周末才结算。外汇与贵金属杠杆高,虚拟风控参数设错可能在实盘复制时放大回撤概率。

MQL5 / C++
              "      [\n"
              "        %s\n"
              "      ],%f\n"
              "    ),\n"
              "    class CVirtualRiskManager(\n"
              "      %d,%.2f,%d,%.2f,%.2f,%d,%.2f,%.2f,%d,%.2f,%d,%.2f,%.2f"
              "    )\n"
              "    ,%d,%s,%d\n"
              ")",
              strategiesParams, scale_,
              rmIsActive_, rmStartBaseBalance_,
              rmCalcDailyLossLimit_, rmMaxDailyLossLimit_, rmCloseDailyPart_,
              rmCalcOverallLossLimit_, rmMaxOverallLossLimit_, rmCloseOverallPart_,
              rmCalcOverallProfitLimit_, rmMaxOverallProfitLimit_,rmMaxOverallProfitDate_,
              rmMaxRestoreTime_, rmLastVirtualProfitFactor_,
              magic_, "SimpleVolumes", useOnlyNewBars_
             );

class=class="str">"cmt">// Create an EA handling class="kw">virtual positions
expert = NEW(expertParams);
...
class=class="str">"cmt">// Successful initialization
  class="kw">return(INIT_SUCCEEDED);
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Risk management class (risk manager)                             |
class=class="str">"cmt">//+------------------------------------------------------------------+
class CVirtualRiskManager : class="kw">public CFactorable {
class="kw">protected:
class=class="str">"cmt">// Main constructor parameters
  ...
  ENUM_RM_CALC_OVERALL_PROFIT m_calcOverallProfitLimit; class=class="str">"cmt">// Method for calculating maximum overall profit
  class="type">class="kw">double      m_maxOverallProfitLimit;                 class=class="str">"cmt">// Parameter for calculating the maximum overall profit
  class="type">class="kw">datetime    m_maxOverallProfitDate;                  class=class="str">"cmt">// Maximum time of reaching the total profit
  ...
class=class="str">"cmt">// Protected methods
  class="type">class="kw">double      DailyLoss();                             class=class="str">"cmt">// Maximum daily loss

◍ 虚拟风控里的整体利润封顶逻辑

在 MT5 的虚拟风险管理器里,整体利润上限不是写死的数,而是靠构造参数动态算出来的。构造函数 CVirtualRiskManager 从初始化字符串里逐个 ReadLong / ReadDouble,把基准余额、计算模式、上限值以及 m_maxOverallProfitDate 这类时间闸门全部灌进成员变量。 OverallProfit() 这个函数决定了封顶值怎么来:若当前时间过了 m_maxOverallProfitDate,直接返回已记录的利润值,保证仓位能被平掉;若模式是 RM_CALC_OVERALL_PROFIT_PERCENT_BB,就用 m_baseBalance * m_maxOverallProfitLimit / 100 算出基准余额的百分比;否则按固定金额 m_maxOverallProfitLimit 返回。 CheckOverallProfitLimit() 的判定核心是 m_overallProfit >= OverallProfit() 且账户占用保证金大于 0。一旦触发,m_overallDepoPart 清零、状态切到 RM_STATE_OVERALL_PROFIT,并调用 SetDepoPart() 锁死后续开仓额度。外汇与贵金属波动剧烈,这套逻辑仅降低超额回吐概率,不等于利润必保。 把下面代码贴进 MT5 的包含文件,改 m_maxOverallProfitLimit 参数从 5 到 20,能在策略测试器里直接看出不同封顶线对净值曲线的截断位置。

MQL5 / C++
class="type">class="kw">double OverallLoss();              class=class="str">"cmt">// Maximum total loss
class="type">class="kw">double OverallProfit();             class=class="str">"cmt">// Maximum profit
 ...
};
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Constructor                                                      |
class=class="str">"cmt">//+------------------------------------------------------------------+
CVirtualRiskManager::CVirtualRiskManager(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 and set the class="kw">property values
  m_isActive = (class="type">bool) ReadLong(p_params);
  m_baseBalance = ReadDouble(p_params);
  m_calcDailyLossLimit = (ENUM_RM_CALC_DAILY_LOSS) ReadLong(p_params);
  m_maxDailyLossLimit = ReadDouble(p_params);
  m_closeDailyPart = ReadDouble(p_params);
  m_calcOverallLossLimit = (ENUM_RM_CALC_OVERALL_LOSS) ReadLong(p_params);
  m_maxOverallLossLimit = ReadDouble(p_params);
  m_closeOverallPart = ReadDouble(p_params);
  m_calcOverallProfitLimit = (ENUM_RM_CALC_OVERALL_PROFIT) ReadLong(p_params);
  m_maxOverallProfitLimit = ReadDouble(p_params);
  m_maxOverallProfitDate   = (class="type">class="kw">datetime) ReadLong(p_params);
  m_maxRestoreTime = ReadDouble(p_params);
  m_lastVirtualProfitFactor = ReadDouble(p_params);
  ...
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Maximum total profit                                             |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">class="kw">double CVirtualRiskManager::OverallProfit() {
  class=class="str">"cmt">// Current time
  class="type">class="kw">datetime tc = TimeCurrent();
  
  class=class="str">"cmt">// If the current time is greater than the specified maximum time,
  if(m_maxOverallProfitDate && tc > m_maxOverallProfitDate) {
    class=class="str">"cmt">// Return the value that guarantees the positions are closed
    class="kw">return m_overallProfit;
  } else if(m_calcOverallProfitLimit == RM_CALC_OVERALL_PROFIT_PERCENT_BB) {
    class=class="str">"cmt">// To get a given percentage of the base balance, calculate it 
    class="kw">return m_baseBalance * m_maxOverallProfitLimit / class="num">100;
  } else {
    class=class="str">"cmt">// To get a fixed value, just class="kw">return it 
    class=class="str">"cmt">// RM_CALC_OVERALL_PROFIT_MONEY_BB
    class="kw">return m_maxOverallProfitLimit;
  }
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Check if the specified profit has been achieved                  |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">bool CVirtualRiskManager::CheckOverallProfitLimit() {
class=class="str">"cmt">// If overall loss is reached and positions are still open
  if(m_overallProfit >= OverallProfit() && CMoney::DepoPart() > class="num">0) {
    class=class="str">"cmt">// Reduce the multiplier of the used part of the overall balance by the overall loss
    m_overallDepoPart = class="num">0;
    class=class="str">"cmt">// Set the risk manager to the achieved overall profit state
    m_state = RM_STATE_OVERALL_PROFIT;
    class=class="str">"cmt">// Set the value of the used part of the overall balance
    SetDepoPart();
    ...
    class="kw">return true;
  }
  class="kw">return false;
}

「EA 重启如何无缝续上昨天的盘」

做虚拟持仓的 EA 最怕重启后状态归零:之前算好的每日基础水平、未平仓尺寸管理属性全丢,等于裸奔。原文里风险管理器那几个 Save()/Load() 一直是空壳,这次把它和 EA 初始化串起来,才算把「断电续跑」补完。外汇与贵金属杠杆高,状态错乱可能让仓位控制逻辑失效,实盘前务必在 MT5 策略测试器里跑一遍。 先给 EA 加一个输入开关 usePrevState_,默认 true。想彻底清盘重来就设 false 重启一次,让旧文件被覆盖,再改回 true——这样不会把上次的脏数据读进来。OnInit 里看到开关为 true 就直接 expert.Load() 然后 Tick() 推一帧。 文件名冲突是个暗坑。旧逻辑用 EA 名+幻数+.test 拼文件名,幻数一改就找不到旧文件,但幻数不变、策略组成变了却会误读旧文件导致崩溃。解决办法是只对「策略单实例」那段初始化串算 MD5 哈希拼进文件名,改风控参数不影响哈希,改策略组成哈希就变,自动隔离旧状态。 风险管理器本身只存两样东西:每日基础水平(每天只算一次)和当前状态相关的属性(总体余额已用部分除外)。输入参数和实时更新的净值、日利不用存,下次启动从 EA 输入直接覆盖。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Inputs                                                              |
class=class="str">"cmt">//+------------------------------------------------------------------+
...
input class="type">bool     usePrevState_      = true;      class=class="str">"cmt">// - Load the previous state
...
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Expert initialization function                                      |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">int OnInit() {
  ...
class=class="str">"cmt">// Create an EA handling class="kw">virtual positions
  expert = NEW(expertParams);
class=class="str">"cmt">// If the EA is not created, then class="kw">return an error
  if(!expert) class="kw">return INIT_FAILED;
class=class="str">"cmt">// If an error occurred while replacing symbols, then class="kw">return an error
  if(!expert.SymbolsReplace(symbolsReplace_)) class="kw">return INIT_FAILED;
class=class="str">"cmt">// If we need to restore the state,
  if(usePrevState_) {
    class=class="str">"cmt">// Load the previous state if available
    expert.Load();
    expert.Tick();
  }
class=class="str">"cmt">// Successful initialization
  class="kw">return(INIT_SUCCEEDED);
}
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:
  ...
  class="kw">virtual class="type">class="kw">string      HashParams(class="type">class="kw">string p_name);  class=class="str">"cmt">// Hash value of EA parameters 
class="kw">public:
  ...
};
...
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Hash value of EA parameters                                         |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">class="kw">string CVirtualAdvisor::HashParams(class="type">class="kw">string p_params) {
  class="type">uchar hash[], key[], data[];
  class=class="str">"cmt">// Calculate the hash from the initialization class="type">class="kw">string  
  StringToCharArray(p_params, data);
  CryptEncode(CRYPT_HASH_MD5, data, key, hash);
  class=class="str">"cmt">// Convert it from the array of numbers to a class="type">class="kw">string with hexadecimal notation
  class="type">class="kw">string res = "";

把参数指纹写进状态文件名

虚拟顾问构造时不会随便起文件名,而是用 EA 名、magic 号加上参数哈希拼出唯一标识。哈希函数里每 4 个字节插一个连字符、且只处理前 15 个索引(i<15 且 i%4==3 时加“-”),最终得到类似“A1B2-3C4D-5E6F”的短串,回测和实盘靠它区分配置。 Save() 的写入闸门很苛刻:必须晚于上次变更时间、不在优化中、且要么不在测试器要么处于可视测试。满足后才以 FILE_CSV|FILE_WRITE 打开以制表符分隔的文件,依次落盘变更时间、各策略与风险管理器状态。 这种机制意味着你改一组止损参数再跑,会自动生成不同 csv,不会覆盖旧状态。外汇与贵金属波动剧烈、滑点随机,这类状态文件仅作恢复用途,不预示任何收益。

MQL5 / C++
FOREACH(hash, res += StringFormat("%X", hash[i]); if(i % class="num">4 == class="num">3 && i < class="num">15) res += "-");

class="kw">return res;
}
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">// If there are no read errors,
  if(IsValid()) {

   class=class="str">"cmt">// Form the name of the file for saving the state from the EA name and parameters
   m_name = StringFormat("%s-%d-%s%s.csv",
                         (p_name != "" ? p_name : "Expert"),
                         p_magic,
                         HashParams(groupParams),
                         (MQLInfoInteger(MQL_TESTER) ? ".test" : "")
                        );;

  }
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Save status                                                      |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">bool CVirtualAdvisor::Save() {
   class="type">bool res = true;
class=class="str">"cmt">// Save status if:
   if(true
class=class="str">"cmt">// later changes appeared
       && m_lastSaveTime < CVirtualReceiver::s_lastChangeTime
class=class="str">"cmt">// currently, there is no optimization
       && !MQLInfoInteger(MQL_OPTIMIZATION)
class=class="str">"cmt">// and there is no testing at the moment or there is a visual test at the moment
       && (!MQLInfoInteger(MQL_TESTER) || MQLInfoInteger(MQL_VISUAL_MODE))
     ) {
      class="type">int f = FileOpen(m_name, FILE_CSV | FILE_WRITE, &class="macro">#x27;\t&class="macro">#x27;);
      if(f != INVALID_HANDLE) {  class=class="str">"cmt">// If file is open, save
         FileWrite(f, CVirtualReceiver::s_lastChangeTime);  class=class="str">"cmt">// Time of last changes
         class=class="str">"cmt">// All strategies
         FOREACH(m_strategies, ((CVirtualStrategy*) m_strategies[i]).Save(f));
         m_riskManager.Save(f);
         FileClose(f);
         class=class="str">"cmt">// Update the last save time
         m_lastSaveTime = CVirtualReceiver::s_lastChangeTime;
         PrintFormat(__FUNCTION__" | OK at %s to %s",
让小布替你跑这套品种映射
不同经纪商的后缀前缀杂乱,手动维护替换规则极易漏配。小布盯盘的AIGC已内置品种别名诊断,打开对应品种页即可看到映射建议,你只需核对后写进EA输入参数。

常见问题

概率最高的原因是交易品种名称不一致,例如经纪商使用EURGBP.x或eurgbp,而EA初始化字符串写死为EURGBP,导致符号查找失败。需要在设置中配置替换规则。
当你计划调整策略实例组成或暂停新开仓、但希望现有浮仓按规则退出时,可切到该模式。EA会停止开新仓并努力将未平仓位了结,可能需一定时间。
EA应在运行期持久化各策略实例与仓位上下文,重启后读取并复原到与未中断一致的状态,否则可能出现重复开仓或风控失效。
可以。小布盯盘的品种页会标注当前经纪商实际符号与常见别名差异,帮你快速生成替换配置,减少上实盘后的映射错误。
替换规则不能只覆盖前后缀,需支持更通用的映射表,否则EA在新标的上无法初始化。建议在配置层用键值对而非固定参数。