请 [注册] 或 [登录]  | 返回主站

量化交易吧 /  量化策略 帖子:3364740 新帖:4

在交易中应用 OLAP(第 2 部分):可视化交互式多维数据分析的结果

蜡笔小新炒外汇发表于:7 月 5 日 16:00回复(1)

在第一篇与在交易中使用OLAP技术有关的文章中,我们探讨了一般的多维数据处理原则,并提供了随时可用的MQL类,这使OLAP在客户历史或交易报告处理中的实际应用成为可能。然而,我们实现了一个简化的结果输出,作为专家日志中的文本。为了更有效地进行可视化表示,您需要创建一个新的类,即显示界面的子类,它可以使用图形可视化OLAP数据。这项任务需要大量的准备工作,涉及许多与OLAP无关的不同方面。所以,让我们把数据处理放在一边,集中讨论MQL程序的图形界面。

有几个MQL库可用于GUI实现,包括控件(Include/Controls)的标准库,在几乎所有的库中,一个明显的缺点与这样一个事实有关:没有办法自动控制窗口中元素的布局。换句话说,元素的定位和对齐是静态执行的,使用带有x和y坐标的硬编码常量。还有一个与第一个问题密切相关的问题:屏幕窗体没有可视化设计。这是一项更艰巨的任务,尽管并非不可能。由于界面不是这个项目中的主要主题,所以决定不再关注屏幕表单编辑器,而是实现一种更简单的自适应界面方法。此界面中的元素必须专门分组排列,可以自动支持相关的定位和缩放规则。

标准库的问题在于其对话框窗口的大小是固定的。但是,在呈现大型的OLAP超立方体时,用户可以更方便地将窗口最大化,或者至少拉伸到足以使单元格标签适合轴而不重叠。

在mql5.com网站上提供了与开放式图形用户界面相关的独立开发:它们解决了不同的问题,但它们的复杂性/能力比远不是最佳的。要么功能有限(例如,解决方案具有布局机制,但不提供缩放选项),要么集成需要大量工作(您必须阅读大量文档,学习非标准方法等)。此外,所有其他条件相同时,最好使用基于标准元素的解决方案,这是更常见和流行的(即,在更多的MQL应用程序中使用,因此具有更高的效用系数)。

因此,我选择了一个看似简单的技术解决方案,它在由Enrico Lambino编写文章使用布局和容器进行GUI控件:CBox类和使用布局和容器进行GUI控件:CGrid类中有所描述。

在第一篇文章中,控件被添加到具有水平或垂直布局的容器中,它们可以被嵌套,从而提供任意的界面布局。第二篇文章介绍了具有表格布局的容器,两者都可以与所有标准控件一起使用,也可以与基于 CWnd 类的任何正确开发的控件一起使用。

解决方案只缺少动态窗口和容器大小调整,这将是解决一般问题的第一步。

"橡胶" 窗口

CBox 和 CGrid 类是通过头文件Box.mqh、Grid.mqh 和 GridTk.mqh 连接到项目的,如果您使用文章中的存档,请在 Include/Layouts目录下安装这些文件。

注意!标准库已经包含CGrid结构,它是为绘制图表网格而设计的。CGrid容器类与此无关。名字的巧合令人不快,但并不重要。

我们将修复 GridTk.mqh 文件中的一个小错误,并在 Box.mqh 文件中添加一些内容,之后我们可以直接继续改进标准对话框类CAppDialog。当然,我们不会破坏现有的类。相反,我们将创建一个从CAppDialog派生的新类。

主要的更改涉及CBox::GettotalControlClassize方法(相关行用注释标记)。您可以将原始项目中的文件与下面附加的文件进行比较。

  void CBox::GetTotalControlsSize(void)
  {
    m_total_x = 0;
    m_total_y = 0;
    m_controls_total = 0;
    m_min_size.cx = 0;
    m_min_size.cy = 0;
    int total = ControlsTotal();
    
    for(int i = 0; i < total; i++)
    {
      CWnd *control = Control(i);
      if(control == NULL) continue;
      if(control == &m_background) continue;
      CheckControlSize(control);
      
      // 添加:为嵌套容器递归调用自身
      if(control.Type() == CLASS_LAYOUT)
      {
        ((CBox *)control).GetTotalControlsSize();
      }
      
      CSize control_size = control.Size();
      if(m_min_size.cx < control_size.cx)
        m_min_size.cx = control_size.cx;
      if(m_min_size.cy < control_size.cy)
        m_min_size.cy = control_size.cy;
      
      // 编辑:根据容器方向有条件地增加m_total_x和m_total_y
      if(m_layout_style == LAYOUT_STYLE_HORIZONTAL) m_total_x += control_size.cx;
      else m_total_x = MathMax(m_min_size.cx, m_total_x);
      if(m_layout_style == LAYOUT_STYLE_VERTICAL) m_total_y += control_size.cy;
      else m_total_y = MathMax(m_min_size.cy, m_total_y);
      m_controls_total++;
    }
    
    // 添加:根据新总计调整容器大小
    CSize size = Size();
    if(m_total_x > size.cx && m_layout_style == LAYOUT_STYLE_HORIZONTAL)
    {
      size.cx = m_total_x;
    }
    if(m_total_y > size.cy && m_layout_style == LAYOUT_STYLE_VERTICAL)
    {
      size.cy = m_total_y;
    }
    Size(size);
  }

简而言之,修改后的版本考虑了元素可能的动态调整大小。

原始文章中的测试示例包括 Controls2 专家顾问(标准 MetaTrader 交付包中的标准控件项目的模拟,位于Experts\Examples\Controls\ 文件夹下)和 SlidingPuzze2游戏。默认情况下,两个容器示例都位于 Experts\Examples\Layout\ 文件夹下。基于这些容器,我们将尝试实现橡胶窗口。

