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.
54 KiB
Разрабатываем менеджер терминалов (Часть 4): сервис вместо опроса — состояние терминалов через общую базу SQLite
Содержание
- Введение
- Чем не устраивает текущий подход
- Идея: сервис пишет, веб-приложение читает
- Сервис как тип MQL5-программы
- Общая папка Common и почему это работает
- Схема базы данных
- Реализация сервиса ServiceStateWriter.mq5
- Определение имени экземпляра
- Открытие базы и PRAGMA
- Регистрация терминала
- Запись состояния
- Основной цикл
- Остановка сервиса и грейс-период
- Что показал первый запуск
- Обновление Python-стороны
- Совместимость с уже написанным интерфейсом
- Развёртывание: находка с
config\services.ini - Заключение и планы
Введение
В первой части мы поставили задачу: удобно управлять несколькими экземплярами MetaTrader 5 без устаревшего MultiTerminal. Во второй научились запускать и останавливать произвольное количество терминалов через веб-интерфейс на FastAPI. В третьей добавили конфигурацию и получение данных о счёте — карточки терминалов на странице стали показывать баланс, прибыль, средства и маржу.
Способ получения этих данных был предельно прямолинейным: веб-сервер сам подключался к каждому терминалу через модуль MetaTrader5 для Python, забирал terminal_info() и account_info(), и тут же отключался. Работает, но у подхода есть предел, который в этой части и разберём.
Чем не устраивает текущий подход
mt5.initialize() привязывается не к терминалу, а к процессу Python. Из одного процесса нельзя одновременно держать соединения с несколькими терминалами — опрос идёт строго по очереди, с обязательным shutdown() между итерациями. При четырёх терминалах это незаметно. При пятнадцати цикл опроса перестаёт укладываться в интервал обновления страницы, а один зависший терминал тормозит очередь для всех остальных.
Есть и второе ограничение, менее очевидное: пока обновление данных зависит от модуля MetaTrader5, мы привязаны к Python не выше версии 3.12 — таково требование самого модуля.
Разворачиваем логику на 180 градусов: пусть каждый терминал сам сообщает о себе, не дожидаясь, пока его спросят.
Идея: сервис пишет, веб-приложение читает
В каждом экземпляре терминала запускается сервис на MQL5 — #property service, а не советник и не скрипт. Раз в 2–3 секунды он читает TerminalInfo/AccountInfo своего терминала и пишет результат в общую базу SQLite. Веб-приложение больше не трогает терминалы напрямую — оно просто читает из этой базы последнюю известную запись для каждого экземпляра.
Что это даёт:
- Сервисы пишут параллельно, каждый в своём процессе — очередь и взаимное ожидание исчезают.
- С пути чтения (веб-сервер) уходит зависимость от
MetaTrader5— модуль нужен только там, где мы ещё явно инициализируем терминал (об этом ниже, в разделе проcreate_mt5()). - Появляется естественный задел под историю показателей и журнал операций — то, что было обещано ещё во второй части.
Цена решения: гибридная архитектура. Кроме данных из базы, нам по-прежнему нужен psutil, чтобы знать, жив ли сам процесс терминала — сервис физически не может записать в базу «я остановлен», если его прибили вместе с терминалом.
Сервис как тип MQL5-программы
Прежде чем переходить к коду, стоит остановиться на том, что вообще такое сервис в терминологии MQL5 — этот тип программы на практике используется заметно реже советников и индикаторов, и в предыдущих частях цикла мы с ним не работали.
Формально сервис — это MQL5-программа с директивой #property service и единственным обработчиком событий OnStart() — как и у скрипта, никакие другие события (OnTick, OnTimer, OnChartEvent и так далее) сервису не передаются в принципе. Но, в отличие от скрипта, который выполняется один раз от начала до конца и завершается, OnStart() сервиса типично организуют как бесконечный цикл — именно так и рекомендует официальная документация: «в котором можно организовать бесконечный цикл получения и обработки данных».
Ключевое архитектурное отличие сервиса от советника или индикатора — отсутствие привязки к графику. Экземпляр сервиса создаётся не через открытие графика, а прямо из Навигатора, командой «Добавить сервис» в контекстном меню. Отсюда следует важное для нашей задачи следствие: сервис переживает переключение счёта в терминале. Советники и индикаторы при смене счёта перезагружаются вместе с графиками, а сервис как работал, так и продолжает работать — идеально для программы, которая должна непрерывно снимать показания с терминала независимо от того, что происходит с графиками и какой счёт сейчас активен.
Второе важное отличие касается многопоточности. Документация по запуску программ прямо противопоставляет модель выполнения индикаторов и всего остального: у индикаторов «один поток выполнения для всех индикаторов на одном символе» — зациклившийся индикатор останавливает работу всех остальных индикаторов на этом графике. Скрипты, советники и сервисы устроены иначе: у каждого запущенного экземпляра — свой собственный поток. Зациклившийся (в штатном, рабочем смысле — это наш случай) сервис никак не мешает другим программам, в том числе другим экземплярам того же сервиса в других терминалах.
С этим же связан и набор запретов. Сервисам, как и советникам, недоступны функции таймера — EventSetTimer(), EventSetMillisecondTimer(), EventKillTimer() — единственный способ организовать периодичность внутри OnStart() — это Sleep() в цикле. Кроме того, для сервисов недоступны ExpertRemove() и функции работы с буферами индикаторов (SetIndexBuffer() и подобные) — что логично, раз у сервиса нет графика, к которому можно было бы такой буфер привязать.
В статье-рецепте по сервисам на сайте приводятся и другие показательные примеры их использования: сервис, который в фоне ежедневно чистит устаревшие лог-файлы через WinAPI, и сервис, который строит собственный расчётный символ (например, индекс доллара DXY) на основе котировок нескольких валютных пар, поставляя терминалу котировки, которых иначе просто не существует. Оба примера объединяет одна и та же идея, что и в нашей задаче: сервис — это фоновый поставщик или потребитель данных, работающий сам по себе, без интерфейса и без привязки к конкретному графику.
Общая папка Common и почему это работает
Файл базы открывается сервисом с флагом DATABASE_OPEN_COMMON. Документация по DatabaseOpen() описывает его лаконично: «файл находится в общей папке всех терминалов» — это кладёт базу в Common\Files, которая у всех терминалов на одной машине одна и та же, независимо от того, в portable-режиме терминал запущен или нет.
Это стоит подчеркнуть отдельно, потому что интуиция подсказывает обратное: раз терминалы у нас в portable-режиме и каждый работает со своей папкой данных как с изолированной, кажется логичным, что и Common тоже должна быть у каждого своя. На практике это не так — Common остаётся общей всегда, и все экземпляры пишут в один и тот же файл базы. Проверено практическим запуском нескольких терминалов одновременно: путь к базе одинаковый для всех, настройка при развёртывании не нужна, один и тот же скомпилированный .ex5 раскладывается по терминалам без изменений.
Схема базы данных
Две таблицы. Осознанно разделяем то, что меняется редко (папка экземпляра, брокер, билд), от того, что меняется каждые несколько секунд:
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
);
Несколько пояснений к решениям, которые не очевидны сразу:
- Таблица
terminalsне пустеет, даже когда все терминалы выключены. Она хранит то, что редко меняется, поэтому список известных экземпляров виден в базе всегда, а не только пока сервис активен. - Размер базы не растёт со временем. Пока в ней хранится только текущее состояние — по одной строке на терминал в каждой таблице. История показателей и журнал операций — это отдельная таблица, и она сознательно отложена до следующей части.
pidиstatusв схеме нет. Сервис не может достоверно знать, что терминал остановлен — если его останавливают, сервис прерывается вместе с ним. Эта часть информации остаётся заpsutilна стороне Python, а не за базой.- Время —
INTEGER, секунды Unix, а не строка. К этому решению вернёмся отдельно — оно оказалось не таким тривиальным, как выглядит на бумаге. margin_level— отдельное поле, хотя формально выводится изequityиmargin. При нулевой марже это отношение не определено, и пусть единственный раз эту особенность обработает сам MQL5, а не JavaScript на клиенте.- Индексов сверх первичных ключей не добавляем — на таких объёмах (по одной строке на терминал) они только мешают.
Реализация сервиса ServiceStateWriter.mq5
Определение имени экземпляра
Дальше нужно решить, как сервис поймёт, в каком именно экземпляре терминала он работает — имя экземпляра является ключом связи с config.json на стороне Python.
MQL5 не даёт готовой функции поиска последнего вхождения подстроки, приходится написать её самим:
int FindLast(const string text, const string what)
{
int pos = -1;
int next = StringFind(text, what, 0);
while(next >= 0)
{
pos = next;
next = StringFind(text, what, next + 1);
}
return pos;
}
С её помощью берём последний компонент пути запуска:
string InstanceFolder()
{
string path = TerminalInfoString(TERMINAL_PATH);
StringReplace(path, "\\", "/");
int len = StringLen(path);
if(len > 0 && StringGetCharacter(path, len - 1) == '/')
path = StringSubstr(path, 0, len - 1);
int pos = FindLast(path, "/");
return (pos >= 0) ? StringSubstr(path, pos + 1) : path;
}
Обратите внимание — используется именно TERMINAL_PATH, а не TERMINAL_DATA_PATH. Причина в том, что Python-сторона (MT5_Control.instance_path()) собирает путь к запускаемому файлу как mt5_folder + имя папки + имя exe, то есть оперирует местом установки, а не данными профиля. В portable-режиме оба этих пути совпадают, но без флага /portable TERMINAL_DATA_PATH вернёт Terminal\<хеш>, а не имя папки, которое мы ожидаем.
Здесь сознательно оставлены два открытых вопроса, о которых честно сказать читателю:
- Windows нечувствителен к регистру в путях, поэтому
MetaTrader5.1иMetatrader5.1для него — одна и та же папка, но разные строки для сравнения. Пока не критично: как только появится автоматическая установка экземпляров, пользователю не придётся вручную вводить имя папки в нескольких местах. - Что делать, если сервис зарегистрировал в базе папку, которой нет в
config.json— вопрос тоже отложен до следующих частей.
Открытие базы и PRAGMA
bool OpenDatabase()
{
g_db = DatabaseOpen(InpDatabase,
DATABASE_OPEN_READWRITE
| DATABASE_OPEN_CREATE
| DATABASE_OPEN_COMMON);
if(g_db == INVALID_HANDLE)
{
PrintFormat("Не удалось открыть базу '%s', код ошибки: %d",
InpDatabase, GetLastError());
return false;
}
ExecutePragma(g_db, StringFormat("PRAGMA busy_timeout=%d", InpBusyTimeout));
ExecutePragma(g_db, "PRAGMA journal_mode=WAL");
if(!EnsureSchema(g_db))
{
DatabaseClose(g_db);
g_db = INVALID_HANDLE;
return false;
}
return true;
}
Здесь заложены два решения, важные для параллельной записи нескольких сервисов в один файл:
journal_mode=WALизбавляет читателей и писателя от блокировки друг друга. Это свойство файла базы — записывается один раз в его заголовок и сохраняется дальше, повторная установка при каждом запуске безвредна и просто подстраховывает на случай, если базу первым создаст другой процесс. Важная оговорка: WAL держится на разделяемой памяти в пределах одной машины и не работает по сетевой шаре — если распределённая архитектура из первой части когда-нибудь понадобится всерьёз, у каждого сервера должна быть своя база.busy_timeout, в отличие отjournal_mode, — свойство соединения, а не файла. Оно не сохраняется и должно устанавливаться заново при каждом открытии базы, в каждом сервисе отдельно. Вместо мгновенной ошибкиSQLITE_BUSYпри попытке писать в занятую базу библиотека сама подождёт и повторит попытку. WAL эту проблему не решает — писатель по-прежнему ровно один, сервисы просто встают в очередь друг за другом на запись, а не сваливаются с ошибкой.
Оба PRAGMA возвращают строку результата, а не просто код успеха, поэтому выполнить их через DatabaseExecute() не получится — вместо этого используется подготовленный запрос:
bool ExecutePragma(const int db, const string sql)
{
int request = DatabasePrepare(db, sql);
if(request == INVALID_HANDLE)
{
PrintFormat("Не удалось подготовить запрос '%s', код ошибки: %d",
sql, GetLastError());
return false;
}
DatabaseRead(request);
DatabaseFinalize(request);
return true;
}
Регистрация терминала
Информация о самом терминале (папка, путь, брокер, билд) меняется редко, поэтому регистрация в terminals вынесена из основного цикла и выполняется один раз при старте сервиса (и повторно, если пришлось переоткрывать базу):
int RegisterTerminal()
{
const string folder = InstanceFolder();
const string path = TerminalInfoString(TERMINAL_PATH);
const string company = TerminalInfoString(TERMINAL_COMPANY);
const int build = (int)TerminalInfoInteger(TERMINAL_BUILD);
const long now = (long)TimeGMT();
const string sql =
"INSERT INTO terminals (folder, path, company, build, first_seen, last_seen) "
"VALUES (?1, ?2, ?3, ?4, ?5, ?5) "
"ON CONFLICT(folder) DO UPDATE SET "
" path = excluded.path,"
" company = excluded.company,"
" build = excluded.build,"
" last_seen = excluded.last_seen";
// ... DatabasePrepare / DatabaseBind / DatabaseRead, см. полный листинг
}
Здесь стоит остановиться на паре моментов, которые не сразу очевидны, если писать такой запрос впервые.
Во-первых, INSERT ... ON CONFLICT DO UPDATE выполняется на каждом цикле регистрации и оказывается надёжнее классической связки «сначала UPDATE, а если ни одна строка не изменилась — INSERT»: он переживает ручное удаление строки из базы и не требует отдельно различать первый запуск и все последующие.
Во-вторых, идентификатор только что вставленной или обновлённой строки нельзя взять через last_insert_rowid() — если сработала ветка обновления, вставки не было вовсе, и функция вернёт устаревшее значение. RETURNING id решил бы вопрос одним запросом, но требует SQLite 3.35+, а рисковать версией движка ради экономии одного запроса при старте не стоит. Поэтому идентификатор забирается отдельным SELECT сразу после вставки.
В-третьих — нумерация параметров у DatabaseBind() начинается с нуля, а плейсхолдеры ?1, ?2 в самом SQL — с единицы. Это выглядит как опечатка при первом знакомстве, но именно так и задумано в MQL5: в документации по DatabaseBind() собственный пример показывает ровно то же самое смещение — ?1 в запросе связывается вызовом DatabaseBind(request, 0, ...). Легко споткнуться, если не знать заранее.
Запись состояния
Основная функция сервиса собирает данные терминала и счёта и пишет их в states тем же способом — INSERT ... ON CONFLICT(terminal_id) DO UPDATE:
const int error_code = connected ? 1 : -2;
const string error_text = connected ? "Success" : "Service: No connection to trade server";
Коды здесь продолжают числовой ряд, уже использованный на стороне менеджера (-1 — "Manager: Terminal not found"), а префикс в тексте описания сразу показывает источник жалобы — сервис это или менеджер. Число 1 для успеха выбрано не произвольно: mt5.last_error() в Python-модуле MetaTrader5 возвращает RES_S_OK, равный именно единице, а не нулю — ноль там уже занят под статусы, которые расставляются вручную. Сохраняя то же соглашение в сервисе, не приходится менять логику отображения статуса на фронтенде.
Запись всегда оборачивается в явную транзакцию:
if(!DatabaseTransactionBegin(g_db))
{
DatabaseFinalize(request);
return false;
}
DatabaseRead(request);
const int error = GetLastError();
DatabaseFinalize(request);
if(error != 0 && error != ERR_DB_NO_MORE_DATA)
{
DatabaseTransactionRollback(g_db);
return false;
}
if(!DatabaseTransactionCommit(g_db))
return false;
Без явной транзакции каждый INSERT становится отдельной транзакцией со сбросом на диск, и окно, в течение которого база занята, растёт с каждой лишней записью — при нескольких параллельно пишущих сервисах это быстро становится заметным. Насколько заметным, показывает пример из документации по DatabaseTransactionBegin(): на вставке 2737 сделок оборачивание в транзакцию ускорило запись примерно в 530 раз (48,5 мс против 25818,9 мс без транзакции). У нас на каждой итерации всего одна запись, а не тысячи, но принцип тот же — каждая транзакция означает отдельный сброс WAL-файла на диск, и при нескольких параллельно пишущих сервисах эти сбросы складываются.
Отдельного объяснения заслуживает константа ERR_DB_NO_MORE_DATA:
#define ERR_DB_NO_MORE_DATA 5126
DatabaseRead() на запросе, у которого нет результата — а INSERT/UPDATE именно такие, — завершается этим кодом. Это не ошибка, а нормальное завершение выборки, и обе функции сервиса должны его знать и пропускать, а не считать провалом. Ниже в разделе про первый запуск разберём, к чему приводит забытая проверка этого кода.
Основной цикл
void OnStart()
{
PrintFormat("Запуск сервиса для экземпляра '%s'", InstanceFolder());
MathSrand((int)TimeLocal() + (int)GetTickCount());
const int interval_ms = InpIntervalSec * 1000;
while(!IsStopped())
{
if(g_db == INVALID_HANDLE)
{
if(!OpenDatabase())
{
Sleep(interval_ms);
continue;
}
g_terminal_id = -1;
}
if(g_terminal_id < 0)
{
g_terminal_id = RegisterTerminal();
if(g_terminal_id < 0)
{
Sleep(interval_ms);
continue;
}
}
if(!StoreState())
{
g_skipped++;
if(g_skipped % 10 == 0)
PrintFormat("Пропущено записей: %d, последняя ошибка: %d",
g_skipped, GetLastError());
}
int pause = interval_ms;
if(InpJitterMs > 0)
pause += (int)(MathRand() % InpJitterMs);
Sleep(pause);
}
if(g_db != INVALID_HANDLE)
DatabaseClose(g_db);
}
Здесь заслуживают внимания два небольших, но осмысленных решения:
- Разброс интервала (
InpJitterMs). Все сервисы стартуют почти одновременно, вместе с запуском своих терминалов, и без разброса их циклыSleep()надолго остаются синхронными — все пишут в базу практически в одну и ту же миллисекунду. Случайная добавка в пределах заданного интервала размазывает записи во времени и снижает число конфликтов на запись. - Ошибка занятости базы просто считается пропуском, без ретраев внутри итерации. Пока в базе хранится только текущее состояние, один пропущенный цикл безвреден — через пару секунд придёт свежее значение. С появлением истории показателей в следующей части это уже станет дырой в ряду данных, и подход придётся пересмотреть.
Остановка сервиса и грейс-период
Раз таймеры недоступны, а OnStart() — это буквально бесконечный while(!IsStopped()) со Sleep() внутри, стоит присмотреться к тому, что вообще происходит, когда сервис останавливают — вручную через Навигатор, при закрытии терминала или при повторном развёртывании .ex5.
Документация по IsStopped() прямо предупреждает: после команды на остановку программе даётся ровно 3 секунды на то, чтобы завершиться самостоятельно, иначе она «будет завершена принудительно извне». Это заставляет присмотреться к длительности нашего Sleep(pause) — при InpIntervalSec = 2 и InpJitterMs = 1000 пауза в худшем случае доходит до 3000 мс, то есть ровно до границы грейс-периода.
На практике опасений это не вызывает — но не потому, что граница «почти не задета», а благодаря отдельному свойству самой функции Sleep(): согласно документации, внутри неё встроена проверка флага остановки каждые 0.1 секунды, и при получении сигнала она прерывается досрочно, а не досыпает заданный интервал до конца. Поэтому цикл while(!IsStopped()) реагирует на остановку в пределах десятой доли секунды независимо от того, сколько миллисекунд запрошено в Sleep() — джиттер увеличивает интервал между записями, но не откладывает реакцию на команду остановки.
К этому стоит добавить ещё пару фактов о сервисах, которые пригодятся при развёртывании и которые мы уже частично использовали, даже не проговорив явно:
- Сервис переживает переключение торгового счёта в терминале — в отличие от советников и индикаторов, которые в этот момент перезагружаются вместе с графиком. Для нас это, впрочем, скорее теоретическое преимущество: экземпляры терминалов и так держат по одному счёту каждый.
- У экземпляра сервиса в Навигаторе есть собственное контекстное меню — «Пауза», «Остановить», «Удалить» — отдельно от меню самого сервиса, которое управляет сразу всеми его экземплярами. Это пригодится, если на одном терминале когда-нибудь понадобится временно остановить запись состояния, не удаляя и не перекомпилируя сервис.
Что показал первый запуск
Код сервиса писался без доступа к работающему MetaTrader, поэтому несколько мест оставались под вопросом до первой реальной компиляции и запуска. Компиляция прошла без ошибок с первого раза, но проверка на живых терминалах сразу же принесла пользу.
Баг в проверке кода 5126. Код действительно оказался равен 5126, как и предполагалось, но именно проверка этого предположения и вскрыла настоящую ошибку: 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, и обе функции проверяют error != 0 && error != ERR_DB_NO_MORE_DATA одинаковым образом — лог показывает мгновенную успешную регистрацию, а первая запись в states появляется уже на первом цикле.
TimeLocal() — не UTC. Изначально для отметок времени в базе использовался TimeLocal(). При первом же прогоне обнаружилось: значение отличается от time.time() в Python ровно на величину смещения часового пояса машины (в данном случае GMT+3, расхождение — 10800 секунд, проверено прямым вычитанием). Оказалось, что TimeLocal() возвращает цифры настенных часов машины, но выданные за отметку UTC — а не настоящий UTC. Сравнение с time.time() на стороне Python при таком подходе было бы систематически неверным на величину смещения пояса. Заменено на TimeGMT() в обеих функциях сервиса — эта функция даёт настоящий UTC и, в отличие от TimeCurrent(), не зависит от того, какое время показывает сервер конкретного брокера.
Сервис переживает жёсткий рестарт терминала. Оставался открытый вопрос: stop_mt5() на Python-стороне останавливает терминал через psutil.Process.terminate(), а на Windows это псевдоним kill() → TerminateProcess() — то есть жёсткое завершение процесса, а не штатное закрытие через интерфейс. Документация по автозагрузке сервисов не уточняет, требуется ли для неё именно штатное закрытие. Проверено вручную: Stop-Process -Force по PID терминала (по эффекту эквивалент TerminateProcess), а затем повторный запуск terminal64.exe /portable — буквально то, что и делают stop_mt5()/start_mt5() в связке. Сервис стартовал сам примерно через две секунды после запуска терминала, без обращения к Навигатору, и states.updated_at продолжил расти. Жёсткое завершение процесса не ломает автозагрузку сервиса.
Автодобавление сервиса при запуске терминала — не реализуемо штатно. Была надежда добавить сервис в автозапуск конфигурационным способом, аналогично тому, как это делается для советников. Проверка по официальной документации и материалам о сервисах показала: секция [StartUp] конфигурационного файла терминала поддерживает Expert и Script, но не Service. Задокументированного программного способа добавить сервис в Навигатор при первом развёртывании терминала не существует. Рассматривалась альтернатива — перейти со Service на Script, запускаемый через /config и [StartUp] Script=..., но она отклонена: это возвращает ровно тот недостаток советника, ради ухода от которого и был выбран сервис — нужен график, а сам скрипт умирает при его закрытии. На этом месте казалось, что вопрос закрыт — ручное добавление через Навигатор остаётся неизбежным шагом развёртывания. К разделу «Развёртывание» ниже стоит вернуться отдельно: там нашёлся обходной путь, который эту оценку меняет.
Обновление Python-стороны
Класс MT5_Control больше не обращается к mt5.initialize() на пути чтения данных — вместо этого он читает последнюю известную запись из общей базы:
def read_state(self, folder: str) -> sqlite3.Row | None:
try:
con = sqlite3.connect(self.database, timeout=3)
con.row_factory = sqlite3.Row
try:
return con.execute(self.STATE_QUERY, (folder,)).fetchone()
finally:
con.close()
except sqlite3.OperationalError:
return None
Информация о том, жив ли сам процесс терминала, по-прежнему приходит от psutil — сочетание этих двух источников и даёт три статуса вместо прежних двух:
Процесс (psutil) |
Свежесть updated_at |
Статус |
|---|---|---|
| жив | свежее порога | running |
| жив | старше порога или записи ещё нет | starting |
| процесса нет | — | stopped |
Средний случай в этой таблице покрывает сразу три разные ситуации: терминал ещё грузится, сервис в нём не запущен, либо сервис завис. Порог свежести выбран в 30 секунд при интервале записи 2–3 секунды — заметный запас на случай редких пропусков записи из-за занятости базы:
FRESHNESS_THRESHOLD_SEC = 30
Отдельно нужно учитывать ситуацию, когда терминал работает, а связи с сервером брокера нет: тогда цифры счёта в базе — последние известные, а не актуальные на данный момент. Это как раз тот самый код -2 («Service: No connection to trade server»), выставляемый сервисом, и веб-интерфейс уже сейчас показывает его отдельным значком статуса.
Совместимость с уже написанным интерфейсом
Структура ответа POST /instances/{name} — словарь с ключами terminal, account, last_update, last_error, status — воспроизводится из базы один в один, так же, как раньше собиралась из ответа MetaTrader5. Значит, templates/index.html и static/script.js, написанные ещё в третьей части, не потребовали изменений в разметке или логике отображения — механизм получения данных сменился полностью, а интерфейс подмены не заметил.
Единственное, что действительно пришлось поправить — формат времени. Раньше в last_update попадала строка str(datetime.now()), а JavaScript разбирал её через new Date(). Формат вида 2026-07-25 12:34:56.789012, без буквы T, Chrome понимает, но спецификация этого не гарантирует. Теперь наружу отдаётся то же самое целое число секунд Unix, что лежит в updated_at — миллисекундная точность здесь не нужна, а лишнее умножение только создавало бы повод для рассинхронизации единиц между Python и JS. На клиенте formatDateTime() просто умножает полученное значение на 1000 перед тем, как передать его в new Date().
Развёртывание: находка с config\services.ini
Сервис компилируется и раскладывается в MQL5/Services каждого терминала, а дальше по документированному пути экземпляр создаётся через Навигатор — вручную, по разу на каждый терминал. Для четырёх экземпляров из наших примеров это не проблема, но масштабировать такое развёртывание вручную дальше не хочется.
В процессе экспериментов нашёлся способ обойти этот шаг. Если положить файл services.ini в папку config внутри папки установки терминала (не в Common, не в папку данных профиля), а исполняемый файл сервиса к моменту запуска терминала уже лежит по пути, указанному в этом файле — сервис стартует сам, без единого обращения к Навигатору:
<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>
Проговорим сразу: это не задокументированное поведение платформы. Страница «Файлы и папки» официальной справки перечисляет содержимое папки config явно — accounts.dat, common.ini, metaeditor.ini, terminal.ini, servers.dat, сертификаты — файла services.ini среди них нет. Секция [StartUp], разобранная в предыдущем разделе, тоже ничего о нём не знает. Учебник, статья про сервисы, форум — ни одного упоминания.
Есть косвенное указание на происхождение файла. Открыв его побайтово, легко увидеть, что он закодирован в UTF-16LE (с BOM FF FE) — в отличие от обычного текстового common.ini. Такая кодировка типична для внутренних служебных файлов, которые терминал пишет и читает сам, не выставляя как публичный API. По всей видимости, services.ini — это ровно то представление, в которое терминал сериализует состояние сервисов, добавленных через Навигатор командой «Добавить сервис»: набор полей name, path, expertmode, enabled и блок <inputs> с входными параметрами один в один соответствует тому, что вводится в диалоге добавления сервиса. Подложив такой файл заранее, мы, по сути, подделываем состояние «сервис уже был однажды добавлен вручную» — а раз терминал загружает сервисы, которые были запущены на момент его последней остановки, он честно выполняет ту же самую автозагрузку и для этого случая.
Практически это меняет процедуру развёртывания нового терминала на воспроизводимую: скопировать скомпилированный ServiceStateWriter.ex5 по нужному пути и положить рядом с установкой заранее подготовленный services.ini — вместо того, чтобы открывать Навигатор и нажимать «Добавить сервис» на каждом из терминалов. Но раз MetaQuotes нигде не фиксирует этот формат как часть публичного контракта, к находке стоит относиться как к рабочему, но неофициальному приёму: он воспроизводится сегодня, однако формальных гарантий, что он переживёт будущие билды терминала без изменений, нет.
Заключение и планы
Главный итог этой части: путь чтения данных больше не зависит от последовательного опроса терминалов из одного процесса Python. Каждый терминал сам сообщает о себе через общую базу SQLite, интерфейс, написанный в предыдущей части, продолжает работать без изменений, а число терминалов, которые можно обслуживать одновременно, перестаёт упираться в очередь mt5.initialize()/shutdown().
Что остаётся на будущее — часть обещаний повторяется из предыдущих частей, часть добавилась в процессе этой:
- таблица истории показателей и более редкий интервал записи для неё (5–10 минут), а также чистка старых записей;
- открытые позиции построчно (символ, объём, прибыль), сейчас в базе — только агрегированное число
positions; - журнал операций;
- программная генерация
services.iniи раскладка.ex5при автоматическом развёртывании новых терминалов — сама раскладка сервиса в Навигатор находкой уже закрыта, остаётся обвязка вокруг неё; - редактирование конфигурации через браузер;
- авторизация и HTTPS;
- работа с несколькими серверами (главный сервер + серверы терминалов) — схема, намеченная ещё в первой части;
- торговые операции из браузера и управление советниками.
Полезные ссылки
- Сервисы — программы MQL5, не привязанные к графику — глава учебника «Программирование на MQL5 для трейдеров»
- Запуск программ MQL5 — модель потоков выполнения для разных типов программ
- IsStopped() и Sleep() — обработка команды остановки программы
- Рецепты MQL5 — Сервисы — практические примеры использования сервисов
- DatabaseOpen(), DatabaseBind(), DatabaseTransactionBegin() — функции работы с SQLite в MQL5
- Файлы и папки и Запуск платформы для продвинутых пользователей — официальный список конфигурационных файлов и секция
[StartUp], на фоне которых видно, чтоservices.iniв документации нет
Как обычно, полный код проекта доступен в репозитории — ссылка в конце статьи.