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

2 七月 2020, 11:29
Stanislav Korotky
0
1 243

在本文中,我们将继续研究 OLAP(在线分析处理),及其在交易中的可适用性。

在早前的文章里,我们讨论了构造累积和分析多维数组的类的通用技术,并且我们还用图形界面来直观分析结果。 从应用程序的角度来看,前两篇文章讨论如何处理以各种途径得来的交易报告:从策略测试器,在线交易历史,HTML 和 CSV 文件(包括 MQL5 交易信号)。 在第三篇文章里对代码进行了微量的重构之后,在报价分析和制定交易策略时运用了 OLAP。 请参阅以前的文章,以便能够理解新资料(查看方括号,以了解您应特别注意的内容):

在本文中,我们将通过分析 MetaTrader 5 优化结果来扩展 OLAP 范围。

为了能够执行该项目,我们首先需要改进之前在第二部分里研究的图形用户界面。 第三部分里执行的所有代码改进,都直接与 OLAP 引擎有关。 但可视化相关部分却并未执行升级。 这就是我们要做的工作,用来自第二篇文章里的 OLAPGUI 交易报告分析器作为本文的测试任务。 我们还将统一图形部分,如此便可以轻松地将其应用到任何新的其他应用领域,尤其是计划中的优化结果分析器。

在应用程序图形上

OLAP 的 GUI 中心是专门开发的 CGraphicInPlot 可视组件。 第二篇文章里展现的第一个实现略有瑕疵。 其中包括在坐标轴上显示标签。 必要时,我们设法在水平 X 轴上显示选择器单元的名称(例如,星期几的名称,或是货币名称)。 但是,在所有其他情况下,数字均按“原样”显示,而这对于用户并不友好。 Y 轴需要另一套自定义,通常显示汇总值。 取决于设置,它可以显示选择器值,这便是需要改进之处。 方法显示不佳的一个示例,譬如请求一个品种的平均持仓时间。

按品种的平均持仓生存期(单位秒)

按品种的平均持仓生存期(单位秒)

因为 Y 轴显示的不是选择器(其值四舍五入到立方单元大小),而显示的是以秒为单位的汇总持续值,所以这么大的数字难以理解。 为了解决此问题,我们尝试把秒数除以当前时间帧柱线的持续时间。 在此情况下,这些值将代表柱线的数量。 为此,我们需要将某个标志传递给 CGraphicInPlot 类,进而会传递给处理坐标轴的 CAxis 类。 改变操作模式的标志可能很多。 因此,在文件 Plot.mqh 中为它们保留一个名为 AxisCustomizer 的特殊新类。

  class AxisCustomizer
  {
    public:
      const CGraphicInPlot *parent;
      const bool y; // true for Y, false for X
      const bool periodDivider;
      const bool hide;
      AxisCustomizer(const CGraphicInPlot *p, const bool axisY,
        const bool pd = false, const bool h = false):
        parent(p), y(axisY), periodDivider(pd), hide(h) {}
  };

潜在地,要把各种标签显示功能添加到该类之中。 但于此刻,它仅存储坐标轴类型(X 或 Y)的符号,和少数逻辑选项,例如 periodDivider 和 '隐藏'。 第一个选项意味着值应除以 PeriodSeconds()。 第二个选项将在以后讲述。

该类的对象通过特殊方法注入 CGraphicInPlot 当中:

  class CGraphicInPlot: public CGraphic
  {
    ...
      void InitAxes(CAxis &axe, const AxisCustomizer *custom = NULL);
      void InitXAxis(const AxisCustomizer *custom = NULL);
      void InitYAxis(const AxisCustomizer *custom = NULL);
  };
  
  void CGraphicInPlot::InitAxes(CAxis &axe, const AxisCustomizer *custom = NULL)
  {
    if(custom)
    {
      axe.Type(AXIS_TYPE_CUSTOM);
      axe.ValuesFunctionFormat(CustomDoubleToStringFunction);
      axe.ValuesFunctionFormatCBData((AxisCustomizer *)custom);
    }
    else
    {
      axe.Type(AXIS_TYPE_DOUBLE);
    }
  }
  
  void CGraphicInPlot::InitXAxis(const AxisCustomizer *custom = NULL)
  {
    InitAxes(m_x, custom);
  }
  
  void CGraphicInPlot::InitYAxis(const AxisCustomizer *custom = NULL)
  {
    InitAxes(m_y, custom);
  }

若此类对象尚未创建且未传递给图形类时,标准库将按常规方式显示为 AXIS_TYPE_DOUBLE 数字。

在此,我们用标准库的方式来自定义坐标轴上的标签:轴类型设置为等于 AXIS_TYPE_CUSTOM,且通过 ValuesFunctionFormatCBData 传递指向 AxisCustomizer 的指针。 进而,它由 CGraphic 基类传递给 CustomDoubleToStringFunction 标签绘制函数(在上述代码中调用 ValuesFunctionFormat 设置)。 当然,我们需要 CustomDoubleToStringFunction 函数,该函数早前曾以简化形式实现,没有 AxisCustomizer 类对象(CGraphicInPlot 图表充当设置对象)。

  string CustomDoubleToStringFunction(double value, void *ptr)
  {
    AxisCustomizer *custom = dynamic_cast<AxisCustomizer *>(ptr);
    if(custom == NULL) return NULL;
    
    // check options
    if(!custom.y && custom.hide) return NULL; // case of X axis and "no marks" mode
    
    // in simple cases return a string
    if(custom.y) return (string)(float)value;  
    
    const CGraphicInPlot *self = custom.parent; // obtain actual object with cache 
    if(self != NULL)
    {
      ... // retrieve selector mark for value
    }
  }

AxisCustomizer 定制对象存储在 CPlot 类中,CPlot 类是一个 GUI 控件(继承自 CWndClient),且是 CGraphicInPlot 的容器:

  class CPlot: public CWndClient
  {
    private:
      CGraphicInPlot *m_graphic;
      ENUM_CURVE_TYPE type;
      
      AxisCustomizer *m_customX;
      AxisCustomizer *m_customY;
      ...
    
    public:
      void InitXAxis(const AxisCustomizer *custom = NULL)
      {
        if(CheckPointer(m_graphic) != POINTER_INVALID)
        {
          if(CheckPointer(m_customX) != POINTER_INVALID) delete m_customX;
          m_customX = (AxisCustomizer *)custom;
          m_graphic.InitXAxis(custom);
        }
      }
      ...
  };

因此,在 m_customX 和 m_customY 对象中设置坐标轴不仅可以在 CustomDoubleToStringFunction中 的数值格式化阶段使用,而是可以更早地使用它们,如调用 CurveAdd 方法之一时,将数据数组传递给 CPlot。 例如:

  CCurve *CPlot::CurveAdd(const PairArray *data, const string name = NULL)
  {
    if(CheckPointer(m_customY) != POINTER_INVALID) && m_customY.periodDivider)
    {
      for(int i = 0; i < ArraySize(data.array); i++)
      {
        data.array[i].value /= PeriodSeconds();
      }
    }
    
    return m_graphic.CurveAdd(data, type, name);
  }

该代码展示了 periodDivider 选项的用法,该选项将所有值除以 PeriodSeconds()。 此操作的执行应在标准库接收数据并计算它们的网格大小之前。 这一步很重要,因为网格计数之后,在 CustomDoubleToStringFunction 函数中再进行自定义则为时已晚。