在 Include\Layouts\ 下创建 MaximizableAppDialog.mqh,窗口类将继承于 CAppDialog。

  #include <Controls\Dialog.mqh>
  #include <Controls\Button.mqh>
  
  class MaximizableAppDialog: public CAppDialog
  {

我们需要两个带图像的新按钮:一个用于最大化窗口(它将位于标题中,最小化按钮旁边),另一个用于任意调整大小(在右下角)。

  protected:
    CBmpButton m_button_truemax;
    CBmpButton m_button_size;

当前最大化状态或调整大小过程的指示将存储在相应的逻辑变量中。

    bool m_maximized;
    bool m_sizing;

另外,让我们添加一个矩形,在这个矩形中,我们将不断地监视图表大小的最大化状态(这样图表大小也需要调整),并设置一个特定的最小大小,窗口不能小于它(用户可以使用 SetSizeLimit 公共方法设置这个限制)。

    CRect m_max_rect;
    CSize m_size_limit;

新添加的最大化和大小调整模式应与标准模式交互:对话框的默认大小和最小化。因此,如果窗口最大化,则不应通过按住标题栏来拖动它,这在标准大小下是允许的。此外,当窗口最大化时,应重置最小化按钮状态。为此,我们需要访问 CDialog 类中的变量 CEdit m_caption 和 CAppDialog中的 CBmpButton m_button_minmax。不幸的是,它们以及这些类的许多其他成员都在私有部分中声明。这看起来很奇怪,尽管这些基类是公共库的一部分,打算广泛使用。更好的解决方案是将所有成员声明为“protected”,或者至少提供访问它们的方法。但在我们的例子中,它们是私有的。所以我们唯一能做的就是通过添加一个“补丁”来修复标准库。修补程序的问题是,在库更新之后,您必须再次应用修补程序。但是,唯一可能的替代解决办法是,创造出重复的 CDialog 和 CAppDialog 类,而不是从OOP意识形态的角度看问题。

当类成员的私有声明将阻止派生类功能的扩展时,这不是最后一种情况。因此,我建议创建include/controls文件夹的副本,如果在编译过程中出现 “private member access error(私有成员访问错误)”,则可以编辑适当的部分,例如将适当的元素移动到“protected”部分或将“private”替换为“protected”。

我们需要重新编写一些基类的虚拟方法:

    virtual bool CreateButtonMinMax(void) override;
    virtual void OnClickButtonMinMax(void) override;
    virtual void Minimize(void) override;
  
    virtual bool OnDialogDragStart(void) override;
    virtual bool OnDialogDragProcess(void) override;
    virtual bool OnDialogDragEnd(void) override;

前三种方法与最小化按钮相关,其他三种方法与基于拖放技术的调整大小过程相关。

还将介绍创建对话框和对事件的反应的虚拟方法(后者总是在事件处理映射的宏定义中隐式使用,稍后将予以考虑)。

    virtual bool Create(const long chart, const string name, const int subwin, const int x1, const int y1, const int x2, const int y2) override;
    virtual bool OnEvent(const int id, const long &lparam, const double &dparam, const string &sparam) override;

最大化按钮将与CreateButtonMinMax的预定义版本中的标准最小化按钮一起创建。首先调用基本实现,以获得标准的标题按钮,然后再绘制新的最大化按钮。源代码包含设置初始坐标和对齐方式以及连接图像资源的一组通用指令。因此,此处不显示此代码,完整的源代码附在下面,这两个按钮的资源位于“res”子目录下:

  #resource "res\\expand2.bmp"
  #resource "res\\size6.bmp"
  #resource "res\\size10.bmp"

以下方法负责最大化按钮单击的处理:

    virtual void OnClickButtonTrueMax(void);

此外,我们还将添加辅助方法来最大化整个图表的窗口,并恢复其原始大小:这些方法可以从OnClickButtonMax调用,并执行所有工作,具体取决于窗口是否最大化。

    virtual void Expand(void);
    virtual void Restore(void);

创建“调整大小”按钮和缩放过程的启动按以下方法实现:

    bool CreateButtonSize(void);
    bool OnDialogSizeStart(void);

事件处理由熟悉的宏决定:

  EVENT_MAP_BEGIN(MaximizableAppDialog)
    ON_EVENT(ON_CLICK, m_button_truemax, OnClickButtonTrueMax)
    ON_EVENT(ON_DRAG_START, m_button_size, OnDialogSizeStart)
    ON_EVENT_PTR(ON_DRAG_PROCESS, m_drag_object, OnDialogDragProcess)
    ON_EVENT_PTR(ON_DRAG_END, m_drag_object, OnDialogDragEnd)
  EVENT_MAP_END(CAppDialog)

m_button_truemax 和 m_button_size 的对象是由我们自己创建的,而 m_drag_object 是从 CWnd 类继承的。该类中的对象用于使用标题栏启用窗口拖动。在我们的类中,这个对象将参与调整大小。

但这并不是所有与事件相关的必需工作。为了拦截图表大小调整,我们需要处理 CHARTEVENT_CHART_CHANGE 事件。为此,让我们描述类中的ChartEvent方法:它将与 CAppDialog中的类似方法重叠。因此,我们需要调用基类实现。此外,我们将检查事件代码,并对 CHARTEVENT_CHART_CHANGE 执行特定处理。

  void MaximizableAppDialog::ChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam)
  {
    if(id == CHARTEVENT_CHART_CHANGE)
    {
      if(OnChartChange(lparam, dparam, sparam)) return;
    }
    CAppDialog::ChartEvent(id, lparam, dparam, sparam);
  }

OnChartChange方法跟踪图表大小,如果在活动的最大化模式下更改了图表大小,则会启动新的元素布局。这是通过 SelfAdjustment 方法执行的。

  bool MaximizableAppDialog::OnChartChange(const long &lparam, const double &dparam, const string &sparam)
  {
    m_max_rect.SetBound(0, 0,
                        (int)ChartGetInteger(ChartID(), CHART_WIDTH_IN_PIXELS) - 0 * CONTROLS_BORDER_WIDTH,
                        (int)ChartGetInteger(ChartID(), CHART_HEIGHT_IN_PIXELS) - 1 * CONTROLS_BORDER_WIDTH);
    if(m_maximized)
    {
      if(m_rect.Width() != m_max_rect.Width() || m_rect.Height() != m_max_rect.Height())
      {
        Rebound(m_max_rect);
        SelfAdjustment();
        m_chart.Redraw();
      }
      return true;
    }
    return false;
  }

此方法在 MaximableAppDialog 类中声明为抽象的和虚拟的,这意味着子类必须将其控件调整为新的大小。

    virtual void SelfAdjustment(const bool minimized = false) = 0;

从“橡胶”窗口类的其他位置调用相同的方法,在该窗口类中执行大小调整。例如,从OnDialogDragProcess(当用户拖动右下角时)和OnDialogDragEnd(用户已完成缩放),

