Визуализация истории мультивалютной торговли по отчетам в форматах HTML и CSV

9 апреля 2019, 21:50
Stanislav Korotky
0
1 357

Как известно, MetaTrader 5 с момента своего появления предоставляет возможность мультивалютного тестирования. Эта функция востребована у большинства трейдеров, но, к сожалению, не столь универсальна, как того хотелось бы. В частности, после проведения теста пользователь может открыть график с проведенными торговыми операциями, но это будет график лишь одного рабочего символа, выбранного в настройках тестера. Увидеть историю торговли по всем использованным символам постфактум невозможно, а проводить визуальное тестирование не всегда удобно. Кроме того, дополнительный анализ может потребоваться спустя некоторое время после теста, или отчет может быть получен от другого человека. Поэтому желательно иметь инструмент для визуализации торговли на множестве рабочих символов на основе HTML-отчета тестера.

Данная задача тесно связана и с другим похожим вариантом применения MetaTrader. Дело в том, что многие торговые сигналы, доступные на mql5.com, также являются мультивалютными, и CSV-файлы с их историей было бы удобно выводить на графики аналогичным образом.

Напишем индикатор, который выполняет вышеупомянутые функции.

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

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

В прошлой статье был описан парсер HTML на основе CSS селекторов[1]. С помощью него мы получим список сделок из HTML-отчета и затем сформируем на их основе трейды (графические объекты). В случае CSV-файлов из раздела сигналов все будет несколько проще: их формат для сигналов МТ4 (*.history.csv) и МТ5 (*.positions.csv) поддерживается встроенными функциями MQL.

Индикатор SubChart

Первым шагом к реализации нашей идеи является создание простого индикатора, отображающего "котировки" стороннего символа в подокне любого графика. Назовем его SubChart.

Для отображения данных со значениями OHLC (Open, High, Low, Close) язык MQL предоставляет несколько стилей отображения, в частности, DRAW_CANDLES и DRAW_BARS. Очевидно, что они используют по четыре индикаторных буфера. Вместо того чтобы выбирать какой-либо один из стилей, поддержим оба варианта, и будем делать выбор динамически на основе текущих настроек окна. Как известно, настройки графика содержат на закладке "Общие" группу радио-кнопок "Бары", "Японские свечи" и "Линия", причем для обеспечения быстрого доступа они продублированы соответствующими кнопками на палитре инструментов. Из MQL мы можем получить эти настройки с помощью вызова:

(ENUM_CHART_MODE)ChartGetInteger(0, CHART_MODE)

Перечисление ENUM_CHART_MODE содержит элементы с аналогичным назначением: CHART_CANDLES, CHART_BARS, CHART_LINE.

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

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

#property indicator_separate_window
#property indicator_buffers 4
#property indicator_plots   1

Добавим входные переменные:

input string SubSymbol = ""; // Symbol
input bool Exact = false;

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

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

  • когда Exact равен false, функция возвращает номер ближайшего подходящего бара для заданного времени, и потому в отсутствии котировок ("окно" в расписании торгов, праздничный день и пр.) индикатор отобразит предыдущий бар;
  • когда Exact равен true, функция возвращает -1, и в графике индикатора будет в данном месте пусто;

По умолчанию параметр SubSymbol равен пустой строке, что подразумевает дублирование котировок главного окна. В этом случае необходимо отредактировать фактические значение переменной на _Symbol, но поскольку input является в MQL переменной только для чтения, нам придется ввести промежуточную переменную Symbol и заполнять её в обработчике OnInit.

string Symbol;

int OnInit()
{
  Symbol = SubSymbol;
  if(Symbol == "") Symbol = _Symbol;
  else SymbolSelect(Symbol, true);
  ...

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

Для контроля текущего режима отображения используем переменную mode:

ENUM_CHART_MODE mode = 0;

Четыре индикаторных буфера получат ожидаемые имена:

// OHLC
double open[];
double high[];
double low[];
double close[];

Для инициализации буферов привычным образом (с установкой свойства "серийности") выделим маленькую функцию:

void InitBuffer(int index, double &buffer[], ENUM_INDEXBUFFER_TYPE style)
{
  SetIndexBuffer(index, buffer, style);
  ArraySetAsSeries(buffer, true);
}

Инициализация графического построения также сведена в одной вспомогательной функции (здесь у нас будет одно построение, но когда их несколько, эта функция экономит место):

void InitPlot(int index, string name, int style, int width = -1, int colorx = -1)
{
  PlotIndexSetInteger(index, PLOT_DRAW_TYPE, style);
  PlotIndexSetDouble(index, PLOT_EMPTY_VALUE, 0);
  PlotIndexSetString(index, PLOT_LABEL, name);
  if(width != -1) PlotIndexSetInteger(index, PLOT_LINE_WIDTH, width);
  if(colorx != -1) PlotIndexSetInteger(index, PLOT_LINE_COLOR, colorx);
}

Для перевода режима отображения графика в стиль буфера напишем функцию:

int Mode2Style(/*global ENUM_CHART_MODE mode*/)
{
  switch(mode)
  {
    case CHART_CANDLES: return DRAW_CANDLES;
    case CHART_BARS: return DRAW_BARS;
    case CHART_LINE: return DRAW_LINE;
  }
  return 0;
}

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

  InitBuffer(0, open, INDICATOR_DATA);
  string title = "# Open;# High;# Low;# Close";
  StringReplace(title, "#", Symbol);
  mode = (ENUM_CHART_MODE)ChartGetInteger(0, CHART_MODE);
  InitPlot(0, title, Mode2Style());

  InitBuffer(1, high, INDICATOR_DATA);
  InitBuffer(2, low, INDICATOR_DATA);
  InitBuffer(3, close, INDICATOR_DATA);

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

void SetPlotColors()
{
  if(mode == CHART_CANDLES)
  {
    PlotIndexSetInteger(0, PLOT_COLOR_INDEXES, 3);
    PlotIndexSetInteger(0, PLOT_LINE_COLOR, 0, (int)ChartGetInteger(0, CHART_COLOR_CHART_LINE));  // rectangle
    PlotIndexSetInteger(0, PLOT_LINE_COLOR, 1, (int)ChartGetInteger(0, CHART_COLOR_CANDLE_BULL)); // up
    PlotIndexSetInteger(0, PLOT_LINE_COLOR, 2, (int)ChartGetInteger(0, CHART_COLOR_CANDLE_BEAR)); // down
  }
  else
  {
    PlotIndexSetInteger(0, PLOT_COLOR_INDEXES, 1);
    PlotIndexSetInteger(0, PLOT_LINE_COLOR, (int)ChartGetInteger(0, CHART_COLOR_CHART_LINE));
  }
}

Добавив вызов SetPlotColors() в OnInit, а также задав там точность значений, мы гарантируем правильное отображение индикатора после запуска.

  SetPlotColors();

  IndicatorSetString(INDICATOR_SHORTNAME, "SubChart (" + Symbol + ")");
  IndicatorSetInteger(INDICATOR_DIGITS, (int)SymbolInfoInteger(Symbol, SYMBOL_DIGITS));
  
  return INIT_SUCCEEDED;
}

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