对话框中的调用者代码必须在多维数据集构建时创建,并初始化 AxisCustomizer 对象。 例如:

  AGGREGATORS at = ...  // get aggregator type from GUI
  ENUM_FIELDS af = ...  // get aggregator field from GUI
  SORT_BY sb = ...      // get sorting mode from GUI
  
  int dimension = 0;    // calculate cube dimensions from GUI
  for(int i = 0; i < AXES_NUMBER; i++)
  {
    if(Selectors[i] != SELECTOR_NONE) dimension++;
  }
  
  bool hideMarksOnX = (dimension > 1 && SORT_VALUE(sb));
  
  AxisCustomizer *customX = NULL;
  AxisCustomizer *customY = NULL;
  
  customX = new AxisCustomizer(m_plot.getGraphic(), false, Selectors[0] == SELECTOR_DURATION, hideMarksOnX);
  if(af == FIELD_DURATION)
  {
    customY = new AxisCustomizer(m_plot.getGraphic(), true, true);
  }
  
  m_plot.InitXAxis(customX);
  m_plot.InitYAxis(customY);

此处,m_plot 是对话框变量,存储 CPlot 控件。 下面是 OLAPDialog::process 方法的完整代码,展示了它是如何实际执行的。 这是上面的示例,其中的 periodDivider 模式会自动启用:

按品种的平均持仓生存期(当前时间帧,D1)

按品种的平均持仓生存期(当前时间帧,D1)

AxisCustomizer 中的另一个变量 “hide”,提供了沿 X 轴完全隐藏标签的功能。 当选择按多维数组中的数值进行排序时,需要此模式。 在此情况下,每行中的标签都有其自己的顺序,故此沿 X 轴没有任何显示。 多维数据集支持排序,它也可以在其他模式下使用,尤其是按标签。

“隐藏”选项在 CustomDoubleToStringFunction 内部操作。 此函数的标准行为意味着选择器的存在。对于 X 轴,选择器的标签被缓存在专门的 CurveSubtitles 类中,并按照网格划分索引返回到图表。 不过,对于任何横坐标,设置的“隐藏”标志可在开始时终止此过程,且该函数将返回 NULL(不可显示数值)。

需要在图形中解决的第二个问题与直方图的渲染有关。 当图表中显示若干行(数据向量)时,直方图柱线相互重叠,并且其中最大的柱线可以彻底遮掩其他所有柱线。

CGraphic 基类含有虚拟 HistogramPlot 方法。 必须覆盖它,以便将每列可视化分隔。 最好在 CCurve 对象中有一个自定义字段,存储任意数据(数据将根据客户代码按需解读)。 不幸的是,这样的字段不存在。 因此,我们借用当前项目中未用的标准属性之一。 我选择了 LinesSmoothStep。 利用 CCurve::LinesSmoothStep 赋值方法,我们的调用者方代码将序列号写入其中。 利用新 HistogramPlot 实现中的 CCurve::LinesSmoothStep 取值方法,可以轻松获得此代码。 这是如何在 LinesSmoothStep 中写代码的示例:

  CCurve *CGraphicInPlot::CurveAdd(const double &x[], const double &y[], ENUM_CURVE_TYPE type, const string name = NULL)
  {
    CCurve *c = CGraphic::CurveAdd(x, y, type, name);
    c.LinesSmoothStep((int)CGraphic::CurvesTotal());    // +
    ...
    return CacheIt(c);
  }

了解行的总数,和当前行号后,可以在渲染时将其每个点稍微向左或向左偏移。 这是 HistogramPlot 的改编版本。 修改后的行带有 “*” 注释标记;新添加的行则用 “+” 标记。

  void CGraphicInPlot::HistogramPlot(CCurve *curve) override
  {
      const int size = curve.Size();
      const double offset = curve.LinesSmoothStep() - 1;                   // +
      double x[], y[];
  
      int histogram_width = curve.HistogramWidth();
      if(histogram_width <= 0) return;
      
      curve.GetX(x);
      curve.GetY(y);
  
      if(ArraySize(x) == 0 || ArraySize(y) == 0) return;
      
      const int w = m_width / size / 2 / CGraphic::CurvesTotal();          // +
      const int t = CGraphic::CurvesTotal() / 2;                           // +
      const int half = ((CGraphic::CurvesTotal() + 1) % 2) * (w / 2);      // +
  
      int originalY = m_height - m_down;
      int yc0 = ScaleY(0.0);
  
      uint clr = curve.Color();
  
      for(int i = 0; i < size; i++)
      {
        if(!MathIsValidNumber(x[i]) || !MathIsValidNumber(y[i])) continue;
        int xc = ScaleX(x[i]);
        int yc = ScaleY(y[i]);
        int xc1 = xc - histogram_width / 2 + (int)(offset - t) * w + half; // *
        int xc2 = xc + histogram_width / 2 + (int)(offset - t) * w + half; // *
        int yc1 = yc;
        int yc2 = (originalY > yc0 && yc0 > 0) ? yc0 : originalY;
  
        if(yc1 > yc2) yc2++;
        else yc2--;
  
        m_canvas.FillRectangle(xc1,yc1,xc2,yc2,clr);
      }
  }

很快我们将检查其模样。

另一个恼人的时刻则与显示线条的标准实现有关。 如果数据为非数字值,则 CGraphic 将断行。 对于我们的任务来说这很糟糕,因为某些多维数据集单元可能不包含数据,且聚合器会在单元格里写入 NaN。 某些多维数据集,譬如若干部分中的累计余额总计,显示不正确,因为每笔成交的数值仅在一个部分中变化。 若要查看断线的负面影响,请查看第二篇文章里的图例“每个品种的余额曲线”。

为了解决此问题,另外重新定义了 LinesPlot 方法(请参阅源代码文件 Plot.mqh)。 操作结果如下所示,位于处理测试器标准文件相关的部分里。

最后,与标准库中零轴定义有关的最后一个图形问题。 在 CGraphic::CreateGrid 方法中按如下简单方式搜索零(显示 Y 轴的情况;且 X 轴的处理相同):

  if(StringToDouble(m_yvalues[i]) == 0.0)
  ...

请注意,m_yvalues 是字符串标签。 显然,任何不包含数字的标签都将产生 0。 This happens even if the AXIS_TYPE_CUSTOM display mode us set for a chart. 结果就是,在按数值、星期几、成交类型和其他选择器的图表中,循环遍历整个网格进行检查时,所有数值均视为零。 然而,最终值取决于最后一个样本,该样本显示为粗线(尽管它不为零)。 进而,由于每个样本变为 0 值候选者(即使是暂时的),因此它跳过了简单的网格线的渲染,由此整个网格都消失了。

