Применение OLAP в трейдинге (Часть 1): Основы оперативного анализа многомерных данных

3 мая 2019, 22:10
Stanislav Korotky
28
1 940

Трейдерам часто приходится анализировать значительные объемы данных. Как правило, это — числа: котировки, показатели индикаторов, результаты из торговых отчетов. Из-за большого количества параметров и условий, от которых эти числа зависят, разбираться с ними лучше по принципу "разделяй и властвуй", то есть по частям, и рассматривая то под одним, то под другим углом зрения. В некотором смысле весь объем информации формирует виртуальный гиперкуб, в котором каждый параметр определяет собственную размерность, перпендикулярную всем остальным. Для обработки и анализа таких гиперкубов существует широко известная технология — OLAP, Online Analytical Processing.

Слово "онлайн" в названии вовсе не относится к сети Интернет, а означает оперативность (или интерактивность) получения результатов. Принцип действия заключается в предварительном расчете ячеек гиперкуба, после чего можно быстро извлечь и в наглядной форме увидеть любое сечение куба. Для иллюстрации это можно сравнить с процессом оптимизации в MetaTrader: сперва тестер обсчитывает варианты торговли (и это может потребовать долгого времени, т.е. неоперативно), а затем мы получаем отчет, в который сведены показатели в привязке к входным параметрам. Начиная со сборки 1860, MetaTrader 5 позволяет динамически менять просматриваемые результаты оптимизации, переключая различные критерии оптимизации. И это приближает нас к идее OLAP. Но для полноценного анализа хотелось бы иметь возможность оперативно выбирать многие другие сечения гиперкуба.

Сегодня мы попробуем применить подход OLAP в MetaTrader и реализовать многомерный анализ средствами MQL. Но прежде чем приступить, необходимо определиться с тем, какие именно данные мы будем анализировать. Это могут быть, например, торговые отчеты, результаты оптимизации, показатели индикаторов. В принципе, наш выбор на данном этапе не столь важен, потому что разрабатываемый фреймворк должен быть универсальным объекто-ориентированным движком, применимым на любых данных. Однако нам потребуется обкатывать его на чем-то конкретном, и одна из наиболее популярных задач — анализ торгового отчета. Так что остановимся на ней.

Для торгового отчета может быть интересно получить разбивку прибылей по символам, дням недели, покупкам и продажам. Или, например, сравнить показатели нескольких торговых роботов (т.е. раздельно для каждого магического числа). А затем возникнет логичный вопрос: а нельзя ли совместить данные сечения: символы по дням недели в привязке к экспертам или добавить какую-то другую группировку? Да, это все можно будет сделать с помощью OLAP.

Архитектура

Объекто-ориентированный подход подразумевает, что необходимо выполнить декомпозицию задачи на набор простых логически связанных частей, где каждая часть выполняет свою собственную роль, основываясь на входящих данных, внутреннем состоянии и некоторых наборах правил.

Первый класс, который нам потребуется — это запись с исходными данными — Record. Такая запись может хранить информацию, например, об одной торговой операции или об одиночном проходе оптимизации.

Запись — это вектор с произвольным количеством полей. Поскольку это абстрактная сущность, здесь даже не важно, что означает каждое поле. Для каждого конкретного прикладного применения записи будет создан производный класс, который "знает" назначение полей и обрабатывает их соответствующим образом.

Для чтения записей из некоего абстрактного источника (которым в будущем может оказаться история торгового счета, CSV-файл, HTML-отчет или данные из интернета, полученные с помощью WebRequest) требуется другой класс — адаптер данных (DataAdapter). На данном уровне он умеет выполнять только одну функцию — последовательно перебирать записи и предоставлять к ним доступ. Впоследствии для каждого реального применения мы сможем создать производные классы, заполняющие массивы записей из соответствующих источников.

Все записи должны каким-то образом отображаться в ячейки гиперкуба. Мы еще не знаем, как это сделать, но в этом суть всего проекта — распределить входные значения из полей записей по ячейкам куба и посчитать для них обобщенную статистику с помощью выбранных агрегатных функций.

На базовом уровне куб предоставляет лишь основные свойства, такие как количество измерений, их имена, размер по каждому измерению. Все это сведено в классе MetaCube.

Производные классы заполнят ячейки специфической для них статистикой. В качестве наиболее понятных примеров конкретных агрегаторов можно привести сумму всех значений или среднее одного и того же поля по всем записям, но на самом деле разных типов агрегаторов будет гораздо больше.

Для того чтобы значения могли агрегироваться в ячейках, каждая запись должна получить набор индексов, однозначным образом отображающих её в некоторую ячейку куба. Эту подзадачу делегируем специальному классу — селектору (Selector). Селектор соответствует одной стороне (оси, координате) гиперкуба.

Абстрактный базовый класс селектора предоставит программный интерфейс для определения набора допустимых значений и отображения каждой записи в одно из этих значений. Например, если требуется разбить записи по дням недели, то производный класс селектора в своей реализации должен возвращать номер дня недели — число от 0 до 6. Количество допустимых значений конкретного селектора задает размер куба по этому измерению. В случае дня недели, это, очевидно, 7.

Кроме этого, иногда бывает полезно отфильтровать некоторые записи (вывести их из рассмотрения). Поэтому нам потребуется класс фильтра (Filter). Он во многом похож на селектор, но налагает дополнительные ограничения на допустимые значения. Например, продолжая пример с селектором по дням недели, мы могли бы создать на его основе фильтр, в котором указать, какие дни исключить или наоборот включить в расчеты.

После того как куб будет построен (т.е. посчитаны агрегатные функции для всех ячеек), потребуется визуализировать и проанализировать результат. Для этого зарезервируем специальный класс — Display.

Наконец, для соединения всех вышеперечисленных классов в единое целое создадим своего рода центр управления — класс Analyst.

Все вместе в UML нотации выглядит примерно так (своего рода, план действий, с которым можно сверяться на любом этапе разработки).

Online Analytical Processing в MetaTrader

Online Analytical Processing в MetaTrader

Кое-какие классы здесь опущены, но в целом по этой схеме уже можно составить представление, по каким признакам предполагается строить гиперкуб и какие агрегатные функции будут доступны для расчета в его ячейках.

Реализация базовых классов

Начнем воплощение классов описанных выше. Сперва — класс Record.

  class Record
  {
    private:
      double data[];
      
    public:
      Record(const int length)
      {
        ArrayResize(data, length);
        ArrayInitialize(data, 0);
      }
      
      void set(const int index, double value)
      {
        data[index] = value;
      }
      
      double get(const int index) const
      {
        return data[index];
      }
  };

Он просто хранит произвольные значения в массиве (векторе) data. Длина вектора задается в конструкторе.

Читать записи из разных источников будем с помощью DataAdapter.

  class DataAdapter
  {
    public:
      virtual Record *getNext() = 0;
      virtual int reservedSize() = 0;
  };

Метод getNext должен вызываться в цикле до тех пор, пока не вернет NULL (что означает — записей больше нет). Все полученные записи должны быть где-то сохранены (мы этим займемся чуть ниже). Метод reservedSize позволяет оптимизировать распределение памяти (если количество записей в источнике заранее известно).

Каждая размерность гиперкуба рассчитывается на основе одного или нескольких полей записей. Удобно обозначить каждое поле как элемент перечисления. Например, для случая анализа торговой истории счета, можно предложить следующее перечисление.

  // MT4 and MT5 hedge
  enum TRADE_RECORD_FIELDS
  {
    FIELD_NONE,          // none
    FIELD_NUMBER,        // serial number
    FIELD_TICKET,        // ticket
    FIELD_SYMBOL,        // symbol
    FIELD_TYPE,          // type (OP_BUY/OP_SELL)
    FIELD_DATETIME1,     // open datetime
    FIELD_DATETIME2,     // close datetime
    FIELD_DURATION,      // duration
    FIELD_MAGIC,         // magic number
    FIELD_LOT,           // lot
    FIELD_PROFIT_AMOUNT, // profit amount
    FIELD_PROFIT_PERCENT,// profit percent
    FIELD_PROFIT_POINT,  // profit points
    FIELD_COMMISSION,    // commission
    FIELD_SWAP,          // swap
    FIELD_CUSTOM1,       // custom 1
    FIELD_CUSTOM2        // custom 2
  };

