Работа с tpl-шаблонами графика

Две функции MQL5 API позволяют работать с так шаблонами — файлами с расширением tpl, в которых сохраняется наполнение графиков, то есть все их настройки, вместе с нанесенными объектами, индикаторами и экспертом (если они есть).

bool ChartSaveTemplate(long chartId, const string filename)

Функция сохраняет текущие настройки графика в tpl-шаблон с указанным именем.

График задается идентификатором chartId, 0 означает текущий график.

Имя файла для сохранения шаблона (filename) можно указывать без расширения ".tpl": оно будет добавлено автоматически. Шаблон по умолчанию сохраняется в папку каталог_терминала/Profiles/Templates/ и может быть использован затем для ручного применения в терминале. Однако допустимо указать не просто имя, но и путь относительно каталога MQL5, в частности, начинающийся c "/Files/". Таким образом появится возможность открыть сохраненный шаблон функциями работы с файлами, проанализировать, и при необходимости отредактировать (см. пример ChartTemplate.mq5 далее).

Если одноименный файл по указанному пути уже существует, его содержимое будет перезаписано.

Объединенный пример для сохранения и применения шаблона мы рассмотрим чуть позже.

bool ChartApplyTemplate(long chartId, const string filename)

Функция применяет к графику chartId шаблон из указанного файла.

Поиск файла шаблона осуществляется по следующим правилам:

  • Если имя filename содержит путь (начинается с обратной "\\" или прямой "/" косой черты), то шаблон ищется относительно пути каталог_данных_терминала/MQL5.
  • Если пути в имени нет, шаблон ищется в том же месте, где расположен исполняемый EX5-файла, в котором происходит вызов функции.
  • Если шаблон не найден в первых двух местах, он ищется в стандартной папке для шаблонов каталог_терминала/Profiles/Templates/.

Обратите внимание, что каталог_данных_терминала означает папку, в которой хранятся изменяемые файлы и ее расположение может зависеть от типа операционной системы, имени пользователя и настроек безопасности компьютера. В общем случае она отличается от папки каталог_терминала, хотя в некоторых случаях (например, при работе под учетной записью из группы администраторов) они могут совпадать. Расположение папок каталог_данных_терминала и каталог_терминала можно узнать с помощью функции TerminalInfoString (см. константы TERMINAL_DATA_PATH и TERMINAL_PATH, соответственно).

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

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

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

Пример скрипта ChartDuplicate.mq5 позволяет создать копию текущего графика.

void OnStart()
{
   const string temp = "/Files/ChartTemp";
   if(ChartSaveTemplate(0temp))
   {
      const long id = ChartOpen(NULL0);
      if(!ChartApplyTemplate(idtemp))
      {
         Print("Apply Error: "_LastError);
      }
   }
   else
   {
      Print("Save Error: "_LastError);
   }
}

Сначала с помощью ChartSaveTemplate создается временный tpl-файл, затем открывается новый график (вызов ChartOpen), и наконец функция ChartApplyTemplate применяет этот шаблон к новому графику.

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

Использование шаблонов позволяет изменять многие свойства графиков, которые недоступны с помощью остальных функций MQL5 API, например, видимость индикаторов в разрезе таймфреймов, порядок подокон индикаторов вместе с нанесенными на них объектами и т.д.

Формат tpl-файла идентичен chr-файлам, используемым терминалом для хранения графиков между сеансами (в папке каталог_терминала/Profiles/Charts/имя_профиля).

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

Контейнер начинается строкой вида "<tag>", где tag — один из предопределенных типов контейнеров (см. далее), а заканчивается парной строкой вида "</tag>" (названия тегов должны совпадать). Иными словами, формат похож в некотором смысле на XML (без заголовка), в котором все лексические единицы должны записываться на отдельных строках, а свойства тегов указываются не их атрибутами (как в XML — внутри открывающей части "<tag attribute1=value1...>"), а во внутреннем тексте тега.