由于 CreateGrid 方法也是虚拟的,因此我们将采用更智能的方式重新定义零值检查。 实现此检查作为 isZero 函数的辅助。

  bool CGraphicInPlot::isZero(const string &value)
  {
    if(value == NULL) return false;
    double y = StringToDouble(value);
    if(y != 0.0) return false;
    string temp = value;
    StringReplace(temp, "0", "");
    ushort c = StringGetCharacter(temp, 0);
    return c == 0 || c == '.';
  }
  
  void CGraphicInPlot::CreateGrid(void) override
  {
    int xc0 = -1.0;
    int yc0 = -1.0;
    for(int i = 1; i < m_ysize - 1; i++)
    {
      m_canvas.LineHorizontal(m_left + 1, m_width - m_right, m_yc[i], m_grid.clr_line);     // *
      if(isZero(m_yvalues[i])) yc0 = m_yc[i];                                               // *
      
      for(int j = 1; j < m_xsize - 1; j++)
      {
        if(i == 1)
        {
          m_canvas.LineVertical(m_xc[j], m_height - m_down - 1, m_up + 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 > 0) m_canvas.LineHorizontal(m_left + 1, m_width - m_right, yc0, m_grid.clr_axis_line);
    if(xc0 > 0) m_canvas.LineVertical(xc0, m_height - m_down - 1, m_up + 1, m_grid.clr_axis_line);
  }

OLAP GUI

我们已实现了图形中所需的修复。 现在,我们修订窗口界面,令其可以通用。 在取自第二篇文章的非交易 EA OLAPGUI,在 OLAPGUI.mqh 头文件里实现了对话框操作。 它存储了之前任务的大量所应用功能,交易报告的分析。 鉴于我们将针对任意数据借用同一对话框,故需将文件分为两部分:一个将实现常规的界面行为,另一个则将含有特定项目的设置。

将 OLAPDialog 类重命名为 OLAPDialogBase。 硬编码的统计数组 'selectors', 'settings', 'defaults',实际描述对话框控件,将会清空作为动态模板,之后由派生类填充。 变量:

    OLAPWrapper *olapcore;    // <-- template <typename S,typename T> class OLAPEngine, since part 3
    OLAPDisplay *olapdisplay;

还变为继承,因为它们需要根据选择器和记录字段的类型进行标准化,而这些是在每个 OLAP 引擎的应用程序部分里定义的。 请记住,在第三篇文章的重构过程中,旧的 OLAPWrapper 类已转换为 OLAPEngine<S,T> 模板类。

为主要逻辑保留了两个新的抽象方法:

  virtual void setup() = 0;
  virtual int process() = 0;

第一个,设置、配置界面:第二个,处理、启动分析。 设置是从 OLAPDialogBase::Create 调用

  bool OLAPDialogBase::Create(const long chart, const string name, const int subwin, const int x1, const int y1, const int x2, const int y2)
  {
    setup(); // +
    ...
  }

用户单击按钮启动分析,因此 OLAPDialogBase::OnClickButton 方法经历了众多的改造:大多数代码已从其中删除,而相应的功能(读取控件属性,并基于它们启动 OLAP 引擎) 已委派给该 “process” 方法。

  void OLAPDialogBase::OnClickButton(void)
  {
    if(processing) return; // prevent re-entrancy
    
    if(browsing)           // 3D-cube browsing support
    {
      currentZ = (currentZ + 1) % maxZ;
      validateZ();
    }
  
    processing = true;
    const int n = process();
    if(n == 0 && processing)
    {
      finalize();
    }
  }

请注意,OLAPDialogBase 类实现了操作界面的整个逻辑,从创建控件开始,直至处理影响控件状态的事件。 不过,它对控件的内容一无所知。

OLAPDisplay 类从 OLAPCommon.mqh 实现了 Display 虚拟接口(在第三篇文章里曾有过讨论)。 众所周知,Display 接口是来自 OLAP 内核的回调,旨在提供分析结果(在 MetaCube 类对象的第一个参数中传递)。 指向 OLAPDisplay 类中“父”窗口的指针可组织为链,以便进一步将数据集传递给对话框(由于 MQL5 不提供多重继承,因此需要进行接力转发)。

  class OLAPDisplay: public Display
  {
    private:
      OLAPDialogBase *parent;
  
    public:
      OLAPDisplay(OLAPDialogBase *ptr,): parent(ptr) {}
      virtual void display(MetaCube *metaData, const SORT_BY sortby = SORT_BY_NONE, const bool identity = false) override;
  };

在此,我将提及一个特殊功能,是关于从派生适配器类获取自定义字段的实名。 以前,我们在第二部分中将自定义字段(例如 MFE 和 MAE)添加到标准字段中。 所以,它们是事先已知的,并已内置在代码中。 然而,在操控优化报告时,我们需要根据 EA 的输入参数进行分析,而这些参数(其名称)只能从所分析数据里提取。

适配器利用新的 AssignCustomFields 方法将自定义字段的名称传递给聚合器(metacube)。 这始终是在“幕后”完成的,即在 Analyst::acquireData 方法中自动完成。 有缘于此,当在 OLAPDisplay::display 内部调用 metaData.getDimensionTitle 方法来获取长轴上的截面标记时,且当字段 n 的序数超过内置字段枚举的容量时,我们可知正在处理扩展字段,并能从数据集请求描述。 OLAPDisplay::display 方法的常规结构并未更改。 您可以比较下面的源代码与第二篇文章里的代码,进行验证。

另外,必须在对话框中预先知道自定义字段的名称,以便填写界面元素。 为此目的,OLAPDialogBase 类包括设置自定义字段的新 setCustomFields 方法。

    int customFieldCount;
    string customFields[];
    
    virtual void setCustomFields(const DataAdapter &adapter)
    {
      string names[];
      if(adapter.getCustomFields(names) > 0)
      {
        customFieldCount = ArrayCopy(customFields, names);
      }
    }

当然,我们需要用此方法将对话框和适配器绑定到测试 EA 之中。 之后,有意义的字段名称(而不是 “custom 1” 诸如此类的编号)将在对话框控件中可见。 这是一个临时方案。 除其他外,这方面需要进一步的代码优化。 但在本文中没花过多精力研究它们。

修改后的 OLAPGUI 中,界面设置的应用程序部分已从 OLAPGUI.mqh “搬移” 到 OLAPGUI_Trades.mqh 头文件。 对话框的类名未更改:OLAPDialog。 不过,它取决于模板参数,并随后用于专门的 OLAPEngine 对象:

  template<typename S, typename F>
  class OLAPDialog: public OLAPDialogBase
  {
    private:
      OLAPEngine<S,F> *olapcore;
      OLAPDisplay *olapdisplay;
  
    public:
      OLAPDialog(OLAPEngine<S,F> &olapimpl);
      ~OLAPDialog(void);
      virtual int process() override;
      virtual void setup() override;
  };
  
  template<typename S, typename F>
  OLAPDialog::OLAPDialog(OLAPEngine<S,F> &olapimpl)
  {
    curveType = CURVE_POINTS;
    olapcore = &olapimpl;
    olapdisplay = new OLAPDisplay(&this);
  }
  
  template<typename S, typename F>
  OLAPDialog::~OLAPDialog(void)
  {
    delete olapdisplay;
  }

所有工作都在方法 “setup” 和 “process” 中进行。 “setup” 方法用同样的数值填充 “settings”,“selectors”,“defaults”数组,这在第二篇文章中已经为我们所知(界面外观未发生变化)。 'process' 方法在指定的部分启动分析,其与之前的处理程序 OnClickButton 几乎完全相同。

  template<typename S, typename F>
  int OLAPDialog::process() override
  {
    SELECTORS Selectors[4];
    ENUM_FIELDS Fields[4];
    AGGREGATORS at = (AGGREGATORS)m_algo[0].Value();
    ENUM_FIELDS af = (ENUM_FIELDS)(AGGREGATORS)m_algo[1].Value();
    SORT_BY sb = (SORT_BY)m_algo[2].Value();
  
    ArrayInitialize(Selectors, SELECTOR_NONE);
    ArrayInitialize(Fields, FIELD_NONE);
  
    int matches[10] = // 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
    };
    
    int subfields[] = // 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(int i = 0; i < AXES_NUMBER; i++) // up to 3 orthogonal axes are supported
    {
      if(!m_axis[i].IsVisible()) continue;
      int v = (int)m_axis[i].Value();
      if(v < 10) // selectors (every one is specialized for a field already)
      {
        Selectors[i] = (SELECTORS)matches[v];
        if(v == 5 || v == 7) Fields[i] = FIELD_OPEN_DATETIME;
        else if(v == 6 || v == 8) Fields[i] = FIELD_CLOSE_DATETIME;
      }
      else // pure fields
      {
        Selectors[i] = at == AGGREGATOR_IDENTITY ? SELECTOR_SCALAR : SELECTOR_QUANTS;
        Fields[i] = (TRADE_RECORD_FIELDS)subfields[v - 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(), false, Selectors[0] == SELECTOR_DURATION, (dimension > 1 && SORT_VALUE(sb)));
    }
    
    if((af == FIELD_DURATION)
    || (at == AGGREGATOR_IDENTITY && Selectors[1] == SELECTOR_DURATION))
    {
      customY = new AxisCustomizer(m_plot.getGraphic(), true, true);
    }
    
    m_plot.InitXAxis(customX);
    m_plot.InitYAxis(customY);
    m_button_ok.Text("Processing...");
    return olapcore.process(Selectors, Fields, at, af, olapdisplay, sb);
  }

在方法末尾创建了早前讲述的设置坐标轴的 AxisCustomizer 对象。 对于两个轴(X 和 Y),在操控 duration 字段时,都启用了除以 PeriodSeconds()(在聚合器或选择器中,如果聚合器类型为 AGGREGATOR_IDENTITY — 在这种情况下,选择器不会在在已命名的单元里分派字段的内容,但内容会直接传递到数据集)。 当数据集尺寸大于 1,并选择排序时,将禁用 X 轴。

现在,我们看一下 OLAPGUI.mq5 程序文件。 与之前版本的不同之处在于,头文件的连接顺序有所变化。 早前,报表适配器包含在核心中(因为没有其他数据源)。 现在,应将它们显式编写为 HTMLcube.mqh 和 CSVcube.mqh。 此外,在 OnInit 代码里,根据输入数据准备相应的适配器类型,然后调用 _defaultEngine.setAdapter 将适配器传递给引擎。 该代码部分曾在第三篇文章的 OLAPRPRT.mq5 程序中用过,我们在其中测试了分解为通用部分和应用部分的正确方式。 尽管,OLAPRPRT 在上一部分中没有图形界面。 我们现在就来修复此缺陷。

为了演示标准字段和自定义字段的严格分离,将计算 MFE 和 MAE 字段的 CustomTradeRecord 类从 OLAPTrades.mqh 移到了 OLAPTradesCustom.mqh(代码已附带)。 因此,若有需要,我们可以简化其他基于成交的自定义字段的开发。 只需在 OLAPTradesCustom.mqh 中修改算法,而 OLAP 内核不变。 所有标准组件,例如交易记录字段、连接的选择器、TradeRecord 基类、OLAPEngineTrade 引擎和历史记录适配器,都位于 OLAPTrades.mqh 之中。 OLAPTradesCustom.mqh 含有指向 OLAPTrades.mqh 的链接,该链接允许将以上所有内容囊括在项目里。

  #include <OLAP/OLAPTradesCustom.mqh> // internally includes OLAPTrades.mqh 
  #include <OLAP/HTMLcube.mqh>
  #include <OLAP/CSVcube.mqh>
  #include <OLAP/GUI/OLAPGUI_trades.mqh>
  
  OLAPDialog<SELECTORS,ENUM_FIELDS> dialog(_defaultEngine);
  
  int OnInit()
  {
    if(ReportFile == "")
    {
      Print("Analyzing account history");
      _defaultEngine.setAdapter(&_defaultHistoryAdapter);
    }
    else
    {
      if(StringFind(ReportFile, ".htm") > 0 && _defaultHTMLReportAdapter.load(ReportFile))
      {
        _defaultEngine.setAdapter(&_defaultHTMLReportAdapter);
      }
      else
      if(StringFind(ReportFile, ".csv") > 0 && _defaultCSVReportAdapter.load(ReportFile))
      {
        _defaultEngine.setAdapter(&_defaultCSVReportAdapter);
      }
      else
      {
        Print("Unknown file format: ", ReportFile);
        return INIT_PARAMETERS_INCORRECT;
      }
    }
    
    ...
    
    if(!dialog.Create(0, "OLAPGUI" + (ReportFile != "" ? " : " + ReportFile : ""), 0,  0, 0, 750, 560)) return INIT_FAILED;
    
    if(!dialog.Run()) return INIT_FAILED;
    return INIT_SUCCEEDED;
  }

启动更新的 OLAPGUI.mq5,并构建若干数据部分,从而确保新原理能够依据所应用适配器和记录类型动态启用内核的正常运行。 我们还将验证修改的视觉效果。

您可以将以下结果与第二篇文章里的屏幕截图进行比较。 以下是每笔成交的“利润”和“持续时间”字段的依存关系。 现在,沿 X 轴的持续时间以柱线当前时间帧(此处为 D1)表示,而不是以秒表示。

利润与持续时间的依赖性(在柱线当前时间帧,D1)

利润与持续时间的依赖性(在柱线当前时间帧,D1)

按品种和星期几细分的盈利图,展示为直方图柱线图,分开扩散并有正确的网格。

按品种和星期几的盈利图

按品种和星期几的盈利图

下图展示按成交手数的利润分析。 与第二篇文章不同,手数值直接显示在 X 轴上,而非显示在日志里。

按手数的盈利

按手数的盈利

最后一个选项是“按品种和类型划分的成交数量”。在以前的版本中,采用了线段,因为直方图有重叠。 该问题不再相关。

按品种和类型划分的成交数量(直方图)

按品种和类型划分的成交数量(直方图)

我们已研究了与交易报告分析相关的所有要素。 另一件值得一提的事情是有新的数据源可供 MQL 程序员使用,即内部测试器的 tst 格式文件。

连接标准测试器文件(*.tst)

MetaTrader 5 开发者最近开放了测试器文件的保存格式。 特别是,单次通关验证数据,以前我们只能导出为 HTML 报表后才能进行分析,现在可以直接从 tst 文件中读取。

我们不会深入探讨有关文件内部结构的细节。 代之,我们会用一个已有的函数库来读取 tst 文件 - fxsaber SingleTesterCache 。 在“黑匣子”基础上使用它,很容易获得一系列成交记录。 成交由函数库中的 TradeDeal 类显示。 若要获取成交列表,需连接函数库,创建主类对象 SINGLETESTERCACHE,然后调用 “load” 方法加载所需的文件。

  #include <fxsaber/SingleTesterCache/SingleTesterCache.mqh>
  ...
  SINGLETESTERCACHE SingleTesterCache;
  if(SingleTesterCache.Load(file))
  {
    Print("Tester cache import: ", ArraySize(SingleTesterCache.Deals), " deals");
  }

SingleTesterCache.Deals 数组包含所有成交。 测试器中现有的每笔成交数据也可从相应的字段中获得。

基于成交生成交易仓位的算法与导入 HTML 报告时完全相同。 良好的 OOP 风格需要在基类中实现通用代码部分,然后从中继承 HTMLReportAdapter 和 TesterReportAdapter。

报告的共同祖先是 BaseReportAdapter 类(文件 ReportCubeBase.mqh)。 您可以在上下文中将此文件与旧的 HTMLcube.mqh 类进行比较,从而亲眼看看它们几乎没有什么区别(新类名除外)。 最吸睛的是 “load” 方法的简约内容。 它现在只是扮演虚拟原型存根:

    virtual bool load(const string file)
    {
      reset();
      TradeRecord::reset();
      return false;
    }

子方法必须覆盖该方法。

“generate” 方法中的代码也已修改。 该方法将成交转换为仓位。 现在,在该方法的开头调用了一个虚拟的空“存根” fillDealsArray。

    virtual bool fillDealsArray() = 0;
    
    int generate()
    {
      ...
      if(!fillDealsArray()) return 0;
      ...
    }

操控 HTML 报表部分的已有代码已移至 HTMLReportAdapter 类中的新虚拟方法。 请注意:整个 HTML 报告适配器类如下所示。 主要代码部分在基类中,因此这里仅需要定义 2 个虚方法。

  template<typename T>
  class HTMLReportAdapter: public BaseReportAdapter<T>
  {
    protected:
      IndexMap *data;
      
      virtual bool fillDealsArray() override
      {
        for(int i = 0; i < data.getSize(); ++i)
        {
          IndexMap *row = data[i];
          if(CheckPointer(row) == POINTER_INVALID || row.getSize() != COLUMNS_COUNT) return false; // something is broken
          string s = row[COLUMN_SYMBOL].get<string>();
          StringTrimLeft(s);
          if(StringLen(s) > 0) // there is a symbol -> this is a deal
          {
            array << new Deal(row);
          }
          else if(row[COLUMN_TYPE].get<string>() == "balance")
          {
            string t = row[COLUMN_PROFIT].get<string>();
            StringReplace(t, " ", "");
            balance += StringToDouble(t);
          }
        }
        return true;
      }
    
    public:
      ~HTMLReportAdapter()
      {
        if(CheckPointer(data) == POINTER_DYNAMIC) delete data;
      }
      
      virtual bool load(const string file) override
      {
        BaseReportAdapter<T>::load(file);
        if(CheckPointer(data) == POINTER_DYNAMIC) delete data;
        data = NULL;
        if(StringFind(file, ".htm") > 0)
        {
          data = HTMLConverter::convertReport2Map(file, true);
          if(data != NULL)
          {
            size = generate();
            Print(data.getSize(), " deals transferred to ", size, " trades");
          }
        }
        return data != NULL;
      }
  };

两种方法的代码都与之前的版本相似,没有任何变化。

现在我们看一下新 TesterReportAdapter 适配器的实现。 首先,我必须添加 TesterDeal 类,它派生自 ReportCubeBase.mqh 中定义的 Deal 类(Deal 是以前位于 HTMLcube.mqh 中的旧类)。 TesterDeal 有一个带 TradeDeal 参数的构造函数,该参数是 SingleTesterCache 函数库中的一笔成交。 此外,TesterDeal 定义了一些帮助方法,可将成交类型和方向枚举转换为字符串。

  class TesterDeal: public Deal
  {
    public:
      TesterDeal(const TradeDeal &td)
      {
        time = (datetime)td.time_create + TimeShift;
        price = td.price_open;
        string t = dealType(td.action);
        type = t == "buy" ? +1 : (t == "sell" ? -1 : 0);
        t = dealDir(td.entry);
        direction = 0;
        if(StringFind(t, "in") > -1) ++direction;
        if(StringFind(t, "out") > -1) --direction;
        volume = (double)td.volume;
        profit = td.profit;
        deal = (long)td.deal;
        order = (long)td.order;
        comment = td.comment[];
        symbol = td.symbol[];
        commission = td.commission;
        swap = td.storage;
      }
      
      static string dealType(const ENUM_DEAL_TYPE type)
      {
        return type == DEAL_TYPE_BUY ? "buy" : (type == DEAL_TYPE_SELL ? "sell" : "balance");
      }
      
      static string dealDir(const ENUM_DEAL_ENTRY entry)
      {
        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";
        return result;
      }
  };

TesterReportAdapter 类包含 'load' 和 fillDealsArray 方法,以及指向 SINGLETESTERCACHE 对象的指针,该对象是 SingleTesterCache 函数库的主类。 该对象根据请求加载 tst 文件。 若成功,该方法将填充 Deals 数组,fillDealsArray 数组会基于此数组进行操作。

  template<typename T>
  class TesterReportAdapter: public BaseReportAdapter<T>
  {
    protected:
      SINGLETESTERCACHE *ptrSingleTesterCache;
      
      virtual bool fillDealsArray() override
      {
        for(int i = 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]);
          }
        }
        return true;
      }
      
    public:
      ~TesterReportAdapter()
      {
        if(CheckPointer(ptrSingleTesterCache) == POINTER_DYNAMIC) delete ptrSingleTesterCache;
      }
      
      virtual bool load(const string file) override
      {
        if(StringFind(file, ".tst") > 0)
        {
          // default cleanup
          BaseReportAdapter<T>::load(file);
          
          // specific cleanup
          if(CheckPointer(ptrSingleTesterCache) == POINTER_DYNAMIC) delete ptrSingleTesterCache;
          
          ptrSingleTesterCache = new SINGLETESTERCACHE();
          if(!ptrSingleTesterCache.Load(file))
          {
            delete ptrSingleTesterCache;
            ptrSingleTesterCache = NULL;
            return false;
          }
          size = generate();
          
          Print("Tester cache import: ", size, " trades from ", ArraySize(ptrSingleTesterCache.Deals), " deals");
        }
        return true;
      }
  };
  
  TesterReportAdapter<RECORD_CLASS> _defaultTSTReportAdapter;