void OnChartEvent(const int id,
                  const long& lparam,
                  const double& dparam,
                  const string& sparam)
{
  if(id == CHARTEVENT_CHART_CHANGE)
  {
    mode = (ENUM_CHART_MODE)ChartGetInteger(0, CHART_MODE);
    PlotIndexSetInteger(0, PLOT_DRAW_TYPE, Mode2Style());
    SetPlotColors();
    ChartRedraw();
  }
}

Нам осталось написать самую главную функцию индикатора - обработчик OnCalculate. Его особенность в нашем случае заключается в том, что индикатор фактически работает на котировках стороннего символа, а не символа графика. Поэтому все стандартные приемы кодирования, базирующиеся на передаваемых из ядра значениях rates_total и prev_calculated не подходят, или подходят лишь отчасти. Загрузка котировок стороннего символа будет происходить асинхронно, и потому в любой момент может "прибыть" новая партия баров, требующая полного пересчета. Поэтому создадим переменные, контролирующие количество баров на стороннем символе (lastAvailable) и редактируемый "клон" константного аргумента prev_calculated.

int OnCalculate(const int rates_total,
                const int prev_calculated,
                const datetime& time[],
                const double& op[],
                const double& hi[],
                const double& lo[],
                const double& cl[],
                const long& tick_volume[],
                const long& volume[],
                const int& spread[])
{
  static int lastAvailable = 0;
  static bool initialized = false;

  int _prev_calculated = prev_calculated;

  if(iBars(Symbol, _Period) - lastAvailable > 1) // bar gap filled
  {
    _prev_calculated = 0;
    lastAvailable = 0;
    initialized = false;
  }

  if(_prev_calculated == 0)
  {
    for(int i = 0; i < rates_total; ++i)
    {
      open[i] = 0;
      high[i] = 0;
      low[i] = 0;
      close[i] = 0;
    }
  }

Если символ индикатора отличен от символа окна, используем функцию iBarShift для нахождения синхронных баров и копирования их значений OHLC.

  if(_Symbol != Symbol)
  {
    for(int i = 0; i < MathMax(rates_total - _prev_calculated, 1); ++i)
    {
      datetime dt = iTime(_Symbol, _Period, i);
      int x = iBarShift(Symbol, _Period, dt, Exact);
      if(x != -1)
      {
        open[i] = iOpen(Symbol, _Period, x);
        high[i] = iHigh(Symbol, _Period, x);
        low[i] = iLow(Symbol, _Period, x);
        close[i] = iClose(Symbol, _Period, x);
      }
    }
  }

Если символ индикатора совпадает с символом окна, то все проще — используем переданные нам массивы-аргументы:

  else
  {
    ArraySetAsSeries(op, true);
    ArraySetAsSeries(hi, true);
    ArraySetAsSeries(lo, true);
    ArraySetAsSeries(cl, true);
    for(int i = 0; i < MathMax(rates_total - _prev_calculated, 1); ++i)
    {
      open[i] = op[i];
      high[i] = hi[i];
      low[i] = lo[i];
      close[i] = cl[i];
    }
  }

Наконец, обеспечим подгрузку данных, воспользовавшись реализацией функции RefreshHistory из известного примера компании MetaQuotes (подключим этот код как заголовочный файл Refresh.mqh).

Статическая переменная initialized содержит признак окончания обновления. Установим её в true, если RefreshHistory вернет признак успеха или если количество баров стороннего символа остается постоянным и ненулевым (на тот случай, если не существует истории на требуемое количество баров по стороннему символу).

  if(lastAvailable == iBars(Symbol, _Period) && lastAvailable != 0)
  {
    if(!initialized)
    {
      Print("Updated ", Symbol, " ", iBars(Symbol, _Period), " bars");
      initialized = true;
    }
    return rates_total;
  }

  if(!initialized)
  {
    if(_Symbol != Symbol)
    {
      Print("Updating ", Symbol, " ", lastAvailable, " -> ", iBars(Symbol, _Period), " bars up to ", (string)time[0], "... Please wait");
      int result = RefreshHistory(Symbol, time[0]);
      if(result >= 0 && result <= 2)
      {
        _prev_calculated = rates_total;
      }
      if(result >= 0)
      {
        initialized = true;
        ChartSetSymbolPeriod(0, _Symbol, _Period);
      }
    }
    else
    {
      initialized = true;
    }
  }
  
  lastAvailable = iBars(Symbol, _Period);
  
  return _Symbol != Symbol ? _prev_calculated : rates_total;
}

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

