mt5-manager/docs/part4-notes.md
Yuriy Bykov 82a79d9b45 Document config/services.ini service autostart workaround
Add static/services.ini as a reference example, and update both the
article draft and working notes with the discovery: placing this file
in the terminal installation's config folder auto-registers the
compiled service, removing the manual Navigator step. Note explicitly
that the file is undocumented (UTF-16LE, absent from the official
config file listing) and likely the terminal's own internal
serialization of Navigator-added services.
2026-07-27 13:07:03 +03:00

33 KiB

Часть 4 «Создание сервиса» — конспект проработки

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

Ветка: article-20150. Репозиторий: https://forge.mql5.io/antekov/mt5-manager


1. Суть изменения

Сейчас веб-приложение получает данные о терминалах по запросу: метод MT5_Control.instance_info() вызывает mt5.initialize() для конкретного экземпляра, забирает terminal_info() и account_info(), затем mt5.shutdown().

Становится наоборот: в каждом терминале работает сервис на MQL5, который раз в 2–3 секунды пишет своё состояние в общую базу SQLite. Веб-приложение только читает эту базу.

Зачем

  • mt5.initialize() работает на уровне процесса — из одного процесса Python нельзя одновременно держать соединения с несколькими терминалами. Опрос строго последовательный, с shutdown() между итерациями. При четырёх терминалах терпимо, при пятнадцати цикл перестаёт укладываться в интервал обновления, а один «залипший» терминал тормозит очередь для всех.
  • Сервисы пишут параллельно, каждый в своём процессе.
  • С пути чтения уходит зависимость от модуля MetaTrader5, а с ней и ограничение «Python не выше 3.12» (частично: create_mt5() пока держит эту зависимость).
  • Появляется задел под историю и журнал операций, обещанные во 2-й части.

2. Принятые решения

Расположение базы

Файл в общей папке терминалов, открывается с флагом DATABASE_OPEN_COMMON.

Проверено на практике: общая папка остаётся общей и в portable-режиме, разные экземпляры терминалов работают с одной базой корректно. Это стоит подчеркнуть в статье — интуиция подсказывает обратное.

Следствие: путь к базе одинаков для всех экземпляров, настройка при развёртывании не нужна, один и тот же .ex5 разложен по терминалам.

Конкурентный доступ

  • WAL (PRAGMA journal_mode=WAL) — читатели и писатель перестают блокировать друг друга. Записывается в заголовок файла базы один раз и сохраняется. Файлы-спутники -wal и -shm рядом с базой — это нормально. Ограничение: работает только в пределах одной машины (нужна разделяемая память), по сетевой шаре не работает. Для распределённой архитектуры из 1-й части это означает, что у каждого сервера должна быть своя база.
  • PRAGMA busy_timeout=3000 — вместо мгновенной ошибки SQLITE_BUSY библиотека сама ждёт и повторяет. WAL этого не решает: одновременный писатель по-прежнему ровно один, сервисы выстраиваются в очередь. Важно: это свойство соединения, а не файла. Не сохраняется, надо выставлять после каждого DatabaseOpen(), в каждом сервисе. На стороне Python — параметр timeout в sqlite3.connect() (в секундах, по умолчанию 5).
  • Явные транзакции вокруг записи (DatabaseTransactionBegin / DatabaseTransactionCommit). Без них каждый INSERT — отдельная транзакция со сбросом на диск, окно занятости базы растёт.
  • Разброс интервала: сервисы стартуют одновременно при запуске терминалов, и циклы Sleep() долго идут синхронно. Случайная добавка в пределах интервала размазывает записи во времени.
  • Ошибку занятости игнорируем, считая пропуски. Пока в базе только текущее состояние, пропуск безвреден — через две секунды приедет свежее значение. С появлением истории это станет дыркой в ряду данных.

Схема

Две таблицы. Таблица истории отложена до следующей части — там же обсудить более редкий интервал (5–10 минут) и чистку старых записей.

CREATE TABLE IF NOT EXISTS terminals (
    id         INTEGER PRIMARY KEY,
    folder     TEXT NOT NULL UNIQUE,  -- ключ связи с config.json
    path       TEXT,                  -- полный путь, для диагностики
    company    TEXT,
    build      INTEGER,
    first_seen INTEGER,
    last_seen  INTEGER
);