高级对话框的行为如下:以标准大小显示在图表上后,用户可以使用标题栏(标准行为)、最小化(标准行为)和最大化(添加的行为)拖动它。调整图表大小时,将保存最大化状态。在最大化状态下,可以使用相同的按钮将窗口重置为原始大小或将其最小化。窗口也可以从最小化状态立即最大化。如果窗口既不最小化也不最大化,则任意缩放的活动区域(三角形按钮)将显示在右下角。如果窗口最小化或最大化,此区域将被停用并隐藏。

这可以完成 MaximableAppDialog 的实现,然而,在测试过程中发现了另一个方面,这需要进一步的开发。

在最小化状态下,活动的调整大小区域与窗口关闭按钮重叠,并截获其鼠标事件。这是一个明显的错误,因为“调整大小”按钮隐藏在“最小化”状态下,并变为非活动状态,问题与 CWnd::OnMouseEvent方法有关,需要进行以下检查:

  // if(!IS_ENABLED || !IS_VISIBLE) return false; - 缺了这一行

因此,即使禁用和不可见控件也会截获事件。显然,可以通过为控制元素设置适当的 Z轴顺序来解决这个问题。但是,库的问题在于它没有考虑控件的Z轴顺序。尤其是,CWndContainer::OnMouseEvent 方法包含一个简单的循环,以相反的顺序遍历所有子元素,因此它不会尝试以Z顺序确定它们的优先级。

因此,我们要么需要一个新的库补丁,要么在子类中需要一种“技巧”。这里使用第二种方法。“技巧”如下:在最小化状态下,调整大小按钮单击应被解释为关闭按钮单击(因为这是重叠的按钮)。为此,已将以下方法添加到MaximableAppDialog中:

  void MaximizableAppDialog::OnClickButtonSizeFixMe(void)
  {
    if(m_minimized)
    {
      Destroy();
    }
  }

方法已添加到事件映射:

  EVENT_MAP_BEGIN(MaximizableAppDialog)
    ...
    ON_EVENT(ON_CLICK, m_button_size, OnClickButtonSizeFixMe)
    ...
  EVENT_MAP_END(CAppDialog)

现在,MaximableAppDialog类已经准备好使用了。请注意,它是为在主图表区使用而设计的。

首先,让我们尝试将其添加到 SlidingPuzzle (滑动拼图) 游戏中。在开始编辑之前,请将 SlidingPuzzle2.mq5 和 SlidingPuzzle2.mqh 复制为 SlidingPuzzle3.mq5 和 SlidingPuzzle3.mqh。在mq5文件中几乎没有要更改的内容:只将对include文件的引用更改为SlidingPuzzle3.mqh。

在 SlidingPuzzle3.mqh 文件中,包括新创建的类,而不是标准对话框类:

  #include <Controls\Dialog.mqh>

新类:

  #include <Layouts\MaximizableAppDialog.mqh>

类说明必须使用新的父类:

  class CSlidingPuzzleDialog: public MaximizableAppDialog // CAppDialog

类名称的类似替换应该在事件映射中执行:

  EVENT_MAP_END(MaximizableAppDialog) // CAppDialog

