优化结果的可视化评估·进阶篇
📊

优化结果的可视化评估·进阶篇

(2/3)·只跑一遍优化就拿到余额净值图与多准则排序,把 80% 靠眼睛接收的信息用起来

含代码示例 第 2/3 篇

多数交易者为了核对几个自定义优化条件,会反复跑多轮测试,耗时且容易漏掉参数组合间的非线性关联。其实一次优化过程中就能把每遍通测的余额、净值和多个准则同时落盘,边跑边看图形比事后翻报表更直接。

用 FrameAdd 把通测结果落盘

只在回测里把真正可能盈利的通测写进文件才有价值。默认只登记达标盈利通测,若你在设置里勾了亏损登记,也能顺带把亏损通测一并留存;前向通测本质是对优化的推测,所以只记余额和净值,不写 ENUM_STATISTICS 细节。 每次测试收尾会触发测试器事件,由 OnTester() 接管,此时调用 FrameAdd() 把收集到的数组和数值写文件。注意 FrameAdd() 一次只能带一个任意类型数组加一个 double 数值:除余额、净值外,把 ENUM_STATISTICS 枚举值按顺序塞进数组,数组长度写进 value 参数即可。 测试结束若还有持仓,测试器会强平。代码里用 changesPos 计数,若结束时实际持仓数和变量对不上,就靠 IsCorrect() 做一笔虚拟了结,把当前余额、净值补写进去,避免落盘数据错位。 下面这段展示了回测达标时如何拼装数组并落盘:先判断 STAT_PROFIT 较初始保证金百分比大于设定阈值、且成交笔数够数,才把 41 个统计项写进 42 长度数组,合并余额净值后 FrameAdd 写出。

MQL5 / C++
class="type">bool  FrameAdd(
   class="kw">const class="type">class="kw">string  name,           class=class="str">"cmt">// class="kw">public name/tag
   class="type">long          id,             class=class="str">"cmt">// class="kw">public id 
   class="type">class="kw">double        value,          class=class="str">"cmt">// value
   class="kw">const class="type">void&   data[]          class=class="str">"cmt">// array of any type
   );
   if(id == class="num">1)  class=class="str">"cmt">// if it is a backward pass
     {
     class=class="str">"cmt">// if profit % and the number of trades exceed those specified in the settings, the pass is written into the file
     if(TesterStatistics(STAT_PROFIT) / TesterStatistics(STAT_INITIAL_DEPOSIT) * class="num">100 > _profit && TesterStatistics(STAT_TRADES) >= trades)
       {
       class="type">class="kw">double TeSt[class="num">42]; class=class="str">"cmt">// total number of elements in the ENUM_STATISTICS enumeration is class="num">41
       IsRecordStat(TeSt); class=class="str">"cmt">// writing testing statistics to the array
       IsCorrect(); class=class="str">"cmt">// adjusting balance and equity arrays
       if(m_sort != none)
         {
         class="kw">while((sort)size_sort != none)
            size_sort++;
         class="type">class="kw">double LRB[], LRE[], coeff[];
         Coeff = Criterion(balance, equity, LRB, LRE, TeSt, coeff, class="num">3);class=class="str">"cmt">// calculating custom criterion
         ArrayInsert(balance, equity, _size + class="num">1, class="num">0);      class=class="str">"cmt">// joining balance and equity arrays into one
         ArrayInsert(balance, TeSt, (_size + class="num">1) * class="num">2, class="num">0); class=class="str">"cmt">// add to the resulting array the array with the ENUM_STATISTICS data
         FrameAdd(name, id, _size + class="num">1, balance);          class=class="str">"cmt">// write the frame into the file
         }
       else
         {
         ArrayInsert(balance, equity, _size + class="num">1, class="num">0);      class=class="str">"cmt">// joining balance and equity arrays into one
         ArrayInsert(balance, TeSt, (_size + class="num">1) * class="num">2, class="num">0); class=class="str">"cmt">// add to the resulting array the array with the ENUM_STATISTICS data
         FrameAdd(name, id, _size + class="num">1, balance);          class=class="str">"cmt">// write the frame into the file
         }
       }
     }
