Советы профессионального программиста (Часть II): Организация хранения и обмена параметров между экспертом, скриптами и внешними программами

Malik Arykov | 6 мая, 2021

Содержание


Введение

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


Места хранения параметров


Примеры параметров

Где хранить подобные параметры?
Места хранения Тип Область видимости Время жизни
Глобальные переменные терминала double Все графики 4 недели после последнего обращения
Графические объекты Любой. Строки <= 63 символов Текущий график Время жизни графика
Комментарии ордеров Строки длиной <= 23 символов Все графики Время жизни терминала
Текстовые файлы Любой. Без ограничений Все графики Время жизни файла


Глобальные переменные терминала

Глобальные переменные терминала доступны с любого графика. Область видимости можно ограничить за счет включения в имя переменной таких составляющих, как ChartId, Symbol, Period. Однако против типа переменной «не попрешь». Нельзя сохранить текст.

Однако есть один прием — упаковка/распаковка целых значений. Как известно, double занимает 8 байтов (64 бит). Покажу на примере, как хранить в одной переменной несколько целых значений. Самое главное — определить разрядность в битах их максимальных значений.

// -----------------------------------------------------------------------------
// Пример упаковки/распаковки целых значений в/из глобальной переменой         |
// с помощью битовых операций                                                  |
// -----------------------------------------------------------------------------
void OnStart() {
    
    int     value10 = 10; // max = 255 (8 разрядов)
    int     value20 = 300; // max = 65535 (16 разрядов)
    bool    value30 = true; // max = 1 (1 разряд)
    
    // упаковываем значения в 25 бит (8+16+1)
    // остаются свободными 39 бит (64-25)
    ulong packedValue = 
        (value10 << 17) + // резервируем место (16+1) для value20, value30
        (value20 << 1) + // резервируем место (1) для value30
        value30;
    
    // сохраняем глоб.переменную
    string nameGVar = "temp";
    GlobalVariableSet(nameGVar, packedValue);
    
    // считываем глоб.переменную
    packedValue = (ulong)GlobalVariableGet(nameGVar);
    
    // распаковываем значения
    // 0xFF, 0xFFFF, 0x1 - битовые маски максимальных значений
    int value11 = (int)((packedValue >> 17) & 0xFF);
    int value21 = (int)((packedValue >> 1) & 0xFFFF);
    bool value31 = (bool)(packedValue & 0x1);
    
    // сравниваем значения
    if (value11 == value10 && value21 == value20 && value31 == value30) Print("OK");
    else PrintFormat("0x%X / 0x%X /0x%X / 0x%X", packedValue, value11, value21, value31);
}


Графические объекты

Хм, в графических объектах можно хранить параметры скриптов? А почему нет. Устанавливаем свойство объекта OBJPROP_PRICE = 0, тогда объект станет визуально «не видимым», но программно доступным. Для надежности можно сохранить такой объект в шаблоне графика. Логика доступа к параметрам: есть объект — извлекаем параметры, нет объекта — устанавливаем значения по умолчанию.


Комментарии ордеров

Максимальная длина комментария ордера 23 символа. Что там можно хранить? Например, SOP/H1/SS/C2/Br/Br/Br. Где (слева на право)

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


Текстовые файлы

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


Настройки приложения

Часть файла настроек приложения AppSettings.txt

# -------------------------------------------------------------------
# Настройки эксперта и скриптов
# Кодировка файла = UCS-2 LE с BOM (обязательна!!!) // это юникод
# -------------------------------------------------------------------
TimeEurWinter = 10:00 # зимнее время начала Европейской сессии (время сервера)
TimeEurSummer = 09:00 # летнее время начала Европейской сессии (время сервера)
ColorSessionEur = 224,255,255 # цвет Европейской сессии
ColorSessionUsd = 255,240,245 # цвет Американской сессии
NumberColorDays = 10 # кол-во подсвеченных дней (сессий)


Класс AppSettings.mqh

#property copyright "Copyright 2020, Malik Arykov"
#property link      "malik.arykov@gmail.com"
#property strict

#include <Cayman/Params.mqh>