Два последних поля предусмотрены для расчета нестандартных показателей.

А если бы мы анализировали результаты оптимизации MetaTrader, то могли бы использовать такое перечисление.

  enum OPTIMIZATION_REPORT_FIELDS
  {
    OPTIMIZATION_PASS,
    OPTIMIZATION_PROFIT,
    OPTIMIZATION_TRADE_COUNT,
    OPTIMIZATION_PROFIT_FACTOR,
    OPTIMIZATION_EXPECTED_PAYOFF,
    OPTIMIZATION_DRAWDOWN_AMOUNT,
    OPTIMIZATION_DRAWDOWN_PERCENT,
    OPTIMIZATION_PARAMETER_1,
    OPTIMIZATION_PARAMETER_2,
    //...
  };

Для каждого прикладного применения следует разработать свое перечисление. Затем оно может использоваться как параметризующий параметр шаблонного класса Selector.

  template<typename E>
  class Selector
  {
    protected:
      E selector;
      string _typename;
      
    public:
      Selector(const E field): selector(field)
      {
        _typename = typename(this);
      }
      
      // returns index of cell to store values from the record
      virtual bool select(const Record *r, int &index) const = 0;
      
      virtual int getRange() const = 0;
      virtual float getMin() const = 0;
      virtual float getMax() const = 0;
      
      virtual E getField() const
      {
        return selector;
      }
      
      virtual string getLabel(const int index) const = 0;
      
      virtual string getTitle() const
      {
        return _typename + "(" + EnumToString(selector) + ")";
      }
  };

Поле selector хранит одно значение — элемент перечисления. Например, если используется TRADE_RECORD_FIELDS, то создать селектор для операций покупки/продажи можно таким образом:

  new Selector<TRADE_RECORD_FIELDS>(FIELD_TYPE);

Поле _typename — вспомогательное. Оно будет перезаписано во всех производных классах для идентификации селекторов, что полезно при визуализации результатов. Поле используется в виртуальном методе getTitle.

Основная работа выполняется классом в методе select. Именно здесь каждая входная запись отображается в конкретное значение индекса по оси координат, формируемой текущим селектором. Индекс должен быть в диапазоне между значениями, возвращаемыми методами getMin и getMax, а общее число индексов — равно числу, возвращаемому методом getRange. Если данная запись не может по каким-то причинам быть корректно отображена в область определения селектора, метод select возвращает false. Если же отображение успешно, возвращается true.

Метод getLabel возвращает понятное пользователю описание конкретного индекса. Например, для операций покупки/продажи, индекс 0 должен генерировать "buy", а индекс 1 — "sell".

Реализация классов конкретных селекторов и адаптера данных для торговой истории

Поскольку мы собираемся сконцентрироваться на анализе торговой истории, введем промежуточный класс селекторов на базе перечисления TRADE_RECORD_FIELDS.

  class TradeSelector: public Selector<TRADE_RECORD_FIELDS>
  {
    public:
      TradeSelector(const TRADE_RECORD_FIELDS field): Selector(field)
      {
        _typename = typename(this);
      }
  
      virtual bool select(const Record *r, int &index) const
      {
        index = 0;
        return true;
      }
      
      virtual int getRange() const
      {
        return 1; // this is a scalar by default, returns 1 value
      }
      
      virtual double getMin() const
      {
        return 0;
      }
      
      virtual double getMax() const
      {
        return (double)(getRange() - 1);
      }
      
      virtual string getLabel(const int index) const
      {
        return EnumToString(selector) + "[" + (string)index + "]";
      }
  };

По умолчанию он отображает все записи в одну и ту же ячейку. С помощью такого селектора можно, например, получить общую прибыль.

Теперь на базе данного селектора легко определять специфические производные типы селекторов, в частности, опять же для деления записей по типу операции (покупка/продажа).

  class TypeSelector: public TradeSelector
  {
    public:
      TypeSelector(): TradeSelector(FIELD_TYPE)
      {
        _typename = typename(this);
      }
  
      virtual bool select(const Record *r, int &index) const
      {
        ...
      }
      
      virtual int getRange() const
      {
        return 2; // OP_BUY, OP_SELL
      }
      
      virtual double getMin() const
      {
        return OP_BUY;
      }
      
      virtual double getMax() const
      {
        return OP_SELL;
      }
      
      virtual string getLabel(const int index) const
      {
        const static string types[2] = {"buy", "sell"};
        return types[index];
      }
  };

Мы определили класс, используя элемент FIELD_TYPE в конструкторе. Метод getRange возвращает 2, так как мы здесь имеем только 2 возможных значения типа: OP_BUY или OP_SELL. Методы getMin и getMax возвращают соответствующие константы. А что же должно быть в методе select?

Чтобы ответить на этот вопрос, нужно решить, какая информация будет храниться в каждой записи. Сделаем это с помощью класса TradeRecord, производного от Record и адаптированного для работы с торговой историей.

  class TradeRecord: public Record
  {
    private:
      static int counter;
  
    protected:
      void fillByOrder()
      {
        set(FIELD_NUMBER, counter++);
        set(FIELD_TICKET, OrderTicket());
        set(FIELD_TYPE, OrderType());
        set(FIELD_DATETIME1, OrderOpenTime());
        set(FIELD_DATETIME2, OrderCloseTime());
        set(FIELD_DURATION, OrderCloseTime() - OrderOpenTime());
        set(FIELD_MAGIC, OrderMagicNumber());
        set(FIELD_LOT, (float)OrderLots());
        set(FIELD_PROFIT_AMOUNT, (float)OrderProfit());
        set(FIELD_PROFIT_POINT, (float)((OrderType() == OP_BUY ? +1 : -1) * (OrderClosePrice() - OrderOpenPrice()) / SymbolInfoDouble(OrderSymbol(), SYMBOL_POINT)));
        set(FIELD_COMMISSION, (float)OrderCommission());
        set(FIELD_SWAP, (float)OrderSwap());
      }
      
    public:
      TradeRecord(): Record(TRADE_RECORD_FIELDS_NUMBER)
      {
        fillByOrder();
      }
  };

Вспомогательный метод fillByOrder демонстрирует, как большинство полей записи может заполняться на основе текущего ордера. Разумеется, ордер должен быть предварительно выбран где-то в другом месте кода. Здесь мы используем нотацию торговых функций MetaTrader 4, а поддержку MetaTrader 5 обеспечим за счет включения библиотеки MT4Orders (одна из версий прилагается в конце статьи, всегда проверяйте и скачивайте актуальную версию)  — таким образом добьемся кроссплатформенности кода.

Количество полей TRADE_RECORD_FIELDS_NUMBER может быть либо жестко зашито в коде в виде макроопределения, либо рассчитываться динамически, исходя из мощности перечисления TRADE_RECORD_FIELDS. В прилагаемом исходном коде использован второй подход, для чего используется специальная шаблонизированная функция EnumToArray.

Как видно из кода метода fillByOrder, поле FIELD_TYPE заполняется типом операции из OrderType. Теперь мы можем вернуться к классу TypeSelector и реализовать его метод select.

    virtual bool select(const Record *r, int &index) const
    {
      index = (int)r.get(selector);
      return index >= getMin() && index <= getMax();
    }

Здесь мы читаем значение поля (selector) из переданной на вход записи (r) и присваиваем его значение (которое может быть OP_BUY или OP_SELL) выходному параметру index. В расчет берутся только рыночные ордера, поэтому для всех остальных типов возвращается false. Чуть позже мы рассмотрим другие типы селекторов.

