在交易中应用 OLAP(第四部分):定量和可视化分析测试器报告·进阶篇
📊

在交易中应用 OLAP(第四部分):定量和可视化分析测试器报告·进阶篇

(2/3)· 还在用眼睛扫优化结果里的几百行参数?用 OLAP 把测试器报告切成可拖拽的维度立方体

实战向进阶 第 2/3 篇
优化跑完直接看排序后的参数表,容易漏掉星期几或品种维度下的隐性衰减。把测试器报告接进 OLAP,能让每个筛选维度都变成可拖拽的切面,先看见结构再下结论。

◍ OLAP 交易分析对话框的构造与处理链路

在 MT5 里做账户历史的多维切片,OLAPDialog 是把引擎、显示与 GUI 绑在一起的入口。模板参数 S、F 分别对应选择器与字段枚举,构造时把曲线类型写死为 CURVE_POINTS,并把外部传入的 OLAPEngine 实例地址存进 olapcore,同时 new 一个 OLAPDisplay 负责绘图。 析构只做一件事:delete olapdisplay,避免 GUI 对象泄漏。process() 才是重头戏——它从下拉框取值,把 0~9 的索引映射到 matches 数组里的 10 种选择器(SELECTOR_NONE 到 SELECTOR_DURATION),把 10 以上的索引映射到 subfields 里的 8 种直接字段(手数、利润金额、利润百分比、利润点数、佣金、库存费、自定义1、自定义2)。

坐标轴最多支持 3 个正交轴(AXES_NUMBER 循环里 i<AXES_NUMBER),若某轴不可见就跳过。v<10 走选择器分支,其中 v==5v==7 绑定开仓时间字段,v==6v==8 绑定平仓时间字段;v>=10 则按聚合器类型决定走标量还是分桶选择器。

聚合器非 AGGREGATOR_PROGRESSIVE 时,给 X 轴挂 AxisCustomizer,当选择器是 SELECTOR_DURATION 或需要按值排序时会启用自定义刻度。Y 轴仅在字段为 FIELD_DURATION 或恒等聚合+第二轴为时长时才挂自定义器。最后移除旧曲线、改按钮文字为 Processing,把组装好的参数甩给 olapcore.process()。 OnInit 里先判断 ReportFile 是否为空:空则接 _defaultHistoryAdapter 直接分析账户历史;非空则按扩展名分流,.htm 走 HTML 适配器,.csv 走 CSV 适配器,两者 load 失败或格式不对就返回 INIT_PARAMETERS_INCORRECT。对话框创建尺寸固定为 750×560,失败即 INIT_FAILED。打开 MT5 把这段 include 和 dialog 声明抄进 EA,接上自己的历史或报表文件就能跑出多维透视。