CREATE TABLE IF NOT EXISTS states (
    terminal_id   INTEGER PRIMARY KEY REFERENCES terminals(id),
    updated_at    INTEGER NOT NULL,
    connected     INTEGER,
    ping_last     INTEGER,
    login         INTEGER,
    server        TEXT,
    company       TEXT,
    holder_name   TEXT,
    currency      TEXT,
    leverage      INTEGER,
    trade_mode    INTEGER,
    trade_allowed INTEGER,
    balance       REAL,
    equity        REAL,
    profit        REAL,
    margin        REAL,
    margin_free   REAL,
    margin_level  REAL,
    credit        REAL,
    positions     INTEGER,
    error_code    INTEGER DEFAULT 0,
    error_text    TEXT
);

Пояснения к решениям:

  • Разделение на две таблицы: редко меняющееся (папка, брокер, билд) отдельно от постоянно меняющегося. Даёт список известных терминалов даже когда все выключены.
  • Размер базы не растёт со временем: 4 строки в каждой таблице.
  • Индексов сверх первичных ключей не нужно, на таких объёмах они мешают.
  • Время — INTEGER, секунды unix. Родной формат datetime в MQL5, кладётся напрямую из TimeGMT(). Не TimeCurrent() — последняя возвращает время сервера брокера, и терминалы у разных брокеров дадут расхождение в часы при вычислении свежести данных. Не TimeLocal() тоже — изначально был выбран именно он, но при первом прогоне обнаружилось: TimeLocal() возвращает цифры настенных часов машины, выданные за UTC-эпоху, а не настоящий UTC (проверено эмпирически, расхождение с time.time() в Python совпало с часовым поясом машины день в день). Сравнение с time.time() на стороне Python было бы systematически неверным на величину смещения пояса. TimeGMT() даёт настоящий UTC и сохраняет исходное преимущество перед TimeCurrent() — не зависит от брокера. Заменено в обеих функциях сервиса (RegisterTerminal(), StoreState()).
  • margin_level отдельным полем, хотя выводится из equity и margin: при нулевой марже не определено, пусть эту ветку обработает MQL5 один раз.
  • pid и status в схеме сознательно отсутствуют — сервис физически не может записать «я остановлен», это остаётся за psutil.
  • Открытые позиции построчно (символ, объём, прибыль) — следующая часть, сейчас только агрегат positions.

Определение имени экземпляра

Сервис берёт TerminalInfoString(TERMINAL_PATH) и отрезает последний компонент пути.

Почему TERMINAL_PATH, а не TERMINAL_DATA_PATH: Python-сторона в MT5_Control.instance_path() собирает путь как mt5_folder + имя папки + mt5_exe, то есть оперирует местом установки. В portable-режиме обе константы совпадают, но без /portable TERMINAL_DATA_PATH даст Terminal\<хеш> вместо ожидаемого имени папки.

Функции StringFindLast() в MQL5 нет, написана вручную через StringFind() в цикле.

Открытые вопросы, отложенные осознанно:

  • Регистр в путях. Windows нечувствителен к регистру, MetaTrader5.1 и Metatrader5.1 — одна папка, но разные строки. Второстепенно: после реализации автоматической установки пользователь не будет вводить имя папки вручную в нескольких местах. В статье упомянуть, не заостряя.
  • Папка, которой нет в конфигурации. Пока ничего не делаем. В финальной реализации такого быть не должно. Вернуться в следующих частях.

Способ записи

INSERT ... ON CONFLICT(terminal_id) DO UPDATE каждый цикл — надёжнее связки «UPDATE, при нуле изменённых строк INSERT», переживает ручное удаление строки, не требует различать первый цикл и последующие.

Регистрация в terminals вынесена из цикла — делается один раз при старте (и повторно при переподключении к базе). Гонять её каждые две секунды незачем.

Идентификатор терминала после вставки забирается отдельным SELECT. last_insert_rowid() не годится: при срабатывании ветки обновления вставки не было. RETURNING id требует SQLite 3.35 — рискованнее, чем лишний запрос раз при старте.


3. Найденные нестыковки с текущим кодом

Разобраны, требуют правки в коде проекта.