Пришло время разработать адаптер данных для торговой истории. Это тот класс, в котором записи TradeRecord будут генерироваться на основе реальной торговой истории счета.

  class HistoryDataAdapter: public DataAdapter
  {
    private:
      int size;
      int cursor;
      
    protected:
      void reset()
      {
        cursor = 0;
        size = OrdersHistoryTotal();
      }
      
    public:
      HistoryDataAdapter()
      {
        reset();
      }
      
      virtual int reservedSize()
      {
        return size;
      }
      
      virtual Record *getNext()
      {
        if(cursor < size)
        {
          while(OrderSelect(cursor++, SELECT_BY_POS, MODE_HISTORY))
          {
            if(OrderType() < 2)
            {
              return new TradeRecord();
            }
          }
          return NULL;
        }
        return NULL;
      }
  };

Адаптер последовательно проходит по всем ордерам в истории и создает экземпляр TradeRecord для каждого рыночного ордера. Приведенный здесь код несколько упрощен. На практике нам может потребоваться создавать объекты не класса TradeRecord, а какого-то производного от него, тем более что в перечислении TRADE_RECORD_FIELDS мы зарезервировали два кастом-поля. В связи с этим класс HistoryDataAdapter на самом деле является шаблонным, и параметром шаблона является актуальный класс генерируемых объектов-записей. Для заполнения кастом-полей в классе Record предусмотрен пустой виртуальный метод:

    virtual void fillCustomFields() {/* does nothing */};

Полную реализацию подхода можно изучить самостоятельно: в ядре используется класс CustomTradeRecord (унаследованный от TradeRecord), который в методе fillCustomFields вычисляет MFE (Maximum Favorable Excursion, максимальную плавающую прибыль) и MAE (Maximum Adverse Excursion, максимальный плавающий убыток) в процентах для каждой позиции и записывает их соответственно в поля FIELD_CUSTOM1 и FIELD_CUSTOM2.

Реализация агрегаторов и управляющего класса

Разумеется, потребуется где-то создавать сам адаптер и вызывать его метод getNext. Таким образом, мы добрались до "центра управления" — класса Analyst. Помимо запуска адаптера он должен сохранять во внутреннем массиве полученные записи.

  template<typename E>
  class Analyst
  {
    private:
      DataAdapter *adapter;
      Record *data[];
      
    public:
      Analyst(DataAdapter &a): adapter(&a)
      {
        ArrayResize(data, adapter.reservedSize());
      }
      
      ~Analyst()
      {
        int n = ArraySize(data);
        for(int i = 0; i < n; i++)
        {
          if(CheckPointer(data[i]) == POINTER_DYNAMIC) delete data[i];
        }
      }
      
      void acquireData()
      {
        Record *record;
        int i = 0;
        while((record = adapter.getNext()) != NULL)
        {
          data[i++] = record;
        }
        ArrayResize(data, i);
      }
  };

Класс не создает адаптер сам, а принимает его уже готовым в качестве параметра конструктора. Это — широко известный принцип проектирования под названием "внедрение зависимости" (dependency injection). Он позволяет отвязать Analyst от конкретной реализации DataAdapter. Другими словами, мы сможем свободно заменять различные варианты адаптера без необходимости модификаций в классе Analyst.

Класс Analyst сейчас способен заполнить внутренний массив записей, но еще не "умеет" выполнять главную функцию — агрегировать данные. Делать это он будет не сам, а делегирует задачу агрегатору.

Напомним, что агрегаторы — это классы, способные рассчитывать предопределенные показатели (статистику) для выбранных полей записей. Базовым классом для агрегаторов будет MetaCube — хранилище на основе многомерного массива.

  class MetaCube
  {
    protected:
      int dimensions[];
      int offsets[];
      double totals[];
      string _typename;
      
    public:
      int getDimension() const
      {
        return ArraySize(dimensions);
      }
      
      int getDimensionRange(const int n) const
      {
        return dimensions[n];
      }
      
      int getCubeSize() const
      {
        return ArraySize(totals);
      }
      
      virtual double getValue(const int &indices[]) const = 0;
  };

Массив dimensions описывает структуру гиперкуба. Его размер равен количеству используемых селекторов, то есть измерений. Каждый элемент массива dimensions содержит размер куба в данном измерении, что определяется диапазоном значений соответстующего селектора. Например, если мы хотим увидеть прибыли по дням недели, потребуется создать селектор, возвращающий номер дня как индекс от 0 до 6, согласно времени открытия или закрытия ордера (позиции). Поскольку это единственный селектор, массив dimensions будет иметь 1 элемент, а его значение — 7. Если мы захотим добавить другой селектор, например, TypeSelector, описанный ранее, чтобы увидеть прибыли в двойной разбивке — и по дням недели, и по типам операций, то массив dimensions будет содержать 2 элемента со значениями 7 и 2. Это также означает, что всего в гиперкубе будет 14 ячеек со статистикой.

Непосредственно массив со всеми значениями (в рассмотренном примере — 14 значений) содержится в totals. Поскольку гиперкуб является многомерным, на первый взгляд кажется странным, что массив объявлен с одним измерением. Это сделано так потому, что мы не знаем заранее размерностей гиперкуба, которые захочет добавить пользователь. Кроме того, MQL не поддерживает многомерные массивы, в которых абсолютно все измерения распределялись бы динамически. Поэтому используется обычный "плоский" массив (вектор), а для хранения в нем ячеек по нескольким размерностям потребуется применять хитрую индексацию. Далее мы покажем, как производится расчет смещений для каждой размерности.

Базовый класс не распределяет и не инициализирует массивы — все это отдано на откуп производным классам.

Поскольку все агрегаторы будут иметь много общих черт, упакуем их в единый промежуточный класс.

  template<typename E>
  class Aggregator: public MetaCube
  {
    protected:
      const E field;

Каждый агрегатор обрабатывает конкретное поле записей, оно задается в классе в переменной field, которая заполняется в конструкторе (см. ниже). Например, это может быть прибыль (FIELD_PROFIT_AMOUNT).

      const int selectorCount;
      const Selector<E> *selectors[];

Вычисления выполняются в многомерном пространстве, формируемом произвольным количеством селекторов (selectorCount). Мы ранее рассматривали пример расчета прибылей в разбивке по дням недели и типам операции, что требует двух селекторов. Они хранятся в массиве ссылок selectors. Непосредственно объекты селекторов передаются опять-таки как параметры конструктора.

    public:
      Aggregator(const E f, const Selector<E> *&s[]): field(f), selectorCount(ArraySize(s))
      {
        ArrayResize(selectors, selectorCount);
        for(int i = 0; i < selectorCount; i++)
        {
          selectors[i] = s[i];
        }
        _typename = typename(this);
      }

Как вы помните, массив для хранения рассчитываемых значений totals является одномерным. Для преобразования индексов многомерного пространства селекторов в смещение в одномерном массиве используется следующая функция.

      int mixIndex(const int &k[]) const
      {
        int result = 0;
        for(int i = 0; i < selectorCount; i++)
        {
          result += k[i] * offsets[i];
        }
        return result;
      }

Она принимает на вход массив с индексами и возвращает сквозной номер элемента. Здесь используется массив offsets, который к этому моменту уже должен быть заполнен. Его инициализация — один из ключевых моментов — осуществляется в методе setSelectorBounds.

      virtual void setSelectorBounds()
      {
        ArrayResize(dimensions, selectorCount);
        int total = 1;
        for(int i = 0; i < selectorCount; i++)
        {
          dimensions[i] = selectors[i].getRange();
          total *= dimensions[i];
        }
        ArrayResize(totals, total);
        ArrayInitialize(totals, 0);
        
        ArrayResize(offsets, selectorCount);
        offsets[0] = 1;
        for(int i = 1; i < selectorCount; i++)
        {
          offsets[i] = dimensions[i - 1] * offsets[i - 1]; // 1, X, Y*X
        }
      }

Его суть — в получении диапазонов всех селекторов и их последовательном перемножении: таким образом определяется количество элементов, через которые необходимо "перескакивать" при увеличении координаты на единицу в каждом измерении гиперкуба.

Непосредственно расчет агрегированных показателей выполняется в методе calculate.

      // build an array with number of dimensions equal to number of selectors
      virtual void calculate(const Record *&data[])
      {
        int k[];
        ArrayResize(k, selectorCount);
        int n = ArraySize(data);
        for(int i = 0; i < n; i++)
        {
          int j = 0;
          for(j = 0; j < selectorCount; j++)
          {
            int d;
            if(!selectors[j].select(data[i], d)) // does record satisfy selector?
            {
              break;                             // skip it, if not
            }
            k[j] = d;
          }
          if(j == selectorCount)
          {
            update(mixIndex(k), data[i].get(field));
          }
        }
      }

Данный метод вызывается для массива записей. Каждая запись в цикле передается по очереди в каждый селектор, и если она успешно отображена в допустимые индексы во всех селекторах (в каждом селекторе — свой индекс), то полный набор индексов сохраняется в локальном массиве k. Если все селекторы определили индексы, вызывается метод update. Он принимает на вход сквозное смещение в массиве totals (смещение рассчитывается функцией mixIndex, показанной ранее) и значение заданного поля field (оно задано в агрегаторе) из текущей записи. Если продолжать пример с анализом распределения прибылей, то переменная field будет равна FIELD_PROFIT_AMOUNT, и значения этого поля будут браться из вызова OrderProfit().

      virtual void update(const int index, const float value) = 0;

Метод update — абстрактный в данном классе и должен быть обязательно переопределен в наследниках.

Наконец, агрегатор должен предоставить хотя бы один метод для доступа к результатам расчетов. Самый простой из них — получение значения конкретной ячейки по полному набору индексов.

      double getValue(const int &indices[]) const
      {
        return totals[mixIndex(indices)];
      }
  };