此外,应在 Create 中也执行替换:

  bool CSlidingPuzzleDialog::Create(const long chart, const string name, const int subwin, const int x1, const int y1, const int x2, const int y2)
  {
    if(!MaximizableAppDialog::Create(chart, name, subwin, x1, y1, x2, y2)) // CAppDialog
      return (false);
    ...

最后,新对话框需要实现响应调整大小的自调整方法。

  void CSlidingPuzzleDialog::SelfAdjustment(const bool minimized = false)
  {
    CSize size;
    size.cx = ClientAreaWidth();
    size.cy = ClientAreaHeight();
    m_main.Size(size);
    m_main.Pack();
  }

相关工作将由 m_main 容器执行:它的“pack”方法将针对窗口客户端区域的最后一个已知大小进行调用。

这绝对足够为游戏提供一个自适应布局。但是,为了提高代码的可读性和效率,我稍微改变了应用程序中的按钮使用原则:现在,它们都被收集在单个数组 CButton m_buttons[16] 中,可以通过索引而不是“switch”运算符访问它们,并在事件映射中使用一行代码进行处理(通过 OnClickButton 方法):

  ON_INDEXED_EVENT(ON_CLICK, m_buttons, OnClickButton)

您可以比较原始游戏的源代码和修改后的代码。

自适应窗口的行为如下所示。

SlidingPuzzle 游戏

SlidingPuzzle 游戏

同样,我们需要修改演示EA交易 Experts\Examples\Layouts\Controls2.mq5:它的主mq5文件和包含对话框描述的include头文件,这些文件在新名称 Controls3.mq5 和 ControlsDialog3.mqh下显示。请注意,游戏使用了网格类型的容器,而带有控件的对话框是基于“box”类型构建的。

如果我们在修改后的项目中使用与游戏中类似的 SelfAdjustment 方法,我们很容易注意到之前未注意到的缺陷:自适应窗口调整只对窗口本身有效,但不影响控件。我们需要实现调整控件大小以适应动态窗口大小的功能。

"橡胶"控件

不同的标准控件对动态调整大小有不同的适应。它们其中的一些,(如CButton按钮)可以正确响应“Width”方法调用。对于其他控件,如 CListView 列表,我们只需使用“Alignment”设置对齐,系统将自动保存控件和窗口边框之间的距离,这相当于使其类似“橡胶”。但是,一些控件不支持任何方法,其中包括 CSpinEdit 和 CComboBox 等。要向它们添加新的功能,我们需要创建子类。

对于 CSpinEdit,可以重写虚拟的 OnResize方法:

  #include <Controls/SpinEdit.mqh> // 需要补丁: private: -> protected:
  
  class SpinEditResizable: public CSpinEdit
  {
    public:
      virtual bool OnResize(void) override
      {
        m_edit.Width(Width());
        m_edit.Height(Height());
        
        int x1 = Width() - (CONTROLS_BUTTON_SIZE + CONTROLS_SPIN_BUTTON_X_OFF);
        int y1 = (Height() - 2 * CONTROLS_SPIN_BUTTON_SIZE) / 2;
        m_inc.Move(Left() + x1, Top() + y1);
        
        x1 = Width() - (CONTROLS_BUTTON_SIZE + CONTROLS_SPIN_BUTTON_X_OFF);
        y1 = (Height() - 2 * CONTROLS_SPIN_BUTTON_SIZE) / 2 + CONTROLS_SPIN_BUTTON_SIZE;
        m_dec.Move(Left() + x1, Top() + y1);
  
        return CWndContainer::OnResize();
      }
  };

由于 CSpinEdit 实际上由3个元素、一个输入字段和两个按钮组成,为了响应调整大小的请求(通过OnResize方法完成),我们需要增加或减少输入字段以适应新的大小,并将按钮移近字段的右边缘。唯一的问题是,下属元素 m_edit、m_inc 和 m_dec 是在私有区域中描述的。因此,我们需要再次修复标准库。这里只使用 CSpinEdit 来演示这种方法,在这种情况下,这种方法很容易实现。对于真正的OLAP界面,我们需要一个适当的下拉列表。

但是在自定义CComboBox类时也会遇到类似的问题。在实现派生类之前,我们需要对CComboBox基类应用一个补丁,其中“private”应替换为“protected”。请注意,所有这些补丁都不会影响与使用标准库的其他项目的兼容性。

要实现“橡胶”组合框,还需要做更多的工作,我们不仅需要重写 OnResize,还需要重写 OnClickButton、Enable 和 Disable,以及添加事件映射。我们管理所有下级对象m_edit、m_list和m_drop,即组合框包含的所有对象。

  #include <Controls/ComboBox.mqh> // 需要打补丁: private: -> protected:
  
  class ComboBoxResizable: public CComboBox
  {
    public:
      virtual bool OnEvent(const int id, const long &lparam, const double &dparam, const string &sparam) override;
  
      virtual bool OnResize(void) override
      {
        m_edit.Width(Width());
        
        int x1 = Width() - (CONTROLS_BUTTON_SIZE + CONTROLS_COMBO_BUTTON_X_OFF);
        int y1 = (Height() - CONTROLS_BUTTON_SIZE) / 2;
        m_drop.Move(Left() + x1, Top() + y1);
        
        m_list.Width(Width());
  
        return CWndContainer::OnResize();
      }
      
      virtual bool OnClickButton(void) override
      {
        // 这是触发列表中元素大小调整的技巧
        // 我们需要它,因为标准ListView的编码方式不正确
        // 只有当存在垂直滚动时才调整元素的大小
        bool vs = m_list.VScrolled();
        if(m_drop.Pressed())
        {
          m_list.VScrolled(true);
        }
        bool b = CComboBox::OnClickButton();
        m_list.VScrolled(vs);
        return b;
      }
      
      virtual bool Enable(void) override
      {
        m_edit.Show();
        m_drop.Show();
        return CComboBox::Enable();
      }
      
      virtual bool Disable(void) override
      {
        m_edit.Hide();
        m_drop.Hide();
        return CComboBox::Disable();
      }
  };
  
  #define EXIT_ON_DISABLED \
        if(!IsEnabled())   \
        {                  \
          return false;    \
        }
  
  EVENT_MAP_BEGIN(ComboBoxResizable)
    EXIT_ON_DISABLED
    ON_EVENT(ON_CLICK, m_drop, OnClickButton)
  EVENT_MAP_END(CComboBox)

现在我们可以使用演示项目 Control3 检查这些“橡胶”控件。分别用 SpinEditResizable 和 ComBoxResizable 替换 CSpinEdit 和 CComboBox类。更改 SelfAdjustment 方法中控件的大小。

  void CControlsDialog::SelfAdjustment(const bool minimized = false)
  {
    CSize min = m_main.GetMinSize();
    CSize size;
    size.cx = ClientAreaWidth();
    size.cy = ClientAreaHeight();
    if(minimized)
    {
      if(min.cx > size.cx) size.cx = min.cx;
      if(min.cy > size.cy) size.cy = min.cy;
    }
    m_main.Size(size);
    int w = (m_button_row.Width() - 2 * 2 * 2 * 3) / 3;
    m_button1.Width(w);
    m_button2.Width(w);
    m_button3.Width(w);
    m_edit.Width(w);
    m_spin_edit.Width(w);
    m_combo_box.Width(m_lists_row.Width() / 2);
    m_main.Pack();
  }

SelfAdjustment 方法将由窗口大小调整后的父 MaximableAppDialog 类自动调用。此外,在窗口初始化时,我们将自己从 CreateMain方法调用此方法一次。

这就是实际情况下的情况(为了简单起见,控件只能水平填充工作区,但可以垂直应用相同的效果)。

控件的演示

控件的演示

此处显示的红色框用于调试,可以使用 LAYOUT_BOX_DEBUG 宏禁用它们。

除了上面的更改,我还稍微修改了控制初始化原理。从窗口的主客户机区域开始,每个块都在专用方法(例如,CreateMain、CreateEditRow、CreateButtonRow等)中完全初始化,如果成功,该方法将返回对已创建容器类型(CWnd*)的引用。父容器通过调用 CWndContainer::Add添加子容器。这就是主对话框初始化对话框现在的样子:

  bool CControlsDialog::Create(const long chart, const string name, const int subwin, const int x1, const int y1, const int x2, const int y2)
  {
      if(MaximizableAppDialog::Create(chart, name, subwin, x1, y1, x2, y2)
      && Add(CreateMain(chart, name, subwin)))
      {
          return true;
      }
      return false;
  }
  
  CWnd *CControlsDialog::CreateMain(const long chart, const string name, const int subwin)
  {
      m_main.LayoutStyle(LAYOUT_STYLE_VERTICAL);
      if(m_main.Create(chart, name + "main", subwin, 0, 0, ClientAreaWidth(), ClientAreaHeight())
      && m_main.Add(CreateEditRow(chart, name, subwin))
      && m_main.Add(CreateButtonRow(chart, name, subwin))
      && m_main.Add(CreateSpinDateRow(chart, name, subwin))
      && m_main.Add(CreateListsRow(chart, name, subwin))
      && m_main.Pack())
      {
          SelfAdjustment();
          return &m_main;
      }
      return NULL;
  }

下面是用按钮初始化一行:

  CWnd *CControlsDialog::CreateButtonRow(const long chart, const string name, const int subwin)
  {
      if(m_button_row.Create(chart, name + "buttonrow", subwin, 0, 0, ClientAreaWidth(), BUTTON_HEIGHT * 1.5)
      && m_button_row.Add(CreateButton1())
      && m_button_row.Add(CreateButton2())
      && m_button_row.Add(CreateButton3()))
      {
        m_button_row.Alignment(WND_ALIGN_LEFT|WND_ALIGN_RIGHT, 2, 0, 2, 0);
        return &m_button_row;
      }
      return NULL;
  }

这种语法似乎比以前使用的更合乎逻辑和紧凑。然而,在这样的实施过程中,新旧项目的上下文比较可能很困难。

关于控件还有更多的事情要做,不要忘记,项目的目的是实现OLAP的图形界面,因此,中心控件是“图表”。问题是标准库中没有这样的控件,我们需要创建它。

"图表"控件 (CPlot)

MQL库提供了几个图形原型,其中包括画布(CCanvas)、基于画布的图形(CGraphic)和用于显示现成图像的图形对象(CChartObjectBitmap、CPicture),但这些对象与所需的图形无关。要将上述任何原型插入窗口界面,我们需要将其包装到相应控件的子类中,该类可以进行绘图。幸运的是,不需要从头开始解决这个任务。请参阅发布在本网站的文章基于 CGraphic 的用于分析数据数组(时间序列)之间相互关联的 PairPlot 图,它提供了一个随时可用的控件类,其中包括一组用于分析交易品种之间相关性的图表。因此,我们只需要修改它来处理控件中的单个图表,从而获得所需的结果。

文章中的文件安装到 Include\PairPlot\ 目录中,包含类的文件称为 PairPlot.mqh,基于此文件,我们将以 Plot.mqh 为名称创建新类,主要的区别:

我们不需要CTimeserie类,所以让我们删除它。从 CWndClient 派生的 CPairPlot 控件类被转换为CPlot,而其使用交叉交易品种图表的操作被一个图表替换。上面提到的项目中的图表是使用特殊的柱状图类(CHistogram)和散点图类(CScatter)绘制的,散点图类是从通用的CPlotBase类(反过来又是从CGraphic派生的)派生的。我们将把 CPlotBase 转换为我们自己的CGraphicInPlot类,它也是从CGraphic派生的。我们不需要任何特殊的图表或散点图。相反,我们将使用由CGraphic类(即相邻的CCurve类)提供的标准绘图样式(CURVE_POINTS、CURVE_LINES、CURVE_POINTS_AND_LINES、CURVE_STEPS、CURVE_HISTOGRAM)。下面提供了类之间关系的简化图。

图形类之间的关系图

图形类之间的关系图

灰色用于新添加的类,而所有其他类都是标准的。

让我们创建 PlotDemo 测试EA来检查新控件。初始化、绑定到事件和启动在 PlotDemo.mq5 文件中实现,而对话框描述包含在 PlotDemo.mqh中(两个文件都已附加)。

EA只接受输入参数,即图形样式。

  #include "PlotDemo.mqh"
  
  input ENUM_CURVE_TYPE PlotType = CURVE_POINTS;
  
  CPlotDemo *pPlotDemo;
  
  int OnInit()
  {
      pPlotDemo = new CPlotDemo;
      if(CheckPointer(pPlotDemo) == POINTER_INVALID) return INIT_FAILED;
  
      if(!pPlotDemo.Create(0, "Plot Demo", 0, 20, 20, 800, 600, PlotType)) return INIT_FAILED;
      if(!pPlotDemo.Run()) return INIT_FAILED;
      pPlotDemo.Refresh();
  
      return INIT_SUCCEEDED;
  }
  
  void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam)
  {
      pPlotDemo.ChartEvent(id, lparam, dparam, sparam);
  }
  
  ...