// имена параметров приложения
#define APP_TIME_EUR_SUMMER "TimeEurSummer"
#define APP_TIME_EUR_WINTER "TimeEurWinter"
#define APP_TIME_TRADE_ASIA "TimeTradeAsia"
#define APP_COLOR_SESSION_EUR "ColorSessionEur"
#define APP_COLOR_SESSION_USD "ColorSessionUsd"
#define APP_NUMBER_COLOR_DAYS "NumberColorDays"

// -----------------------------------------------------------------------------
// Общие настройки Советника и Скриптов                                        |
// -----------------------------------------------------------------------------
class AppSettings {
private:
    Params  *m_params;
public:
    // устанавливаются в файле AppSettings.txt
    string  TimeEurSummer; // летнее время начала Европейской сессии
    string  TimeEurWinter; // зимнее время начала Европейской сессии
    string  TimeTradeAsia; // время окончания торговли коридора Азии
    color   ColorSessionEur; // цвет Европейской сессии
    color   ColorSessionUsd; // цвет Американской сессии
    int     NumberColorDays; // кол-во подсвеченных дней

    // устанавливаются программно
    string  PeriodTrends; // Периоды расчета трендов (D1,H4)
    string  TradePlan; // Направление торговли (краткий план)
    bool    IsValid; // допустимость параметров
    
    // методы
    AppSettings();
    ~AppSettings() { delete m_params; };
    void Dump(string sender);
};

// -----------------------------------------------------------------------------
// Конструктор                                                                 |
// -----------------------------------------------------------------------------
AppSettings::AppSettings() {

    IsValid = true;
    m_params = new Params();
    m_params.Load(PATH_APP_SETTINGS);
    if (m_params.Total() == 0) {
        PrintFormat("%s / ERROR: Не допустимый файл / %s", __FUNCTION__, PATH_APP_SETTINGS);
        IsValid = false;
        return;
    }
        
    TimeEurWinter = m_params.GetValue(APP_TIME_EUR_WINTER);
    TimeEurSummer = m_params.GetValue(APP_TIME_EUR_SUMMER);
    TimeTradeAsia = m_params.GetValue(APP_TIME_TRADE_ASIA);
    ColorSessionEur = StringToColor(m_params.GetValue(APP_COLOR_SESSION_EUR));
    ColorSessionUsd = StringToColor(m_params.GetValue(APP_COLOR_SESSION_USD));
    NumberColorDays = (int)StringToInteger(m_params.GetValue(APP_NUMBER_COLOR_DAYS));
}

// -----------------------------------------------------------------------------
// Распечатать параметры настроек                                              | 
// -----------------------------------------------------------------------------
void AppSettings::Dump(string sender) {
    PrintFormat("sender=%s / %s", sender, PATH_APP_SETTINGS);
    PrintFormat("%s = %s", APP_TIME_EUR_WINTER, TimeEurWinter);
    PrintFormat("%s = %s", APP_TIME_EUR_SUMMER, TimeEurSummer);
    PrintFormat("%s = %s", APP_TIME_TRADE_ASIA, TimeTradeAsia);
    PrintFormat("%s = %s / %s", APP_COLOR_SESSION_EUR, ColorToString(ColorSessionEur), ColorToString(ColorSessionEur, true));
    PrintFormat("%s = %s / %s", APP_COLOR_SESSION_USD, ColorToString(ColorSessionEur), ColorToString(ColorSessionEur, true));
    PrintFormat("%s = %i", APP_NUMBER_COLOR_DAYS, NumberColorDays);
}


Особености

Объявление класса AppSettings я разместил в файле Uterminal.mqh, который подключается через #include к эксперту и любому скрипту.

extern AppSettings  *gAppSettings; // настройки приложения

Такое решение позволяет:


Параметры анализаторов

Эксперт Cayman управляет различными анализаторами, например AnalyserTrend, AnalyserLevel, AnalyserVirtSL. Каждый анализатор привязан к определенному таймфрейму. Т.е. анализ запускается только в момент появления нового бара на заданном периоде. Параметры анализатора хранятся в текстовом файле со строками Key = Value. Например, анализатор торгового уровня на H4 хранит свои параметры в файле Files\Cayman\Params\128968168864101576\exp_05_Lev607A160E_H4.txt