Базовый класс Aggregator выполняет почти всю черновую работу. Теперь мы можем реализовать много конкретных агрегаторов с минимумом усилий.

Но прежде вернемся ненадолго к классу Analyst и дополним его ссылкой на агрегатор, которую также будем передавать через параметр конструктора.

  template<typename E>
  class Analyst
  {
    private:
      DataAdapter *adapter;
      Record *data[];
      Aggregator<E> *aggregator;
      
    public:
      Analyst(DataAdapter &a, Aggregator<E> &g): adapter(&a), aggregator(&g)
      {
        ArrayResize(data, adapter.reservedSize());
      }

В методе acquireData произведем настройку размеров гиперкуба с помощью дополнительного вызова метода setSelectorBounds агрегатора.

    void acquireData()
    {
      Record *record;
      int i = 0;
      while((record = adapter.getNext()) != NULL)
      {
        data[i++] = record;
      }
      ArrayResize(data, i);
      aggregator.setSelectorBounds(i);
    }

Основную задачу, то есть обсчет всех значений гиперкуба, как и было предусмотрено, делегируем агрегатору (метод calculate мы уже рассмотрели выше, а здесь ему передается массив записей).

    void build()
    {
      aggregator.calculate(data);
    }

Но это еще не весь класс Analyst. Ранее мы планировали наделить его способностью выводить результаты, формализовав её в виде специального интерфейса Display. Интерфейс подключается к Analyst похожим образом (через передачу ссылки в конструктор):

  template<typename E>
  class Analyst
  {
    private:
      ...
      Display *output;
      
    public:
      Analyst(DataAdapter &a, Aggregator<E> &g, Display &d): adapter(&a), aggregator(&g), output(&d)
      {
        ...
      }
      
      void display()
      {
        output.display(aggregator);
      }
  };

Состав Display довольно прост:

  class Display
  {
    public:
      virtual void display(MetaCube *metaData) = 0;
  };

Он содержит абстрактный виртуальный метод, принимающий на вход гиперкуб как источник данных. Здесь для краткости опущены некоторые параметры, влияющие на порядок печати значений. Специфические нюансы визуализации и необходимые дополнительные настройки будут появляться в производных классах.

Для проверки работоспособности аналитических классов нам необходимо иметь хотя бы одну реализацию интерфейса Display. Создадим её с помощью вывода сообщений в журнал экспертов и назовем LogDisplay. Она будет в цикле обходить гиперкуб по всем координатам и печатать агрегированные значения вместе с соответствующими координатами, примерно следующим образом.

  class LogDisplay: public Display
  {
    public:
      virtual void display(MetaCube *metaData) override
      {
        int n = metaData.getDimension();
        int indices[], cursors[];
        ArrayResize(indices, n);
        ArrayResize(cursors, n);
        ArrayInitialize(cursors, 0);
  
        for(int i = 0; i < n; i++)
        {
          indices[i] = metaData.getDimensionRange(i);
        }
        
        bool looping = false;
        int count = 0;
        do
        {
          ArrayPrint(cursors);
          Print(metaData.getValue(cursors));
  
          for(int i = 0; i < n; i++)
          {
            if(cursors[i] < indices[i] - 1)
            {
              looping = true;
              cursors[i]++;
              break;
            }
            else
            {
              cursors[i] = 0;
            }
            looping = false;
          }
        }
        while(looping && !IsStopped());
      }
  };

Я написал "примерно", потому что, на самом деле, для более удобочитаемого форматирования логов реализация LogDisplay немного сложнее, но это несущественно. Полная версия класса имеется в исходных кодах.

Разумеется, это в любом случае не так наглядно как график, но построение двумерных или трехмерных изображений — это целая отдельная история, так что оставим её на время за кадром, тем более что применить можно самые разные технологии — объекты, канву, внешние графические библиотеки, включая и те, что построены на веб-технологиях.

Итак, у нас имеется базовый класс Aggregator. На его основе легко получить несколько производных классов со специфическими расчетами агрегированных показателей в методе update. В частности, чтобы подсчитать сумму значений, извлекаемых неким селектором из всех записей, достаточно написать:

  template<typename E>
  class SumAggregator: public Aggregator<E>
  {
    public:
      SumAggregator(const E f, const Selector<E> *&s[]): Aggregator(f, s)
      {
        _typename = typename(this);
      }
      
      virtual void update(const int index, const float value) override
      {
        totals[index] += value;
      }
  };

А чтобы рассчитать среднее, потребуется лишь незначительное усложнение:

  template<typename E>
  class AverageAggregator: public Aggregator<E>
  {
    protected:
      int counters[];
      
    public:
      AverageAggregator(const E f, const Selector<E> *&s[]): Aggregator(f, s)
      {
        _typename = typename(this);
      }
      
      virtual void setSelectorBounds() override
      {
        Aggregator<E>::setSelectorBounds();
        ArrayResize(counters, ArraySize(totals));
        ArrayInitialize(counters, 0);
      }
  
      virtual void update(const int index, const float value) override
      {
        totals[index] = (totals[index] * counters[index] + value) / (counters[index] + 1);
        counters[index]++;
      }
  };

Рассмотрев, наконец, все задействованные классы, обобщим алгоритм их взаимодействия:

  • Создаем объект HistoryDataAdapter;
  • Создаем несколько конкретных селекторов (каждый селектор привязан как минимум к одному полю, например, к типу торговой операции) и сохраняем в массив;
  • Создаем объект конкретного агрегатора, например, SumAggregator, передавая ему массив селекторов и обозначение поля, по которому следует выполнять агрегирование;
  • Создаем объект LogDisplay;
  • Создаем объект Analyst с использованием объектов адаптера, агрегатора и дисплея;
  • Вызываем последовательно:
      analyst.acquireData();
      analyst.build();
      analyst.display();
  • В конце не забываем удалить объекты.

Особый случай: динамические селекторы

Наша программа уже почти готова. Но есть одно маленькое упущение, которое было сделано намеренно в целях упрощения изложения, а теперь настало время его ликвидировать.

Все селекторы, с которыми мы познакомились до сих пор, имели постоянный диапазон значений. Например, в неделе всегда 7 дней, а рыночные ордера всего либо на покупку, либо на продажу. Однако диапазон может быть неизвестен заранее, и такое встречается довольно часто.

Вполне обоснованно желание построить гиперкуб в разрезе рабочих символов или магических номеров экспертов. Для решения этой задачи нам потребуется сперва собрать все уникальные символы или "магики" в некоем внутреннем массиве, а затем использовать его размер как диапазон соответствующего селектора.

Создадим выделенный класс Vocabulary для управления подобными внутренними массивами и покажем, как его применять, например, в связке с классом SymbolSelector.

Наша реализация словаря довольно прямолинейна (вы можете заменить её на любую предпочтительную).

  template<typename T>
  class Vocabulary
  {
    protected:
      T index[];

Мы резервируем массив index для хранения уникальных значений.

    public:
      int get(const T &text) const
      {
        int n = ArraySize(index);
        for(int i = 0; i < n; i++)
        {
          if(index[i] == text) return i;
        }
        return -(n + 1);
      }

Метод get позволяет проверить, есть ли конкретное значение уже в массиве. Если есть, метод возвращает найденный индекс. Если нет, метод возвращает увеличенный на 1 текущий размер массива со знаком минус. Это позволяет слегка оптимизировать следующий метод для добавления нового значения в массив.

      int add(const T text)
      {
        int n = get(text);
        if(n < 0)
        {
          n = -n;
          ArrayResize(index, n);
          index[n - 1] = text;
          return n - 1;
        }
        return n;
      }

Также мы должны предоставить методы для получения размера массива и хранимых в нем значений по индексу.

      int size() const
      {
        return ArraySize(index);
      }
      
      T operator[](const int slot) const
      {
        return index[slot];
      }
  };

Поскольку рабочие символы в нашем случае анализируются в контексте ордеров (позиций), встроим словарь в класс TradeRecord.

  class TradeRecord: public Record
  {
    private:
      ...
      static Vocabulary<string> symbols;
  
    protected:
      void fillByOrder(const double balance)
      {
        ...
        set(FIELD_SYMBOL, symbols.add(OrderSymbol())); // symbols are stored as indices from vocabulary
      }
  
    public:
      static int getSymbolCount()
      {
        return symbols.size();
      }
      
      static string getSymbol(const int index)
      {
        return symbols[index];
      }
      
      static int getSymbolIndex(const string s)
      {
        return symbols.get(s);
      }

Словарь описан как статическая переменная, так как он является общим для всей торговой истории.

Теперь мы можем реализовать SymbolSelector.

  class SymbolSelector: public TradeSelector
  {
    public:
      SymbolSelector(): TradeSelector(FIELD_SYMBOL)
      {
        _typename = typename(this);
      }
      
      virtual bool select(const Record *r, int &index) const override
      {
        index = (int)r.get(selector);
        return (index >= 0);
      }
      
      virtual int getRange() const override
      {
        return TradeRecord::getSymbolCount();
      }
      
      virtual string getLabel(const int index) const override
      {
        return TradeRecord::getSymbol(index);
      }
  };

Селектор для магических чисел устроен аналогичным образом.

Общий список предоставляемых селекторов включает (в скобках указана необходимость внешней привязки к полю, а если она опущена, то это значит, что внутри класса селектора уже есть привязка к конкретному полю):

  • TradeSelector (любое поле) — скаляр, одно значение (обобщение по всем записям в случае "настоящих" агрегаторов или значение поля конкретной записи в случае IdentityAggregator (см. далее));
  • TypeSelector — покупка или продажа в зависимости от OrderType();
  • WeekDaySelector (поле типа datetime) — день недели, например, в OrderOpenTime() или OrderCloseTime();
  • DayHourSelector (поле типа datetime) — час внутри дня;
  • HourMinuteSelector (поле типа datetime) — минута внутри часа;
  • SymbolSelector — рабочий символ, индекс в словаре уникальных OrderSymbol();
  • SerialNumberSelector — порядковый номер записи (ордера);
  • MagicSelector — магическое число, индекс в словаре уникальных OrderMagicNumber();
  • ProfitableSelector — true = прибыль, false = убыток, из поля OrderProfit();
  • QuantizationSelector (поле типа double) — словарь произвольных значений типа double (например, размеров лота);
  • DaysRangeSelector — пример пользовательского селектора на двух полях типа datetime (OrderCloseTime() и OrderOpenTime()), построенного на базе класса DateTimeSelector — общего предка всех селекторов для полей типа datetime; в отличие от остальных селекторов, определенных в ядре, данный селектор реализован в демонстрационном эксперте (см. далее).

Существенно отличается от других селектор SerialNumberSelector. Его диапазон равен общему количеству записей. Это позволяет формировать гиперкуб, в котором по одному из измерений (как правило, первому, то есть X) последовательно отсчитываются сами записи, а по второму копируются заданные поля. Поля определяются селекторами: в специализированных селекторах привязка к полю уже есть, а если требуется поле, для которого нет готового селектора, такое как swap, то можно использовать универсальный TradeSelector. Иными словами, с помощью SerialNumberSelector получается возможность в рамках метафоры агрегирующего гиперкуба прочитать исходные данные записей. Для этой цели предназначен псевдо-агрегатор IdentityAggregator (см. далее).

Среди агрегаторов доступны:

  • SumAggregator — сумма значений поля;
  • AverageAggregator — среднее значение поля;
  • MaxAggregator — максимальное значение поля;
  • MinAggregator — минимальное значение поля;
  • CountAggregator — количество записей;
  • ProfitFactorAggregator — отношение суммы положительных значений поля к сумме отрицательных значений поля;
  • IdentityAggregator (SerialNumberSelector по оси X) — особый тип селектора для "прозрачного" копирования значений полей в гиперкуб, без агрегирования;
  • ProgressiveTotalAggregator (SerialNumberSelector по оси X) — нарастающий итог по полю;

Два последних агрегатора отличаются от остальных. Когда выбран IdentityAggregator, размер гиперкуба всегда равен 2. По первой оси X предполагается перебор записей с помощью SerialNumberSelector, а по второй оси каждый отсчет (фактически вектор/колонка) соответствует одному селектору, с помощью которого определяется поле, которое необходимо прочитать из исходных записей. Таким образом, если дополнительных селекторов (помимо SerialNumberSelector) — 3, то по оси Y будет 3 отсчета. Однако размерность куба — по-прежнему 2: оси X и Y. Напомним, что в штатном режиме гиперкуб строится по другому принципу: каждый селектор соответствует своей размерности, поэтому если их — 3, то и осей будет 3.

Агрегатор ProgressiveTotalAggregator тоже особым образом трактует первую размерность. Как следует из его названия, он позволяет рассчитать нарастающий итог, причем делает это вдоль оси X. Например, если в параметре этого агрегатора указать поле прибыли, то получим общую кривую баланса. Если во втором селекторе (по оси Y) указать разбивку по символам (SymbolSelector), получим несколько [N] кривых баланса — каждая для своего символа. Если же вторым селектором будет MagicSelector, получим раздельные [M] кривые баланса разных экспертов. Но можно обе разбивки совместить, выбрав SymbolSelector по Y, а MagicSelector по Z (или наоборот), и тогда получим [N*M] кривых баланса, разделенных и признаком рабочего символа и кодом эксперта.

В принципе, движок OLAP готов к работе. В целях сокращения изложения мы опустили некоторые нюансы. Например, в статье не описаны фильтры (классы Filter, FilterRange), которые были представлены в архитектуре. Кроме того, наш гиперкуб умеет отдавать агрегированные значения не только по одному (метод getValue(const int &indices[])), но и в виде вектора — для этого реализован метод:

  virtual bool getVector(const int dimension, const int &consts[], PairArray *&result, const SORT_BY sortby = SORT_BY_NONE)

В качестве выходного параметра здесь выступает специальный класс PairArray. В нем хранится массив структур с парами [значение;название]. Например, если мы построили куб с прибылью по символам, то каждая сумма соответствует конкретному символу, и потому его название указывается в паре рядом со значением. Как видно из прототипа метода, ядро умеет сортировать PairArray в разных режимах — по возрастанию или по убыванию, по значениям или по меткам:

  enum SORT_BY // applicable only for 1-dimensional cubes
  {
    SORT_BY_NONE,             // none
    SORT_BY_VALUE_ASCENDING,  // value (ascending)
    SORT_BY_VALUE_DESCENDING, // value (descending)
    SORT_BY_LABEL_ASCENDING,  // label (ascending)
    SORT_BY_LABEL_DESCENDING  // label (descending)
  };

Сортировка поддерживается только в одномерных гиперкубах. Гипотетически, её можно было бы реализовать и для произвольной размерности, но это довольно рутинная работа. Желающие могут ею заняться.

Полные исходные коды прилагаются.

Пример OLAPDEMO

Давайте попробуем гиперкуб в действии. Для этого создадим неторгующий эксперт, способный провести аналитическую обработку торговой истории счета. Назовем его OLAPDEMO. Включим заголовочный файл, в котором находятся все основные классы OLAP.

  #include <OLAPcube.mqh>

Хотя гиперкуб может обрабатывать произвольное количество измерений, ограничим для простоты их количество тремя. Это означает, что пользователь сможет выбрать до 3 селекторов одновременно. Обозначим типы поддерживаемых селекторов элементами специального перечисления:

  enum SELECTORS
  {
    SELECTOR_NONE,       // none
    SELECTOR_TYPE,       // type
    SELECTOR_SYMBOL,     // symbol
    SELECTOR_SERIAL,     // ordinal
    SELECTOR_MAGIC,      // magic
    SELECTOR_PROFITABLE, // profitable
    /* custom selector */
    SELECTOR_DURATION,   // duration in days
    /* all the next require a field as parameter */
    SELECTOR_WEEKDAY,    // day-of-week(datetime field)
    SELECTOR_DAYHOUR,    // hour-of-day(datetime field)
    SELECTOR_HOURMINUTE, // minute-of-hour(datetime field)
    SELECTOR_SCALAR,     // scalar(field)
    SELECTOR_QUANTS      // quants(field)
  };

Используем перечисление для описания входных переменных, задающих селекторы:

  sinput string X = "————— X axis —————";
  input SELECTORS SelectorX = SELECTOR_SYMBOL;
  input TRADE_RECORD_FIELDS FieldX = FIELD_NONE /* field does matter only for some selectors */;
  
  sinput string Y = "————— Y axis —————";
  input SELECTORS SelectorY = SELECTOR_NONE;
  input TRADE_RECORD_FIELDS FieldY = FIELD_NONE;
  
  sinput string Z = "————— Z axis —————";
  input SELECTORS SelectorZ = SELECTOR_NONE;
  input TRADE_RECORD_FIELDS FieldZ = FIELD_NONE;

В группе каждого селектора есть входная переменная для задания опционального поля записи (некоторые селекторы требуют поля, некоторые — нет).

Позволим указать один фильтр (хотя на самом деле их может быть много), причем он будет по умолчанию отключен.

  sinput string F = "————— Filter —————";
  input SELECTORS Filter1 = SELECTOR_NONE;
  input TRADE_RECORD_FIELDS Filter1Field = FIELD_NONE;
  input float Filter1value1 = 0;
  input float Filter1value2 = 0;

Суть работы фильтра в том, чтобы учитывать в расчетах только те записи, в которых указанное поле Filter1Field имеет конкретное значение Filter1value1 (Filter1value2 должно быть при этом таким же, поскольку только при таких условиях в примере создается объект Filter). Имейте в виду, что для таких полей как символ или магическое число, значение обозначает индекс в соответствующем словаре. Опционально фильтр может включать не одно, а диапазон значений между Filter1value1 и Filter1value2 (если они не равны, поскольку только в этом случае создается объект FilterRange). Данная реализация создана для демонстрации самой возможности фильтрации и оставляет широкий фронт работ по совершенствованию пользовательского интерфейса для практического применения.

Для обозначения агрегаторов опишем другое перечисление:

  enum AGGREGATORS
  {
    AGGREGATOR_SUM,         // SUM
    AGGREGATOR_AVERAGE,     // AVERAGE
    AGGREGATOR_MAX,         // MAX
    AGGREGATOR_MIN,         // MIN
    AGGREGATOR_COUNT,       // COUNT
    AGGREGATOR_PROFITFACTOR, // PROFIT FACTOR
    AGGREGATOR_PROGRESSIVE,  // PROGRESSIVE TOTAL
    AGGREGATOR_IDENTITY      // IDENTITY
  };

Используем его в группе входных переменных, описывающих рабочий агрегатор:

  sinput string A = "————— Aggregator —————";
  input AGGREGATORS AggregatorType = AGGREGATOR_SUM;
  input TRADE_RECORD_FIELDS AggregatorField = FIELD_PROFIT_AMOUNT;

Все селекторы, включая те, что используются в опциональном фильтре, инициализируются в OnInit.

  int selectorCount;
  SELECTORS selectorArray[4];
  TRADE_RECORD_FIELDS selectorField[4];
  
  int OnInit()
  {
    selectorCount = (SelectorX != SELECTOR_NONE) + (SelectorY != SELECTOR_NONE) + (SelectorZ != SELECTOR_NONE);
    selectorArray[0] = SelectorX;
    selectorArray[1] = SelectorY;
    selectorArray[2] = SelectorZ;
    selectorArray[3] = Filter1;
    selectorField[0] = FieldX;
    selectorField[1] = FieldY;
    selectorField[2] = FieldZ;
    selectorField[3] = Filter1Field;
  
    EventSetTimer(1);
    return(INIT_SUCCEEDED);
  }

OLAP запускается лишь однажды, по таймеру.

  void OnTimer()
  {
    process();
    EventKillTimer();
  }
  
  void process()
  {
    HistoryDataAdapter history;
    Analyst<TRADE_RECORD_FIELDS> *analyst;
    
    Selector<TRADE_RECORD_FIELDS> *selectors[];
    ArrayResize(selectors, selectorCount);
    
    for(int i = 0; i < selectorCount; i++)
    {
      selectors[i] = createSelector(i);
    }
    Filter<TRADE_RECORD_FIELDS> *filters[];
    if(Filter1 != SELECTOR_NONE)
    {
      ArrayResize(filters, 1);
      Selector<TRADE_RECORD_FIELDS> *filterSelector = createSelector(3);
      if(Filter1value1 != Filter1value2)
      {
        filters[0] = new FilterRange<TRADE_RECORD_FIELDS>(filterSelector, Filter1value1, Filter1value2);
      }
      else
      {
        filters[0] = new Filter<TRADE_RECORD_FIELDS>(filterSelector, Filter1value1);
      }
    }
    
    Aggregator<TRADE_RECORD_FIELDS> *aggregator;
    
    // MQL does not support a 'class info' metaclass.
    // Otherwise we could use an array of classes instead of the switch
    switch(AggregatorType)
    {
      case AGGREGATOR_SUM:
        aggregator = new SumAggregator<TRADE_RECORD_FIELDS>(AggregatorField, selectors, filters);
        break;
      case AGGREGATOR_AVERAGE:
        aggregator = new AverageAggregator<TRADE_RECORD_FIELDS>(AggregatorField, selectors, filters);
        break;
      case AGGREGATOR_MAX:
        aggregator = new MaxAggregator<TRADE_RECORD_FIELDS>(AggregatorField, selectors, filters);
        break;
      case AGGREGATOR_MIN:
        aggregator = new MinAggregator<TRADE_RECORD_FIELDS>(AggregatorField, selectors, filters);
        break;
      case AGGREGATOR_COUNT:
        aggregator = new CountAggregator<TRADE_RECORD_FIELDS>(AggregatorField, selectors, filters);
        break;
      case AGGREGATOR_PROFITFACTOR:
        aggregator = new ProfitFactorAggregator<TRADE_RECORD_FIELDS>(AggregatorField, selectors, filters);
        break;
      case AGGREGATOR_PROGRESSIVE:
        aggregator = new ProgressiveTotalAggregator<TRADE_RECORD_FIELDS>(AggregatorField, selectors, filters);
        break;
      case AGGREGATOR_IDENTITY:
        aggregator = new IdentityAggregator<TRADE_RECORD_FIELDS>(AggregatorField, selectors, filters);
        break;
    }
    
    LogDisplay display;
    
    analyst = new Analyst<TRADE_RECORD_FIELDS>(history, aggregator, display);
    
    analyst.acquireData();
    
    Print("Symbol number: ", TradeRecord::getSymbolCount());
    for(int i = 0; i < TradeRecord::getSymbolCount(); i++)
    {
      Print(i, "] ", TradeRecord::getSymbol(i));
    }
  
    Print("Magic number: ", TradeRecord::getMagicCount());
    for(int i = 0; i < TradeRecord::getMagicCount(); i++)
    {
      Print(i, "] ", TradeRecord::getMagic(i));
    }
  
    Print("Filters: ", aggregator.getFilterTitles());
    
    Print("Selectors: ", selectorCount);
    
    analyst.build();
    analyst.display();
    
    delete analyst;
    delete aggregator;
    for(int i = 0; i < selectorCount; i++)
    {
      delete selectors[i];
    }
    for(int i = 0; i < ArraySize(filters); i++)
    {
      delete filters[i].getSelector();
      delete filters[i];
    }
  }

Вспомогательная функция createSelector определена следующим образом.

  Selector<TRADE_RECORD_FIELDS> *createSelector(int i)
  {
    switch(selectorArray[i])
    {
      case SELECTOR_TYPE:
        return new TypeSelector();
      case SELECTOR_SYMBOL:
        return new SymbolSelector();
      case SELECTOR_SERIAL:
        return new SerialNumberSelector();
      case SELECTOR_MAGIC:
        return new MagicSelector();
      case SELECTOR_PROFITABLE:
        return new ProfitableSelector();
      case SELECTOR_DURATION:
        return new DaysRangeSelector(15); // up to 14 days
      case SELECTOR_WEEKDAY:
        return selectorField[i] != FIELD_NONE ? new WeekDaySelector(selectorField[i]) : NULL;
      case SELECTOR_DAYHOUR:
        return selectorField[i] != FIELD_NONE ? new DayHourSelector(selectorField[i]) : NULL;
      case SELECTOR_HOURMINUTE:
        return selectorField[i] != FIELD_NONE ? new DayHourSelector(selectorField[i]) : NULL;
      case SELECTOR_SCALAR:
        return selectorField[i] != FIELD_NONE ? new TradeSelector(selectorField[i]) : NULL;
      case SELECTOR_QUANTS:
        return selectorField[i] != FIELD_NONE ? new QuantizationSelector(selectorField[i]) : NULL;
    }
    return NULL;
  }

Все классы кроме DaysRangeSelector импортируются из заголовочного файла, а DaysRangeSelector описан в самом эксперте OLAPDEMO следующим образом.

  class DaysRangeSelector: public DateTimeSelector<TRADE_RECORD_FIELDS>
  {
    public:
      DaysRangeSelector(const int n): DateTimeSelector<TRADE_RECORD_FIELDS>(FIELD_DURATION, n)
      {
        _typename = typename(this);
      }
      
      virtual bool select(const Record *r, int &index) const override
      {
        double d = r.get(selector);
        int days = (int)(d / (60 * 60 * 24));
        index = MathMin(days, granularity - 1);
        return true;
      }
      
      virtual string getLabel(const int index) const override
      {
        return index < granularity - 1 ? ((index < 10 ? " ": "") + (string)index + "D") : ((string)index + "D+");
      }
  };

Это пример реализации пользовательского селектора. Он группирует торговые позиции по длительности нахождения в рынке, в днях.

Если запустить эксперт на каком-либо онлайн счете и выбрать 2 селектора — SymbolSelector и WeekDaySelector, то можно получить в логе результаты вроде таких:

	Analyzing account history
	Symbol number: 5
	0] FDAX
	1] XAUUSD
	2] UKBrent
	3] NQ
	4] EURUSD
	Magic number: 1
	0] 0
	Filters: no
	Selectors: 2
	SumAggregator<TRADE_RECORD_FIELDS> FIELD_PROFIT_AMOUNT [35]
	X: SymbolSelector(FIELD_SYMBOL) [5]
	Y: WeekDaySelector(FIELD_DATETIME2) [7]
	     ...
	     0.000: FDAX Monday
	     0.000: XAUUSD Monday
	   -20.400: UKBrent Monday
	     0.000: NQ Monday
	     0.000: EURUSD Monday
	     0.000: FDAX Tuesday
	     0.000: XAUUSD Tuesday
	     0.000: UKBrent Tuesday
	     0.000: NQ Tuesday
	     0.000: EURUSD Tuesday
	    23.740: FDAX Wednesday
	     4.240: XAUUSD Wednesday
	     0.000: UKBrent Wednesday
	     0.000: NQ Wednesday
	     0.000: EURUSD Wednesday
	     0.000: FDAX Thursday
	     0.000: XAUUSD Thursday
	     0.000: UKBrent Thursday
	     0.000: NQ Thursday
	     0.000: EURUSD Thursday
	     0.000: FDAX Friday
	     0.000: XAUUSD Friday
	     0.000: UKBrent Friday
	    13.900: NQ Friday
	     1.140: EURUSD Friday
	     ...