MQL5 / C++
OLAPDialog(OLAPEngine<S,F> &olapimpl);
~OLAPDialog(class="type">void);
class="kw">virtual class="type">int process() class="kw">override;
class="kw">virtual class="type">void setup() class="kw">override;
};
class="kw">template<class="kw">typename S, class="kw">typename F>
OLAPDialog::OLAPDialog(OLAPEngine<S,F> &olapimpl)
{
curveType = CURVE_POINTS;
olapcore = &olapimpl;
olapdisplay = new OLAPDisplay(&this);
}
class="kw">template<class="kw">typename S, class="kw">typename F>
OLAPDialog::~OLAPDialog(class="type">void)
{
class="kw">delete olapdisplay;
}
class="kw">template<class="kw">typename S, class="kw">typename F>
class="type">int OLAPDialog::process() class="kw">override
{
SELECTORS Selectors[class="num">4];
ENUM_FIELDS Fields[class="num">4];
AGGREGATORS at = (AGGREGATORS)m_algo[class="num">0].Value();
ENUM_FIELDS af = (ENUM_FIELDS)(AGGREGATORS)m_algo[class="num">1].Value();
SORT_BY sb = (SORT_BY)m_algo[class="num">2].Value();
ArrayInitialize(Selectors, SELECTOR_NONE);
ArrayInitialize(Fields, FIELD_NONE);
class="type">int matches[class="num">10] = class=class="str">"cmt">// selectors in combo-boxes(specific record fields are bound internally)
{
SELECTOR_NONE, SELECTOR_SERIAL, SELECTOR_SYMBOL, SELECTOR_TYPE, SELECTOR_MAGIC,
SELECTOR_WEEKDAY, SELECTOR_WEEKDAY, SELECTOR_DAYHOUR, SELECTOR_DAYHOUR, SELECTOR_DURATION
};
class="type">int subfields[] = class=class="str">"cmt">// record fields listed in combo-boxes after selectors and accessible directly  
{
FIELD_LOT, FIELD_PROFIT_AMOUNT, FIELD_PROFIT_PERCENT, FIELD_PROFIT_POINT,
FIELD_COMMISSION, FIELD_SWAP, FIELD_CUSTOM_1, FIELD_CUSTOM_2
};
for(class="type">int i = class="num">0; i < AXES_NUMBER; i++) class=class="str">"cmt">// up to class="num">3 orthogonal axes are supported
{
if(!m_axis[i].IsVisible()) class="kw">continue;
class="type">int v = (class="type">int)m_axis[i].Value();
if(v < class="num">10) class=class="str">"cmt">// selectors(every one is specialized for a field already)
{
Selectors[i] = (SELECTORS)matches[v];
if(v == class="num">5 || v == class="num">7) Fields[i] = FIELD_OPEN_DATETIME;
else if(v == class="num">6 || v == class="num">8) Fields[i] = FIELD_CLOSE_DATETIME;
}
else class=class="str">"cmt">// pure fields
{
Selectors[i] = at == AGGREGATOR_IDENTITY ? SELECTOR_SCALAR : SELECTOR_QUANTS;
Fields[i] = (TRADE_RECORD_FIELDS)subfields[v - class="num">10];
}
}
m_plot.CurvesRemoveAll();
AxisCustomizer *customX = NULL;
AxisCustomizer *customY = NULL;
if(at == AGGREGATOR_IDENTITY || at == AGGREGATOR_COUNT) af = FIELD_NONE;
if(at != AGGREGATOR_PROGRESSIVE)
{
customX = new AxisCustomizer(m_plot.getGraphic(), class="kw">false, Selectors[class="num">0] == SELECTOR_DURATION, (dimension > class="num">1 && SORT_VALUE(sb)));
}
if((af == FIELD_DURATION)
|| (at == AGGREGATOR_IDENTITY && Selectors[class="num">1] == SELECTOR_DURATION))
{
customY = new AxisCustomizer(m_plot.getGraphic(), true, true);
}
m_plot.InitXAxis(customX);
m_plot.InitYAxis(customY);
m_button_ok.Text("Processing...");
class="kw">return olapcore.process(Selectors, Fields, at, af, olapdisplay, sb);
}
class="macro">#include <OLAP/OLAPTradesCustom.mqh> class=class="str">"cmt">// internally includes OLAPTrades.mqh 
class="macro">#include <OLAP/HTMLcube.mqh>
class="macro">#include <OLAP/CSVcube.mqh>
class="macro">#include <OLAP/GUI/OLAPGUI_trades.mqh>
OLAPDialog<SELECTORS,ENUM_FIELDS> dialog(_defaultEngine);
class="type">int OnInit()
{
if(ReportFile == "")
{
Print("Analyzing account history");
_defaultEngine.setAdapter(&_defaultHistoryAdapter);
}
else
{
if(StringFind(ReportFile, ".htm") > class="num">0 && _defaultHTMLReportAdapter.load(ReportFile))
{
_defaultEngine.setAdapter(&_defaultHTMLReportAdapter);
}
else
if(StringFind(ReportFile, ".csv") > class="num">0 && _defaultCSVReportAdapter.load(ReportFile))
{
_defaultEngine.setAdapter(&_defaultCSVReportAdapter);
}
else
{
Print("Unknown file format: ", ReportFile);
class="kw">return INIT_PARAMETERS_INCORRECT;
}
}
...
if(!dialog.Create(class="num">0, "OLAPGUI" + (ReportFile != "" ? " : " + ReportFile : ""), class="num">0,  class="num">0, class="num">0, class="num">750, class="num">560)) class="kw">return INIT_FAILED;
if(!dialog.Run()) class="kw">return INIT_FAILED;
class="kw">return INIT_SUCCEEDED;
}

