mt5-manager/docs/article4-draft.md

472 lines
54 KiB
Markdown
Raw Permalink Normal View History

2026-07-27 12:32:10 +03:00
# Разрабатываем менеджер терминалов (Часть 4): сервис вместо опроса — состояние терминалов через общую базу SQLite
## Содержание
- Введение
- Чем не устраивает текущий подход
- Идея: сервис пишет, веб-приложение читает
- Сервис как тип MQL5-программы
2026-07-27 12:32:10 +03:00
- Общая папка Common и почему это работает
- Схема базы данных
- Реализация сервиса ServiceStateWriter.mq5
- Определение имени экземпляра
- Открытие базы и PRAGMA
- Регистрация терминала
- Запись состояния
- Основной цикл
- Остановка сервиса и грейс-период
2026-07-27 12:32:10 +03:00
- Что показал первый запуск
- Обновление Python-стороны
- Совместимость с уже написанным интерфейсом
- Развёртывание: находка с `config\services.ini`
2026-07-27 12:32:10 +03:00
- Заключение и планы
## Введение
В [первой части](https://www.mql5.com/ru/articles/19804) мы поставили задачу: удобно управлять несколькими экземплярами MetaTrader 5 без устаревшего MultiTerminal. Во [второй](https://www.mql5.com/ru/articles/19852) научились запускать и останавливать произвольное количество терминалов через веб-интерфейс на FastAPI. В [третьей](https://www.mql5.com/ru/articles/19946) добавили конфигурацию и получение данных о счёте — карточки терминалов на странице стали показывать баланс, прибыль, средства и маржу.
Способ получения этих данных был предельно прямолинейным: веб-сервер сам подключался к каждому терминалу через модуль `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()` — [как и у скрипта](https://www.mql5.com/ru/docs/runtime/running), никакие другие события (`OnTick`, `OnTimer`, `OnChartEvent` и так далее) сервису не передаются в принципе. Но, в отличие от скрипта, который выполняется один раз от начала до конца и завершается, `OnStart()` сервиса типично организуют как бесконечный цикл — [именно так и рекомендует официальная документация](https://www.mql5.com/ru/book/applications/script_service/services): «в котором можно организовать бесконечный цикл получения и обработки данных».
Ключевое архитектурное отличие сервиса от советника или индикатора — отсутствие привязки к графику. Экземпляр сервиса создаётся не через открытие графика, а прямо из Навигатора, командой «Добавить сервис» в контекстном меню. Отсюда следует важное для нашей задачи следствие: **сервис переживает переключение счёта в терминале**. Советники и индикаторы при смене счёта перезагружаются вместе с графиками, а сервис как работал, так и продолжает работать — идеально для программы, которая должна непрерывно снимать показания с терминала независимо от того, что происходит с графиками и какой счёт сейчас активен.
Второе важное отличие касается многопоточности. Документация по запуску программ прямо противопоставляет модель выполнения индикаторов и всего остального: у индикаторов «один поток выполнения для всех индикаторов на одном символе» — зациклившийся индикатор останавливает работу всех остальных индикаторов на этом графике. Скрипты, советники и сервисы устроены иначе: у каждого запущенного экземпляра — свой собственный поток. Зациклившийся (в штатном, рабочем смысле — это наш случай) сервис никак не мешает другим программам, в том числе другим экземплярам того же сервиса в других терминалах.
С этим же связан и набор запретов. Сервисам, как и советникам, недоступны функции таймера — `EventSetTimer()`, `EventSetMillisecondTimer()`, `EventKillTimer()` — единственный способ организовать периодичность внутри `OnStart()` — это `Sleep()` в цикле. Кроме того, для сервисов недоступны `ExpertRemove()` и функции работы с буферами индикаторов (`SetIndexBuffer()` и подобные) — что логично, раз у сервиса нет графика, к которому можно было бы такой буфер привязать.
В [статье-рецепте по сервисам](https://www.mql5.com/ru/articles/11826) на сайте приводятся и другие показательные примеры их использования: сервис, который в фоне ежедневно чистит устаревшие лог-файлы через WinAPI, и сервис, который строит собственный расчётный символ (например, индекс доллара DXY) на основе котировок нескольких валютных пар, поставляя терминалу котировки, которых иначе просто не существует. Оба примера объединяет одна и та же идея, что и в нашей задаче: сервис — это фоновый поставщик или потребитель данных, работающий сам по себе, без интерфейса и без привязки к конкретному графику.
2026-07-27 12:32:10 +03:00
## Общая папка Common и почему это работает
Файл базы открывается сервисом с флагом `DATABASE_OPEN_COMMON`. [Документация по `DatabaseOpen()`](https://www.mql5.com/ru/docs/database/databaseopen) описывает его лаконично: «файл находится в общей папке всех терминалов» — это кладёт базу в `Common\Files`, которая у всех терминалов на одной машине одна и та же, независимо от того, в portable-режиме терминал запущен или нет.
2026-07-27 12:32:10 +03:00
Это стоит подчеркнуть отдельно, потому что интуиция подсказывает обратное: раз терминалы у нас в portable-режиме и каждый работает со своей папкой данных как с изолированной, кажется логичным, что и `Common` тоже должна быть у каждого своя. На практике это не так — Common остаётся общей всегда, и все экземпляры пишут в один и тот же файл базы. Проверено практическим запуском нескольких терминалов одновременно: путь к базе одинаковый для всех, настройка при развёртывании не нужна, один и тот же скомпилированный `.ex5` раскладывается по терминалам без изменений.
## Схема базы данных
Две таблицы. Осознанно разделяем то, что меняется редко (папка экземпляра, брокер, билд), от того, что меняется каждые несколько секунд:
```sql
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 не даёт готовой функции поиска последнего вхождения подстроки, приходится написать её самим:
```cpp
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;
}
```
С её помощью берём последний компонент пути запуска:
```cpp
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
```cpp
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()` не получится — вместо этого используется подготовленный запрос:
```cpp
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` вынесена из основного цикла и выполняется один раз при старте сервиса (и повторно, если пришлось переоткрывать базу):
```cpp
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()`](https://www.mql5.com/ru/docs/database/databasebind) собственный пример показывает ровно то же самое смещение — `?1` в запросе связывается вызовом `DatabaseBind(request, 0, ...)`. Легко споткнуться, если не знать заранее.
2026-07-27 12:32:10 +03:00
### Запись состояния
Основная функция сервиса собирает данные терминала и счёта и пишет их в `states` тем же способом — `INSERT ... ON CONFLICT(terminal_id) DO UPDATE`:
```cpp
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`, равный именно единице, а не нулю — ноль там уже занят под статусы, которые расставляются вручную. Сохраняя то же соглашение в сервисе, не приходится менять логику отображения статуса на фронтенде.
Запись всегда оборачивается в явную транзакцию:
```cpp
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()`](https://www.mql5.com/ru/docs/database/databasetransactionbegin): на вставке 2737 сделок оборачивание в транзакцию ускорило запись примерно в 530 раз (48,5 мс против 25818,9 мс без транзакции). У нас на каждой итерации всего одна запись, а не тысячи, но принцип тот же — каждая транзакция означает отдельный сброс WAL-файла на диск, и при нескольких параллельно пишущих сервисах эти сбросы складываются.
2026-07-27 12:32:10 +03:00
Отдельного объяснения заслуживает константа `ERR_DB_NO_MORE_DATA`:
```cpp
#define ERR_DB_NO_MORE_DATA 5126
```
`DatabaseRead()` на запросе, у которого нет результата — а `INSERT`/`UPDATE` именно такие, — завершается этим кодом. Это не ошибка, а нормальное завершение выборки, и обе функции сервиса должны его знать и пропускать, а не считать провалом. Ниже в разделе про первый запуск разберём, к чему приводит забытая проверка этого кода.
### Основной цикл
```cpp
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()`](https://www.mql5.com/ru/docs/check/isstopped) прямо предупреждает: после команды на остановку программе даётся ровно 3 секунды на то, чтобы завершиться самостоятельно, иначе она «будет завершена принудительно извне». Это заставляет присмотреться к длительности нашего `Sleep(pause)` — при `InpIntervalSec = 2` и `InpJitterMs = 1000` пауза в худшем случае доходит до 3000 мс, то есть ровно до границы грейс-периода.
2026-07-27 12:32:10 +03:00
На практике опасений это не вызывает — но не потому, что граница «почти не задета», а благодаря отдельному свойству самой функции `Sleep()`: [согласно документации](https://www.mql5.com/ru/docs/common/sleep), внутри неё встроена проверка флага остановки каждые 0.1 секунды, и при получении сигнала она прерывается досрочно, а не досыпает заданный интервал до конца. Поэтому цикл `while(!IsStopped())` реагирует на остановку в пределах десятой доли секунды независимо от того, сколько миллисекунд запрошено в `Sleep()` — джиттер увеличивает интервал между записями, но не откладывает реакцию на команду остановки.
2026-07-27 12:32:10 +03:00
К этому стоит добавить ещё пару фактов о сервисах, которые пригодятся при развёртывании и которые мы уже частично использовали, даже не проговорив явно:
- Сервис **переживает переключение торгового счёта** в терминале — в отличие от советников и индикаторов, которые в этот момент перезагружаются вместе с графиком. Для нас это, впрочем, скорее теоретическое преимущество: экземпляры терминалов и так держат по одному счёту каждый.
- У экземпляра сервиса в Навигаторе есть собственное контекстное меню — «Пауза», «Остановить», «Удалить» — отдельно от меню самого сервиса, которое управляет сразу всеми его экземплярами. Это пригодится, если на одном терминале когда-нибудь понадобится временно остановить запись состояния, не удаляя и не перекомпилируя сервис.
2026-07-27 12:32:10 +03:00
## Что показал первый запуск
Код сервиса писался без доступа к работающему 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=...`, но она отклонена: это возвращает ровно тот недостаток советника, ради ухода от которого и был выбран сервис — нужен график, а сам скрипт умирает при его закрытии. На этом месте казалось, что вопрос закрыт — ручное добавление через Навигатор остаётся неизбежным шагом развёртывания. К разделу «Развёртывание» ниже стоит вернуться отдельно: там нашёлся обходной путь, который эту оценку меняет.
2026-07-27 12:32:10 +03:00
## Обновление Python-стороны
Класс `MT5_Control` больше не обращается к `mt5.initialize()` на пути чтения данных — вместо этого он читает последнюю известную запись из общей базы:
```python
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 секунды — заметный запас на случай редких пропусков записи из-за занятости базы:
```python
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`
2026-07-27 12:32:10 +03:00
Сервис компилируется и раскладывается в `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>
```
Проговорим сразу: **это не задокументированное поведение платформы.** [Страница «Файлы и папки»](https://www.metatrader5.com/en/terminal/help/start_advanced/structure) официальной справки перечисляет содержимое папки `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 нигде не фиксирует этот формат как часть публичного контракта, к находке стоит относиться как к рабочему, но неофициальному приёму: он воспроизводится сегодня, однако формальных гарантий, что он переживёт будущие билды терминала без изменений, нет.
2026-07-27 12:32:10 +03:00
## Заключение и планы
Главный итог этой части: путь чтения данных больше не зависит от последовательного опроса терминалов из одного процесса Python. Каждый терминал сам сообщает о себе через общую базу SQLite, интерфейс, написанный в предыдущей части, продолжает работать без изменений, а число терминалов, которые можно обслуживать одновременно, перестаёт упираться в очередь `mt5.initialize()`/`shutdown()`.
Что остаётся на будущее — часть обещаний повторяется из предыдущих частей, часть добавилась в процессе этой:
- таблица истории показателей и более редкий интервал записи для неё (5–10 минут), а также чистка старых записей;
- открытые позиции построчно (символ, объём, прибыль), сейчас в базе — только агрегированное число `positions`;
- журнал операций;
- программная генерация `services.ini` и раскладка `.ex5` при автоматическом развёртывании новых терминалов — сама раскладка сервиса в Навигатор находкой уже закрыта, остаётся обвязка вокруг неё;
2026-07-27 12:32:10 +03:00
- редактирование конфигурации через браузер;
- авторизация и HTTPS;
- работа с несколькими серверами (главный сервер + серверы терминалов) — схема, намеченная ещё в первой части;
- торговые операции из браузера и управление советниками.
## Полезные ссылки
- [Сервисы — программы MQL5, не привязанные к графику](https://www.mql5.com/ru/book/applications/script_service/services) — глава учебника «Программирование на MQL5 для трейдеров»
- [Запуск программ MQL5](https://www.mql5.com/ru/docs/runtime/running) — модель потоков выполнения для разных типов программ
- [IsStopped()](https://www.mql5.com/ru/docs/check/isstopped) и [Sleep()](https://www.mql5.com/ru/docs/common/sleep) — обработка команды остановки программы
- [Рецепты MQL5 — Сервисы](https://www.mql5.com/ru/articles/11826) — практические примеры использования сервисов
- [DatabaseOpen()](https://www.mql5.com/ru/docs/database/databaseopen), [DatabaseBind()](https://www.mql5.com/ru/docs/database/databasebind), [DatabaseTransactionBegin()](https://www.mql5.com/ru/docs/database/databasetransactionbegin) — функции работы с SQLite в MQL5
- [Файлы и папки](https://www.metatrader5.com/en/terminal/help/start_advanced/structure) и [Запуск платформы для продвинутых пользователей](https://www.metatrader5.com/en/terminal/help/start_advanced/start) — официальный список конфигурационных файлов и секция `[StartUp]`, на фоне которых видно, что `services.ini` в документации нет
2026-07-27 12:32:10 +03:00
Как обычно, полный код проекта доступен в репозитории — ссылка в конце статьи.