В данном случае на счете торговалось 5 разных символов. Размер гиперкуба — 35 ячеек. Все сочетания символов и дней недели перечислены вместе с соответствующей суммой прибылей/убытков. Обратите внимание, что WeekDaySelector требует явного указания поля, так как каждая позиция имеет две даты — открытия (FIELD_DATETIME1) и закрытия (FIELD_DATETIME2). Здесь мы выбрали FIELD_DATETIME2.

Для того чтобы анализировать не только историю текущего счета, но и произвольные торговые отчеты в формате HTML, а также CSV-файлы с историей сигналов MQL5, в OLAP-библиотеку были добавлены классы из моих предыдущих статей Извлечение структурированных данных из HTML-страниц с помощью CSS-селекторов и Визуализация истории мультивалютной торговли по отчетам в форматах HTML и CSV. Для их интеграции с OLAP написаны классы-прослойки. В частности, заголовочный файл HTMLcube.mqh содержит класс торговых записей HTMLTradeRecord и адаптер HTMLReportAdapter, унаследованный от DataAdapter. А заголовочный файл CSVcube.mqh — соответственно класс записей CSVTradeRecord и адаптер CSVReportAdapter. Чтение HTML обеспечивает WebDataExtractor.mqh, а чтение CSV — CSVReader.mqh. Входные параметры для загрузки отчетов и общие принципы работы с ними (включая выбор подходящих рабочих символов с помощью префиксов и суффиксов, если чужой отчет содержит символы, отсутствующие на вашем счете) подробно описаны во второй из упомянутых статей.

