# Разрабатываем менеджер терминалов (Часть 4): сервис вместо опроса — состояние терминалов через общую базу SQLite ## Содержание - Введение - Чем не устраивает текущий подход - Идея: сервис пишет, веб-приложение читает - Сервис как тип MQL5-программы - Общая папка Common и почему это работает - Схема базы данных - Реализация сервиса ServiceStateWriter.mq5 - Определение имени экземпляра - Открытие базы и PRAGMA - Регистрация терминала - Запись состояния - Основной цикл - Остановка сервиса и грейс-период - Что показал первый запуск - Обновление Python-стороны - Совместимость с уже написанным интерфейсом - Развёртывание: находка с `config\services.ini` - Заключение и планы ## Введение В [первой части](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) на основе котировок нескольких валютных пар, поставляя терминалу котировки, которых иначе просто не существует. Оба примера объединяет одна и та же идея, что и в нашей задаче: сервис — это фоновый поставщик или потребитель данных, работающий сам по себе, без интерфейса и без привязки к конкретному графику. ## Общая папка Common и почему это работает Файл базы открывается сервисом с флагом `DATABASE_OPEN_COMMON`. [Документация по `DatabaseOpen()`](https://www.mql5.com/ru/docs/database/databaseopen) описывает его лаконично: «файл находится в общей папке всех терминалов» — это кладёт базу в `Common\Files`, которая у всех терминалов на одной машине одна и та же, независимо от того, в portable-режиме терминал запущен или нет. Это стоит подчеркнуть отдельно, потому что интуиция подсказывает обратное: раз терминалы у нас в 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, ...)`. Легко споткнуться, если не знать заранее. ### Запись состояния Основная функция сервиса собирает данные терминала и счёта и пишет их в `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-файла на диск, и при нескольких параллельно пишущих сервисах эти сбросы складываются. Отдельного объяснения заслуживает константа `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 мс, то есть ровно до границы грейс-периода. На практике опасений это не вызывает — но не потому, что граница «почти не задета», а благодаря отдельному свойству самой функции `Sleep()`: [согласно документации](https://www.mql5.com/ru/docs/common/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()` на пути чтения данных — вместо этого он читает последнюю известную запись из общей базы: ```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` Сервис компилируется и раскладывается в `MQL5/Services` каждого терминала, а дальше по документированному пути экземпляр создаётся через Навигатор — вручную, по разу на каждый терминал. Для четырёх экземпляров из наших примеров это не проблема, но масштабировать такое развёртывание вручную дальше не хочется. В процессе экспериментов нашёлся способ обойти этот шаг. Если положить файл `services.ini` в папку `config` **внутри папки установки терминала** (не в `Common`, не в папку данных профиля), а исполняемый файл сервиса к моменту запуска терминала уже лежит по пути, указанному в этом файле — сервис стартует сам, без единого обращения к Навигатору: ``` name=ServiceStateWriter path=Services\Shared Projects\mt5-manager\ServiceStateWriter.ex5 expertmode=0 enabled=1 InpDatabase=mt5-manager.sqlite InpIntervalSec=2 InpJitterMs=1000 InpBusyTimeout=3000 ``` Проговорим сразу: **это не задокументированное поведение платформы.** [Страница «Файлы и папки»](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` и блок `` с входными параметрами один в один соответствует тому, что вводится в диалоге добавления сервиса. Подложив такой файл заранее, мы, по сути, подделываем состояние «сервис уже был однажды добавлен вручную» — а раз терминал загружает сервисы, которые были запущены на момент его последней остановки, он честно выполняет ту же самую автозагрузку и для этого случая. Практически это меняет процедуру развёртывания нового терминала на воспроизводимую: скопировать скомпилированный `ServiceStateWriter.ex5` по нужному пути и положить рядом с установкой заранее подготовленный `services.ini` — вместо того, чтобы открывать Навигатор и нажимать «Добавить сервис» на каждом из терминалов. Но раз MetaQuotes нигде не фиксирует этот формат как часть публичного контракта, к находке стоит относиться как к рабочему, но неофициальному приёму: он воспроизводится сегодня, однако формальных гарантий, что он переживёт будущие билды терминала без изменений, нет. ## Заключение и планы Главный итог этой части: путь чтения данных больше не зависит от последовательного опроса терминалов из одного процесса Python. Каждый терминал сам сообщает о себе через общую базу SQLite, интерфейс, написанный в предыдущей части, продолжает работать без изменений, а число терминалов, которые можно обслуживать одновременно, перестаёт упираться в очередь `mt5.initialize()`/`shutdown()`. Что остаётся на будущее — часть обещаний повторяется из предыдущих частей, часть добавилась в процессе этой: - таблица истории показателей и более редкий интервал записи для неё (5–10 минут), а также чистка старых записей; - открытые позиции построчно (символ, объём, прибыль), сейчас в базе — только агрегированное число `positions`; - журнал операций; - программная генерация `services.ini` и раскладка `.ex5` при автоматическом развёртывании новых терминалов — сама раскладка сервиса в Навигатор находкой уже закрыта, остаётся обвязка вокруг неё; - редактирование конфигурации через браузер; - авторизация и 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` в документации нет Как обычно, полный код проекта доступен в репозитории — ссылка в конце статьи.