「直接啃 tst 文件拿成交序列」

MT5 近期放开了测试器标准文件(*.tst)的读取权限。过去单次回测的成交明细只能先导出 HTML 再解析,现在用 fxsaber 的 SingleTesterCache 函数库,可在‘黑盒’层面直接载入 tst 拿到 Deal 数组,省掉报表转换环节。 接好库以后,建一个 SINGLETESTERCACHE 对象,调用 Load(文件路经) 即可。成功后 SingleTesterCache.Deals 里就是全部成交,每笔的字段都能直接点出来。下面这段演示了最小载入与计数打印: 把成交转成仓位的逻辑和之前解析 HTML 时一致。基类 BaseReportAdapter 放通用代码,HTMLReportAdapter 与新增的 TesterReportAdapter 各自继承,只需覆写 load 和 fillDealsArray 两个虚方法。TesterReportAdapter 内部挂一个 SINGLETESTERCACHE 指针,load 时把 tst 成交灌进 Deals,再由 fillDealsArray 加工。 项目里用 OLAPTradesCustom.mqh 把 CustomTradeRecord 宏定义为 RECORD_CLASS。连上适配器、输入里指定 tst 文件,就会自动产出带 MFE / MAE 自定义字段的仓位记录。下面示例取自某 tst,按品种画的余额曲线是连续不断的,说明 CGraphicInPlot::LinesPlot 没断点;用累积式聚合时,首个选择器务必用记录序号或索引。外汇与贵金属回测仅反映历史概率,实盘高风险。