После того как инициализация единожды пройдена, новые бары будут обсчитываться в экономном режиме, т.е. с учетом _prev_calculated и rates_total. Если в будущем количество баров lastAvailable вдруг изменится больше, чем на 1, проведем полную перерисовку, сбросив initialized в false.

Разместим индикатор на графике, например, EURUSD и введем другой символ в параметрах, например, UKBrent (это CFD на Brent — он интересен тем, что на внутридневных таймфреймах при установке Exact в true заметно отсутствие ночных баров). "Пощелкаем" кнопками смены режима и убедимся, что индикатор отрисовывается правильно.

Переключение режима отображения индикатора

Переключение режима отображения индикатора

Обратите внимание, что в режиме отображения одной линии наш индикатор использует первый буфер (индекс 0), то есть open. Это отличает его от основного графика, который выводится по ценам close. Так сделано, чтобы не перерисовывать индикатор целиком при переключении на стиль линии или с него — ведь для того, чтобы вывести цены закрытия необходимо их скопировать в первый (и единственный) буфер, а для режимов свечей и баров (когда буферов 4) в первом буфере хранятся цены открытия. Текущая реализация использует тот факт, что буфер open является первым в четверке OHLC, и потому смена стиля сразу же приводит к смене внешнего представления без пересчета. При анализе истории режим линий вряд ли будет востребован, и потому данная особенность не является критичной.

Запустим индикатор в визуальном тестере.

Индикатор SubChart в визуальном тестере

Индикатор SubChart в визуальном тестере

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

Индикатор SubChartReporter

Назовем новый индикатор SubChartReporter. Уже созданный ранее код дополним чтением отчетов в форматах HTML и CSV. Имя анализируемого файла будем задавать во входной переменной ReportFile. Также предусмотрим входные параметры для указания временного сдвига, а также префиксов и суффиксов символов для случаев, когда отчет получен от другого пользователя (из другого торгового окружения).

input string ReportFile = ""; // · ReportFile
input string Prefix = ""; // · Prefix
input string Suffix = ""; // · Suffix
input int  TimeShift = 0; // · TimeShift

В индикаторе SubChartReporter обработкой полученных данных и генерацией графических объектов (трендовых линий) будут заниматься специальные классы.

Поскольку мы предполагаем анализировать не только HTML, но и CSV файлы, был спроектирован базовый общий класс для всех типов отчетов Processor, а от него наследованы конкретные реализации для форматов HTML и CSV — ReportProcessor и HistoryProcessor соответственно.

В классе Processor описаны следующие переменные:

class Processor
{
  protected:
    string symbol;
    string realsymbol;
    IndexMap *data;
    ulong timestamp;
    string prefix;
  • symbol — имя текущего рабочего символа, взятое из отчета;
  • realsymbol — имя реального доступного рабочего символа - оно может отличаться при работе с чужими отчетами из-за префиксов и суффиксов;
  • data — массив торговых операций (класс IndexMap уже знаком по статье [1] и подключается из одноименного заголовочного файла);
  • timestamp и prefix — вспомогательные переменные для уникального именования графических объектов, которые индикатор будет генерировать на графике;

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

    string pressed;

В классе Processor реализованы следующие вспомогательные методы генерации объектов:

    void createTrend(const long dealIn, const long dealOut, const int type, const datetime time1, const double price1, const datetime time2, const double price2, const string &description)
    void createButton(const int x, const int y, const int dx, const int dy, const string text, const bool selected)
    void controlPanel(const IndexMap &symbols)

Как можно понять из их названий, createTrend создает визуальное представление для отдельного трейда (трендовая линия и две стрелки), createButton — кнопку одного рабочего символа, а controlPanel — полный набор кнопок для всех символов из отчета. Кнопки выводятся в левом нижнем углу подокна.

Публичный интерфейс класса Processor включает две группы методов:

  • виртуальные, подлежащие специфической реализации в классах-наследниках;
  • невиртуальные, предоставляющие единую стандартную функциональность;

    virtual IndexMap *load(const string file) = 0;
    virtual int getColumnCount() = 0;
    virtual int getSymbolColumn() = 0;
    virtual datetime getStart() = 0;
    virtual bool applyInit() { return true; }
    virtual void makeTrade(IndexMap *row) = 0;
    virtual int render() = 0;

    bool attach(const string file)
    bool apply(const string _s = NULL)
    bool isEmpty() const
    string findrealsymbol()
    string _symbol() const
    string _realsymbol() const
    void onChartEvent(const int id, const long& lparam, const double& dparam, const string& sparam)

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