Перечень поддерживаемых тегов включает, в частности:

  • chart — корневой контейнер с основными свойствами графика и всеми подчиненными контейнерами;
  • expert — контейнер с общими свойствами эксперта, например, разрешением на торговлю (внутри chart);
  • window — контейнер со свойствами окна/подокна и его подчиненными контейнерами (внутри chart);
  • object — контейнер со свойствами графического объекта (внутри window);
  • indicator — контейнер со свойствами индикатора (внутри window);
  • graph — контейнер со свойствами диаграммы индикатора (внутри indicator);
  • level — контейнер со свойствами уровня индикатора (внутри indicator);
  • period — контейнер со свойствами видимости объекта или индикатора на конкретном таймфрейме (внутри object или indicator);
  • inputs — контейнер с настройками (входными переменными) пользовательских индикаторов и экспертов.

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

Вот фрагменты из одного tpl-файла (отступы в форматировании сделаны для наглядного представления вложенности контейнеров).

<chart>
id=0
symbol=EURUSD
description=Euro vs US Dollar
period_type=1
period_size=1
digits=5
...
<window>
  height=117.133747
  objects=0
  <indicator>
    name=Main
    path=
    apply=1
    show_data=1
    ...
    fixed_height=-1
  </indicator>
</window>
<window>
  <indicator>
    name=Momentum
    path=
    apply=6
    show_data=1
    ...
    fixed_height=-1
    period=14
    <graph>
      name=
      draw=1
      style=0
      width=1
      color=16748574
    </graph>
  </indicator>
  ...
</window>
</chart>

Для работы с tpl-файлами подготовлен заголовочный файл TplFile.mqh, с помощью которого можно анализировать и модифицировать шаблоны. В нем реализовано 2 класса:

  • Container — для чтения и хранения элементов файла с учетом иерархии (вложенности), а также записи в файл после возможной модификации;
  • Selector — для последовательного обхода элементов иерархии (объектов Container) в поисках совпадения с неким запросом, который записывается в виде строки, похожей на xpath-селектор ("/path/element[attribute=value]").

Объекты класса Container создаются с помощью конструктора, который принимает первым параметром дескриптор tpl-файла для чтения, а вторым — название тега. По умолчанию название тега равно NULL, что подразумевает корневой контейнер (весь файл целиком). Таким образом, контейнер сам заполняет себя содержимым в процессе чтения файла (см. метод read).

Свойства текущего элемента, то есть пары "ключ=значение", расположенные непосредственно внутри данного контейнера, предполагается складывать в карту MapArray<string,string> properties. Вложенные контейнеры попадают в массив Container *children[].

#include <MQL5Book/MapArray.mqh>
   
#define PUSH(A,V) (A[ArrayResize(AArraySize(A) + 1) - 1] = V)
   