在末尾,将创建 RECORD_CLASS 模板类型的默认适配器实例。 我们的项目包含 OLAPTradesCustom.mqh 文件,该文件定义了 CustomTradeRecord 自定义记录类。 在此文件中,该类由预处理器指令定义为 RECORD_CLASS 宏。 因此,一旦新适配器连接到项目,并且用户在输入中指定了 tst 文件,适配器将开始生成 CustomTradeRecord 类对象,自动为其生成 MFE 和 MAE 自定义字段。

我们来看看新适配器如何执行其任务。 下面示例来自 tst 文件,按品种的余额曲线。

按品种的余额曲线

按品种的余额曲线

请注意,线是不间断的,这意味着我们的 CGraphicInPlot::LinesPlot 实现可正确运行。 当使用“渐进式”聚合器(累积式)时,第一个选择器应始终为记录的序列号(或索引)。

测试器优化报告可作为一项 OLAP 分析应用程序的领域

除了单个测试文件,MetaQuotes 现在还允许利用优化缓存访问 opt 文件。 此类文件可用 TesterCache 函数库(同样由 fxsaber 创建)来读取。 在该函数库的基础上,我们可以轻松地为 OLAP 分析优化结果创建应用层。 为此我们需要什么:记录类,其中包含存储每次优化通关数据的字段,适配器和选择器(可选)。 我们已有针对其他应用领域的组件实现,如此可将其用作指南(计划)。 今后,我们还将增加一个图形界面(几乎所有东西都已就绪,我们只需要更改设置)。