MQL5 / C++
class="macro">#include <fxsaber/SingleTesterCache/SingleTesterCache.mqh>
...
SINGLETESTERCACHE SingleTesterCache;
if(SingleTesterCache.Load(file))
{
   Print("Tester cache class="kw">import: ", ArraySize(SingleTesterCache.Deals), " deals");
}
class="kw">virtual class="type">bool load(class="kw">const class="type">class="kw">string file)
{
   reset();
   TradeRecord::reset();
   class="kw">return class="kw">false;
}
class="kw">virtual class="type">bool fillDealsArray() = class="num">0;
class="type">int generate()
{
   ...
   if(!fillDealsArray()) class="kw">return class="num">0;
   ...
}
class="kw">template<class="kw">typename T>
class HTMLReportAdapter: class="kw">public BaseReportAdapter<T>
{
class="kw">protected:
   IndexMap *data;
   class="kw">virtual class="type">bool fillDealsArray() class="kw">override
   {
      for(class="type">int i = class="num">0; i < data.getSize(); ++i)
      {
         IndexMap *row = data[i];
         if(CheckPointer(row) == POINTER_INVALID || row.getSize() != COLUMNS_COUNT) class="kw">return class="kw">false; class=class="str">"cmt">// something is broken
         class="type">class="kw">string s = row[COLUMN_SYMBOL].get<class="type">class="kw">string>();
         StringTrimLeft(s);
         if(StringLen(s) > class="num">0) class=class="str">"cmt">// there is a symbol -> this is a deal
         {
            array << new Deal(row);
         }
         else if(row[COLUMN_TYPE].get<class="type">class="kw">string>() == "balance")
         {
            class="type">class="kw">string t = row[COLUMN_PROFIT].get<class="type">class="kw">string>();
            StringReplace(t, " ", "");
            balance += StringToDouble(t);
         }
      }
      class="kw">return true;
   }
class="kw">public:
   ~HTMLReportAdapter()
   {
      if(CheckPointer(data) == POINTER_DYNAMIC) class="kw">delete data;
   }
   class="kw">virtual class="type">bool load(class="kw">const class="type">class="kw">string file) class="kw">override
   {
      BaseReportAdapter<T>::load(file);
      if(CheckPointer(data) == POINTER_DYNAMIC) class="kw">delete data;
      data = NULL;
      if(StringFind(file, ".htm") > class="num">0)
      {
         data = HTMLConverter::convertReport2Map(file, true);
         if(data != NULL)
         {
            size = generate();
            Print(data.getSize(), " deals transferred to ", size, " trades");
         }
      }
      class="kw">return data != NULL;
   }
};
class TesterDeal: class="kw">public Deal
{
class="kw">public:
   TesterDeal(class="kw">const TradeDeal &td)
   {
      time = (class="type">class="kw">datetime)td.time_create + TimeShift;
      price = td.price_open;
      class="type">class="kw">string t = dealType(td.action);
      type = t == "buy" ? +class="num">1 : (t == "sell" ? -class="num">1 : class="num">0);
      t = dealDir(td.entry);
      direction = class="num">0;
      if(StringFind(t, "in") > -class="num">1) ++direction;

回测成交缓存的适配器写法

这段 MQL5 代码实现了一个针对策略测试器 .tst 缓存文件的报告适配器,核心是把底层 deal 流按类型拆成可统计的成交记录。 TesterReportAdapter 继承自 BaseReportAdapter,在 fillDealsArray() 里遍历 ptrSingleTesterCache.Deals:若 dealType 返回 balance 则累加至余额变量,否则封装成 TesterDeal 压入数组。这样回测里的赠金或余额变动不会污染真实交易统计。 load() 方法只认文件名含 .tst 的缓存;载入后会 Print 出一句话,例如 Tester cache import: 142 trades from 318 deals,说明 318 笔 deal 最终提炼成 142 笔可统计交易。你在 MT5 里跑完回测,直接用这个适配器读 .tst,能省掉自己解析二进制缓存的麻烦。 外汇与贵金属回测含高杠杆风险,适配器只解决数据解析,不替代样本外验证。

MQL5 / C++
if(StringFind(t, "out") > -class="num">1) --direction;
volume = (class="type">class="kw">double)td.volume;
profit = td.profit;
deal = (class="type">long)td.deal;
order = (class="type">long)td.order;
comment = td.comment[];
symbol = td.symbol[];
commission = td.commission;
swap = td.storage;
}
class="kw">static class="type">class="kw">string dealType(class="kw">const ENUM_DEAL_TYPE type)
{
class="kw">return type == DEAL_TYPE_BUY ? "buy" : (type == DEAL_TYPE_SELL ? "sell" : "balance");
}
class="kw">static class="type">class="kw">string dealDir(class="kw">const ENUM_DEAL_ENTRY entry)
{
class="type">class="kw">string result = "";
if(entry == DEAL_ENTRY_IN) result += "in";
else if(entry == DEAL_ENTRY_OUT || entry == DEAL_ENTRY_OUT_BY) result += "out";
else if(entry == DEAL_ENTRY_INOUT) result += "in out";
class="kw">return result;
}
};
class="kw">template<class="kw">typename T>
class TesterReportAdapter: class="kw">public BaseReportAdapter<T>
{
class="kw">protected:
SINGLETESTERCACHE *ptrSingleTesterCache;
class="kw">virtual class="type">bool fillDealsArray() class="kw">override
{
for(class="type">int i = class="num">0; i < ArraySize(ptrSingleTesterCache.Deals); i++)
{
if(TesterDeal::dealType(ptrSingleTesterCache.Deals[i].action) == "balance")
{
balance += ptrSingleTesterCache.Deals[i].profit;
}
else
{
array << new TesterDeal(ptrSingleTesterCache.Deals[i]);
}
}
class="kw">return true;
}
class="kw">public:
~TesterReportAdapter()
{
if(CheckPointer(ptrSingleTesterCache) == POINTER_DYNAMIC) class="kw">delete ptrSingleTesterCache;
}
class="kw">virtual class="type">bool load(class="kw">const class="type">class="kw">string file) class="kw">override
{
if(StringFind(file, ".tst") > class="num">0)
{
class=class="str">"cmt">// class="kw">default cleanup
BaseReportAdapter<T>::load(file);
class=class="str">"cmt">// specific cleanup
if(CheckPointer(ptrSingleTesterCache) == POINTER_DYNAMIC) class="kw">delete ptrSingleTesterCache;
ptrSingleTesterCache = new SINGLETESTERCACHE();
if(!ptrSingleTesterCache.Load(file))
{
class="kw">delete ptrSingleTesterCache;
ptrSingleTesterCache = NULL;
class="kw">return class="kw">false;
}
size = generate();
Print("Tester cache class="kw">import: ", size, " trades from ", ArraySize(ptrSingleTesterCache.Deals), " deals");
}
class="kw">return true;
}
};
TesterReportAdapter<RECORD_CLASS> _defaultTSTReportAdapter;