在对话框的头文件中创建控件对象,并添加两条测试曲线。

  #include <Controls\Dialog.mqh>
  #include <PairPlot/Plot.mqh>
  #include <Layouts/MaximizableAppDialog.mqh>
  
  class CPlotDemo: public MaximizableAppDialog // CAppDialog
  {
    private:
      CPlot m_plot;
  
    public:
      CPlotDemo() {}
      ~CPlotDemo() {}
  
      bool Create(const long chart, const string name, const int subwin, const int x1, const int y1, const int x2, const int y2, const ENUM_CURVE_TYPE curveType = CURVE_POINTS);
      virtual bool OnEvent(const int id, const long &lparam, const double &dparam, const string &sparam);
      bool Refresh(void);
  
      virtual void SelfAdjustment(const bool minimized = false) override
      {
        if(!minimized)
        {
          m_plot.Size(ClientAreaWidth(), ClientAreaHeight());
          m_plot.Resize(0, 0, ClientAreaWidth(), ClientAreaHeight());
        }
        m_plot.Refresh();
      }
  };
  
  EVENT_MAP_BEGIN(CPlotDemo)
  EVENT_MAP_END(MaximizableAppDialog)
  
  bool CPlotDemo::Create(const long chart, const string name, const int subwin, const int x1, const int y1, const int x2, const int y2, const ENUM_CURVE_TYPE curveType = CURVE_POINTS)
  {
      const int maxw = (int)ChartGetInteger(0, CHART_WIDTH_IN_PIXELS);
      const int maxh = (int)ChartGetInteger(0, CHART_HEIGHT_IN_PIXELS);
      int _x1 = x1;
      int _y1 = y1;
      int _x2 = x2;
      int _y2 = y2;
      if(x2 - x1 > maxw || x2 > maxw)
      {
        _x1 = 0;
        _x2 = _x1 + maxw - 0;
      }
      if(y2 - y1 > maxh || y2 > maxh)
      {
        _y1 = 0;
        _y2 = _y1 + maxh - 1;
      }
      
      if(!MaximizableAppDialog::Create(chart, name, subwin, _x1, _y1, _x2, _y2))
          return false;
      if(!m_plot.Create(m_chart_id, m_name + "Plot", m_subwin, 0, 0, ClientAreaWidth(), ClientAreaHeight(), curveType))
          return false;
      if(!Add(m_plot))
          return false;
      double x[] = {-10, -4, -1, 2, 3, 4, 5, 6, 7, 8};
      double y[] = {-5, 4, -10, 23, 17, 18, -9, 13, 17, 4};
      m_plot.CurveAdd(x, y, "Example 1");
      m_plot.CurveAdd(y, x, "Example 2");
      return true;
  }
  
  bool CPlotDemo::Refresh(void)
  {
      return m_plot.Refresh();
  }

EA交易操作可视化如下:

用图形演示控件

用图形演示控件

我们已经完成了大量的工作,现在通过图形支持创建自适应界面的可能性已经足够用于OLAP项目了。为了总结,我将给出一个与图形用户界面相关的主要类的图表。