Код успеха равен 1, а не 0

В static/script.js:

  • last_error.code == 1 → зелёная галочка (работает)
  • last_error.code == 0 → серый значок (Stopped / Starting)
  • всё остальное → красный восклицательный знак

Это следствие того, что mt5.last_error() возвращает RES_S_OK, равный 1. Ноль занят под статусы, проставляемые вручную в instance_info().

Принятое соглашение для сервиса:

  • 1 / "Success" — есть связь с торговым сервером
  • -2 / "Service: No connection to trade server" — терминал работает, связи нет

Отрицательные коды продолжают ряд ошибок менеджера (-1"Manager: Terminal not found"), префикс в тексте показывает источник жалобы.

Формат времени

Сейчас Python кладёт в last_update результат str(datetime.now()), JS парсит через new Date(). Строка вида 2026-07-25 12:34:56.789012 без буквы T — нестандартный формат, Chrome его переваривает, спецификация не гарантирует.

Решено: миллисекундная точность не нужна, наружу отдаётся то же целое число секунд Unix, что лежит в updated_at (mt5_control.py), без умножения на 1000. formatDateTime() в static/script.js сам умножает полученное значение на 1000 перед new Date(). Сделано.

Недостающие поля

Фронтенд обращается к data.account.company. Добавлены company и holder_name (ACCOUNT_COMPANY, ACCOUNT_NAME) в states, чтобы словарь account собирался из базы без потерь. Учесть, что company у счёта и company у терминала — разные вещи, хотя обычно совпадают.

Мелочи

  • config.json: у MetaTrader5.4 логин совпадает с MetaTrader5.1. Не баг — так задумано намеренно (терминал 4 подключается к тому же счёту, что и терминал 1). Логины из конфигурации пока нигде не используются, начнём использовать в следующих частях — тогда и проверится, что дублирование обрабатывается корректно.
  • script.js: в расчёте profitPct нет защиты от нулевого баланса.
  • mt5_control.py: self.instances = terminals.copy() — поверхностная копия, запись pid и status меняет вложенные словари самой конфигурации. Сейчас безобидно, аукнется при появлении перечитывания конфига. Исправлено: заменено на copy.deepcopy(terminals).
  • mt5_control.py: в именах instanсes_paths и paths_instanсes буква «с» в слове instanсes кириллическая. Код рабочий (используется последовательно), но поиск по латинскому написанию ничего не найдёт. Исправлено: переименовано на латиницу (instances_paths, paths_instances).
  • create_mt5() и POST /create/{name} выглядят заготовкой: внутри mt5.initialize() без пути, возвращается {folder: folder}. В статьях цикла этого нет. Решено: оставляем как есть, не трогаем и не упоминаем в 4-й части — дойдём до этого функционала в 5-й части.
  • main.py: в обработчике index() был закомментированный черновой код (for name in instances: ... instances[name]['info'] = ...). Исправлено: убран — эта информация приходит на главную страницу через ajax-запросы (POST /instances/{name}), а не при первом рендере.

4. Совместимость API

Структура ответа POST /instances/{name} — словарь с ключами terminal, account, last_update, last_error, status — воспроизводится из базы один в один.

Значит templates/index.html и static/script.js не требуют изменений, кроме формата времени. Для статьи это сильный аргумент: механизм получения данных меняется полностью, интерфейс подмены не замечает. Стоит вынести в отдельный подзаголовок.


5. Что проверить при первой компиляции

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

  1. PRAGMA через DatabaseExecute(). И journal_mode, и busy_timeout возвращают строку результата. DatabaseExecute() на таких запросах может вернуть ошибку. Запасной путь — DatabasePrepare() + DatabaseRead() + DatabaseFinalize(), он и использован в наброске.
  2. Выполнение INSERT через подготовленный запрос. Возвращать нечего, DatabaseRead() вернёт false с кодом «нет данных». В коде результат DatabaseRead() игнорируется, проверяется GetLastError() с отсечением кода завершения выборки. Константа 5126 приведена по памяти, сверить с фактическим поведением.
  3. Поддержка ON CONFLICT ... DO UPDATE. Требует SQLite 3.24+. Встроенная в MetaTrader библиотека почти наверняка новее, но проверить первым делом. Если нет — схема упрощается до UPDATE, при нуле изменённых строк INSERT.
  4. Нумерация в DatabaseBind() начинается с нуля, а плейсхолдеры ?1, ?2 в SQL — с единицы. Выглядит как ошибка, но так и задумано. В статье отметить явно, читатели будут спотыкаться.
  5. Поведение DatabaseTransactionBegin() — какую форму BEGIN он использует. Документация умалчивает. Пока не критично: сервис только пишет, ничего не вычитывая в той же транзакции. Станет важно, если появится логика «прочитать предыдущее значение, сравнить, записать» — тогда нужен BEGIN IMMEDIATE, иначе SQLITE_BUSY возвращается немедленно, не выжидая таймаут.