Вот, например, как выглядят результаты анализа одного из сигналов (загружен как CSV-файл) с помощью агрегатора по фактору прибыльности в разбивке по символам с сортировкой по убыванию показателя:

	Reading csv-file ***.history.csv
	219 records transferred to 217 trades
	Symbol number: 8
	0] GBPUSD
	1] EURUSD
	2] NZDUSD
	3] USDJPY
	4] USDCAD
	5] GBPAUD
	6] AUDUSD
	7] NZDJPY
	Magic number: 1
	0] 0
	Filters: no
	Selectors: 1
	ProfitFactorAggregator<TRADE_RECORD_FIELDS> FIELD_PROFIT_AMOUNT [8]
	X: SymbolSelector(FIELD_SYMBOL) [8]
	    [value]  [title]
	[0]     inf "NZDJPY"
	[1]     inf "AUDUSD"
	[2]     inf "GBPAUD"
	[3]   7.051 "USDCAD"
	[4]   4.716 "USDJPY"
	[5]   1.979 "EURUSD"
	[6]   1.802 "NZDUSD"
	[7]   1.359 "GBPUSD"

Значение inf генерируется в исходном коде, когда есть прибыли и нет убытков. Как можно убедиться, сравнение вещественных значений и их сортировка производится таким образом, что "бесконечность" больше любых других конечных чисел.