  • load — загружает указанный файл с данными;
  • getColumnCount — возвращает количество колонок в таблице данных;
  • getSymbolColumn — возвращает номер колонки с названием символов торговых операций;
  • getStart — возвращает дату первой торговой операции;
  • applyInit — метод для опциональной инициализации внутренних структур данных перед началом обработки;
  • makeTrade — метод регистрации одной записи из отчета во внутренних структурах данных;
  • render — метод генерации графических объектов на основе записей во внутренних структурах данных;

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

Невиртуальные методы представлены далее в упрощенном виде. Метод attach принимает имя анализируемого файла, загружает его с помощью виртуального метода load, формирует список уникальных символов и создает для них "панель" из кнопок.

    bool attach(const string file)
    {
      data = load(file);
      
      IndexMap symbols;
      
      for(int i = 0; i < data.getSize(); ++i)
      {
        IndexMap *row = data[i];
        // collect all unique symbols
        string s = row[getSymbolColumn()].get<string>();
        StringTrimLeft(s);
        if(StringLen(s) > 0) symbols.set(s);
      }

      if(symbols.getSize() > 0)
      {
        controlPanel(symbols);
      }
      return true;
    }

Метод apply активизирует в индикаторе выбранный рабочий символ из числа тех, что есть в отчете. Сперва с графика удаляются старые объекты (если они есть), затем ищется подходящий реальный символ (например, имеющийся у вашего брокера EURUSD вместо EURUSD.m, упомянутого в отчете), и классам наследникам дается возможность обнулить старые внутренние массивы с помощью applyInit. Затем для записей в таблице данных, в которых название символа совпадает с выбранным, генерируются трейды (вызов makeTrade), на основе этих трейдов создаются графические объекты (вызов render) и производится обновление интерфейса (выделение активной кнопки, смена названия индикатора, вызов ChartRedraw).

                                                                                                                                          
    bool apply(const string _s = NULL)
    {
      ObjectsDeleteAll(0, "SCR", ChartWindowFind(), OBJ_TREND);

      if(_s != NULL && _s != "") symbol = _s;
      if(symbol == NULL)
      {
        Print("No symbol selected");
        return false;
      }
      
      string real = findrealsymbol();
      if(real == NULL)
      {
        Print("No suitable symbol found");
        return false;
      }
      
      SymbolSelect(real, true);

      if(!applyInit()) return false;
      
      int selected = 0;
  
      for(int i = 0; i < data.getSize(); ++i)
      {
        IndexMap *row = data[i];
        
        string s = row[getSymbolColumn()].get<string>();
        StringTrimLeft(s);
        
        if(s == symbol)
        {
          selected++;
          makeTrade(row);
        }
      }
      
      pressed = prefix + "#" + symbol;
      ObjectSetInteger(0, pressed, OBJPROP_BGCOLOR, clrGreen);
      
      int trends = render();
      Print(data.getSize(), " records in total");
      Print(selected, " trades for ", symbol);
      
      string title = CHART_REPORTER_TITLE + " (" + symbol + ", " + (string)selected + " records, " + (string)trends + " trades)";
      IndicatorSetString(INDICATOR_SHORTNAME, title);
      
      ChartRedraw();
      return true;
    }

Метод findrealsymbol использует несколько подходов для поиска подходящих символов. Он проверяет доступность рыночной информации (цены bid) для символа, и если она есть, то символ считается реальным. Если её нет, то программа пытается применить параметры Suffix и/или Prefix (разумеется, если они заданы), то есть удаляет или добавляет их в название символа. Если модификация приводит к получению цены, значит, скорее всего, найден рабочий алиас для символа.

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

Метод onChartEvent предназначен для обработки событий OnChartEvent, а точнее — нажатий на кнопки. Прежняя выделенная кнопка (если она была) становится серой как и все остальные, а самое главное — вызывается виртуальный метод apply, куда передается имя нового символа, выделенное из идентификатора кнопки.

    void onChartEvent(const int id, const long& lparam, const double& dparam, const string& sparam)
    {
      if(id == CHARTEVENT_OBJECT_CLICK)
      {
        int x = StringFind(sparam, "_#");
        if(x != -1)
        {
          string s = StringSubstr(sparam, x + 2);
          Print(s, " ", sparam, " ", pressed);
          
          ObjectSetInteger(0, sparam, OBJPROP_STATE, false);
          ObjectSetInteger(0, pressed, OBJPROP_STATE, false);
          ObjectSetInteger(0, pressed, OBJPROP_BGCOLOR, clrGray);
          pressed = "";
          
          if(apply(s)) // will set pressed and other properties
          {
            ChartSetSymbolPeriod(0, _Symbol, _Period);
          }
        }
      }
    }

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

Для парсинга HTML-страниц используем WebDataExtractor из статьи[1], оформив его в виде включаемого файла WebDataExtractor.mqh. По сравнению с оригинальными исходными кодами в данном файле все рабочие методы обернуты в класс HTMLConverter, чтобы не замусоривать глобальный контекст. Нас в первую очередь интересует метод HTMLConverter::convertReport2Map — он почти полностью совпадает с рассмотренной в [1] функцией process. На вход convertReport2Map подается имя файла отчета (из ReportFile), а на выходе мы получаем карту IndexMap со строками, соответствующими торговым операциям в таблице отчета.

Все настройки, необходимые для парсинга HTML-отчетов, такие как RowSelector, ColumnSettingsFile, уже указаны в заголовочном файле, но могут редактироваться, т.к. они описаны как входные параметры. По умолчанию, в параметрах указаны настройки для отчетов MT5, причем из них извлекается таблица сделок (deal), а уже на их основе, как будет показано далее, вычисляются отображаемые позиции. Каждая сделка описывается экземпляром специального класса Deal. Конструктор Deal принимает одну запись из таблицы отчета, упакованную в IndexMap. Внутри Deal, разумеется, хранит поля со временем и ценой сделки, её типом и направлением, объемом и прочими свойствами.

class ReportProcessor: public Processor
{
  private:
    class Deal   // if MQL5 could respect private access specifier for classes,
    {            // Trades will be unreachable from outer world, so it would be fine to have
      public:    // fields made public for direct access from Processor only
        datetime time;
        double price;
        int type;      // +1 - buy, -1 - sell
        int direction; // +1 - in, -1 - out, 0 - in/out
        double volume;
        double profit;
        long deal;
        long order;
        string comment;
        