class Container
{
   MapArray<string,stringproperties;
   Container *children[];
   const string tag;
   const int handle;
public:
   Container(const int hconst string t = NULL): handle(h), tag(t) { }
   ~Container()
   {
      for(int i = 0i < ArraySize(children); ++i)
      {
         if(CheckPointer(children[i]) == POINTER_DYNAMICdelete children[i];
      }
   }
      
   bool read(const bool verbose = false)
   {
      while(!FileIsEnding(handle))
      {
         string text = FileReadString(handle);
         const int len = StringLen(text);
         if(len > 0)
         {
            if(text[0] == '<' && text[len - 1] == '>')
            {
               const string subtag = StringSubstr(text1len - 2);
               if(subtag[0] == '/' && StringFind(subtagtag) == 1)
               {
                  if(verbose)
                  {
                     print();
                  }
                  return true;       // элемент готов
               }
               
               PUSH(childrennew Container(handlesubtag)).read(verbose);
            }
            else
            {
               string pair[];
               if(StringSplit(text, '=', pair) == 2)
               {
                  properties.put(pair[0], pair[1]);
               }
            }
         }
      }
      return false;
   }
   ...
};

В методе read мы построчно читаем и анализируем файл. Если встречается открывающий тег вида "<tag>", мы создаем новый объект-контейнер и продолжаем чтение в нем. Если встречается закрывающий тег вида "</tag>" с совпадающим именем, возвращаем признак успеха (true) — контейнер сформирован. В остальных строках считываем пары "ключ=значение" и складываем в массив properties.

Для поиска элементов в шаблоне разработан класс Selector. В его конструктор передается строка с иерархией искомых тегов. Например, строка "/chart/window/indicator" соответствует графику, в котором есть окно/подокно, а в нем, в свою очередь, любой индикатор. Результатом поиска будет первое совпадение. Данный запрос, как правило, найдет график котировок, потому что он хранится в шаблоне как индикатор с именем "Main" и идет в начале файла, до прочих подокон.

Более практичны запросы с указанием названия и значения конкретного атрибута. В частности, модифицированная строка "/chart/window/indicator[name=Momentum]" будет искать только индикатор Momentum. Данный поиск отличается от вызова ChartWindowFind, потому что здесь имя указано без параметров, в то время как ChartWindowFind использует короткое имя индикатора, в которое обычно включаются значения параметров, а они могут варьироваться.

Для встроенных индикаторов свойство name содержит непосредственно название, а для пользовательских в нем будет написано "Custom Indicator". Ссылка на пользовательский индикатор дается в свойстве path в виде пути к исполняемому файлу, например, "Indicators\MQL5Book\IndTripleEMA.ex5".

Итак, обратимся к внутреннему устройству класса Selector.

class Selector
{
   const string selector;
   string path[];
   int cursor;
public:
   Selector(const string s): selector(s), cursor(0)
   {
      StringSplit(selector, '/', path);
   }
   ...

В конструкторе мы раскладываем запрос selector на отдельные составляющие и сохраняем их в массиве path. Текущий компонент пути, который проверяется на соответствие в шаблоне, задается переменной cursor. В начале поиска мы находимся в корневом контейнере (рассматриваем tpl-файл целиком), и cursor равен 0. По мере нахождения совпадений cursor должен будет увеличиваться (см. метод accept ниже).

В классе перегружен оператор [], с помощью которого можно получить i-й фрагмент пути. Здесь также учитывается, что во фрагменте, в квадратных скобках, может быть задана пара "[ключ=значение]".

   string operator[](int iconst
   {
      if(i < 0 || i >= ArraySize(path)) return NULL;
      const int param = StringFind(path[i], "[");
      if(param > 0)
      {
         return StringSubstr(path[i], 0param);
      }
      return path[i];
   }
   ...

Метод accept проверяет, совпадает ли название элемента (tag) и его свойства (properties) с теми данными, что указаны в пути селектора для текущей позиции курсора. Запись this[cursor] использует вышеприведенную перегрузку оператора [].

   bool accept(const string tagMapArray<string,string> &properties)
   {
      const string name = this[cursor];
      if(!(name == "" && tag == NULL) && (name != tag))
      {
         return false;
      }
      
      // если в запросе есть параметр, проверяем его среди свойств
      // NB! пока поддерживается только один атрибут, а нужно много "tag[a1=v1][a2=v2]..."      
      const int start = StringLen(path[cursor]) > 0 ? StringFind(path[cursor], "[") : 0;
      if(start > 0)
      {
         const int stop = StringFind(path[cursor], "]");
         const string prop = StringSubstr(path[cursor], start + 1stop - start - 1);
         
         // NB! поддерживается только проверка на равно '=', а должно быть '>', '<', и т.д.
         string kv[];   // ключ и значение
         if(StringSplit(prop, '=', kv) == 2)
         {
            const string value = properties[kv[0]];
            if(kv[1] != value)
            {
               return false;
            }
         }
      }
      
      cursor++;
      return true;
   }
   ...

Метод вернет false, если название тега не совпадает с текущим фрагментом пути, а также если во фрагменте было указано значение какого-либо параметра и оно не равно или отсутствует в массиве properties. В остальных случаях мы получим совпадение условий, в результате чего курсор будет продвинут вперед (cursor++), а метод вернет true.

Процесс поиска будет успешно завершен, когда курсор достигнет последнего фрагмента в запросе, поэтому нам нужен метод для определения этого момента — isComplete.

   bool isComplete() const
   {
      return cursor == ArraySize(path);
   }
   
   int level() const
   {
      return cursor;
   }

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

   bool unwind()
   {
      if(cursor > 0)
      {
         cursor--;
         return true;
      }
      return false;
   }
};

Теперь все готово для организации поиска в иерархии контейнеров (которые мы получаем после чтения tpl-файла) с помощью объекта Selector. Все необходимые действия будет выполнять метод find в классе Container. Он принимает в качестве входного параметра объект Selector и рекурсивно вызывает сам себя, пока имеются совпадения согласно методу Selector::accept. Достижение конца запроса означает успех, и метод find вернет текущий контейнер в вызывающий код.