Разумеется, просматривать результаты анализа реальных торговых отчетов по логам не совсем удобно. Было бы гораздо лучше иметь реализацию интерфейса Display, которая показывала бы гиперкуб в наглядном графическом виде. Данная задача, несмотря на кажущуюся простоту, требует кое-какой подготовки и большого объема рутинного кодирования. В связи с этим мы рассмотрим её во второй части.


Заключение

В статье изложен известный подход для оперативного анализа больших данных (OLAP) в применении к истории торговых операций. С помощью MQL мы реализовали основные классы, позволяющие строить виртуальный гиперкуб в пространстве выбранных признаков (селекторов) и рассчитывать в их разрезе различные агрегированные показатели. Данный механизм может быть также применен для уточнения и расшифровки результатов оптимизации, подбора торговых сигналов по критериям и в других областях, где большой объем данных требует применения алгоритмов извлечения знаний для принятия решений.

Прилагаемые файлы:

  • Experts/OLAP/OLAPDEMO.mq5 — демонстрационный эксперт;
  • Include/OLAP/OLAPcube.mqh — основной заголовочный файл с классами OLAP;
  • Include/OLAP/PairArray.mqh — класс массива пар [значение;название] с поддержкой всех вариантов сортировки;
  • Include/OLAP/HTMLcube.mqh — сопряжение OLAP с загрузкой данных из HTML-отчетов;
  • Include/OLAP/CSVcube.mqh — сопряжение OLAP с загрузкой данных из CSV-файлов;
  • Include/MT4orders.mqh — библиотека MT4orders для работы с ордерами в едином стиле в МТ4 и в МТ5;
  • 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 — вспомогательный заголовочный файл с реализацией функций работы с датами в стиле MT4;
  • Include/Marketeer/Converter.mqh — вспомогательный заголовочный файл с объединением для конвертации типов данных;
  • Include/Marketeer/GroupSettings.mqh — вспомогательный заголовочный файл групповых настроек входных параметров;