      public:
        Deal(const IndexMap *row) // this is MT5 deal
        {
          time = StringToTime(row[COLUMN_TIME].get<string>()) + TimeShift;
          price = StringToDouble(row[COLUMN_PRICE].get<string>());
          string t = row[COLUMN_TYPE].get<string>();
          type = t == "buy" ? +1 : (t == "sell" ? -1 : 0);
          t = row[COLUMN_DIRECTION].get<string>();
          direction = 0;
          if(StringFind(t, "in") > -1) ++direction;
          if(StringFind(t, "out") > -1) --direction;
          volume = StringToDouble(row[COLUMN_VOLUME].get<string>());
          t = row[COLUMN_PROFIT].get<string>();
          StringReplace(t, " ", "");
          profit = StringToDouble(t);
          deal = StringToInteger(row[COLUMN_DEAL].get<string>());
          order = StringToInteger(row[COLUMN_ORDER].get<string>());
          comment = row[COLUMN_COMMENT].get<string>();
        }
    
        bool isIn() const
        {
          return direction >= 0;
        }
        
        bool isOut() const
        {
          return direction <= 0;
        }
        
        bool isOpposite(const Deal *t) const
        {
          return type * t.type < 0;
        }
        
        bool isActive() const
        {
          return volume > 0;
        }
    };

Все сделки попадают в массив array. А в процессе поступательного анализа истории мы будем помещать сделки входа в рынок в очередь (queue) и вынимать их оттуда по мере нахождения подходящей обратной сделки на выход. Если очередь окажется непустой после прохода всей истории, значит есть открытая позиция.

    RubbArray<Deal *> array;
    RubbArray<Deal *> queue;

Класс RubbArray представляет собой обертку для динамического массива, который автоматически расширяется под поступающие данные.

Вот как реализуются некоторые виртуальные методы:

    virtual IndexMap *load(const string file) override
    {
      return HTMLConverter::convertReport2Map(file, true);
    }

    virtual int getColumnCount() override
    {
      return COLUMNS_COUNT;
    }

    virtual int getSymbolColumn() override
    {
      return COLUMN_SYMBOL;
    }

Два последних метода используют макроопределения для стандартной таблицы сделок HTML-отчета MT5.

#define COLUMNS_COUNT 13
#define COLUMN_TIME 0
#define COLUMN_DEAL 1
#define COLUMN_SYMBOL 2
...

Метод applyInit очищает массивы array и queue.

    virtual bool applyInit() override
    {
      ((BaseArray<Deal *> *)&queue).clear();
      array.clear();
      return true;
    }

Объекты сделок создаются и помещается в массив array в методе makeTrade.

    virtual void makeTrade(IndexMap *row) override
    {
      array << new Deal(row);
    }

Наконец, самое интересное, но и самое сложное — анализ списка сделок и генерация на их основе объектов-трейдов.