6. Ограничения сервисов, которые стоит проговорить в статье

  • В сервисах запрещены EventSetTimer(), EventSetMillisecondTimer(), EventKillTimer(). Цикл строится на Sleep() с проверкой IsStopped().
  • Сервисы загружаются автоматически после запуска терминала, если на момент его остановки были запущены. Сбор данных стартует сам, без участия менеджера. Хороший контраст с советником, которого пришлось бы вешать на график.
  • Каждый сервис работает в своём потоке, зацикленный сервис не мешает другим программам.
  • Экземпляр создаётся через Навигатор.

7. Развёртывание

Сервис компилируется и раскладывается в MQL5/Services каждого терминала, экземпляр создаётся через Навигатор — вручную, четыре раза. Для статьи нормально, но честно назвать временным решением. Автоматизация установки логично ложится в следующую часть и смыкается с планом «загружать советника через браузер».


8. Что остаётся на стороне Python

База скажет, что было в момент последней записи, но не скажет, что терминал упал. Определение «запущен / остановлен» через psutil никуда не уходит, архитектура становится гибридной.

Комбинирование даёт три статуса вместо нынешних двух, и на странице их стоит различать визуально:

Процесс (psutil) Свежесть updated_at Статус
жив свежее порога работает
жив старше порога запущен, данные не поступают
нет процесса остановлен

Второй случай покрывает загрузку терминала, потерю связи и незапустившийся сервис. Порог — порядка 30 секунд при интервале записи 2–3 секунды.

Отдельно: терминал может работать, а связи с сервером брокера не быть — тогда цифры счёта в базе последние известные, а не актуальные. Это error_code = -2, веб-серверу стоит отрисовывать отдельно.


9. Незакрытые обещания из предыдущих частей

Держать в виду при планировании, что войдёт в 4-ю часть, а что дальше:

  • SQLite как хранилище — закрывается этой частью (частично: только состояние)
  • авторизация и HTTPS — отложены
  • журнал операций — отложен вместе с историей
  • главный сервер + серверы терминалов (схема из 1-й части) — отложено
  • торговые операции из браузера, загрузка советников, сведение логов — из комментариев к 3-й части
  • автоматизация установки сервиса — закрыта частично находкой config\services.ini (раздел 10); программная раскладка самого .ex5 по папкам новых терминалов и генерация services.ini под конкретную установку остаются на будущее

10. Результаты первого запуска

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

Баг: RegisterTerminal() не учитывал код 5126

Код 5126 подтвердился на практике (пункт риска 2), но именно это вскрыло реальную ошибку: RegisterTerminal() проверял успех как DatabaseRead(request) || GetLastError() == 0, не зная про 5126 — в отличие от StoreState(), где это уже было учтено. В журнале (MQL5\Logs, не корневой Logs терминала — это два разных лога) сервис раз в цикл писал «Не удалось зарегистрировать терминал», g_terminal_id оставался -1 навсегда, и StoreState() не вызывался вообще — при этом сама запись в terminals через ON CONFLICT DO UPDATE реально проходила, last_seen продвигался. То есть SQL отработал, а приложенческая проверка кода ошибки — нет. Хороший пример для статьи: база подтверждает успех, а сервис думает, что провалился.

Исправлено: вынесена именованная константа ERR_DB_NO_MORE_DATA = 5126, обе функции проверяют error != 0 && error != ERR_DB_NO_MORE_DATA одинаково. После исправления и перекомпиляции лог показывает мгновенную успешную регистрацию и первую запись в states уже на первом цикле.