class="type">void IsCorrect()
  {
  if(changesPos > class="num">0) class=class="str">"cmt">// if there is an open position by the testing end time, it should be virtually closed as the tester will close such a position
    {
    _size++;

◍ 余额回撤时如何取舍权益极值

这段逻辑处理的是账户快照数组在每次刷新时的写入规则,核心在于区分「余额是否发生回撤」两种情形。 当上一帧余额高于当前账户余额时,代码判定出现了余额回撤,此时把当前余额写入数组,并按视图模式选择权益极值:min_max_E 模式记 tempEquityMax,其余情况记 tempEquityMin。 若余额未回撤,则权益直接取 tempEquityMin,不区分视图。两种分支最后都会把当前余额与权益补写进数组末尾,保证数组长度与 _size 同步。 在 MT5 里跑这段时,留意 tempEquityMax / tempEquityMin 必须在调用前算好,否则数组里会落进脏值。外汇与贵金属品种杠杆高,回撤判定容易因点差跳变而频繁触发,建议先用模拟盘验证数组增长节奏。

MQL5 / C++
   ArrayResize(balance, _size + class="num">1);
   ArrayResize(equity, _size + class="num">1);
   if(balance[_size - class="num">2] > AccountInfoDouble(ACCOUNT_BALANCE))
     {
      balance[_size - class="num">1] = AccountInfoDouble(ACCOUNT_BALANCE);
      class="kw">switch(s_view)
        {
         case   min_max_E:
           equity[_size - class="num">1] = tempEquityMax;
           class="kw">break;
         class="kw">default:
           equity[_size - class="num">1] = tempEquityMin;
           class="kw">break;
        }
     }
   else
     {
      balance[_size - class="num">1] = AccountInfoDouble(ACCOUNT_BALANCE);
      equity[_size - class="num">1] = tempEquityMin;
     }
   balance[_size] = AccountInfoDouble(ACCOUNT_BALANCE);
   equity[_size] = AccountInfoDouble(ACCOUNT_EQUITY);
   }
  else
   {
    ArrayResize(balance, _size + class="num">1);
    ArrayResize(equity, _size + class="num">1);
    balance[_size] = AccountInfoDouble(ACCOUNT_BALANCE);
    equity[_size] = AccountInfoDouble(ACCOUNT_EQUITY);
   }

「回测帧文件怎么读进 MT5」

优化跑完以后,终端会在 MQL5\Files\Tester 下丢出一个 .mqd 文件,命名格式是 EA名.品种.周期.mqd。注意优化刚结束时这个文件被锁着,常规文件函数读不到,必须重启一次终端才能用 FileOpen 正常访问。 为了避免几千张截图把硬盘和时间都吃光,脚本里通常给一个盈利百分比阈值:低于该值的回测结果直接不存帧。读文件时也是先按这个准则过滤,再把合格结果写进数据数组。 FRAME 结构负责从二进制流里把 Data 填起来。GetArrayB 先释放旧数组,再用 FileReadArray 按 Value 指定的长度把 Balance、Equity 读进来,TeSt 则按 SizeOfArray 算偏移读取;GetArrayF 只补前向通测那段,从 size 位置继续读同样长度。 前向图形有两种画法:一种从初始本金起画,跟策略测试器里看到的一致;另一种把回测末端资金额接上前向余额和净值,相当于把回测利润贴到数组末尾——但这只有跑了前向通测优化才办得到。 下面这段是遍历 Tester 目录统计 .mqd 数量并打开首个文件读结构的骨架,复制到 MT5 脚本里就能验证路径和句柄行为。

MQL5 / C++
  class="type">int count = class="num">0;
  class="type">long search_handle = FileFindFirst("Tester\\*.mqd", FileName);
  do
    {
      if(FileName != "")
        count++;
      FileName = "Tester\\" + FileName;
    }
  class="kw">while(FileFindNext(search_handle, FileName));
  FileFindClose(search_handle);
FRAME Frame = {class="num">0};
FileReadStruct(handle, Frame);
class="kw">struct FRAME
  {
   class="type">class="kw">ulong                Pass;
   class="type">long                 ID;
   class="type">class="kw">short                String[class="num">64];
   class="type">class="kw">double               Value;
   class="type">int                  SizeOfArray;
   class="type">long                 Tmp[class="num">2];
   class="type">void                 GetArrayB(class="type">int handle, Data & m_FB)
     {
      ArrayFree(m_FB.Balance);
      FileReadArray(handle, m_FB.Balance, class="num">0, (class="type">int)Value);
      ArrayFree(m_FB.Equity);
      FileReadArray(handle, m_FB.Equity, class="num">0, (class="type">int)Value);
      ArrayFree(m_FB.TeSt);
      FileReadArray(handle, m_FB.TeSt, class="num">0, (SizeOfArray / class="kw">sizeof(m_FB.TeSt[class="num">0]) - (class="type">int)Value * class="num">2));
     }
   class="type">void                 GetArrayF(class="type">int handle, Data & m_FB, class="type">int size)
     {
      FileReadArray(handle, m_FB.Balance, size, (class="type">int)Value);
      FileReadArray(handle, m_FB.Equity, size, (class="type">int)Value);
     }
  };
class="kw">struct Data
  {
   class="type">class="kw">ulong                Pass;
   class="type">long                 id;
   class="type">int                  size;
   class="type">class="kw">double               Balance[];
   class="type">class="kw">double               Equity[];
   class="type">class="kw">double               LRegressB[];
   class="type">class="kw">double               LRegressE[];
   class="type">class="kw">double               coeff[];
   class="type">class="kw">double               TeSt[];
  };
Data                    m_Data[];
  class="type">int handle = FileOpen(FileName, FILE_READ | FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_BIN);
  if(handle != INVALID_HANDLE)
    {

回放文件里挑合格趟次的逻辑

这段代码干的事是从 MT5 优化结果文件里按偏移 260 字节定位帧头,然后逐帧读结构体。遇到 ID==1 的回测趟次(Backward pass),先把帧里的数组塞进 m_Data,再用收益率门槛做即时过滤。 过滤条件是该趟次净利润除以初始保证金再乘 100,必须 ≥ 你传入的 profitPersent 变量。达标才调 Criterion 算优化评分并让 size 自增,不达标直接丢弃,内存里只留过关的。 Forward 趟次(ID!=1)只有在设置不是「只画回测」时才处理。逻辑是拿 Frame.Pass 去匹配已存 m_Data 里的 Pass 号,命中后若选了「回测接正向」模式,就把正向数据错位一笔接到原数组尾部。外汇与贵金属回测过拟合风险高,这套读取只解决筛选,不代表实盘概率。

MQL5 / C++
FileSeek(handle, class="num">260, SEEK_SET);
class="kw">while(Res && !IsStopped())
  {
   FRAME Frame = {class="num">0};
   class=class="str">"cmt">// read from the file to the Frame structure
   Res = (FileReadStruct(handle, Frame) == class="kw">sizeof(Frame));
   if(Res)
     if(Frame.ID == class="num">1) class=class="str">"cmt">// if it is a Backward pass, write data to the m_Data structure
       {
        ArrayResize(m_Data, size + class="num">1);
        m_Data[size].Pass = Frame.Pass;
        m_Data[size].id = Frame.ID;
        m_Data[size].size = (class="type">int)Frame.Value;
        Frame.GetArrayB(handle, m_Data[size]);  class=class="str">"cmt">// write data to the m_Data structure arrays
        class=class="str">"cmt">// if profit of this pass corresponds to the class="kw">input settings, immediately calculate optimization criteria
        if(m_Data[size].TeSt[STAT_PROFIT] / m_Data[size].TeSt[STAT_INITIAL_DEPOSIT] * class="num">100 >= profitPersent)
          {
           Criterion(m_Data[size].Balance, m_Data[size].Equity, m_Data[size].LRegressB, m_Data[size].LRegressE, m_Data[size].TeSt, m_Data[size].coeff, m_lineR);
           size++;
          }
       }
     else  class=class="str">"cmt">// if it is a Forward pass, write to the end of the m_Data data structures
       if(m_Forward != BackOnly) class=class="str">"cmt">// if drawing of only Backward passes is not selected in settings
         for(class="type">int i = class="num">0; i < size; i++)
           {
            if(Frame.Pass == m_Data[i].Pass) class=class="str">"cmt">// if Back and Forward pass numbers match
              {
               class="type">int m = class="num">0;
               if(m_Forward == Back_Next_Forward) class=class="str">"cmt">// if selected drawing of Forward graph as a continuation of Backward
                 {
                  Frame.GetArrayF(handle, m_Data[i], m_Data[i].size - class="num">1); class=class="str">"cmt">// write data at the end of the the m_Data structure array, with a one-trade shift
                  for(class="type">int x = m_Data[i].size - class="num">1; x < m_Data[i].size + (class="type">int)Frame.Value - class="num">1; x++)
                    {

◍ 前向回测余额的拼接与指针复位

这段逻辑处理的是前向测试(Forward pass)与后向测试(Backward test)收益如何并入同一资金曲线。当检测到后向测试存在时,代码直接把 STAT_PROFIT 累加进 Balance 与 Equity 数组,相当于把后向样本外的盈利续接到前向通道上。 [CODE] m_Data[i].Balance[x] = m_Data[i].Balance[x] + m_Data[i].TeSt[STAT_PROFIT]; // 把后向测试盈利加到前向余额 m_Data[i].Equity[x] = m_Data[i].Equity[x] + m_Data[i].TeSt[STAT_PROFIT]; } m = 1; } else Frame.GetArrayF(handle, m_Data[i], m_Data[i].size); // 若选择从起始余额画前向通道 m_Data[i].coeff[Forward_Trade] = (int)(Frame.Value / 2); // 前向交易数(非精确) m_Data[i].coeff[Profit_Forward] = m_Data[i].Balance[m_Data[i].size + (int)Frame.Value - m - 1] - m_Data[i].Balance[m_Data[i].size - m]; break; } if(i == size - 1) // 若该前向通道无对应后向,把文件指针跳到帧尾 FileSeek(handle, Frame.SizeOfArray, SEEK_CUR); // 假装已读数组数据 } } FileClose(handle); //--- [/CODE] 如果没有匹配的后向测试,程序走 else 分支:用 GetArrayF 从文件读入前向数组,并以 Frame.Value/2 估算前向成交笔数,用尾部与起点余额差计算前向利润。 当 i 等于 size-1 仍没找到后向段,FileSeek 按帧尺寸跳过,避免文件指针错位导致下一帧解析失败。外汇与贵金属回测中外汇杠杆与滑点会放大这类拼接误差,实盘前应在 MT5 用真实点差重跑验证。

MQL5 / C++
m_Data[i].Balance[x] = m_Data[i].Balance[x] + m_Data[i].TeSt[STAT_PROFIT]; class=class="str">"cmt">//   add profit of the Backward test to the Forward pass
m_Data[i].Equity[x] = m_Data[i].Equity[x] + m_Data[i].TeSt[STAT_PROFIT];
   }
   m = class="num">1;
   }
   else
    Frame.GetArrayF(handle, m_Data[i], m_Data[i].size); class=class="str">"cmt">// if drawing of a Forward pass from a starting balance is selected
   m_Data[i].coeff[Forward_Trade] = (class="type">int)(Frame.Value / class="num">2); class=class="str">"cmt">// number of forward trades(not exact))
   m_Data[i].coeff[Profit_Forward] = m_Data[i].Balance[m_Data[i].size + (class="type">int)Frame.Value - m - class="num">1] - m_Data[i].Balance[m_Data[i].size - m];
   class="kw">break;
   }
   if(i == size - class="num">1) class=class="str">"cmt">// if no Backward is found for this Forward pass, move the file pointer to the end of writing
    FileSeek(handle, Frame.SizeOfArray, SEEK_CUR); class=class="str">"cmt">// of this frame as if we read array data from the file
   }
   }
  FileClose(handle);
  class=class="str">"cmt">//---

「把通测结果画成可筛选的图」

脚本用 CGraphic 把每一组优化通测画成独立截图,存进终端文件目录里以「EA名.品种.周期」命名的文件夹。若勾了「保存所有屏幕截图」,文件名按 排序+利润+通测编号 拼;只留最佳结果时,文件名只剩 自定义准则+利润。一次跑完曾在文件夹里落下 7000 张图,靠肉眼根本筛不动,所以后来改成按准则先过滤再出图。 策略测试器里的图 X 轴是成交对应时间,脚本画的图 X 轴是交易笔数,两者形态大多不同。脚本为压文件体积只写最少信息,净值细节不足以深分析,但够做通测效率初判和自定义准则计算。优化跑完、正式调 ScreenShotOptimization 前,记得重启终端,否则可能读到脏缓存。 算法交易者大致分两派:一派主张拿几十年数据一次性优化,指望 EA 从此完美;另一派像我,倾向用小区间定期重优化,比如优化一月+交易一周,或三月+一月。后者才需要「按准则筛最佳通测」这套过滤器。下面这段是绘图核心函数的骨架,参数全是引用数组,pass 是通测编号。

MQL5 / C++
class="type">class="kw">string _GraphPlot(class="type">class="kw">double& y1[],
                  class="type">class="kw">double& y2[],
                  class="type">class="kw">double& LRegressB[],
                  class="type">class="kw">double& LRegressE[],
                  class="type">class="kw">double& coeff[],
                  class="type">class="kw">double& TeSt[],
                  class="type">class="kw">ulong pass)
  {
  CGraphic graphic;
class=class="str">"cmt">//--- create graphic
   class="type">bool res = class="kw">false;
   if(ObjectFind(class="num">0, "Graphic") >= class="num">0)
      res = graphic.Attach(class="num">0, "Graphic");
   else
      res = graphic.Create(class="num">0, "Graphic", class="num">0, class="num">0, class="num">0, _width, _height);
   if(!res)
      class="kw">return(NULL);
   graphic.BackgroundMain(FolderName);   class=class="str">"cmt">// print the Expert Advisor name
   graphic.BackgroundMainSize(FontSet + class="num">1); class=class="str">"cmt">// font size for the Expert Advisor name
   graphic.IndentLeft(FontSet);
   graphic.HistoryNameSize(FontSet); class=class="str">"cmt">// font size for the line names
   graphic.HistorySymbolSize(FontSet);
   graphic.XAxis().Name("pass " + IntegerToString(pass)); class=class="str">"cmt">// show the pass number along the X axis
   graphic.XAxis().NameSize(FontSet + class="num">1);
   graphic.XAxis().ValuesSize(class="num">12); class=class="str">"cmt">// price font size
   graphic.YAxis().ValuesSize(class="num">12);
class=class="str">"cmt">//--- add curves
   CCurve *curve = graphic.CurveAdd(y1, ColorToARGB(clrBlue), CURVE_POINTS_AND_LINES, "Balance"); class=class="str">"cmt">// plot the balance graph
   curve.LinesWidth(widthL);   class=class="str">"cmt">// graph line width
   curve.PointsSize(widthL + class="num">1); class=class="str">"cmt">// size of dots on the balance graph
   CCurve *curve1 = graphic.CurveAdd(y2, ColorToARGB(clrGreen), CURVE_LINES, "Equity");   class=class="str">"cmt">// plot the equity graph
   curve1.LinesWidth(widthL);
   class="type">int size = class="num">0;
   class="kw">switch(m_lineR) class=class="str">"cmt">// plot the regression line
     {
      case  lineR_Balance: class=class="str">"cmt">// balance regression line
        {
         size = ArraySize(LRegressB);
         CCurve *curve2 = graphic.CurveAdd(LRegressB, ColorToARGB(clrBlue), CURVE_LINES, "LineR_Balance");
         curve2.LinesWidth(widthL);
        }
      class="kw">break;
      case  lineR_Equity: class=class="str">"cmt">// equity regression line
        {
         size = ArraySize(LRegressE);
把通测截图诊断交给小布
这些多准则净值截图与排序结果,小布盯盘的 AIGC 看板已可自动生成,打开对应品种页就能直接比对各轮优化的图形差异,你只管判断哪组参数更合意。

常见问题

每次即时报价都写会造成文件膨胀且大多为冗余帧;在持仓数量变化时记录开盘与收尾净值,既能还原曲线又大幅压缩数据量。
只写入最必要的信息:开仓时记余额净值,平仓亏损记最大净值、盈利记最小净值,避免一笔报价内重复开平都落盘。
默认只收集可盈利通测,但设置里可勾选登记亏损通测;前向测试也都会记录,便于横向看过拟合程度。
可以,把导出的帧数据丢进小布对应品种页,AIGC 会自动按自定义准则排序并渲染净值走势,省去自己写绘图脚本。
计算本身开销很低,瓶颈通常在文件写入与截图;用独立 include 文件隔离逻辑、控制写入字段,对整体耗时影响有限。