将创建 OLAPOpts.mqh 文件,其用途类似于 OLAPTrades.mqh。 TesterCache.mqh 头文件也将被加入其中。

  #include <fxsaber/TesterCache/TesterCache.mqh>

用优化器的所有字段定义一个枚举。 我所用字段来自 ExpTradeSummary 结构(它位于 fxsaber/TesterCache/ExpTradeSummary.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
  };
  
  #define OPT_CACHE_RECORD_FIELDS_LAST (FIELD_AVERAGE_SERIAL_LOSS_TRADES + 1)

该结构拥有所有常见的变量,例如利润、余额和回撤净值、交易操作数、锋锐比率、等等。 我们增加的唯一字段是 FIELD_INDEX:记录索引。 结构中的字段具有不同的类型:long,double,int。 所有这些都将添加到派生自 Record 的 OptCacheRecord 记录类当中,并存储在其内的 double 类型数组之中。

利用特殊的 OptCacheRecordInternal 结构访问该函数库:

  struct OptCacheRecordInternal
  {
    ExpTradeSummary summary;
    MqlParam params[][5]; // [][name, current, low, step, high]
  };

每个测试的通关数据不仅可按性能变量来表征,还可与一组特定的输入参数相关联。 在此结构中,将输入参数作为 MqlParam 数组添加到 ExpTradeSummary 之后。 有了这种结构,您可以轻松地编写 OptCacheRecord 类,向该类中填充优化器格式的数据。

  class OptCacheRecord: public Record
  {
    protected:
      static int counter; // number of passes
      
      void fillByTesterPass(const OptCacheRecordInternal &internal)
      {
        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);
        
        const int n = ArrayRange(internal.params, 0);
        for(int i = 0; i < n; i++)
        {
          set(OPT_CACHE_RECORD_FIELDS_LAST + i, internal.params[i][PARAM_VALUE].double_value);
        }
      }
    
    public:
      OptCacheRecord(const int customFields = 0): Record(OPT_CACHE_RECORD_FIELDS_LAST + customFields)
      {
      }
      
      OptCacheRecord(const OptCacheRecordInternal &record, const int customFields = 0): Record(OPT_CACHE_RECORD_FIELDS_LAST + customFields)
      {
        fillByTesterPass(record);
      }
      
      static int getRecordCount()
      {
        return counter;
      }
  
      static void reset()
      {
        counter = 0;
      }
  };
  
  static int OptCacheRecord::counter = 0;