控件类的图表

控件类的图表

标准类使用白色;容器类使用黄色;对话框类和自定义元素类使用粉色,支持大小调整;带有内置图形的控件使用绿色。

用于 OLAP 的 GUI

让我们创建一个新的EA交易,它将实现交易历史数据的交互式处理和可视化:OLAPGUI。有关创建窗口和控件、对用户操作的响应和OLAP函数调用的所有操作都包含在 OLAPGUI.mqh 头文件中。

让我们只留下那些与从HTML或CSV导入数据相关的EA输入。首先,这涉及到 ReportFile、Prefix、Suffix 变量,这可能是您在第一个 OLAPDEMO 项目中已经熟悉的。如果 ReportFile为空,则EA将分析当前账户的交易历史记录。

选择器、聚合器和图表样式将使用控件元素进行选择。我们将保留为超立方体设置3个维度的功能,即为条件轴X、Y、Z设置3个选择器。为此,我们需要3个下拉列表,将它们放在控件的上排。靠近同一行的右边缘,添加“处理(Process)”按钮,单击该按钮将启动分析。

使用第二行控件中的其他两个下拉列表来实现聚合器函数和字段的选择(根据该字段执行聚合)。为排序顺序和图表样式添加一个下拉列表。将取消筛选以简化UI。

其余区域将被图表占用。

带有选择器的下拉列表将包含相同的选项集,它将结合选择器的类型和直接输出记录的类型。下一个表显示控件的名称以及相应的字段和/或选择器类型。

  • (selector/field), FIELD_NONE
  • ordinal [SerialNumberSelector], FIELD_NUMBER
  • symbol [SymbolSelector], FIELD_SYMBOL
  • type [TypeSelector], FIELD_TYPE
  • magic number [MagicSelector], FIELD_MAGIC
  • day of week open [WeekDaySelector], FIELD_DATETIME1
  • day of week close [WeekDaySelector], FIELD_DATETIME2
  • hour of day open [DayHourSelector], FIELD_DATETIME1
  • hour of day close [DayHourSelector], FIELD_DATETIME2
  • duration [DaysRangeSelector], FIELD_DATETIME1 и FIELD_DATETIME2
  • lot [TradeSelector/QuantizationSelector*], FIELD_LOT
  • profit [TradeSelector/QuantizationSelector*], FIELD_PROFIT_AMOUNT
  • profit percent [TradeSelector/QuantizationSelector*], FIELD_PROFIT_PERCENT
  • profit points [TradeSelector/QuantizationSelector*], FIELD_PROFIT_POINT
  • commission [TradeSelector/QuantizationSelector*], FIELD_COMMISSION
  • swap [TradeSelector/QuantizationSelector*], FIELD_SWAP
  • custom 1 [TradeSelector/QuantizationSelector*], FIELD_CUSTOM1
  • custom 2 [TradeSelector/QuantizationSelector*], FIELD_CUSTOM2

标记为*的选择器的选择由聚合器类型决定:TradeSelector用于IdentityAggregator;否则使用QuantitationSelector。

下拉列表中的选择器(点1到9)的名称在引号中显示。

应按顺序从左到右、从X到Z选择选择器。只有在选择上一个测量选择器之后,才能取消隐藏后续轴的组合框。

支持的聚合函数:

  • sum
  • average
  • max
  • min
  • count
  • profit factor
  • progressive total
  • identity

所有函数(最后一个函数除外)都需要使用聚合器右侧的下拉列表指定聚合记录字段。

“progressive total”功能意味着选择“ordinal”作为沿X轴的选择器(这意味着连续传递记录)。

如果只选择了选择器(X),则具有排序功能的组合框可用。

X轴和Y轴分别位于图表上的水平和垂直位置。对于沿Z轴具有不同坐标的三维超立方体,我采用了最原始的可能方法:使用“处理”按钮可以滚动Z平面中的多个部分。如果存在z坐标,按钮名称将更改为“i/n title>>”,其中“i”是当前z坐标的编号,“n”是z轴上的样本总数,“title”显示沿轴绘制的内容(例如,一周中的某一天或交易类型取决于z轴)选择器)。如果更改了超立方体构造条件,按钮标题将再次设置为“处理”,并将在正常模式下开始工作。请注意,“标识”聚合器的处理方式会有所不同:在这种情况下,多维数据集总是有两个维度,而所有三条曲线(用于X、Y和Z字段)都一起绘制在图表上,而不滚动。

除了图形显示之外,每个多维数据集还以文本形式显示在日志中。如果聚合是由简单字段而不是选择器执行的,则这一点尤其重要。选择器沿轴提供标签输出,而当量化一个简单字段时,系统只能输出单元格索引。例如,为了分析按批次大小细分的利润,在X选择器中选择“lot”字段,在“profit amount”字段中选择“sum”聚合器。以下值可以沿X轴显示:0、0.5、1、1.0、1.5等,直至不同交易量的数量。然而,这些将是单元格编号,但不是批次值,而后者反映在日志中。日志将包含以下消息:

	Selectors: 1
	SumAggregator<TRADE_RECORD_FIELDS> FIELD_PROFIT_AMOUNT [6]
	X: QuantizationSelector(FIELD_LOT) [6]
	===== QuantizationSelector(FIELD_LOT) =====
	      [value] [title]
	[0] 365.96000 "0.01"
	[1]   0.00000 "0.0"
	[2]   4.65000 "0.03"
	[3]  15.98000 "0.06"
	[4]  34.23000 "0.02"
	[5]   0.00000 "1.0"

这里“value”是总利润,“title”是与该利润对应的实际手数值,而左边的数字是沿x轴的坐标。请注意,分数值沿轴显示在图表上,尽管只有整数索引才有意义。这个标签显示方面当然可以改进,