Приведу содержимое файла с комментариями (реальный файл — без комментариев)

// параметры торгового уровня
nameObj=Lev607A160E // имя торгового уровня
kindLevel=1 // вид уровня (1 - сопротивление)
riskValue=1.00 // объем сделки при пробое уровня (1)
riskUnit=1 // единица измерения объема сделки (1 - % средств для залога)
algClose=2 // алгоритм закрытия сделки (2 – два коррекционных бара)
ticketNew=0 // тикет сделки открытый при пробое уровня
ticketOld=0 // тикет для закрытия сделки при пробое уровня
profits=0 // планируемый профит в пунктах
losses=0 // планируемый убыток в пунктах
// параметры анализатора
symbol=EURUSD // имя символа
period=16388 // период анализатора (H4)
time0Bar=1618603200 // время нулевого бара (сек)
typeAnalyser=5 // тип анализатора
colorAnalyser=16711935 // цвет результатов анализатора
resultAnalyser=Lev607A160E, H4, 20:00, RS // результат анализатора

Есть базовый класс Analyser, который умеет сохранять и восстанавливать параметры любого анализатора. При перезапуске эксперта (например, при переключении таймфрейма) анализаторы восстанавливают свои параметры из соответствующих текстовых файлов. При этом, если не наступило время нового бара, анализ повторно не запускается. Результаты анализаторов (reseultAnalyser, colorAnalyser), рассчитанные на предыдущем баре, отображаются в комментариях эксперта.


Передача параметров скрипта эксперту

Скрипт SetTradeLevel позволяет установить параметры торгового уровня. На графике выделяется один объект (прямая или трендовая линия, или прямоугольник). Скрипт SetTradeLevel находит выделенный объект (торговый уровень) и устанавливает его параметры.

Параметры скрипта SetTradeLevel

Далее, скрипт сохраняет параметры в файл Files\Cayman\Params\128968168864101576\exp_05_Lev607A160E_H4.txt и оправляет команду и путь к файлу через функцию SendCommand

// -----------------------------------------------------------------------------
// Отправить параметры уровня эксперту                                         |
// -----------------------------------------------------------------------------
NCommand SendCommand() {

    // загружаем параметры уровня (если есть)
    Params *params = new Params();
    string speriod = UConvert::PeriodToStr(_Period);
    params.Load(PREFIX_EXPERT, anaLevel, gNameLev, speriod);

    // определяем команду
    NCommand cmd = 
        (gKindLevel == levUnknown) ? cmdDelete :
        (params.Total() > 0) ? cmdUpdate :
        cmdCreate;

    // сохраняем параметры
    params.Clear();
    params.Add(PARAM_NAME_OBJ, gNameLev);
    params.Add(PARAM_TYPE_ANALYSER, IntegerToString(anaLevel));
    params.Add(PARAM_PERIOD, IntegerToString(_Period));
    params.Add(PARAM_KIND_LEVEL, IntegerToString(gKindLevel));
    params.Add(PARAM_RISK_VALUE, DoubleToString(gRiskValue, 2));
    params.Add(PARAM_RISK_UNIT, IntegerToString(gRiskUnit));
    params.Add(PARAM_ALG_CLOSE, IntegerToString(gAlgClose));
    params.Add(PARAM_TICKET_OLD, IntegerToString(gTicketOld));
    params.Add(PARAM_PROFITS, IntegerToString(gProfits));
    params.Add(PARAM_LOSSES, IntegerToString(gLosses));
    params.Save();
    
    // отправляем команду эксперту
    params.SendCommand(cmd);
    delete params;
    
    return cmd;
}


Функция params.SendCommand(cmd) имеет вид

// -----------------------------------------------------------------------------
// Послать команду эксперту                                                    |
// -----------------------------------------------------------------------------
void Params::SendCommand(NCommand cmd) {
    string nameObj = NAME_OBJECT_CMD;
    ObjectCreate(0, nameObj, OBJ_LABEL, 0, 0, 0);
    ObjectSetString(0, nameObj, OBJPROP_TEXT, m_path);
    ObjectSetInteger(0, nameObj, OBJPROP_ZORDER, cmd);
    ObjectSetInteger(0, nameObj, OBJPROP_TIMEFRAMES, 0);
}

