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.
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, ниже места с наибольшим риском расхождения.
PRAGMAчерезDatabaseExecute(). Иjournal_mode, иbusy_timeoutвозвращают строку результата.DatabaseExecute()на таких запросах может вернуть ошибку. Запасной путь —DatabasePrepare()+DatabaseRead()+DatabaseFinalize(), он и использован в наброске.- Выполнение
INSERTчерез подготовленный запрос. Возвращать нечего,DatabaseRead()вернётfalseс кодом «нет данных». В коде результатDatabaseRead()игнорируется, проверяетсяGetLastError()с отсечением кода завершения выборки. Константа 5126 приведена по памяти, сверить с фактическим поведением. - Поддержка
ON CONFLICT ... DO UPDATE. Требует SQLite 3.24+. Встроенная в MetaTrader библиотека почти наверняка новее, но проверить первым делом. Если нет — схема упрощается доUPDATE, при нуле изменённых строкINSERT. - Нумерация в
DatabaseBind()начинается с нуля, а плейсхолдеры?1,?2в SQL — с единицы. Выглядит как ошибка, но так и задумано. В статье отметить явно, читатели будут спотыкаться. - Поведение
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 нигде не фиксирует этот формат как публичный контракт.