Прикрепленные файлы |
MQLOLAP1.zip (50.46 KB)
Последние комментарии | Перейти к обсуждению на форуме трейдеров (28)
Stanislav Korotky
Stanislav Korotky | 8 май 2019 в 11:27

Для апологетов трейдинга в чистом виде еще раз повторю - пишите конкретно, чего не хватает. Абстрактные рассуждения не принимаются. Прежде чем предлагать тему, потрудитесь убедиться, что статей по ней еще нет. Может быть под "трейдингом" подразумевается информация по стратегиям, индикаторам, управлению капиталом, обработке торговых транзакций, по сеткам, мультивалютному анализу, тестированию и оптимизации, интеграции с внешним аналитическим софтом и так далее? Так все это обсосано по много раз. Я пишу на темы, по которым материалы отсутствуют, а лично для себя я такие инструменты сделал. В частности, OLAP как средство анализа показателей торговой системы в разных разрезах дополняет торговый отчет или отчет оптимизации информацией, которой там явно не хватает. По-хорошему, это все должно было бы быть встроенным. Всё это имеет самое непосредственное отношение к трейдингу. Если кто-то не согласен - это его проблемы. Пишите в ветку по публикациям, а не в обсуждении данной статьи.

Valeriy Yastremskiy
Valeriy Yastremskiy | 8 май 2019 в 14:36
Хорошая статья. Не хватает оценки воздействия параметров советника на результаты в случае более 3 параметров. Либо оптимальных сочетаний параметров. Многомерность очень далека от понимания. 2 параметра для входа или выхода обычно не дают результата, 3 уже трудно оценивать, а 4х мерное седло вообще тяжело. Настраиваемая оптимизация это хорошая вещь. И к трейдингу ближе)))) 
Maxim Dmitrievsky
Maxim Dmitrievsky | 8 май 2019 в 14:54

не хватает ничего, т.е. вообще не понятно о чем статья. Здесь, нужно обладать определенным пониманием законов пустоты, что бы, отказавшись от абстрактных рассуждений, сформулировать свои конкретные претензии к пустоте.

темы, непосредственно касающиеся ТС интересны, исследований рынков. Лично мне.

fxsaber
fxsaber | 8 май 2019 в 15:00
Дискуссия не соответствует техническому ресурсу. Статья отличная!
Aleksandr Masterskikh
Aleksandr Masterskikh | 9 май 2019 в 21:51
Artyom Trishkin:

15-й и 17-й год. Две статьи. И возмущаетесь, что мало пишут про трейдинг. Так и говорю - восполните пробел, раз уж есть востребованность и желание.

Что не даёт вам это делать? Вопрос-то в этом.

Да, у меня две статьи и именно про трейдинг. 

Кстати, по мнению англоязычного читателя, моя статья "Как снизить риски..." входит в десятку лучших (по крайней мере, 60 тыс. читателей на нескольких языках - это неплохой результат).

Я это к тому, что лучше написать 2 статьи, которые многим помогут в разработке торговой системы, чем 100 статей про библиотеки, которые почти ничего не дают для алгоритмического трейдинга.

Динамика рынка крайне сложна (это нестационарный процесс), поэтому меня удивляют программы, в которых 1 строчка - это анализ рынка и 1000 строчек - это код сомнительных сервисов. 

Уверен, что задача ресурса (сайта www.mql5.com, а это безусловно, ресурс №1 в индустрии) - это популяризация алгоритмического трейдинга, а не программирования ради программирования. 

Библиотека для простого и быстрого создания программ для MetaTrader (Часть VII): События срабатывания StopLimit-ордеров, подготовка функционала для событий модификации ордеров и позиций Библиотека для простого и быстрого создания программ для MetaTrader (Часть VII): События срабатывания StopLimit-ордеров, подготовка функционала для событий модификации ордеров и позиций

В предыдущих статьях мы начали создавать большую кроссплатформенную библиотеку, целью которой является упростить создание программ для платформы MetaTrader 5 и MetaTrader 4. В шестой части мы научили библиотеку работать с позициями на счетах с типом "неттинг". В данной части сделаем отслеживание событий срабатывания StopLimit-ордеров и подготовим функционал для отслеживания событий модификации рыночных ордеров и позиций.

ZUP - зигзаг универсальный с паттернами Песавенто: Графический интерфейс. Дополнения и изменения. Вилы Эндрюса в ZUP ZUP - зигзаг универсальный с паттернами Песавенто: Графический интерфейс. Дополнения и изменения. Вилы Эндрюса в ZUP

В версии 153 редактирование почти всех параметров ZUP можно осуществлять через графический интерфейс. В статье дано описание последних изменений в графическом интерфейсе ZUP. Описаны также основные элементы вил Эндрюса в ZUP для использования этого инструмента при анализе рыночной ситуации.

Исследование методов свечного анализа (Часть IV): Обновление и дополнение приложения Исследование методов свечного анализа (Часть IV): Обновление и дополнение приложения

В этой статье представлена следующая версия приложения Pattern Analyzer. В нем были исправлены некоторые недоработки, добавлены новые возможности, пересмотрено удобство и актуальность текущего интерфейса. При этом были рассмотрены пожелания и идеи из комментариев предыдущих статей. Что в итоге получилось — читайте далее в этой статье.

Библиотека для простого и быстрого создания программ для MetaTrader (Часть VIII): События модификации ордеров и позиций Библиотека для простого и быстрого создания программ для MetaTrader (Часть VIII): События модификации ордеров и позиций

В предыдущих статьях мы начали создавать большую кроссплатформенную библиотеку, целью которой является упростить написания программ для платформы MetaTrader 5 и MetaTrader 4. В седьмой части мы добавили отслеживание событий срабатывания StopLimit-ордеров и подготовили функционал для отслеживания остальных событий, происходящих с ордерами и позициями. В данной статье сделаем класс для отслеживания событий модификации рыночных ордеров и позиций.