Остальные пункты риска из раздела 5 закрыты без сюрпризов: PRAGMA через DatabasePrepare/DatabaseRead работает, journal_mode=wal подтверждён прямо в файле базы, ON CONFLICT DO UPDATE поддерживается, нумерация DatabaseBind() с нуля отработала верно. Поведение DatabaseTransactionBegin()/Commit() подтвердилось косвенно: как только починили баг регистрации, StoreState() начал доходить до транзакции и писать states без ошибок.

Находка: TimeLocal() — не UTC

Описано в разделе 2 (схема). Коротко: TimeLocal() вернул значение, отличающееся от time.time() в Python ровно на часовой пояс машины (GMT+3 → расхождение 10800 секунд, проверено вычитанием). Заменено на TimeGMT() в обеих функциях сервиса.

Проверено: сервис переживает жёсткий рестарт терминала

Открытый вопрос был в том, что stop_mt5() останавливает терминал через psutil.Process.terminate(), а на Windows это алиас kill()TerminateProcess() — жёсткое завершение, не штатное закрытие через GUI. Утверждение «сервис загружается сам, если был запущен на момент остановки терминала» (раздел 1, из справки MetaTrader) не уточняет, требует ли это именно штатного закрытия.

Проверено вручную: Stop-Process -Force по PID терминала (эквивалент TerminateProcess), затем повторный запуск terminal64.exe /portable — то есть буквально то, что делают stop_mt5()/start_mt5(). Сервис стартовал сам через ~2 секунды после запуска терминала, без обращения к Навигатору, и states.updated_at продолжил расти. Жёсткий килл не ломает автозагрузку сервиса.

Решено: автодобавление сервиса через команду запуска — не реализуемо штатно

Проверено по официальной документации (metatrader5.com/ru/terminal/help/start_advanced/start) и статье про сервисы на mql5.com: секция [StartUp] конфиг-файла поддерживает Expert и Script, но не Service. Программного или конфигурационного способа добавить сервис в Навигатор при первом развёртывании терминала не существует задокументированным способом.

Рассмотренная альтернатива — перейти со Service на Script, запускаемый через /config + [StartUp] Script=... — отклонена: это возвращает тот самый минус советника, который сервис был выбран избежать (нужен график, скрипт умирает при его закрытии). Решение: разовое ручное добавление через Навигатор на каждый из терминалов при разворачивании, дальше сервис переживает перезапуски терминала сам (см. проверку выше) без участия Python. Честно назвать в статье шагом разворачивания, а не ограничением.

Пересмотрено ниже — найден рабочий обходной путь, см. следующий раздел.

Найдено: config\services.ini — недокументированный обходной путь

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

Файл config\services.ini (пример — static/services.ini в репозитории) кладётся в папку config внутри папки установки терминала (не Common, не папку данных профиля). Если на момент запуска терминала по пути, указанному в файле, уже лежит скомпилированный .ex5, сервис стартует сам, без обращения к Навигатору вручную:

<service>
name=ServiceStateWriter
path=Services\Shared Projects\mt5-manager\ServiceStateWriter.ex5
expertmode=0
enabled=1
<inputs>
InpDatabase=mt5-manager.sqlite
InpIntervalSec=2
InpJitterMs=1000
InpBusyTimeout=3000
</inputs>
</service>

В официальной документации этого файла нет. Проверено: страница «Файлы и папки» (metatrader5.com/ru/terminal/help/start_advanced/structure) перечисляет содержимое config явно — accounts.dat, common.ini, metaeditor.ini, terminal.ini, servers.dat, сертификаты — services.ini среди них нет. Страница про [StartUp] — тоже без него. Учебник, статья про сервисы и форум ничего не знают об этом файле.

Косвенное подтверждение гипотезы о происхождении файла: services.ini закодирован в UTF-16LE (BOM FF FE), в отличие от читаемых текстовых конфигов вроде common.ini. Это типично для внутренних служебных файлов, которые терминал пишет и читает сам, а не для документированного пользовательского API. Похоже, это ровно то представление, в которое терминал сериализует состояние сервисов, добавленных через Навигатор командой «Добавить сервис» — то есть подложенный файл имитирует состояние «сервис уже был добавлен один раз вручную».

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