fillByTesterPass 方法清晰地展示了枚举元素和 ExpTradeSummary 字段之间的对应关系。 构造函数接受一个已填充的 OptCacheRecordInternal 结构作为参数。

TesterCache 库和 OLAP 之间的中间层是专门的数据适配器。 适配器将生成 OptCacheRecord 记录。

  template<typename T>
  class OptCacheDataAdapter: public DataAdapter
  {
    private:
      int size;
      int cursor;
      int paramCount;
      string paramNames[];
      TESTERCACHE<ExpTradeSummary> Cache;

“size” 字段 — 记录总数,cursor — 高速缓存中当前记录的编号,paramCount — 优化参数的数量。 参数的名称存储在 paramNames 数组中。 TESTERCACHE<ExpTradeSummary> 类型的 Cache 变量是 TesterCache 库的工作对象。

最初,优化缓存被初始化,并在 reset、load 和 customize 方法中读取。

      void customize()
      {
        size = (int)Cache.Header.passes_passed;
        paramCount = (int)Cache.Header.opt_params_total;
        const int n = ArraySize(Cache.Inputs);
  
        ArrayResize(paramNames, n);
        int k = 0;
        
        for(int i = 0; i < n; i++)
        {
          if(Cache.Inputs[i].flag)
          {
            paramNames[k++] = Cache.Inputs[i].name[];
          }
        }
        if(k > 0)
        {
          ArrayResize(paramNames, k);
          Print("Optimized Parameters (", paramCount, " of ", n, "):");
          ArrayPrint(paramNames);
        }
      }
  
    public:
      OptCacheDataAdapter()
      {
        reset();
      }
      
      void load(const string optName)
      {
        if(Cache.Load(optName))
        {
          customize();
          reset();
        }
        else
        {
          cursor = -1;
        }
      }
      
      virtual void reset() override
      {
        cursor = 0;
        if(Cache.Header.version == 0) return;
        T::reset();
      }
      
      virtual int getFieldCount() const override
      {
        return OPT_CACHE_RECORD_FIELDS_LAST;
      }

opt 文件将通过 load 方法加载,在该方法中将调用函数库的 Cache.Load 方法。 若成功,则从头部(在辅助方法 “customize” 中)选择智能交易系统参数。 “reset” 方法重置当前记录编号,迭代 OLAP 内核的所有记录时,下一次的 getNext 会递增记录编号。 在此,OptCacheRecordInternal 结构中填充了来自优化缓存的数据。 在此基础上,将创建模板参数类 (T) 的新记录。

      virtual Record *getNext() override
      {
        if(cursor < size)
        {
          OptCacheRecordInternal internal;
          internal.summary = Cache[cursor];
          Cache.GetInputs(cursor, internal.params);
          cursor++;
          return new T(internal, paramCount);
        }
        return NULL;
      }
      ...
  };

模板参数是上述 OptCacheRecord 类。

  #ifndef RECORD_CLASS
  #define RECORD_CLASS OptCacheRecord
  #endif
  
  OptCacheDataAdapter<RECORD_CLASS> _defaultOptCacheAdapter;

它也被定义为宏,类似于 OLAP 内核其他部分中使用的 RECORD_CLASS。 以下是这些类的示意图,支持之前的全部数据适配器和新的适配器。

数据适配器类的示意图

数据适配器类的示意图

现在,我们需要确定哪些选择器类型可用来分析优化结果。 提议把以下枚举作为首个最小选项。

  enum OPT_CACHE_SELECTORS
  {
    SELECTOR_NONE,       // none
    SELECTOR_INDEX,      // ordinal number
    /* all the next require a field as parameter */
    SELECTOR_SCALAR,     // scalar(field)
    SELECTOR_QUANTS,     // quants(field)
    SELECTOR_FILTER      // filter(field)
  };

所有记录字段都属于以下两种类型之一:交易统计信息,和 EA 参数。 一种便捷的方案是组织参数至单元格,并与测试值完全对应。 例如,如果参数包括均线周期,其值有 10 个,则 OLAP 数据集必须为此参数包含10 个单元格。 这由量化选择器(SELECTOR_QUANTS)完成,其 "basket" 大小为零。

对于可变字段,最好在特定步骤设置单元格。 例如,您在按盈利查看通关分布时,可用 100 单位为步长。 同样,也可以按量化选择器来完成。 尽管 “basket” 的大小必须设置为所需的步长。 其他添加的选择器会执行其他服务功能。 例如,SELECTOR_INDEX 用于计算累计总数。 SELECTOR_SCALAR 能够接收一个数字作为整体选择的特征。

选择器类已准备就绪,位于 OLAPCommon.mqh 文件中。

我们遵照 OLAPEngine 类的模板规范,为这些选择器类型编写 createSelector 方法:

  class OLAPEngineOptCache: public OLAPEngine<OPT_CACHE_SELECTORS,OPT_CACHE_RECORD_FIELDS>
  {
    protected:
      virtual Selector<OPT_CACHE_RECORD_FIELDS> *createSelector(const OPT_CACHE_SELECTORS selector, const OPT_CACHE_RECORD_FIELDS field) override
      {
        const int standard = adapter.getFieldCount();
        switch(selector)
        {
          case SELECTOR_INDEX:
            return new SerialNumberSelector<OPT_CACHE_RECORD_FIELDS,OptCacheRecord>(FIELD_INDEX);
          case SELECTOR_SCALAR:
            return new OptCacheSelector(field);
          case SELECTOR_QUANTS:
            return field != FIELD_NONE ? new QuantizationSelector<OPT_CACHE_RECORD_FIELDS>(field, (int)field < standard ? quantGranularity : 0) : NULL;
        }
        return NULL;
      }
  
    public:
      OLAPEngineOptCache(): OLAPEngine() {}
      OLAPEngineOptCache(DataAdapter *ptr): OLAPEngine(ptr) {}
  };
  
  OLAPEngineOptCache _defaultEngine;

创建量化选择器时,根据字段为 “standard”(存储标准测试器统计信息),还是自定义(智能交易系统参数),将 basket 大小设置为 quantGranularity 变量或置为零。 quantGranularity 字段已在 OLAPEngine 基类中讲述过了。 它可在引擎构造函数中设置,也可以稍后利用 setQuant 方法设置。

OptCacheSelector 是 BaseSelector<OPT_CACHE_RECORD_FIELDS> 的简单包装器。

分析测试器优化报告的图形界面

优化结果的分析将采用与交易报告相同的界面来可视化。 实际上,我们可把 OLAPGUI_Trade.mqh 文件复制为新名称 OLAPGUI_Opts.mqh,并对其进行微调。 调整涉及虚拟方法 “setup” 和 “process”。

  template<typename S, typename F>
  void OLAPDialog::setup() override
  {
    static const string _settings[ALGO_NUMBER][MAX_ALGO_CHOICES] =
    {
      // enum AGGREGATORS 1:1, default - sum
      {"sum", "average", "max", "min", "count", "profit factor", "progressive total", "identity", "variance"},
      // enum RECORD_FIELDS 1:1, default - profit amount
      {""},
      // enum SORT_BY, default - none
      {"none", "value ascending", "value descending", "label ascending", "label descending"},
      // enum ENUM_CURVE_TYPE partially, default - points
      {"points", "lines", "points/lines", "steps", "histogram"}
    };
    
    static const int _defaults[ALGO_NUMBER] = {0, FIELD_PROFIT, 0, 0};
  
    const int std = EnumSize<F,PackedEnum>(0);
    const int fields = std + customFieldCount;
  
    ArrayResize(settings, fields);
    ArrayResize(selectors, fields);
    selectors[0] = "(<selector>/field)"; // none
    selectors[1] = "<serial number>"; // the only selector, which can be chosen explicitly, it corresponds to the 'index' field
  
    for(int i = 0; i < ALGO_NUMBER; i++)
    {
      if(i == 1) // pure fields
      {
        for(int j = 0; j < fields; j++)
        {
          settings[j][i] = j < std ? Record::legendFromEnum((F)j) : customFields[j - std];
        }
      }
      else
      {
        for(int j = 0; j < MAX_ALGO_CHOICES; j++)
        {
          settings[j][i] = _settings[i][j];
        }
      }
    }
  
    for(int j = 2; j < fields; j++) // 0-th is none
    {
      selectors[j] = j < std ? Record::legendFromEnum((F)j) : customFields[j - std];
    }
    
    ArrayCopy(defaults, _defaults);
  }