Эксперт каждый тик (OnTick) через функцию CheckExpernalCommand() проверяет наличие объекта с именем NAME_OBJECT_CMD. При его наличии считывается команда и путь к файлу с параметрами анализатора, и объект сразу же удаляется. Далее эксперт ищет работающий анализатор по имени файла. Если cmd == cmdDelete, то анализатор удаляется. Если cmd == cmdUpdate, то обновляются параметры анализатора из файла. Если cmd == cmdNew, то создается новый анализатор с параметрами из файла.

Полный текст класса Params, который инкапсулирует логику работы с файлами параметров (строки Key=Value).

#property copyright "Copyright 2020, Malik Arykov"
#property link      "malik.arykov@gmail.com"

#include <Arrays/ArrayString.mqh>
#include <Cayman/UConvert.mqh>
#include <Cayman/UFile.mqh>

// -----------------------------------------------------------------------------
// Класс параметров (строки key=value с коментами #)                           |
// -----------------------------------------------------------------------------
class Params {
private:
    string  m_path; // путь к файлу параметров
    NCommand m_cmd; // команда для эксперта
    CArrayString *m_items; // массив пар {key=value}
    int Find(string key);
public:
    Params();
    ~Params() { delete m_items; };
    void Clear() { m_items.Clear(); };
    int Total() { return m_items.Total(); };
    string Path() { return m_path; };
    CArrayString *Items() { return m_items; };
    void Add(string line) { m_items.Add(line); };
    bool Add(string key, string value);
    string GetValue(string key);
    void Load(string prefix, int typeAnalyser, string nameObj, string speriod);
    void Load(string path);
    void Save();
    void SendCommand(NCommand cmd);
    NCommand TakeCommand();
    void Dump(string sender);
};

// -----------------------------------------------------------------------------
// Конструктор по умолчанию                                                    |
// -----------------------------------------------------------------------------
Params::Params() {
    m_items = new CArrayString();
}

// -----------------------------------------------------------------------------
// Добавить пару ключ=значение                                                 | 
// -----------------------------------------------------------------------------
bool Params::Add(string key, string value) {

    int j = Find(key);
    string line = key + "=" + value;
    if (j >= 0) { // обновляем
        m_items.Update(j, line);
        return false;
    }
    else { // добавляем
        m_items.Add(line);
        return true;
    }
}

// -----------------------------------------------------------------------------
// Получить значение параметра по ключу                                        |
// -----------------------------------------------------------------------------
string Params::GetValue(string key) {

    // ищем ключ
    int j = Find(key);
    if (j < 0) return NULL; // нет ключа
    
    // проверяем разделитель
    string line = m_items.At(j);
    j = StringFind(line, "=");
    if (j < 0) { // нет =
        PrintFormat("%s / ERROR: не допустимая строка %s", __FUNCTION__, line);
        return NULL;
    }
    
    // возвращаем значение
    return UConvert::Trim(StringSubstr(line, j + 1));
}

// -----------------------------------------------------------------------------
// Найти значение параметра по ключу                                           |
// -----------------------------------------------------------------------------
int Params::Find(string key) {

    int index = -1;
    for (int j = 0; j < m_items.Total(); j++) {
        if (StringFind(m_items.At(j), key) == 0) {
            index = j;
            break;
        }
    }
    return index;
}

// -----------------------------------------------------------------------------
// Загрузить параметры                                                         |
// -----------------------------------------------------------------------------
void Params::Load(string prefix, int typeAnalyser, string nameObj, string speriod) {
    string nameFile = StringFormat("%s%02i_%s_%s.txt", prefix, typeAnalyser, nameObj, speriod);
    m_path = StringFormat("%s%s/%s", PATH_PARAMS, IntegerToString(ChartID()), nameFile);
    if (FileIsExist(m_path)) Load(m_path);
}