    virtual int render() override
    {
      int count = 0;
      
      for(int i = 0; i < array.size(); ++i)
      {
        Deal *current = array[i];
        
        if(!current.isActive()) continue;
        
        if(current.isOut())
        {
          // first try to find exact match
          for(int j = 0; j < queue.size(); ++j)
          {
            if(queue[j].isIn() && queue[j].isOpposite(current) && queue[j].volume == current.volume)
            {
              string description;
              StringConcatenate(description, (float)queue[j].volume, "[", queue[j].deal, "/", queue[j].order, "-", current.deal, "/", current.order, "] ", (current.profit < 0 ? "-" : ""), current.profit, " ", current.comment);
              createTrend(queue[j].deal, current.deal, queue[j].type, queue[j].time, queue[j].price, current.time, current.price, description);
              current.volume = 0;
              queue >> j; // remove from queue
              ++count;
              break;
            }
          }

          if(!current.isActive()) continue;
          
          // second try to perform partial close
          for(int j = 0; j < queue.size(); ++j)
          {
            if(queue[j].isIn() && queue[j].isOpposite(current))
            {
              string description;
              if(current.volume >= queue[j].volume)
              {
                StringConcatenate(description, (float)queue[j].volume, "[", queue[j].deal, "/", queue[j].order, "-", current.deal, "/", current.order, "] ", (current.profit < 0 ? "-" : ""), current.profit, " ", current.comment);
                createTrend(queue[j].deal, current.deal, queue[j].type, queue[j].time, queue[j].price, current.time, current.price, description);

                current.volume -= queue[j].volume;
                queue[j].volume = 0;
                ++count;
              }
              else
              {
                StringConcatenate(description, (float)current.volume, "[", queue[j].deal, "/", queue[j].order, "-", current.deal, "/", current.order, "] ", (current.profit < 0 ? "-" : ""), current.profit, " ", current.comment);
                createTrend(queue[j].deal, current.deal, queue[j].type, queue[j].time, queue[j].price, current.time, current.price, description);

                queue[j].volume -= current.volume;
                current.volume = 0;
                ++count;
                break;
              }
            }
          }
          
          // purge all inactive from queue
          for(int j = queue.size() - 1; j >= 0; --j)
          {
            if(!queue[j].isActive())
            {
              queue >> j;
            }
          }
        }
        
        if(current.isActive()) // is _still_ active
        {
          if(current.isIn())
          {
            queue << current;
          }
        }
      }
      
      if(!isQueueEmpty())
      {
        Print("Warning: not all deals are processed (probably, open positions left).");
      }
      
      return count;
    }

Алгоритм продвигается по списку всех сделок и помещает в очередь сделки входа в рынок. При обнаружении сделки выхода в очереди сперва ищется подходящая по размеру противоположная сделка. Если такой нет, то по принципу FIFO последовательно выбираются объемы входных сделок, покрывающие объем выхода. Сделки, потерявшие свой объем вплоть до нуля, удаляются из очереди. На каждое сочетание входного и выходного объема создается своя трендовая линия (createTrend).

Режим FIFO выбран как наиболее обоснованный с алгоритмической точки зрения, но это не делает его единственно верным. Конкретный робот может закрывать сделки не только по FIFO, но и LIFO, и даже в произвольном порядке. То же самое тем более относится к ручной торговле. Поэтому для установления соответствия между открывающей и закрывающей сделкой в режиме хеджа требуется найти некий "обходной маневр", такой как анализ прибылей или комментариев. Пример расчета прибыли между двумя ценовыми точками можно найти в моем блоге, однако там нет учета курсовых разниц. В целом, это задача не столь тривиальна как кажется на первый взгляд и оставлена за рамками данной статьи. 

Таким образом, мы в общих чертах рассмотрели, как в описанных классах происходит обработка HTML-отчета. Для случая CSV-файла реализация класса HistoryProcessor заметно проще. Её при необходимости легко изучить по прилагаемым исходным кодам. Стоит лишь обратить внимание, что CSV-файлы с историей сигналов mql5.com имеют разное количество колонок для MT4 и MT5 — конкретный формат выбирается автоматически, исходя из двойного расширения: ".history.csv" для MT4 и ".positions.csv" для MT5. Единственной настройкой для CSV-файлов является символ-разделитель (в файлах сигналов mql5.com, по умолчанию - ';').

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

Объекты классов создаются и используются в обработчиках событий. В частности, очевидно, что процессор создается в OnInit и уничтожается в OnDeinit:

Processor *processor = NULL;

int OnInit()
{
  if(StringFind(ReportFile, ".htm") > 0)
  {
    processor = new ReportProcessor();
  }
  else if(StringFind(ReportFile, ".csv") > 0)
  {
    processor = new HistoryProcessor();
  }
  string Symbol = SubSymbol;
  if(Symbol == "") Symbol = _Symbol;
  else SymbolSelect(Symbol, true);
  processor.apply(Symbol);
  ...
}

void OnDeinit(const int reason)
{
  if(processor != NULL) delete processor;
}

Обработка событий от графических объектов добавляется в обработчик OnChartEvent:

void OnChartEvent(const int id,
                  const long& lparam,
                  const double& dparam,
                  const string& sparam)
{
  if(id == CHARTEVENT_CHART_CHANGE)
  {
    ... // same code
  }
  else
  {
    processor.onChartEvent(id, lparam, dparam, sparam);
  }
}

В обработчике OnCalculate требуется отследить ситуацию, когда в ответ на действия пользователя сменился текущий анализируемый символ, в связи с чем запускается полный пересчет:

  string Symbol = processor._realsymbol();
  if(Symbol == NULL) Symbol = _Symbol;
  if(lastSymbol != Symbol)
  {
    _prev_calculated = 0;
    lastAvailable = 0;
    initialized = false;
    IndicatorSetInteger(INDICATOR_DIGITS, (int)SymbolInfoInteger(Symbol, SYMBOL_DIGITS));
  }

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