要将GUI控件与 OLAPcube.mqh 头文件中的OLAP核心(第一篇文章中介绍的思想按原样使用)链接起来,需要实现 OLAPWrapper 层类。它具有与数据相同的准备操作,由第一个演示项目 OLAPDEMO 中的“process”函数执行。现在它是一个类方法。

  class OLAPWrapper
  {
    protected:
      Selector<TRADE_RECORD_FIELDS> *createSelector(const SELECTORS selector, const TRADE_RECORD_FIELDS field);
  
    public:
      void process(
          const SELECTORS &selectorArray[], const TRADE_RECORD_FIELDS &selectorField[],
          const AGGREGATORS AggregatorType, const TRADE_RECORD_FIELDS AggregatorField, Display &display,
          const SORT_BY SortBy = SORT_BY_NONE,
          const double Filter1value1 = 0, const double Filter1value2 = 0)
      {
        int selectorCount = 0;
        for(int i = 0; i < MathMin(ArraySize(selectorArray), 3); i++)
        {
          selectorCount += selectorArray[i] != SELECTOR_NONE;
        }
        ...
        HistoryDataAdapter<CustomTradeRecord> history;
        HTMLReportAdapter<CustomTradeRecord> report;
        CSVReportAdapter<CustomTradeRecord> external;
        
        DataAdapter *adapter = &history;
        
        if(ReportFile != "")
        {
          if(StringFind(ReportFile, ".htm") > 0 && report.load(ReportFile))
          {
            adapter = &report;
          }
          else
          if(StringFind(ReportFile, ".csv") > 0 && external.load(ReportFile))
          {
            adapter = &external;
          }
          else
          {
            Alert("未知的文件格式: ", ReportFile);
            return;
          }
        }
        else
        {
          Print("分析账户历史");
        }
        
        Selector<TRADE_RECORD_FIELDS> *selectors[];
        ArrayResize(selectors, selectorCount);
        
        for(int i = 0; i < selectorCount; i++)
        {
          selectors[i] = createSelector(selectorArray[i], selectorField[i]);
        }
  
        Aggregator<TRADE_RECORD_FIELDS> *aggregator;
        switch(AggregatorType)
        {
          case AGGREGATOR_SUM:
            aggregator = new SumAggregator<TRADE_RECORD_FIELDS>(AggregatorField, selectors, filters);
            break;
            ...
        }
        
        Analyst<TRADE_RECORD_FIELDS> *analyst;
        analyst = new Analyst<TRADE_RECORD_FIELDS>(adapter, aggregator, display);
        
        analyst.acquireData();
        ...
        analyst.build();
        analyst.display(SortBy, AggregatorType == AGGREGATOR_IDENTITY);
        ...
      }

完整的源代码附在下面,请注意,在 OLAPDEMO 项目中,所有的设置都是从输入变量接收的,现在作为“process”方法的参数传入,它们显然应该根据控件的状态进行填充。

“display”参数尤其令人感兴趣。OLAP核心为数据可视化声明了这个特殊的“显示”接口。现在我们需要在程序的图形部分实现它。通过使用这个接口创建一个对象,我们实现了第一篇文章中讨论的“依赖注入”。这将启用新结果显示方法的连接,而不更改OLAP核心。

在 OLAPGUI.mq5文件中,创建一个对话框并将OLAPWrapper示例传递给它。

  #include "OLAPGUI.mqh"
  
  OLAPWrapper olapcore;
  OLAPDialog dialog(olapcore);
  
  int OnInit()
  {
      if(!dialog.Create(0, "OLAPGUI" + (ReportFile != "" ? " : " + ReportFile : ""), 0,  0, 0, 584, 456)) return INIT_FAILED;
      if(!dialog.Run()) return INIT_FAILED;
      return INIT_SUCCEEDED;
  }
  ...

OLAPDialog 对话框类定义在 OLAPGUI.mqh 中。

  class OLAPDialog;
  
  // 由于MQL5不支持多个继承,因此我们需要此委托对象
  class OLAPDisplay: public Display
  {
    private:
      OLAPDialog *parent;
  
    public:
      OLAPDisplay(OLAPDialog *ptr): parent(ptr) {}
      virtual void display(MetaCube *metaData, const SORT_BY sortby = SORT_BY_NONE, const bool identity = false) override;
  };
  
  class OLAPDialog: public MaximizableAppDialog
  {
    private:
      CBox m_main;
  
      CBox m_row_1;
      ComboBoxResizable m_axis[AXES_NUMBER];
      CButton m_button_ok;
  
      CBox m_row_2;
      ComboBoxResizable m_algo[ALGO_NUMBER]; // aggregator, field, graph type, sort by
  
      CBox m_row_plot;
      CPlot m_plot;
      ...
      OLAPWrapper *olapcore;
      OLAPDisplay *olapdisplay;
      ...
  
    public:
      OLAPDialog(OLAPWrapper &olapimpl)
      {
        olapcore = &olapimpl;
        olapdisplay = new OLAPDisplay(&this);
      }
      
      ~OLAPDialog(void);
      ...

响应“Process”按钮的单击,一个对话框根据控件的位置为OLAPWrapper::Process方法填充必要的参数,并在将olapdisplay对象作为显示传递时调用此方法:

  void OLAPDialog::OnClickButton(void)
  {
    SELECTORS Selectors[4];
    TRADE_RECORD_FIELDS Fields[4];
    AGGREGATORS at = (AGGREGATORS)m_algo[0].Value();
    TRADE_RECORD_FIELDS af = (TRADE_RECORD_FIELDS)(AGGREGATORS)m_algo[1].Value();
    SORT_BY sb = (SORT_BY)m_algo[2].Value();
  
    ArrayInitialize(Selectors, SELECTOR_NONE);
    ArrayInitialize(Fields, FIELD_NONE);
    ...
    
    olapcore.process(Selectors, Fields, at, af, olapdisplay, sb);
  }

所有设置的完整代码附在下面。

辅助 OLAPDisplay类是必需的,因为MQL不支持多重继承。OLAPDialog类是从MaximableAppDialog派生的,因此它不能直接实现对话框接口。相反,此任务将由 OLAPDisplay类执行:它的对象将在窗口内创建,并通过构造函数参数由指向开发人员的链接提供。

创建多维数据集后,OLAP核心调用 OLAPDisplay::display方法:

  void OLAPDisplay::display(MetaCube *metaData, const SORT_BY sortby = SORT_BY_NONE, const bool identity = false) override
  {
    int consts[];
    int selectorCount = metaData.getDimension();
    ArrayResize(consts, selectorCount);
    ArrayInitialize(consts, 0);
  
    Print(metaData.getMetaCubeTitle(), " [", metaData.getCubeSize(), "]");
    for(int i = 0; i < selectorCount; i++)
    {
      Print(CharToString((uchar)('X' + i)), ": ", metaData.getDimensionTitle(i), " [", metaData.getDimensionRange(i), "]");
    }
    
    if(selectorCount == 1)
    {
      PairArray *result;
      if(metaData.getVector(0, consts, result, sortby))
      {
        Print("===== " + metaData.getDimensionTitle(0) + " =====");
        ArrayPrint(result.array);
        parent.accept1D(result, metaData.getDimensionTitle(0));
      }
      parent.finalize();
      return;
    }
    ...

这样做的目的是从元数据对象获取要显示的数据(getDimension()、getDimensionTitle()、getVector())并将它们传递到窗口。上面的片段以使用单个选择器处理案例为特征。对话框类中保留了特殊的数据接收方法:

  void OLAPDialog::accept1D(const PairArray *data, const string title)
  {
    m_plot.CurveAdd(data, title);
  }
  
  void OLAPDialog::accept2D(const double &x[], const double &y[], const string title)
  {
    m_plot.CurveAdd(x, y, title);
  }
  
  void OLAPDialog::finalize()
  {
    m_plot.Refresh();
    m_button_ok.Text("Process");
  }

以下是可以使用OLAPGUI以图形方式显示的分析配置文件示例。

按交易品种排列的利润,降序排列

按交易品种排列的利润,降序排列

按交易品种字母顺序排序的利润

按交易品种字母顺序排序的利润

按交易品种计算的利润,持仓结束的星期几,交易类型“买入”

按交易品种计算的利润,持仓结束的星期几,交易类型“买入”

按交易品种计算的利润,头寸关闭的星期几,交易类型“卖出”

按交易品种计算的利润,头寸关闭的星期几,交易类型“卖出”

按手数计算的利润(手数表示为单元格索引,数值显示在日志中)

按手数计算的利润(手数表示为单元格索引,数值显示在日志中)

总余额曲线

总余额曲线

按买卖操作计算的余额

按买卖操作计算的余额

分别为每个交易品种绘制余额曲线

分别为每个交易品种绘制余额曲线

每个交易品种单独的隔夜息曲线

每个交易品种单独的隔夜息曲线

利润依赖于每个交易品种的交易“持续时间”

利润依赖于每个交易品种的交易“持续时间”

按交易品种和类型划分的交易数量

按交易品种和类型划分的交易数量

每个交易的“利润”和“持续时间”(秒)字段的依赖性

每个交易的“利润”和“持续时间”(秒)字段的依赖性

所有交易 MFE (%) 和 MAE (%) 的依赖性

所有交易 MFE (%) 和 MAE (%) 的依赖性

不幸的是,标准的柱状图绘制样式不提供具有相同索引的不同数组列的偏移量的多个数组的显示。换句话说,具有相同坐标的值可以完全重叠。这个问题可以通过实现自定义的柱状图可视化方法(可以使用 CGraphic 类实现)来解决。但是这个解决方案超出了本文的范围。

结论

在本文中,我们回顾了为MQL程序创建GUI的一般原则,这些原则支持控件的大小调整和通用布局。基于此技术,我们创建了一个用于分析交易报告的交互式应用程序,它使用了OLAP系列第一篇文章的开发成果。各种指标任意组合的可视化有助于识别隐藏模式,简化多准则分析,可用于交易系统的优化。

附件说明见下表。

OLAPGUI 项目

  • Experts/OLAP/OLAPGUI.mq5 — 一个演示EA交易;
  • Experts/OLAP/OLAPGUI.mqh — 图形界面的描述;
  • Include/OLAP/OLAPcore.mqh — 把图形界面与 OLAP 核心绑定;
  • Include/OLAP/OLAPcube.mqh — OLAP 类的主头文件;
  • Include/OLAP/PairArray.mqh — 支持所有排序变化的 [value;name] 对数组;
  • Include/OLAP/HTMLcube.mqh — 把 OLAP 与从 HTML 报告中载入的数据相结合;
  • Include/OLAP/CSVcube.mqh — 把 OLAP 与从 CSV 文件中载入的数据相结合;
  • Include/MT4orders.mqh — MT4orders 开发库, 用于在MT4和MT5中以单一的形式操作订单;
  • Include/Layouts/Box.mqh — 控件的容器;
  • Include/Layouts/ComboBoxResizable.mqh — 下拉控件, 可以动态改变大小;
  • Include/Layouts/MaximizableAppDialog.mqh — 对话框窗口, 可以动态改变大小;
  • Include/PairPlot/Plot.mqh — 图表图形控件, 支持动态改变大小;
  • Include/Marketeer/WebDataExtractor.mqh — HTML 分析器;
  • Include/Marketeer/empty_strings.h — 空白 HTML 标签列表;
  • Include/Marketeer/HTMLcolumns.mqh — 在 HTML 报告中列索引的定义;
  • Include/Marketeer/CSVReader.mqh — CSV 分析器;
  • Include/Marketeer/CSVcolumns.mqh — 在 CSV 报告中列索引的定义;
  • Include/Marketeer/IndexMap.mqh — 辅助头文件,实现了基于键值和索引组合访问的数组;
  • Include/Marketeer/RubbArray.mqh — 辅助头文件, 含有 "橡胶" 数组;
  • Include/Marketeer/TimeMT4.mqh — 辅助头文件, 实现了以 MetaTrader 4 方式处理数据的函数;
  • Include/Marketeer/Converter.mqh — 辅助头文件, 用于转换数据类型;
  • Include/Marketeer/GroupSettings.mqh — 辅助头文件, 包含了输入参数组设置.

SlidingPuzzle3 项目

  • Experts/Examples/Layouts/SlidingPuzzle3.mq5
  • Experts/Examples/Layouts/SlidingPuzzle3.mqh
  • Include/Layouts/GridTk.mqh
  • Include/Layouts/Grid.mqh
  • Include/Layouts/Box.mqh

Controls3 项目

  • Experts/Examples/Layouts/Controls3.mq5
  • Experts/Examples/Layouts/ControlsDialog3.mqh
  • Include/Layouts/Box.mqh
  • Include/Layouts/SpinEditResizable.mqh
  • Include/Layouts/ComboBoxResizable.mqh
  • Include/Layouts/MaximizableAppDialog.mqh

PlotDemo 项目

  • Experts/Examples/Layouts/PlotDemo.mq5
  • Experts/Examples/Layouts/PlotDemo.mqh
  • Include/OLAP/PairArray.mqh
  • Include/Layouts/MaximizableAppDialog.mqh

全部回复

0/140

量化课程

    移动端课程