字段和选择器之间几乎没有区别,因为任何字段都代表一个针对同字段的量化选择器。 换句话说,量化选择器负责一切。 与报表和报价相关的早期项目中,我们针对各个字段分别指定了特殊的选择器(例如盈利能力选择器、周内星期选择器、烛条类型选择器,等等)。

含字段的下拉列表的所有元素名称(也作为 X、Y、Z 轴的选择器)由 OPT_CACHE_RECORD_FIELDS 枚举元素的名称,以及 EA 参数的 customFields 数组组成。 早前,我们在 OLAPDialogBase 基类中研究过 setCustomFields 方法,该方法用来自适配器的名称填充 customFields 数组。 在 OLAPGUI_Opts.mq5 分析 EA 的代码中,有这两种方法可彼此链接(参见下文)。

标准字段按枚举元素的顺序显示。 标准字段后跟与优化中的 EA 参数相关的自定义字段。 自定义字段的顺序与 opt 文件中的参数顺序相对应。

控制状态的读取,和分析过程的启动在 “process” 方法里执行。

  template<typename S, typename F>
  int OLAPDialog::process() override
  {
    SELECTORS Selectors[4];
    ENUM_FIELDS Fields[4];
    AGGREGATORS at = (AGGREGATORS)m_algo[0].Value();
    ENUM_FIELDS af = (ENUM_FIELDS)(AGGREGATORS)m_algo[1].Value();
    SORT_BY sb = (SORT_BY)m_algo[2].Value();
    
    if(at == AGGREGATOR_IDENTITY)
    {
      Print("Sorting is disabled for Identity");
      sb = SORT_BY_NONE;
    }
  
    ArrayInitialize(Selectors, SELECTOR_NONE);
    ArrayInitialize(Fields, FIELD_NONE);
  
    int matches[2] =
    {
      SELECTOR_NONE,
      SELECTOR_INDEX
    };
    
    for(int i = 0; i < AXES_NUMBER; i++)
    {
      if(!m_axis[i].IsVisible()) continue;
      int v = (int)m_axis[i].Value();
      if(v < 2) // selectors (which is specialized for a field already)
      {
        Selectors[i] = (SELECTORS)matches[v];
      }
      else // pure fields
      {
        Selectors[i] = at == AGGREGATOR_IDENTITY ? SELECTOR_SCALAR : SELECTOR_QUANTS;
        Fields[i] = (ENUM_FIELDS)(v);
      }
    }
    
    m_plot.CurvesRemoveAll();
  
    if(at == AGGREGATOR_IDENTITY) af = FIELD_NONE;
  
    m_plot.InitXAxis(at != AGGREGATOR_PROGRESSIVE ? new AxisCustomizer(m_plot.getGraphic(), false) : NULL);
    m_plot.InitYAxis(at == AGGREGATOR_IDENTITY ? new AxisCustomizer(m_plot.getGraphic(), true) : NULL);
  
    m_button_ok.Text("Processing...");
    return olapcore.process(Selectors, Fields, at, af, olapdisplay, sb);
  }

优化报告的 OLAP 分析和可视化

MetaTrader 测试器提供了多种测试优化结果的方式,但仅限于标准集。 可用的集合能够利用所创建的 OLAP 引擎进行扩展。 例如,内置的 2D 可视化总是显示两个组合 EA 参数的最大利润值,然而通常有两个以上的参数。 在表面的每个点上,我们看到其他参数不同组合的结果,而这些结果并未显示在坐标轴上。 评估所显示的参数特定值时,也许会导致对其盈利能力过于乐观。 可以从平均利润值和其值的范围获得更加均衡的考核。 除其他考核外,该评估还可以运用 OLAP 执行。

优化报告的 OLAP 分析将由新的非交易智能交易系统 OLAPGUI_Opts.mq5 执行。 其结构与 OLAPGUI.mq5 完全相同。 进而,它更简单,因为不需要根据指定的文件类型连接适配器。 这会始终是优化结果的 opt 文件。

在输入中指定文件名,并为统计参数指定量化步长。

  input string OptFileName = "Integrity.opt";
  input uint QuantGranularity = 0;

请注意,期望每个字段都有一个单独的量化步长。 尽管,现在我们只设置了一次,而该值不会由 GUI 更改。 该缺陷提供了潜在的深入改进的区域。 请记住,步长值可能适合一个字段,而不适合另一个字段(它可能太大,或太小)。 所以,从OLAP 界面的下拉列表中选择字段之前,若有必要应调用 EA 属性对话框更改量化因子。

在包含所有类的头文件之后,创建一个对话框实例,并将其与 OLAP 引擎绑定。

  #include <OLAP/OLAPOpts.mqh>
  #include <OLAP/GUI/OLAPGUI_Opts.mqh>
  
  OLAPDialog<SELECTORS,ENUM_FIELDS> dialog(_defaultEngine);

在 OnInit 应对程序中,将新适配器连接到引擎,然后启动从文件加载数据。

  int OnInit()
  {
    _defaultEngine.setAdapter(&_defaultOptCacheAdapter);
    _defaultEngine.setShortTitles(true);
    _defaultEngine.setQuant(QuantGranularity);
    _defaultOptCacheAdapter.load(OptFileName);
    dialog.setCustomFields(_defaultOptCacheAdapter);
  
    if(!dialog.Create(0, "OLAPGUI" + (OptFileName != "" ? " : " + OptFileName : ""), 0,  0, 0, 750, 560)) return INIT_FAILED;
    if(!dialog.Run()) return INIT_FAILED;
    
    return INIT_SUCCEEDED;
  }

我们尝试为 QuantGranularity = 100 的 Integrity.opt 文件构建一些分析部分。 在优化过程中选择了以下三个参数:PricePeriod,Momentum,Sigma。

以下屏幕截图展示的盈利按 PricePeriod 值细分。

取决于 EA 参数值的平均利润

取决于 EA 参数值的平均利润

结果提供的信息几乎没有分散。

取决于 EA 参数值的利润分散

取决于 EA 参数值的利润分散

比较这两个直方图,我们可以估计出哪些参数值的离散度没有超过平均值,这意味着盈亏平衡。 更好的方案是在同一张图表上自动执行比较。 但这超出了本文的范畴。

轮带,我们查看该参数的获利能力(所有通关的盈亏比)。

取决于 EA 参数值的策略利润因子

取决于 EA 参数值的策略利润因子

另一个棘手的考核方式是评估按利润水平细分的平均期间大小,以 100 为增量(该步长在 QuantGranularity 输入参数中设置)。

各种范围内的获利参数的平均值(以 100 单位为增量)

各种范围内的获利参数的平均值(以 100 单位为增量)

下图显示了根据周期的利润分配(利用 “identity” 聚合器显示所有通关)。

所有仓位的利润与参数值

所有仓位的利润与参数值

按动量和西格玛细分的利润如下。

两个参数的平均利润

两个参数的平均利润

要以 100 为增量按等级查看利润的一般分布,沿 X 轴的统计信息中选择 “profit” 字段,并选择 “count” 聚合器。

按范围的利润分布,增量为 100 单位

按范围的所有利润分布,增量为 100 单位