  if(lastAvailable == iBars(Symbol, _Period) && lastAvailable != 0)
  {
    if(!initialized)
    {
      Print("Updated ", Symbol, " ", iBars(Symbol, _Period), " bars");
      initialized = true;
      if(ReportFile != "") //
      {                    //
        EventSetTimer(1);  //
      }                    //
    }
    
    return rates_total;
  }

В обработчике таймера загружаем отчет (если он еще не был загружен) и активируем выбранный символ (он устанавливается из параметров в OnInit или по нажатию кнопки в OnChartEvent).

void OnTimer()
{
  EventKillTimer();
  
  if(processor.isEmpty()) // load file only once
  {
    if(processor.attach(ReportFile))
    {
      processor.apply(/*keep already selected symbol*/);
    }
    else
    {
      Print("File loading failed: ", ReportFile);
    }
  }
}

Попробуем индикатор в действии. Для этого откроем график EURUSD, набросим на него индикатор и укажем в параметре ReportFile файл ReportTester-example.html (прилагается). После инициализации мы увидим в подокне график EURUSD (потому что параметр Symbol был оставлен пустым) и ряд кнопок с именами всех символов, упомянутых в отчете.

Индикатор SubChartReporter с невыбранным рабочим символом

Индикатор SubChartReporter с невыбранным рабочим символом

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

Индикатор SubChartReporter с выбранным рабочим символом

Индикатор SubChartReporter с выбранным рабочим символом

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

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

Скрипт SubChartsBuilder

Скрипт имеет полностью аналогичный набор параметров как и запускаемый индикатор SubChartReporter. Чтобы обеспечить одиночный запуск, параметры складываются в массив MqlParam и затем вызывается IndicatorCreate из MQL API. Все это сведено в одну прикладную функцию createIndicator.

bool createIndicator(const string symbol)
{
  MqlParam params[18] =
  {
    {TYPE_STRING, 0, 0.0, "::Indicators\\SubChartReporter.ex5"},
    
    {TYPE_INT, 0, 0.0, NULL}, // chart settings
    {TYPE_STRING, 0, 0.0, "XYZ"},
    {TYPE_BOOL, 1, 0.0, NULL},
    
    {TYPE_INT, 0, 0.0, NULL}, // common settings
    {TYPE_STRING, 0, 0.0, "HTMLCSV"},
    {TYPE_STRING, 0, 0.0, "PREFIX"},
    {TYPE_STRING, 0, 0.0, "SUFFIX"},
    {TYPE_INT, 0, 0.0, NULL}, // time shift

    {TYPE_INT, 0, 0.0, NULL}, // html settings
    {TYPE_STRING, 0, 0.0, "ROW"},
    {TYPE_STRING, 0, 0.0, "COLUMNS"},
    {TYPE_STRING, 0, 0.0, "SUBST"},
    {TYPE_BOOL, 0, 0.0, NULL},
    {TYPE_BOOL, 0, 0.0, NULL},
    {TYPE_BOOL, 0, 0.0, NULL},

    {TYPE_INT, 0, 0.0, NULL}, // csv settings
    {TYPE_STRING, 0, 0.0, ";"}
  };
  
  params[2].string_value = symbol;
  params[5].string_value = ReportFile;
  params[6].string_value = Prefix;
  params[7].string_value = Suffix;
  params[8].integer_value = TimeShift;
  params[10].string_value = RowSelector;
  params[11].string_value = ColumnSettingsFile;
  params[12].string_value = SubstitutionSettingsFile;
  params[17].string_value = CSVDelimiter;
  
  int handle = IndicatorCreate(_Symbol, _Period, IND_CUSTOM, 18, params);
  if(handle == INVALID_HANDLE)
  {
    Print("Can't create SubChartReporter for ", symbol, ": ", GetLastError());
    return false;
  }
  else
  {
    if(!ChartIndicatorAdd(0, (int)ChartGetInteger(0, CHART_WINDOWS_TOTAL), handle))
    {
      Print("Can't attach SubChartReporter for ", symbol, ": ", GetLastError());
      return false;
    }
  }
  return true;
}

Обратите внимание, что индикатор берется из ресурса, для чего он прописан ранее в исходном коде в инструкции:

#resource "\\Indicators\\SubChartReporter.ex5"

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

Кроме того, данный подход применен и с еще одной целью. Дело в том, что при генерации экземпляров индикаторов для всех символов отчета отпадает необходимость в кнопках управления. Однако MQL, к сожалению, не предоставляет возможности определить, запущен ли индикатор пользователем, или через IndicatorCreate, то есть работает ли он сам по себе или является составной (зависимой) частью более крупной программы. Разместив индикатор в ресурсе, мы получаем возможность менять видимость кнопок в зависимости от пути индикатора: путь в ресурсе (т.е. наличие строки "::Indicators\\") сигнализирует необходимость подавить вывод кнопок.

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

int OnStart()
{
  IndexMap *data = NULL;
  int columnsCount = 0, symbolColumn = 0;
  
  if(ReportFile == "")
  {
    Print("cleanUpChart");
    return cleanUpChart();
  }
  else if(StringFind(ReportFile, ".htm") > 0)
  {
    data = HTMLConverter::convertReport2Map(ReportFile, true);
    columnsCount = COLUMNS_COUNT;
    symbolColumn = COLUMN_SYMBOL;
  }
  else if(StringFind(ReportFile, ".csv") > 0)
  {
    data = CSVConverter::ReadCSV(ReportFile);
    if(data != NULL && data.getSize() > 0)
    {
      IndexMap *row = data[0];
      columnsCount = row.getSize();
      symbolColumn = CSV_COLUMN_SYMBOL;
    }
  }
  
  if(data != NULL)
  {
    IndexMap symbols;
    
    for(int i = 0; i < data.getSize(); ++i)
    {
      IndexMap *row = data[i];
      if(CheckPointer(row) == POINTER_INVALID || row.getSize() != columnsCount) break;
      
      string s = row[symbolColumn].get<string>();
      StringTrimLeft(s);
      if(StringLen(s) > 0) symbols.set(s);
    }
    
    for(int i = 0; i < symbols.getSize(); ++i)
    {
      createIndicator(symbols.getKey(i));
    }
    delete data;
  }

  return 0;
}

Обратите внимание, что если запустить скрипт с пустым именем отчета, он удалит из окна все подокна с экземплярами индикатора (если они были ранее созданы). Это делает функция cleanUpChart.

bool cleanUpChart()
{
  bool result = true;
  int n = (int)ChartGetInteger(0, CHART_WINDOWS_TOTAL);
  for(int i = n - 1; i > 0; --i)
  {
    string name = ChartIndicatorName(0, i, 0);
    if(StringFind(name, "SubChartReporter") == 0)
    {
      Print("Deleting ", name);
      result &= ChartIndicatorDelete(0, i, name);
    }
  }
  return result;
}

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

Чтобы протестировать скрипт я скачал несколько CSV-файлов с историями сигналов. Вот как это может выглядеть (основной график минимизирован):

Несколько экземпляров SubChartReporter при анализе мультивалютной торговли

Несколько экземпляров SubChartReporter при анализе мультивалютной торговли

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

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

Индикатор MainChartReporter

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

Исходный код трех основных классов — Processor, ReportProcessor, HistoryProcessor — вынесем в заголовочный файл и подключим его в обоих индикаторах. Те отличия, которые специфичны для каждой из версий, будем обрамлять инструкциями препроцессора для условной компиляции. Для кода индикатора SubChartReporter определим макрос CHART_REPORTER_SUB, а для кода индикатора MainChartReporter — CHART_REPORTER_MAIN.

В случае MainChartReporter потребуется добавить лишь 2 строки. В методе apply необходимо переключать график на новый реальный символ:

#ifdef CHART_REPORTER_MAIN
      ChartSetSymbolPeriod(0, real, _Period);
#endif

а также выводить комментарий с текстом, который в первой версии индикатора устанавливался как его имя (INDICATOR_SHORTNAME).

#ifdef CHART_REPORTER_MAIN
      Comment(title);
#endif

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

#ifdef CHART_REPORTER_SUB
            ChartSetSymbolPeriod(0, _Symbol, _Period);
#endif

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

#property indicator_chart_window
#property indicator_buffers 0
#property indicator_plots   0

Также удаляем за ненадобностью группу настроек подокна:

input GroupSettings Chart_Settings; // S U B C H A R T    S E T T I N G S
input string SubSymbol = ""; // · Symbol
input bool Exact = true; // · Exact

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

void OnTimer()
{
  EventKillTimer();
  
  if(processor.isEmpty()) // load file only once
  {
    if(processor.attach(ReportFile))
    {
      processor.apply();
      datetime start = processor.getStart();
      if(start != 0)
      {
        ChartSetInteger(ChartID(), CHART_AUTOSCROLL, false);
        // FIXME: this does not work as expected
        ChartNavigate(ChartID(), CHART_END, -1 * (iBarShift(_Symbol, _Period, start)));
      }
    }
  }
}

Здесь предпринята попытка прокрутить график к первой сделке с помощью вызова ChartNavigate. К сожалению, мне не удалось добиться работоспособности этого фрагмента — смещения просто не происходит. Возможное решение видится в определении текущей позиции и навигации относительно неё с помощью CHART_CURRENT_POS, но оно не кажется оптимальным.

Вот как выглядит индикатор MainChartReporter на графике.

Индикатор MainChartReporter

Индикатор MainChartReporter

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

