在交易中应用 OLAP(第四部分):定量和可视化分析测试器报告(基础篇)
◍ 把 OLAP 从交易报告拽进优化结果
OLAP(在线分析处理)在 MT5 里的价值,不只是回头翻交易记录。前几篇已经把累积数组、多维选择器、聚合器,以及拖拽式 GUI 都跑通了,数据源覆盖策略测试器、在线历史、HTML 和 CSV,甚至报价的策略预研也接进去了。 本文要把这套多维分析直接怼到 MetaTrader 5 的优化结果上——也就是策略测试器跑完参数扫描吐出的那堆表格。 落地前有个坑:第三篇文章只动了 OLAP 引擎类,第二篇文章的 GUI 没同步升级。所以先拿第二部分的 OLAPGUI 交易报告分析器当练手对象,把图形层抽象统一,之后接优化结果分析器才不用重写界面。 高风险提示:外汇和贵金属杠杆品种,参数优化过拟合概率偏高,MT5 回测漂亮不等于实盘能打,请自行开终端验证。
坐标轴标签与柱线重叠的实操修补
在 MT5 里用 CGraphic 做 OLAP 图表时,Y 轴直接吐出“按品种平均持仓秒数”这种原始汇总值,读数极差。比如请求某品种平均持仓生存期,Y 轴显示的是上千秒的整数,用户根本没法直觉判断长短。 解决办法是给 CGraphicInPlot 传一个 AxisCustomizer 对象,把 periodDivider 置 true,让数值在进标准库算网格前先除以 PeriodSeconds()。以 D1 图为例,同样的平均持仓数据会变成“多少根日线”的概念,可读性明显提升。 别把正态当圣经 AxisCustomizer 里的 hide 标志用于 X 轴:当多维数据集按数值排序时,每行标签顺序独立,横轴没必要显示任何字,函数开头直接 return NULL 即可,避免脏标签干扰视图。 直方图多行叠加也是坑。CGraphic 基类的 HistogramPlot 是虚函数,覆盖它并利用 CCurve 里没用上的 LinesSmoothStep 属性存行号,渲染时按行号左右偏移,柱线就不会互相遮没。 线条断点同样要管。聚合出 NaN 的单元格会让标准 LinesPlot 断线,导致余额曲线缺段;重定义该方法后,测试器标准文件相关的部分能连续绘制。 零轴判定也得换脑。原 CreateGrid 把字符串标签全当 0,AXIS_TYPE_CUSTOM 下也中招,最后一条样本被加粗却丢了整张网格。重写为 isZero 辅助函数做智能检查,网格才正常显示。
class AxisCustomizer { class="kw">public: class="kw">const CGraphicInPlot *parent; class="kw">const class="type">bool y; class=class="str">"cmt">// true for Y, class="kw">false for X class="kw">const class="type">bool periodDivider; class="kw">const class="type">bool hide; AxisCustomizer(class="kw">const CGraphicInPlot *p, class="kw">const class="type">bool axisY, class="kw">const class="type">bool pd = class="kw">false, class="kw">const class="type">bool h = class="kw">false): parent(p), y(axisY), periodDivider(pd), hide(h) {} }; class CGraphicInPlot: class="kw">public CGraphic { ... class="type">void InitAxes(CAxis &axe, class="kw">const AxisCustomizer *custom = NULL); class="type">void InitXAxis(class="kw">const AxisCustomizer *custom = NULL); class="type">void InitYAxis(class="kw">const AxisCustomizer *custom = NULL); }; class="type">void CGraphicInPlot::InitAxes(CAxis &axe, class="kw">const AxisCustomizer *custom = NULL) { if(custom) { axe.Type(AXIS_TYPE_CUSTOM); axe.ValuesFunctionFormat(CustomDoubleToStringFunction); axe.ValuesFunctionFormatCBData((AxisCustomizer *)custom); } else { axe.Type(AXIS_TYPE_DOUBLE); } } class="type">void CGraphicInPlot::InitXAxis(class="kw">const AxisCustomizer *custom = NULL) { InitAxes(m_x, custom); } class="type">void CGraphicInPlot::InitYAxis(class="kw">const AxisCustomizer *custom = NULL) { InitAxes(m_y, custom); } class="type">class="kw">string CustomDoubleToStringFunction(class="type">class="kw">double value, class="type">void *ptr) { AxisCustomizer *custom = class="kw">dynamic_cast<AxisCustomizer *>(ptr); if(custom == NULL) class="kw">return NULL; class=class="str">"cmt">// check options if(!custom.y && custom.hide) class="kw">return NULL; class=class="str">"cmt">// case of X axis and "no marks" mode class=class="str">"cmt">// in simple cases class="kw">return a class="type">class="kw">string if(custom.y) class="kw">return (class="type">class="kw">string)(class="type">class="kw">float)value;
「自绘图表里的坐标轴与直方图定位」
在 MT5 自定义图形类里,CPlot 通过 InitXAxis 接管外部传入的 AxisCustomizer,从而把 GUI 上选好的聚合维度直接压到绘图底层。注意指针释放顺序:先 CheckPointer 判活,再 delete 旧对象,最后赋值并向下透传,避免悬空指针导致图表刷新崩脚本。 CurveAdd 里有个容易被忽略的坑:当 Y 轴聚合字段是 FIELD_DURATION 时,代码会把 data.array[i].value 除以 PeriodSeconds()。这意味着你喂进去的「持续时间」若已是秒数,渲染出来会缩小成「根数除以周期秒数」的比例值,回测时坐标轴刻度会偏密。 直方图绘制 HistogramPlot 的核心在 xc1/xc2 的偏移计算:用 LinesSmoothStep() 减 1 得到 offset,再叠 (offset - t) * w + half 做多曲线错位。其中 w = m_width / size / 2 / CurvesTotal(),当同一图里 CurvesTotal() 从 1 增到 3,单根柱宽会塌成约 1/3,肉眼能直接看出拥挤。 别把正态当圣经:上面这段偏移公式在 size 很小(比如少于 10 根 K 线聚合)时,half 的奇偶补偿会让柱体整体右移 1~2 像素,建议开 MT5 把 CurvesTotal 打印出来对照像素坐标验证。
class="kw">const CGraphicInPlot *self = custom.parent; class=class="str">"cmt">// obtain actual object with cache if(self != NULL) { ... class=class="str">"cmt">// retrieve selector mark for value } } class CPlot: class="kw">public CWndClient { class="kw">private: CGraphicInPlot *m_graphic; ENUM_CURVE_TYPE type; AxisCustomizer *m_customX; AxisCustomizer *m_customY; ... class="kw">public: class="type">void InitXAxis(class="kw">const AxisCustomizer *custom = NULL) { if(CheckPointer(m_graphic) != POINTER_INVALID) { if(CheckPointer(m_customX) != POINTER_INVALID) class="kw">delete m_customX; m_customX = (AxisCustomizer *)custom; m_graphic.InitXAxis(custom); } } ... }; CCurve *CPlot::CurveAdd(class="kw">const PairArray *data, class="kw">const class="type">class="kw">string name = NULL) { if(CheckPointer(m_customY) != POINTER_INVALID) && m_customY.periodDivider) { for(class="type">int i = class="num">0; i < ArraySize(data.array); i++) { data.array[i].value /= PeriodSeconds(); } } class="kw">return m_graphic.CurveAdd(data, type, name); } AGGREGATORS at = ... class=class="str">"cmt">// get aggregator type from GUI ENUM_FIELDS af = ... class=class="str">"cmt">// get aggregator field from GUI SORT_BY sb = ... class=class="str">"cmt">// get sorting mode from GUI class="type">int dimension = class="num">0; class=class="str">"cmt">// calculate cube dimensions from GUI for(class="type">int i = class="num">0; i < AXES_NUMBER; i++) { if(Selectors[i] != SELECTOR_NONE) dimension++; } class="type">bool hideMarksOnX = (dimension > class="num">1 && SORT_VALUE(sb)); AxisCustomizer *customX = NULL; AxisCustomizer *customY = NULL; customX = new AxisCustomizer(m_plot.getGraphic(), class="kw">false, Selectors[class="num">0] == SELECTOR_DURATION, hideMarksOnX); if(af == FIELD_DURATION) { customY = new AxisCustomizer(m_plot.getGraphic(), true, true); } m_plot.InitXAxis(customX); m_plot.InitYAxis(customY); CCurve *CGraphicInPlot::CurveAdd(class="kw">const class="type">class="kw">double &x[], class="kw">const class="type">class="kw">double &y[], ENUM_CURVE_TYPE type, class="kw">const class="type">class="kw">string name = NULL) { CCurve *c = CGraphic::CurveAdd(x, y, type, name); c.LinesSmoothStep((class="type">int)CGraphic::CurvesTotal()); class=class="str">"cmt">// + ... class="kw">return CacheIt(c); } class="type">void CGraphicInPlot::HistogramPlot(CCurve *curve) class="kw">override { class="kw">const class="type">int size = curve.Size(); class="kw">const class="type">class="kw">double offset = curve.LinesSmoothStep() - class="num">1; class=class="str">"cmt">// + class="type">class="kw">double x[], y[]; class="type">int histogram_width = curve.HistogramWidth(); if(histogram_width <= class="num">0) class="kw">return; curve.GetX(x); curve.GetY(y); if(ArraySize(x) == class="num">0 || ArraySize(y) == class="num">0) class="kw">return; class="kw">const class="type">int w = m_width / size / class="num">2 / CGraphic::CurvesTotal(); class=class="str">"cmt">// + class="kw">const class="type">int t = CGraphic::CurvesTotal() / class="num">2; class=class="str">"cmt">// + class="kw">const class="type">int half = ((CGraphic::CurvesTotal() + class="num">1) % class="num">2) * (w / class="num">2); class=class="str">"cmt">// + class="type">int originalY = m_height - m_down; class="type">int yc0 = ScaleY(class="num">0.0); class="type">uint clr = curve.Color(); for(class="type">int i = class="num">0; i < size; i++) { if(!MathIsValidNumber(x[i]) || !MathIsValidNumber(y[i])) class="kw">continue; class="type">int xc = ScaleX(x[i]); class="type">int yc = ScaleY(y[i]); class="type">int xc1 = xc - histogram_width / class="num">2 + (class="type">int)(offset - t) * w + half; class=class="str">"cmt">// * class="type">int xc2 = xc + histogram_width / class="num">2 + (class="type">int)(offset - t) * w + half; class=class="str">"cmt">// * class="type">int yc1 = yc; class="type">int yc2 = (originalY > yc0 && yc0 > class="num">0) ? yc0 : originalY; if(yc1 > yc2) yc2++; else yc2--; m_canvas.FillRectangle(xc1,yc1,xc2,yc2,clr); } } if(StringToDouble(m_yvalues[i]) == class="num">0.0) ...
◍ 零值轴判定与网格绘制的耦合逻辑
在自定义图表类里,零值轴不能简单用 y==0 判断。字符串转 double 后,像 "0.00"、".0" 这类写法转出来都是 0.0,但显示含义不同,所以 isZero 先转数值再剥掉所有 '0' 字符,只剩首字符为结束符或小数点时才认作真零值。
CreateGrid 在双重循环里逐格画线:横向线遍历 m_ysize-1 次,纵向线只在 i==1 时遍历 x 方向,避免重复绘制。每次遇到 isZero 为真就记下该格像素坐标 yc0 / xc0,循环结束后再用 clr_axis_line 加粗零轴。
如果你在 MT5 里改了坐标标签格式(比如隐藏小数点),isZero 的 StringReplace 逻辑可能漏判,零轴就不画了。建议直接复制下面代码到你的 CGraphicInPlot 派生类验证,把 m_yvalues 塞几个 "0.0" 和 "0" 看 yc0 能否正确捕获。
外汇与贵金属图表叠加自绘网格时,像素坐标和报价精度的高频刷新会带来渲染开销,实盘使用需评估终端负载风险。
class="type">bool CGraphicInPlot::isZero(class="kw">const class="type">class="kw">string &value) { if(value == NULL) class="kw">return class="kw">false; class="type">class="kw">double y = StringToDouble(value); if(y != class="num">0.0) class="kw">return class="kw">false; class="type">class="kw">string temp = value; StringReplace(temp, "class="num">0", ""); class="type">class="kw">ushort c = StringGetCharacter(temp, class="num">0); class="kw">return c == class="num">0 || c == &class="macro">#x27;.&class="macro">#x27;; } class="type">void CGraphicInPlot::CreateGrid(class="type">void) class="kw">override { class="type">int xc0 = -class="num">1.0; class="type">int yc0 = -class="num">1.0; for(class="type">int i = class="num">1; i < m_ysize - class="num">1; i++) { m_canvas.LineHorizontal(m_left + class="num">1, m_width - m_right, m_yc[i], m_grid.clr_line); if(isZero(m_yvalues[i])) yc0 = m_yc[i]; for(class="type">int j = class="num">1; j < m_xsize - class="num">1; j++) { if(i == class="num">1) { m_canvas.LineVertical(m_xc[j], m_height - m_down - class="num">1, m_up + class="num">1, m_grid.clr_line); if(isZero(m_xvalues[j])) xc0 = m_xc[j]; } if(m_grid.has_circle) { m_canvas.FillCircle(m_xc[j], m_yc[i], m_grid.r_circle, m_grid.clr_circle); m_canvas.CircleWu(m_xc[j], m_yc[i], m_grid.r_circle, m_grid.clr_circle); } } } if(yc0 > class="num">0) m_canvas.LineHorizontal(m_left + class="num">1, m_width - m_right, yc0, m_grid.clr_axis_line); if(xc0 > class="num">0) m_canvas.LineVertical(xc0, m_height - m_down - class="num">1, m_up + class="num">1, m_grid.clr_axis_line); }
把对话框拆成通用底座与派生层
原 OLAPDialog 类已重命名为 OLAPDialogBase,原先硬编码的 selectors、settings、defaults 数组清空为动态模板,改由派生类在运行时填充。这意味着同一套窗口逻辑能套到任意数据源,不必为每个报告重写界面。 基类实现了从创建控件到处理控件事件的完整界面逻辑,但对控件里装什么数据一无所知。两个抽象方法 setup 和 process 留给派生类:setup 在 OLAPDialogBase::Create 里被调用做界面配置,process 在用户点按钮时由 OnClickButton 委派启动分析。 OLAPDisplay 类实现了 Display 虚拟接口,作为 OLAP 内核的回调接收 MetaCube 分析结果。由于 MQL5 没有多重继承,指向父窗口的指针用链式的接力转发把数据集递给对话框。自定义字段名(如优化报告里的 EA 输入参数)只能从数据里提取,适配器通过 AssignCustomFields 在 Analyst::acquireData 中自动传给聚合器,OLAPDisplay::display 遇到超出内置枚举容量的字段序数时就知道是扩展字段并向数据集要描述。 派生的 OLAPDialog 模板类依赖 OLAPEngine<S,F> 专用对象,setup 填回原数组数值、process 启动分析并创建 AxisCustomizer。操控 duration 字段时两个轴都除以 PeriodSeconds(),数据集尺寸大于 1 且选了排序就禁用 X 轴。 启动更新后的 OLAPGUI.mq5 可验证动态内核:利润与持续时间依存图里 X 轴已按当前时间帧(D1)显示而非秒;按手数盈利分析的手数值直接标在 X 轴;按品种和类型的成交数量改用直方图不再重叠。外汇与贵金属历史测试涉及高风险,回测表现不代表实盘概率。 CustomTradeRecord 类(算 MFE/MAE)移到 OLAPTradesCustom.mqh,标准字段与自定义字段严格分离,改算法不必动内核。程序员现在还能直接读内部测试器的 tst 格式文件做数据源。
OLAPWrapper *olapcore; class=class="str">"cmt">// <-- class="kw">template <class="kw">typename S,class="kw">typename T> class OLAPEngine, since part class="num">3 OLAPDisplay *olapdisplay; class="kw">virtual class="type">void setup() = class="num">0; class="kw">virtual class="type">int process() = class="num">0; class="type">bool OLAPDialogBase::Create(class="kw">const class="type">long chart, class="kw">const class="type">class="kw">string name, class="kw">const class="type">int subwin, class="kw">const class="type">int x1, class="kw">const class="type">int y1, class="kw">const class="type">int x2, class="kw">const class="type">int y2) { setup(); class=class="str">"cmt">// + ... } class="type">void OLAPDialogBase::OnClickButton(class="type">void) { if(processing) class="kw">return; class=class="str">"cmt">// prevent re-entrancy if(browsing) class=class="str">"cmt">// 3D-cube browsing support { currentZ = (currentZ + class="num">1) % maxZ; validateZ(); } processing = true; class="kw">const class="type">int n = process(); if(n == class="num">0 && processing) { finalize(); } } class OLAPDisplay: class="kw">public Display { class="kw">private: OLAPDialogBase *parent; class="kw">public: OLAPDisplay(OLAPDialogBase *ptr,): parent(ptr) {} class="kw">virtual class="type">void display(MetaCube *metaData, class="kw">const SORT_BY sortby = SORT_BY_NONE, class="kw">const class="type">bool identity = class="kw">false) class="kw">override; }; class="type">int customFieldCount; class="type">class="kw">string customFields[]; class="kw">virtual class="type">void setCustomFields(class="kw">const DataAdapter &adapter) { class="type">class="kw">string names[]; if(adapter.getCustomFields(names) > class="num">0) { customFieldCount = ArrayCopy(customFields, names); } } class="kw">template<class="kw">typename S, class="kw">typename F> class OLAPDialog: class="kw">public OLAPDialogBase { class="kw">private: OLAPEngine<S,F> *olapcore; OLAPDisplay *olapdisplay; class="kw">public: