优化结果的可视化评估·进阶篇
(2/3)·只跑一遍优化就拿到余额净值图与多准则排序,把 80% 靠眼睛接收的信息用起来
多数交易者为了核对几个自定义优化条件,会反复跑多轮测试,耗时且容易漏掉参数组合间的非线性关联。其实一次优化过程中就能把每遍通测的余额、净值和多个准则同时落盘,边跑边看图形比事后翻报表更直接。
用 FrameAdd 把通测结果落盘
只在回测里把真正可能盈利的通测写进文件才有价值。默认只登记达标盈利通测,若你在设置里勾了亏损登记,也能顺带把亏损通测一并留存;前向通测本质是对优化的推测,所以只记余额和净值,不写 ENUM_STATISTICS 细节。 每次测试收尾会触发测试器事件,由 OnTester() 接管,此时调用 FrameAdd() 把收集到的数组和数值写文件。注意 FrameAdd() 一次只能带一个任意类型数组加一个 double 数值:除余额、净值外,把 ENUM_STATISTICS 枚举值按顺序塞进数组,数组长度写进 value 参数即可。 测试结束若还有持仓,测试器会强平。代码里用 changesPos 计数,若结束时实际持仓数和变量对不上,就靠 IsCorrect() 做一笔虚拟了结,把当前余额、净值补写进去,避免落盘数据错位。 下面这段展示了回测达标时如何拼装数组并落盘:先判断 STAT_PROFIT 较初始保证金百分比大于设定阈值、且成交笔数够数,才把 41 个统计项写进 42 长度数组,合并余额净值后 FrameAdd 写出。
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 必须在调用前算好,否则数组里会落进脏值。外汇与贵金属品种杠杆高,回撤判定容易因点差跳变而频繁触发,建议先用模拟盘验证数组增长节奏。
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 脚本里就能验证路径和句柄行为。
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 号,命中后若选了「回测接正向」模式,就把正向数据错位一笔接到原数组尾部。外汇与贵金属回测过拟合风险高,这套读取只解决筛选,不代表实盘概率。
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 用真实点差重跑验证。
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 是通测编号。
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);