// -----------------------------------------------------------------------------
// Загрузить параметры                                                         |
// -----------------------------------------------------------------------------
void Params::Load(string path) {
  
    m_path = path;
    if (!FileIsExist(m_path)) return;

    //PrintFormat("%s / %s", __FUNCTION__, m_path);
    string text = UFile::LoadText(m_path);
    if (text == NULL) return;
    
    // разбиваем текст на строки
    string line, lines[];
    int numLines = StringSplit(text, DLM_LINE, lines);
    for (int j = 0; j < numLines; j++) {
        line = lines[j];
        // удаляем коментарий
        int k = StringFind(line, "#");
        if (k == 0) continue; // вся строка - коммент
        if (k > 0) line = StringSubstr(line, 0, k);
        // добавляем не пустую строку
        if (line != "") m_items.Add(line);
    }
}

// -----------------------------------------------------------------------------
// Сохранить параметры                                                         |
// -----------------------------------------------------------------------------
void Params::Save() {

    string text = "";
    for (int j = 0; j < m_items.Total(); j++) {
        text += m_items.At(j) + "\n";
    }
    // перезаписываем существующий файл
    UFile::SaveText(text, m_path, true);
}

// -----------------------------------------------------------------------------
// Послать команду эксперту                                                    | 
// -----------------------------------------------------------------------------
void Params::SendCommand(NCommand cmd) {
    string nameObj = NAME_OBJECT_CMD;
    ObjectCreate(0, nameObj, OBJ_LABEL, 0, 0, 0);
    ObjectSetString(0, nameObj, OBJPROP_TEXT, m_path);
    ObjectSetInteger(0, nameObj, OBJPROP_ZORDER, cmd);
    ObjectSetInteger(0, nameObj, OBJPROP_TIMEFRAMES, 0);
}

// -----------------------------------------------------------------------------
// Принять команду от скрипта                                                  |
// -----------------------------------------------------------------------------
NCommand Params::TakeCommand() {
    string nameObj = NAME_OBJECT_CMD;
    if (ObjectFind(0, nameObj) < 0) return cmdUnknown;
    
    m_path = ObjectGetString(0, nameObj, OBJPROP_TEXT);
    m_cmd = (NCommand)ObjectGetInteger(0, nameObj, OBJPROP_ZORDER);
    ObjectDelete(0, nameObj);
    Load(m_path);
    
    return m_cmd;
}

// -----------------------------------------------------------------------------
// |Дамп параметров                                                            |                                                             
// -----------------------------------------------------------------------------
void Params::Dump(string sender) {
    for (int j = 0; j < m_items.Total(); j++) {
        PrintFormat("%s / %s", sender, m_items.At(j));
    }
}

Фанатам MQL5: при изменении типа m_items на CHashMap код функций Add, GetValue, Find  существенно сократится. Но класс Params используется и в MQL4. Кроме того, скорость доступа к параметрам в данном случае не важна. Поскольку параметры считываются один раз для инициализации локальных переменных. Почему же я не переделал класс под CHashMap для MQL5. Наверное из-за того, что долгое время работал в банке. У разработчиков финансового софта есть очень важный принцип: Работает — не трогай! ;-)


Передача параметров внешним программам

Единицей обмена между различными системами, де-факто является json-файл. Раньше был xml-файл. Основными преимуществами json-файлов являются:

Например, есть класс Bar с полями m_time, m_open, m_high, m_low, m_close, m_body. Где m_body — цвет свечи: белая, черная или дожи. Класс Bar имеет метод ToJson(), который формирует json строку

string Bar::ToJson() {
        return "{" +
        "\n\t\"symbol\":\"" + _Symbol + "\"," +
        "\n\t\"period\":" + IntegerToString(_Period) + "," +
        "\n\t\"digits\":" + IntegerToString(_Digits) + "," +
        "\n\t\"timeBar\":\"" + TimeToStr(m_time) + "\"," +
        "\n\t\"open\":" + DoubleToString(m_open, _Digits) + "," +
        "\n\t\"high\":" + DoubleToString(m_high, _Digits) + "," +
        "\n\t\"low\":" + DoubleToString(m_low, _Digits) + "," +
        "\n\t\"close\":" + DoubleToString(m_close, _Digits) + "," +
        "\n\t\"body\":" + IntegerToString(m_body) + "," +
        "\n}";
}