◍ 把优化缓存变成可透视的 OLAP 数据集

MT5 的优化结果除了单个测试文件,现在能用 TesterCache 函数库直接读 opt 缓存。在这个库之上套一层记录类、适配器和选择器,就能把每次优化通关变成 OLAP 可切片的数据,思路类似之前做成交记录的 OLAP 封装。 核心是从 ExpTradeSummary 结构里取字段,定义 OPT_CACHE_RECORD_FIELDS 枚举,再派生 OptCacheRecord 类把 long / double / int 统一塞进 double 数组。额外加的 FIELD_INDEX 记录索引,方便后续按通关编号定位。 适配器层用 OptCacheRecordInternal 承接函数库数据,把 EA 输入参数以 MqlParam 数组挂到 summary 后面。fillByTesterPass 方法就是枚举项和结构字段的映射表,构造函数吃进填充好的 internal 结构即可生成一条记录。 选择器决定怎么切数据:SELECTOR_QUANTS 把均线周期这类参数按取值摊成单元格(basket 大小设 0);盈利分布可按 100 单位步长分桶,这时 basket 设成步长。SELECTOR_INDEX 算累计、SELECTOR_SCALAR 收整体特征值,都在 OLAPCommon.mqh 里现成。 实盘外汇或贵金属策略做这类优化分析时,回测分布再漂亮也只代表历史样本,参数过拟合可能导致实盘失效,属高风险环节,建议先用小资金或模拟盘验证。