利用 “identity” 聚合器,我们可以评估交易数量对利润的影响。 通常,此聚合器可以对许多其他依赖性进行直观评估。

利润与交易数量

利润与交易数量

结束语

在本文中,我们扩充了 MQL OLAP 的范畴。 现在,它可由单次通关数据和优化来分析测试器报告。 更新的类结构可以进一步扩展 OLAP 功能。 所提议的实现方式并不理想,且可极大地改进(特别是在 3D 可视化,交互式 GUI 里不同坐标轴上的过滤设置和量化的实现)。 无论如何,它只是一个最小的起点,能有助于更轻松地感受 OLAP。 OLAP 分析令交易者能够处理大量原始数据,并得到新认知,从而进一步制定决策。

附带文件:

智能系统

  • OLAPRPRT.mq5 — 分析帐户历史记录,以及 HTML 和 CSV 报告(第三篇文章里的更新文件,不带 GUI)的智能交易系统
  • OLAPQTS.mq5 — 分析报价的智能交易系统(第三篇文章里的更新文件,不带 GUI)
  • OLAPGUI.mq5 — 分析帐户历史记录,HTML 和 CSV 格式报告,以及 TST 标准测试程序文件(第二篇文章里的更新文件,不带 GUI)的智能交易系统
  • OLAPGUI_Opts.mq5 — 分析来自标准 OPT 测试器文件的优化结果(新 GUI)的智能交易系统

包含

内核

  • OLAP/OLAPCommon.mqh — 含有 OLAP 类的主要头文件
  • OLAP/OLAPTrades.mqh — 交易历史 OLAP 分析的标准类
  • OLAP/OLAPTradesCustom.mqh — 交易历史 OLAP 分析的自定义类
  • OLAP/OLAPQuotes.mqh — 报价 OLAP 分析类
  • OLAP/OLAPOpts.mqh — 智能交易系统优化结果 OLAP 分析类
  • OLAP/ReportCubeBase.mqh — 交易历史 OLAP 分析基本类
  • OLAP/HTMLcube.mqh — HTML 格式交易历史的 OLAP 分析类
  • OLAP/CSVcube.mqh — CSV 格式交易历史的 OLAP 分析类
  • OLAP/TSTcube.mqh — TST 格式交易历史的 OLAP 分析类
  • OLAP/PairArray.mqh — [value; name] 数据对数组类,支持所有排序类型
  • OLAP/GroupReportInputs.mqh — 一组分析交易报告的输入参数
  • MT4Bridge/MT4Orders.mqh — MT4orders 库,可处理单一样式的 MetaTrader 4 和 MetaTrader 5 的订单
  • MT4Bridge/MT4Time.mqh — 一个辅助头文件,该文件以 MetaTrader 4 样式实现数据处理功能
  • Marketeer/IndexMap.mqh — 辅助头文件,该文件实现基于键-索引组合来访问数组
  • Marketeer/Converter.mqh — 转换数据类型的辅助头文件
  • Marketeer/GroupSettings.mqh — 包含一组设置输入参数的辅助头文件
  • Marketeer/WebDataExtractor.mqh — HTML 解析器
  • Marketeer/empty_strings.h — 空的 HTML 标签列表
  • Marketeer/HTMLcolumns.mqh — HTML 报告中列索引的定义
  • Marketeer/RubbArray.mqh — 含有 “rubber” 数组的辅助头文件
  • Marketeer/CSVReader.mqh — CSV 解析器
  • Marketeer/CSVcolumns.mqh — CSV 报告中列索引的定义

图形界面

  • OLAP/GUI/OLAPGUI.mqh — 交互式窗口界面的一般实现
  • OLAP/GUI/OLAPGUI_Trades.mqh — 分析交易报告的专业化图形界面
  • OLAP/GUI/OLAPGUI_Opts.mqh — 分析优化结果的专业图形界面
  • Layouts/Box.mqh — 控件容器
  • Layouts/ComboBoxResizable.mqh — 下拉控件,可以动态调整大小
  • Layouts/MaximizableAppDialog.mqh — 对话框窗口,可以动态调整大小
  • PairPlot/Plot.mqh — 图表图形控件,支持动态调整大小
  • Layouts/res/expand2.bmp — 窗口最大化按钮
  • Layouts/res/size6.bmp — 调整大小按钮
  • Layouts/res/size10.bmp — 调整大小按钮

TypeToBytes

  • TypeToBytes.mqh

SingleTesterCache

  • fxsaber/SingleTesterCache/SingleTesterCache.mqh
  • fxsaber/SingleTesterCache/SingleTestCacheHeader.mqh
  • fxsaber/SingleTesterCache/String.mqh
  • fxsaber/SingleTesterCache/ExpTradeSummaryExt.mqh
  • fxsaber/SingleTesterCache/ExpTradeSummarySingle.mqh
  • fxsaber/SingleTesterCache/TradeDeal.mqh
  • fxsaber/SingleTesterCache/TradeOrder.mqh
  • fxsaber/SingleTesterCache/TesterPositionProfit.mqh
  • fxsaber/SingleTesterCache/TesterTradeState.mqh

TesterCache

  • fxsaber/TesterCache/TesterCache.mqh
  • fxsaber/TesterCache/TestCacheHeader.mqh
  • fxsaber/TesterCache/String.mqh
  • fxsaber/TesterCache/ExpTradeSummary.mqh
  • fxsaber/TesterCache/TestCacheInput.mqh
  • fxsaber/TesterCache/TestInputRange.mqh
  • fxsaber/TesterCache/Mathematics.mqh
  • fxsaber/TesterCache/TestCacheRecord.mqh
  • fxsaber/TesterCache/TestCacheSymbolRecord.mqh

标准库补丁包

  • Controls/Dialog.mqh
  • Controls/ComboBox.mqh

文件

  • 518562.history.csv
  • Integrity.tst
  • Integrity.opt

本文译自 MetaQuotes Software Corp. 撰写的俄文原文
原文地址: https://www.mql5.com/ru/articles/7656

附加的文件 |
MQLOLAP4.zip (365.7 KB)
DoEasy 函数库中的时间序列(第三十七部分):时间序列集合 - 按品种和周期的时间序列数据库 DoEasy 函数库中的时间序列(第三十七部分):时间序列集合 - 按品种和周期的时间序列数据库

本文探讨开发针对程序中所有品种指定时间帧的时间序列集合。 我们将开发时间序列集合,为集合设置时间序列参数的方法,以及取用历史数据初始填充已开发的时间序列。

连续前行优化 (第五部分): 自动优化器项目概述和 GUI 的创建 连续前行优化 (第五部分): 自动优化器项目概述和 GUI 的创建

本文深入讲述在 MetaTrader 5 终端里的前向优化。 在先前的文章中,我们研究了生成和过滤优化报告的方法,并开始分析负责优化过程的应用程序的内部结构。 自动优化器是作为 C# 应用程序实现的,并且拥有自己的图形界面。 第五篇文章专门论述了此图形界面的创建。

监视多币种的交易信号(第三部分):引入搜索算法 监视多币种的交易信号(第三部分):引入搜索算法

在前一篇文章中,我们开发了应用程序的可视部分,以及基本的 GUI 交互元素。 这次,我们将添加内部逻辑,并准备交易信号数据的算法,还要有建立信号、搜索信号、并在监视器中对其可视化的能力。

DoEasy 函数库中的时间序列(第三十八部分):时间序列集合 - 实时更新以及从程序访问数据 DoEasy 函数库中的时间序列(第三十八部分):时间序列集合 - 实时更新以及从程序访问数据

本文研究实时更新时间序列数据,并从所有品种的所有时间序列里发送有关“新柱线”事件的消息至控制程序图表,从而能够在自定义程序中处理这些事件。 “新即时报价”类用于判断是否需要更新非当前图表品种和周期的时间序列。