Можно сделать через StringFormat, но возникнут трудности с перестановкой или удалением значений. Можно убрать форматирование “\n\t”, поскольку есть достаточно много онлайн сервисов форматирования json. Один из них JSON Parser. Достаточно один раз отладить получение валидного json и пользоваться функцией bar.ToJson() не задумываясь.

Внешняя программа, например на C#, может преобразовать json файл любой сложности в объект. Как же передать json файл из MQL? Да очень просто. Скидываете (сохраняете) json файл, например, в каталог терминала Files/Json. Внешняя программа следит за этим каталогом на предмет появления новых файлов. Обнаружив файл, считывает его, преобразовает в объект и сразу удаляет файл (чтобы не болтался) или перемещает в архив (для статистики).


Прием параметров от внешних программ

Подключать json библиотеку (не дай бог самому делать такой «велосипед») к своим MQL программам — лишние хлопоты. Проще передавать текстовые файлы со строками Key=Value. Для обработки файлов можно воспользоваться классом Params (см. выше). Кандидатами на прием параметров от внешних программ или скриптов являются Эксперт и Индикатор. Например, в обработчике OnTick нужно вызывать функцию CheckExternalCommand(), которая будет проверять наличие файлов в каталоге Files/ExtCmd. При обнаружении файла считать, обработать(принять параметры) и удалить файл.

Итак, рассмотрены способы приема и передачи параметров между MQL и внешними программами. Теперь задумайтесь над вопросом: Зачем для MQL программ нужны DLL?. Тем более MQL-маркет не принимает такие программы. Причина одна — безопасность, поскольку из DLL можно залезть куда угодно.


Передача параметров на смартфон

Обратите внимание на Android приложение WirePusher. Снимаю шляпу перед разработчикам за такой сервис (бесплатный и без рекламы). Не знаю есть ли подобное на iPhone. Фанаты iPhone отпишитесь в обсуждении статьи. 

Для работы с сервисом предварительно нужно:

Далее запустить скрипт, предварительно заменив звездочки в id = “********” на свой id

void OnStart() {
    string id = "**********"; // id вашего смартфона в WirePusher
    WirePusher("Профит $1000", "Сделка", "Закрылась", id);
}

// ------------------------------------------------------------------------------------------------
// Отправить уведомление на смартфон через веб-сервис WirePusher
// Добавить https://wirepusher.com в Терминал/Сервис/Настройки/Советники/Разрешить WebRequest
// message - текст уведомления
// title - заголовок уведомления (например, Внимание / Сигнал / Сделка)
// type - тип уведомления (например, Сработала отложка / Пробит уровень / Закрылась)
// id - уникальный номер смартфона в андроид-приложении WirePusher
// ------------------------------------------------------------------------------------------------
bool WirePusher(string message, string title, string type, string id) {

    char data[]; // массив данных тела HTTP-сообщения
    char result[]; // массив с данными ответа веб-сервиса
    string answer; // заголовки ответа веб-сервиса
    string url = "https://wirepusher.com/send?id={id}&title={title}&message={message}&type={type}";
    
    StringReplace(url, "{id}", id);
    StringReplace(url, "{type}", type);
    StringReplace(url, "{title}", title);
    StringReplace(url, "{message}", message);
    
    ResetLastError();
    int rcode = WebRequest("GET", url, NULL, 3000, data, result, answer);
    if (rcode != 200) {
        PrintFormat("%s / error=%i / url=%s / answer=%s / %s", 
            __FUNCTION__, GetLastError(), url, answer, CharArrayToString(result));
        return false;
    }
    PrintFormat("%s / %s / %s", __FUNCTION__, title, message);
    return true;
}

В эксперте Cayman, функция WirePusher вызывается в AnalyserTrade при:

  • Срабатывании отложки
  • Пробое торгового уровня
  • Закрытии сделки

В приложении WirePusher к каждому типу уведомления можно привязать свой звук. Раньше у меня при закрытии сделки с профитом звучало «та-да», для сделки с убытком — «взрыв». Но потом я устал от взрывов ;-)


Заключение

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