  • SubChart.mq5 — индикатор SubChart;
  • SubChartReporter.mq5 — индикатор SubChartReporter;
  • MainChartReporter.mq5 — индикатор MainChartReporter;
  • SubChartsBuilder.mq5 — скрипт для создания группы экземпляров индикатора SubChartReporter по всем символам отчета;
  • ChartReporterCore.mqh — основные общие классы для индикаторов;
  • WebDataExtractor.mqh — парсер HTML;
  • CSVReader.mqh — парсер CSV;
  • HTMLcolumns.mqh — определение колонок HTML-отчетов;
  • CSVcolumns.mqh — определение колонок CSV-файлов;
  • IndexMap.mqh — вспомогательный класс карты;
  • RubbArray.mqh — вспомогательный класс резинового массива;
  • StringUtils.mqh — вспомогательные функции для работы со строками;
  • empty_strings.h — перечень пустых тегов для HTML-парсера;
  • GroupSettings.mqh — оформление групп входных параметров;
  • Refresh.mqh — запрос котировок по символам;
  • ReportTester-example.html — пример HTML-отчета тестера;
  • ReportHistoryDeals.cfg.csv — настройки CSS-селекторов для выделения колонок таблицы HTML-парсером.

Заключение

Мы рассмотрели несколько индикаторов, которые позволяют визуализировать котировки и торговые сделки по нескольким символам. В качестве источника входных данных выступают отчеты в формате HTML, причем благодаря применению универсального парсера возможно подключение не только стандартных отчетов (настройки для которых уже включены в исходный код), но и других. Также обеспечена поддержка файлов формата CSV, в которых предоставляется торговая история сигналов mql5.com. Открытый исходный код позволяет адаптировать программы под собственные нужды.

Прикрепленные файлы |
report2chart.zip (34.55 KB)
Как за 10 минут написать DLL библиотеку на MQL5 (Часть II): Пишем в среде Visual Studio 2017 Как за 10 минут написать DLL библиотеку на MQL5 (Часть II): Пишем в среде Visual Studio 2017

Первоначальная "базовая" статья отнюдь не потеряла актуальности и всем интересующимся данной темой просто необходимо ее прочесть. Но с тех пор прошло достаточно много времени, сейчас актуальна версия Visual Studio 2017, в которой изменился, пусть и не значительно, интерфейс, да и сама платформа MetaTrader 5 развивалась и не стояла на месте. В статье рассмотрены этапы создания проекта dll, его настройки и совместной работы с инструментами терминала MetaTrader 5.

Исследование методов свечного анализа (Часть III): Библиотека работы с паттернами Исследование методов свечного анализа (Часть III): Библиотека работы с паттернами

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

Библиотека для простого и быстрого создания программ для MetaTrader (Часть V): Классы и коллекция торговых событий, отправка событий в программу Библиотека для простого и быстрого создания программ для MetaTrader (Часть V): Классы и коллекция торговых событий, отправка событий в программу

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

Веб-скрапинг данных о доходности облигаций Веб-скрапинг данных о доходности облигаций

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