   Container *find(Selector *selector)
   {
      const string element = StringFormat("%*s"2 * selector.level(), " ")
         + "<" + tag + "> " + (string)ArraySize(children);
      if(selector.accept(tagproperties))
      {
         Print(element + " accepted");
         
         if(selector.isComplete())
         {
            return &this;
         }
         
         for(int i = 0i < ArraySize(children); ++i)
         {
            Container *c = children[i].find(selector);
            if(creturn c;
         }
         selector.unwind();
      }
      else
      {
         Print(element);
      }
      
      return NULL;
   }
   ...

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

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

Для упрощения вызова поиска добавлен одноименный метод, принимающий строковый параметр и создающий объект Selector локально.

   Container *find(const string selector)
   {
      Selector s(selector);
      return find(&s);
   }

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

   void assign(const string keyconst string value)
   {
      properties.put(keyvalue);
   }
   
   Container *add(const string subtag)
   {
      return PUSH(childrennew Container(handlesubtag));
   }
   
   void remove(const string key)
   {
      properties.remove(key);
   }

А после редактирования необходимо будет записать содержимое контейнеров обратно в файл (тот же самый или другой). Вспомогательный метод save сохраняет объект в описанном выше tpl-формате: начинает с открывающего тега "<tag>", продолжает выгрузкой всех свойств "ключ=значение" и вызывает save у вложенных объектов, после чего завершает закрывающим тегом "</tag>". Дескриптор файла для сохранения передается в параметре.

   bool save(const int h)
   {
      if(tag != NULL)
      {
         if(FileWriteString(h"<" + tag + ">\n") <= 0)
            return false;
      }
      for(int i = 0i < properties.getSize(); ++i)
      {
         if(FileWriteString(hproperties.getKey(i) + "=" + properties[i] + "\n") <= 0)
            return false;
      }
      for(int i = 0i < ArraySize(children); ++i)
      {
         children[i].save(h);
      }
      if(tag != NULL)
      {
         if(FileWriteString(h"</" + tag + ">\n") <= 0)
            return false;
      }
      return true;
   }

Высокоуровневый метод записи всего шаблона в файл называется write. Его входной параметр (дескриптор файла) может быть равен 0, что означает запись в тот же файл, откуда производилось чтение. Однако файл для этого должен быть открыт с правами на запись.

Важно отметить, что при перезаписи текстового Unicode-файла MQL5 не пишет начальную метку UTF (так называемый BOM, Byte Order Mark), в связи с чем мы вынуждены делать это сами. В противном случае (без метки), терминал не станет читать и применять наш шаблон.

Если вызывающий код передаст в параметре h другой файл, открытый исключительно на запись в формате Unicode, MQL5 запишет BOM автоматически.

   bool write(int h = 0)
   {
      bool rewriting = false;
      if(h == 0)
      {
         h = handle;
         rewriting = true;
      }
      if(!FileGetInteger(hFILE_IS_WRITABLE))
      {
         Print("File is not writable");
         return false;
      }
      
      if(rewriting)
      {
         // NB! Пишем BOM вручную, потому что MQL5 не делает это при перезаписи
         ushort u[1] = {0xFEFF};
         FileSeek(hSEEK_SET0);
         FileWriteString(hShortArrayToString(u));
      }
      
      bool result = save(h);
      
      if(rewriting)
      {
         // NB! MQL5 не позволяет уменьшить размер файла,
         // поэтому заполняем лишнюю концовку пробелами
         while(FileTell(h) < FileSize(h) && !IsStopped())
         {
            FileWriteString(h" ");
         }
      }
      return result;
   }

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

В шаблоне видимость индикатора для таймфреймов указывается в контейнере <indicator>, внутри которого для каждого видимого таймфрейма записывается свой контейнер <period>. Например, видимость на таймфрейме M15 выглядит так:

<period>
period_type=0
period_size=15
</period>

Внутри контейнера <period> используются свойства period_type и period_size. period_type — это единица измерения, одна из следующих:

  • 0 — минуты;
  • 1 — часы;
  • 2 — недели;
  • 3 — месяцы;

period_size — это количество единиц измерения в таймфрейме. Следует отметить, что дневной таймфрейм обозначается, как 24 часа.

Когда в контейнере <indicator> нет ни одного вложенного контейнера <period>, индикатор выводится на всех таймфреймах.

К книге прилагается скрипт ChartTemplate.mq5, который добавляет на график индикатор Momentum (если он еще отсутствует) и делает его видимым на единственном месячном таймфрейме.

void OnStart()
{
   // если Momentum(14) еще нет на графике, добавляем его
   const int w = ChartWindowFind(0"Momentum(14)");
   if(w == -1)
   {
      const int momentum = iMomentum(NULL014PRICE_TYPICAL);
      ChartIndicatorAdd(0, (int)ChartGetInteger(0CHART_WINDOWS_TOTAL), momentum);
      // не обязательно здесь, потому что скрипт скоро завершит работу,
      // однако явно декларирует, что дескриптор больше не потребуется в коде
      IndicatorRelease(momentum);
   }
   ...

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

   const string filename = _Symbol + "-" + PeriodToString(_Period) + "-momentum-rw";
   if(PRTF(ChartSaveTemplate(0"/Files/" + filename)))
   {
      int handle = PRTF(FileOpen(filename + ".tpl",
         FILE_READ | FILE_WRITE | FILE_TXT | FILE_SHARE_READ | FILE_SHARE_WRITE));
      // альтернатива - другой файл, открытый только на запись
      // int writer = PRTF(FileOpen(filename + "w.tpl",
      //    FILE_WRITE | FILE_TXT | FILE_SHARE_READ | FILE_SHARE_WRITE));

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

      Container main(handle);
      main.read();

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

С помощью селектора выполняем поиск, распечатываем найденный объект (просто для справки) и добавляем в него вложенный контейнер <period> с настройками для отображения месячного таймфрейма.

      Container *found = main.find("/chart/window/indicator[name=Momentum]");
      if(found)
      {
         found.print();
         Container *period = found.add("period");
         period.assign("period_type""3");
         period.assign("period_size""1");
      }

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

      main.write(); // или main.write(writer);
      FileClose(handle);
      
      PRTF(ChartApplyTemplate(0"/Files/" + filename));
   }
}

При запуске скрипта на чистом графике увидим такие записи в жрунале.

ChartSaveTemplate(0,/Files/+filename)=true / ok
FileOpen(filename+.tpl,FILE_READ|FILE_WRITE|FILE_TXT| »
» FILE_SHARE_READ|FILE_SHARE_WRITE|FILE_UNICODE)=1 / ok
 <> 1 accepted
  <chart> 2 accepted
    <window> 1 accepted
      <indicator> 0
    <window> 1 accepted
      <indicator> 1 accepted
Tag: indicator
                    [key]    [value]
[ 0] "name"               "Momentum"
[ 1] "path"               ""        
[ 2] "apply"              "6"       
[ 3] "show_data"          "1"       
[ 4] "scale_inherit"      "0"       
[ 5] "scale_line"         "0"       
[ 6] "scale_line_percent" "50"      
[ 7] "scale_line_value"   "0.000000"
[ 8] "scale_fix_min"      "0"       
[ 9] "scale_fix_min_val"  "0.000000"
[10] "scale_fix_max"      "0"       
[11] "scale_fix_max_val"  "0.000000"
[12] "expertmode"         "0"       
[13] "fixed_height"       "-1"      
[14] "period"             "14"      
ChartApplyTemplate(0,/Files/+filename)=true / ok

Здесь видно, что прежде чем найти нужный индикатор (с пометкой "accepted"), алгоритм нашел индикатор в предыдущем, основном окне, но он не подошел, потому что его имя не равно искомому "Momentum".

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

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