MQL5 / C++
class="macro">#include <fxsaber/TesterCache/TesterCache.mqh>
enum OPT_CACHE_RECORD_FIELDS
{
FIELD_NONE,
FIELD_INDEX,
FIELD_PASS,
FIELD_DEPOSIT,
FIELD_WITHDRAWAL,
FIELD_PROFIT,
FIELD_GROSS_PROFIT,
FIELD_GROSS_LOSS,
FIELD_MAX_TRADE_PROFIT,
FIELD_MAX_TRADE_LOSS,
FIELD_LONGEST_SERIAL_PROFIT,
FIELD_MAX_SERIAL_PROFIT,
FIELD_LONGEST_SERIAL_LOSS,
FIELD_MAX_SERIAL_LOSS,
FIELD_MIN_BALANCE,
FIELD_MAX_DRAWDOWN,
FIELD_MAX_DRAWDOWN_PCT,
FIELD_REL_DRAWDOWN,
FIELD_REL_DRAWDOWN_PCT,
FIELD_MIN_EQUITY,
FIELD_MAX_DRAWDOWN_EQ,
FIELD_MAX_DRAWDOWN_PCT_EQ,
FIELD_REL_DRAWDOWN_EQ,
FIELD_REL_DRAWDOWN_PCT_EQ,
FIELD_EXPECTED_PAYOFF,
FIELD_PROFIT_FACTOR,
FIELD_RECOVERY_FACTOR,
FIELD_SHARPE_RATIO,
FIELD_MARGIN_LEVEL,
FIELD_CUSTOM_FITNESS,
FIELD_DEALS,
FIELD_TRADES,
FIELD_PROFIT_TRADES,
FIELD_LOSS_TRADES,
FIELD_LONG_TRADES,
FIELD_SHORT_TRADES,
FIELD_WIN_LONG_TRADES,
FIELD_WIN_SHORT_TRADES,
FIELD_LONGEST_WIN_CHAIN,
FIELD_MAX_PROFIT_CHAIN,
FIELD_LONGEST_LOSS_CHAIN,
FIELD_MAX_LOSS_CHAIN,
FIELD_AVERAGE_SERIAL_WIN_TRADES,
FIELD_AVERAGE_SERIAL_LOSS_TRADES
};
class="macro">#define OPT_CACHE_RECORD_FIELDS_LAST(FIELD_AVERAGE_SERIAL_LOSS_TRADES + class="num">1)
class="kw">struct OptCacheRecordInternal
{
ExpTradeSummary summary;
class="type">MqlParam params[][class="num">5]; class=class="str">"cmt">// [][name, current, low, step, high]
};
class OptCacheRecord: class="kw">public Record
{
class="kw">protected:
class="kw">static class="type">int counter; class=class="str">"cmt">// number of passes
class="type">void fillByTesterPass(class="kw">const OptCacheRecordInternal &internal)
{
class="kw">const ExpTradeSummary record = internal.summary;
set(FIELD_INDEX, counter++);
set(FIELD_PASS, record.Pass);
set(FIELD_DEPOSIT, record.initial_deposit);
set(FIELD_WITHDRAWAL, record.withdrawal);
set(FIELD_PROFIT, record.profit);
set(FIELD_GROSS_PROFIT, record.grossprofit);
set(FIELD_GROSS_LOSS, record.grossloss);
set(FIELD_MAX_TRADE_PROFIT, record.maxprofit);
set(FIELD_MAX_TRADE_LOSS, record.minprofit);
set(FIELD_LONGEST_SERIAL_PROFIT, record.conprofitmax);
set(FIELD_MAX_SERIAL_PROFIT, record.maxconprofit);
set(FIELD_LONGEST_SERIAL_LOSS, record.conlossmax);
set(FIELD_MAX_SERIAL_LOSS, record.maxconloss);
set(FIELD_MIN_BALANCE, record.balance_min);
set(FIELD_MAX_DRAWDOWN, record.maxdrawdown);
set(FIELD_MAX_DRAWDOWN_PCT, record.drawdownpercent);
set(FIELD_REL_DRAWDOWN, record.reldrawdown);
set(FIELD_REL_DRAWDOWN_PCT, record.reldrawdownpercent);
set(FIELD_MIN_EQUITY, record.equity_min);
set(FIELD_MAX_DRAWDOWN_EQ, record.maxdrawdown_e);
set(FIELD_MAX_DRAWDOWN_PCT_EQ, record.drawdownpercent_e);
set(FIELD_REL_DRAWDOWN_EQ, record.reldrawdown_e);
set(FIELD_REL_DRAWDOWN_PCT_EQ, record.reldrawdownpercnt_e);
set(FIELD_EXPECTED_PAYOFF, record.expected_payoff);
set(FIELD_PROFIT_FACTOR, record.profit_factor);
set(FIELD_RECOVERY_FACTOR, record.recovery_factor);
set(FIELD_SHARPE_RATIO, record.sharpe_ratio);
set(FIELD_MARGIN_LEVEL, record.margin_level);
set(FIELD_CUSTOM_FITNESS, record.custom_fitness);
set(FIELD_DEALS, record.deals);
set(FIELD_TRADES, record.trades);
set(FIELD_PROFIT_TRADES, record.profittrades);
set(FIELD_LOSS_TRADES, record.losstrades);
set(FIELD_LONG_TRADES, record.longtrades);
set(FIELD_SHORT_TRADES, record.shorttrades);
set(FIELD_WIN_LONG_TRADES, record.winlongtrades);
set(FIELD_WIN_SHORT_TRADES, record.winshorttrades);
set(FIELD_LONGEST_WIN_CHAIN, record.conprofitmax_trades);
set(FIELD_MAX_PROFIT_CHAIN, record.maxconprofit_trades);
set(FIELD_LONGEST_LOSS_CHAIN, record.conlossmax_trades);
set(FIELD_MAX_LOSS_CHAIN, record.maxconloss_trades);
set(FIELD_AVERAGE_SERIAL_WIN_TRADES, record.avgconwinners);
set(FIELD_AVERAGE_SERIAL_LOSS_TRADES, record.avgconloosers);

「把优化缓存接进 OLAP 引擎」

上面这段把策略优化结果从 MT5 的 tester 缓存里读出来,再塞进一个轻量 OLAP 分析层。核心类是 OptCacheDataAdapter,它用 Cache.Load(optName) 把 .op 优化文件载入,size 直接取 Cache.Header.passes_passed——也就是实际跑过的优化 pass 总数,这个数就是你能遍历的最大记录条数。 customize() 里会扫 Cache.Inputs,把 flag 为真的输入参数名收进 paramNames,并用 ArrayPrint 打到日志。如果你只优化了 5 个参数但总输入有 20 个,控制台会明确打印 'Optimized Parameters (5 of 20):' 再加参数名数组,方便确认维度没漏。 getNext() 是遍历入口:cursor 从 0 走到 size-1,每步用 Cache.GetInputs(cursor, internal.params) 取出该 pass 的参数值,包成 T 类型记录返回。cursor 越界就返 NULL,外层循环据此停。 最底下 OLAPEngineOptCache 挂了四种选择器:SELECTOR_INDEX 按序号、SELECTOR_SCALAR 取单字段标量、SELECTOR_QUANTS 做分箱量化、SELECTOR_FILTER 做过滤。标准字段数由 adapter.getFieldCount() 给(即 OPT_CACHE_RECORD_FIELDS_LAST),自定义字段走 quantGranularity=0 不分箱。 开 MT5 跑完一次参数优化后,把 optName 换成你的 .op 文件名,用 _defaultOptCacheAdapter.load() 载入,再拿 _defaultEngine 套 SELECTOR_QUANTS 对某盈利字段分箱,就能在日志里直接看哪些参数区间倾向出好结果。外汇与贵金属优化结果受滑点与点差影响大,回测优异区间实盘可能失效,属高风险验证。

MQL5 / C++
class="kw">const class="type">int n = ArrayRange(internal.params, class="num">0);
for(class="type">int i = class="num">0; i < n; i++)
{
set(OPT_CACHE_RECORD_FIELDS_LAST + i, internal.params[i][PARAM_VALUE].double_value);
}
}
class="kw">public:
OptCacheRecord(class="kw">const class="type">int customFields = class="num">0): Record(OPT_CACHE_RECORD_FIELDS_LAST + customFields)
{
}
OptCacheRecord(class="kw">const OptCacheRecordInternal &record, class="kw">const class="type">int customFields = class="num">0): Record(OPT_CACHE_RECORD_FIELDS_LAST + customFields)
{
fillByTesterPass(record);
}
class="kw">static class="type">int getRecordCount()
{
class="kw">return counter;
}
class="kw">static class="type">void reset()
{
counter = class="num">0;
}
};
class="kw">static class="type">int OptCacheRecord::counter = class="num">0;
class="kw">template<class="kw">typename T>
class OptCacheDataAdapter: class="kw">public DataAdapter
{
class="kw">private:
class="type">int size;
class="type">int cursor;
class="type">int paramCount;
class="type">class="kw">string paramNames[];
TESTERCACHE<ExpTradeSummary> Cache;
class="type">void customize()
{
size = (class="type">int)Cache.Header.passes_passed;
paramCount = (class="type">int)Cache.Header.opt_params_total;
class="kw">const class="type">int n = ArraySize(Cache.Inputs);
ArrayResize(paramNames, n);
class="type">int k = class="num">0;
for(class="type">int i = class="num">0; i < n; i++)
{
if(Cache.Inputs[i].flag)
{
paramNames[k++] = Cache.Inputs[i].name[];
}
}
if(k > class="num">0)
{
ArrayResize(paramNames, k);
Print("Optimized Parameters(", paramCount, " of ", n, "):");
ArrayPrint(paramNames);
}
}
class="kw">public:
OptCacheDataAdapter()
{
reset();
}
class="type">void load(class="kw">const class="type">class="kw">string optName)
{
if(Cache.Load(optName))
{
customize();
reset();
}
else
{
cursor = -class="num">1;
}
}
class="kw">virtual class="type">void reset() class="kw">override
{
cursor = class="num">0;
if(Cache.Header.version == class="num">0) class="kw">return;
T::reset();
}
class="kw">virtual class="type">int getFieldCount() class="kw">const class="kw">override
{
class="kw">return OPT_CACHE_RECORD_FIELDS_LAST;
}
class="kw">virtual Record *getNext() class="kw">override
{
if(cursor < size)
{
OptCacheRecordInternal internal;
internal.summary = Cache[cursor];
Cache.GetInputs(cursor, internal.params);
cursor++;
class="kw">return new T(internal, paramCount);
}
class="kw">return NULL;
}
...
};
class="macro">#ifndef RECORD_CLASS
class="macro">#define RECORD_CLASS OptCacheRecord
class="macro">#endif
OptCacheDataAdapter<RECORD_CLASS> _defaultOptCacheAdapter;
enum OPT_CACHE_SELECTORS
{
SELECTOR_NONE,       class=class="str">"cmt">// none
SELECTOR_INDEX,      class=class="str">"cmt">// ordinal number
class=class="str">"cmt">/* all the next require a field as parameter */
SELECTOR_SCALAR,     class=class="str">"cmt">// scalar(field)
SELECTOR_QUANTS,     class=class="str">"cmt">// quants(field)
SELECTOR_FILTER      class=class="str">"cmt">// filter(field)
};
class OLAPEngineOptCache: class="kw">public OLAPEngine<OPT_CACHE_SELECTORS,OPT_CACHE_RECORD_FIELDS>
{
class="kw">protected:
class="kw">virtual Selector<OPT_CACHE_RECORD_FIELDS> *createSelector(class="kw">const OPT_CACHE_SELECTORS selector, class="kw">const OPT_CACHE_RECORD_FIELDS field) class="kw">override
{
class="kw">const class="type">int standard = adapter.getFieldCount();
class="kw">switch(selector)
{
case SELECTOR_INDEX:
class="kw">return new SerialNumberSelector<OPT_CACHE_RECORD_FIELDS,OptCacheRecord>(FIELD_INDEX);
case SELECTOR_SCALAR:
class="kw">return new OptCacheSelector(field);
case SELECTOR_QUANTS:
class="kw">return field != FIELD_NONE ? new QuantizationSelector<OPT_CACHE_RECORD_FIELDS>(field, (class="type">int)field < standard ? quantGranularity : class="num">0) : NULL;
}
class="kw">return NULL;
}
class="kw">public:
OLAPEngineOptCache(): OLAPEngine() {}
OLAPEngineOptCache(DataAdapter *ptr): OLAPEngine(ptr) {}
};
OLAPEngineOptCache _defaultEngine;
让小布替你跑这套切片
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到多维切片视图,把重复劳动交给小布,你专注决策。

常见问题

汇总值若按原样显示秒数,数值过大且脱离图表周期语境;除以当前时间帧柱线持续时间后转为柱线数,可读性明显提升,需在 CGraphicInPlot 传标志给 CAxis 切换显示模式。
小布盯盘内置的多维诊断模块覆盖了测试器报告域,可省去自行接适配器的步骤,但深度定制选择器仍建议在 MQL5 环境改引擎类。
需把 tst 里的优化参数与绩效字段映射为 OLAP 选择器和聚合器,尤其留意浮点精度与缺失值处理,否则立方体汇总会偏移。
两者复用同一套 OLAP 引擎类与 GUI 组件,仅适配器与选择器不同;统一图形层后换域成本很低,这也是本篇重构的重点。