# Разрабатываем менеджер терминалов (Часть 4): Используем сервис для получения информации о терминалах ## Содержание - Введение - Чем не устраивает текущий подход - Намечаем путь - Сервис как тип MQL5-программы - Схема базы данных - Реализация сервиса ServiceStateWriter.mq5 - Что показал первый запуск - Обновление Python-стороны - Совместимость с уже написанным интерфейсом - Развёртывание через `config/services.ini` - Тестирование - Заключение ## Введение В [первой части](https://www.mql5.com/ru/articles/19804) мы определили, что хотим получить: удобный инструмент для управления несколькими экземплярами MetaTrader 5, которого для этой платформы раньше попросту не существовало. Во [второй](https://www.mql5.com/ru/articles/19852) мы научились запускать и останавливать произвольное количество терминалов через веб-интерфейс на FastAPI, а в [третьей](https://www.mql5.com/ru/articles/19946) добавили конфигурацию и получение данных о счёте, после чего карточки терминалов на странице стали показывать баланс, прибыль, средства и маржу. Способ получения этих данных мы тогда выбрали самый прямолинейный: веб-сервер сам подключался к каждому терминалу через модуль `MetaTrader5` для Python, вызывал функции `mt5.terminal_info()` и `mt5.account_info()`, получая от них информацию о терминале и о торговом счёте, и сразу же отключался. Для первого шага этого вполне достаточно, но у такого подхода есть предел, и в этой части нам как раз предстоит с ним разобраться. ## Чем не устраивает текущий подход Прежде чем что-то менять, давайте разберёмся, чем нас не устраивает нынешний способ получения данных. Дело в том, что функции библиотеки MetaTrader 5 могут одновременно работать только с одним терминалом, а именно с тем, соединение с которым было установлено вызовом `mt5.initialize()`. Поэтому опрос разных терминалов идёт строго по очереди, а между итерациями приходится вызывать функцию `mt5.shutdown()`. Для четырёх терминалов это почти незаметно, но если их станет пятнадцать, цикл опроса, скорее всего, перестанет укладываться в интервал обновления страницы, а один зависший терминал начнёт задерживать всех остальных. Есть и второе, менее очевидное ограничение. Пока получение данных зависит от модуля `MetaTrader5`, мы привязаны к тем версиям Python, которые этот модуль поддерживает. Когда мы писали предыдущие части, модуль ещё не работал на последней на тот момент версии Python 3.14, и нам пришлось остановиться на 3.12. Сейчас ситуация изменилась: для модуля выпущены сборки под 3.14, и мы проверили, что на этой версии подключение к терминалам и чтение данных работают точно так же, как на 3.12, так что переживать по этому поводу больше не нужно. Правда, за это время успела выйти Python 3.15, а сборок модуля под неё пока нет, поэтому полностью зависимость от версии Python мы не снимаем, а лишь отодвигаем. Всё это вполне терпимо на начальном этапе, но если мы хотим двигаться дальше, нашу схему получения данных стоит пересмотреть. ## Намечаем путь Сменим направление: пусть каждый терминал сам сообщает о своём состоянии, не дожидаясь, пока его спросят. Чтобы это стало возможным, внутри каждого экземпляра терминала должна работать наша собственная программа на MQL5, которая раз в две-три секунды будет снимать показания терминала и торгового счёта и передавать их туда, откуда их сможет забрать веб-приложение. Само веб-приложение при этом перестанет обращаться к терминалам напрямую и будет просто брать последние известные данные по каждому экземпляру. Что нам это даёт? Во-первых, такие программы, по одной на каждый терминал, работают параллельно, каждая в своём процессе, и очередь с взаимным ожиданием исчезает. Во-вторых, с пути чтения данных уходит зависимость от модуля `MetaTrader5`. И в-третьих, у нас появляется задел под историю показателей и журнал операций, обещанные ещё во второй части. Конечно, есть и цена. Архитектура получается гибридной: программа внутри терминала не может сообщить «я остановлен», если её завершили вместе с терминалом, поэтому информацию о том, жив ли сам процесс, нам по-прежнему придётся получать через `psutil`. Теперь давайте определимся, в каком порядке мы будем всё это реализовывать. Первым делом нужно выбрать, какой из типов программ на MQL5 лучше всего подойдёт для нашей цели, поскольку у каждого типа свои возможности и ограничения. Следующим вопросом станет выбор места, куда программы будут передавать свои данные. В принципе, можно было бы обойтись обычными файлами, но нам больше подойдёт база данных SQLite: в MQL5 для неё есть встроенные функции, а в Python модуль для работы с ней входит в стандартную библиотеку, так что ничего дополнительно устанавливать не придётся. К тому же база берёт на себя заботу о блокировках и целостности записи, что для нескольких одновременно работающих программ далеко не лишнее. При этом нам предстоит решить, где разместить файл базы, как позволить программам записывать в него данные одновременно и не мешать друг другу, и какие таблицы понадобятся. Когда данные начнут появляться в базе, останется научить веб-приложение их читать. Как мы уже говорили, одной базы для этого недостаточно: в ней хранится только то, что было записано в последний раз, а о внезапной остановке терминала она узнать не может. Поэтому состояние процесса мы по-прежнему будем проверять через `psutil` и сопоставлять его со временем последней записи в базу. Заодно посмотрим, удастся ли оставить без изменений главную страницу и написанный ранее JavaScript. Наконец, нашу программу нужно будет доставить на каждый терминал. На первых порах мы делали это вручную, но довольно быстро стало понятно, что повторять такие действия при каждой правке не хочется. Поэтому мы попробуем их автоматизировать. Историю показателей и подробный список открытых позиций мы оставим для следующих частей, а в этой ограничимся текущим состоянием терминалов. Кроме того, первые запуски редко проходят гладко с первого раза, поэтому по дороге нам встретится несколько неожиданностей, о которых мы тоже расскажем. ## Сервис как тип MQL5-программы Итак, нам нужна программа, которая запускается вместе с терминалом, работает в фоне непрерывно и не зависит ни от открытых графиков, ни от того, какой счёт сейчас подключен. Среди типов программ MQL5 есть один, который как нельзя лучше подходит для такой задачи, — сервис. На практике он используется заметно реже советников и индикаторов, а в предыдущих частях цикла мы с ним вообще не работали, поэтому прежде чем переходить к коду, давайте разберёмся, что это такое. ### Чем сервис отличается от других программ Формально сервис — это программа на MQL5 с директивой `#property service` и единственным обработчиком событий `OnStart()`. Этим он похож на скрипт: как сказано в [документации по запуску программ](https://www.mql5.com/ru/docs/runtime/running), ни тому, ни другому никакие другие события (`OnTick()`, `OnTimer()`, `OnChartEvent()` и так далее) не передаются. Но скрипт выполняется один раз от начала до конца и завершается, а `OnStart()` сервиса обычно организуют в виде бесконечного цикла получения и обработки данных, как это и предлагается в [учебнике по MQL5](https://www.mql5.com/ru/book/applications/script_service/services). Заготовка сервиса, таким образом, может выглядеть, например, так: ```cpp // 1. Говорим компилятору, что это сервис #property service // 2. Единственная точка входа void OnStart() { // 3. Работаем, пока терминал или пользователь не попросят остановиться while(!IsStopped()) { // Здесь выполняется полезная работа // ... // 4. Делаем паузу до следующей итерации Sleep(1000); } } ``` Главное отличие сервиса от советников, скриптов и индикаторов состоит в том, что он не привязан к графику. Те программы запускаются на графике, а экземпляр сервиса создаётся прямо из Навигатора командой «Добавить сервис» в контекстном меню. Из этого следует несколько полезных для нас вещей. Во-первых, судя по учебнику, сервис продолжает работать при переключении торгового счёта, тогда как советники и индикаторы при смене счёта перезагружаются вместе с графиками. Во-вторых, если на момент остановки терминала сервис был запущен, то после следующего запуска терминала он загрузится сам, без нашего участия. Для программы, которая должна непрерывно снимать показания с терминала, это почти то, что нужно. Есть и ещё одно отличие, связанное с многопоточностью. В документации по запуску программ модель выполнения индикаторов противопоставлена всем остальным: для индикаторов выделяется один общий поток на все индикаторы на одном символе, поэтому зациклившийся индикатор останавливает работу остальных. Скрипты, советники и сервисы устроены иначе, и у каждого запущенного экземпляра есть собственный поток. Так что сервис, работающий в бесконечном цикле (а это для него штатный режим), не мешает другим программам, в том числе таким же сервисам в других терминалах. ### Ограничения У сервисов, как и у любых программ без графика, есть и свои ограничения. Для них недоступны функции таймера `EventSetTimer()`, `EventSetMillisecondTimer()` и `EventKillTimer()`, поэтому единственный способ сделать работу периодической — это вызывать `Sleep()` внутри цикла. Недоступна также функция `ExpertRemove()`, а вместе с ней и функции работы с буферами индикаторов, вроде `SetIndexBuffer()`. Это вполне логично, раз у сервиса нет графика, на котором всё это могло бы отображаться. Кроме того, сервис сам не получает никаких событий. Он может лишь отправлять пользовательские события графикам с помощью `EventChartCustom()`, но в нашей задаче нам это не понадобится. ### Для чего сервисы применяются Сервис — это фоновый поставщик или потребитель данных, работающий сам по себе, без интерфейса и без привязки к конкретному графику. В [статье-рецепте](https://www.mql5.com/ru/articles/11826) приведены, например, сервис, который в фоне ежедневно чистит устаревшие лог-файлы, и сервис, строящий собственный символ (индекс доллара DXY) на основе котировок нескольких валютных пар, так что терминал получает котировки, которых у него иначе просто не было бы. В учебнике разобран пример сервиса, который отслеживает смену торговых счетов и проверяет привязку программ к их номерам. Наша задача — периодически снимать показания терминала и записывать их в общее место — вполне вписывается в этот ряд. Осталось выбрать, куда именно записывать. ## Схема базы данных Файл базы мы разместим в общей папке `Common`, которая доступна всем терминалам на компьютере. Для этого достаточно открыть его в MQL5 с флагом `DATABASE_OPEN_COMMON` (подробности есть в [документации по функции `DatabaseOpen()`](https://www.mql5.com/ru/docs/database/databaseopen)), а веб-приложение будет читать тот же самый файл. Заодно один и тот же скомпилированный файл сервиса можно будет раскладывать по терминалам без изменений. Теперь перейдём к таблицам. Их будет две: в одной мы храним то, что меняется редко (папка экземпляра, брокер, билд), а в другой то, что обновляется каждые несколько секунд: ```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, 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, company TEXT, holder_name TEXT, error_code INTEGER DEFAULT 0, error_text TEXT ); ``` Несколько решений в этой схеме стоит пояснить отдельно. Начнём с того, что записи в таблице `terminals` не удаляются, даже когда все терминалы выключены. Она хранит то, что меняется редко, поэтому список известных экземпляров виден в базе всегда, а не только пока работает сервис. Размер базы при этом не растёт со временем: сейчас в ней лежит только текущее состояние, по одной строке на терминал в каждой таблице. Историю показателей и журнал операций мы сознательно отложили до следующих частей, под них потребуется отдельная таблица. По той же причине мы не стали добавлять индексы сверх первичных ключей: на таких объёмах пользы от них вряд ли будет много. Кроме того, в схеме нет полей `pid` и `status`, и это сделано намеренно. Сервис не может достоверно знать, что терминал остановлен: если терминал закрывают, сервис прерывается вместе с ним. Поэтому эта часть информации остаётся за `psutil` на стороне Python, а не за базой. Время мы храним в виде целого числа секунд Unix, а не строки. Это решение оказалось не таким простым, как выглядит на бумаге, поэтому мы ещё вернёмся к нему. Отдельное поле `margin_level` понадобилось потому, что при нулевой марже отношение `equity` к `margin` не определено, и лучше один раз обработать такую особенность в MQL5, чем в JavaScript на клиенте. ## Реализация сервиса ServiceStateWriter.mq5 Весь код сервиса находится в одном файле `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_DATA_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_DATA_PATH`, то есть папку, в которой хранятся данные терминала. Это сделано намеренно: менеджер всегда запускает терминалы с флагом `/portable`, и в этом режиме папка данных совпадает с папкой самого терминала, а её последний компонент является именем экземпляра, по которому мы ищем терминал в `config.json`. Таким образом, мы вводим ограничение: сервис рассчитан на работу только в portable-режиме. Два вопроса мы пока оставим открытыми. Во-первых, Windows не различает регистр в путях, поэтому `MetaTrader5.1` и `Metatrader5.1` для неё одна и та же папка, хотя как строки они, конечно, различаются. Пока имя папки вписывается в `config.json` вручную, такое расхождение вполне возможно, но когда у нас появится автоматическое создание экземпляров, имя будет подставляться само. Во-вторых, непонятно, что делать, если сервис зарегистрировал в базе папку, которой нет в `config.json`. Этот вопрос мы отложим до следующих частей. ### Открытие базы и PRAGMA Все действия с базой данных мы пока оформим в виде отдельных функций. В дальнейшем их можно будет собрать в отдельный класс, как мы уже делали в другой серии статей, но на данном этапе так проще проследить, что и в каком порядке происходит. Базу открывает функция `OpenDatabase()`. Файл создаётся, если его ещё нет, и открывается на чтение и запись в общей папке `Common`, о чём говорят флаги `DATABASE_OPEN_READWRITE`, `DATABASE_OPEN_CREATE` и `DATABASE_OPEN_COMMON`. Затем с помощью `DatabaseExecute()` выполняются два PRAGMA (если что-то пойдёт не так, код ошибки просто попадёт в журнал), а функция `EnsureSchema()` создаёт описанные выше таблицы, если их там ещё нет: ```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; } if(!DatabaseExecute(g_db, StringFormat("PRAGMA busy_timeout=%d", InpBusyTimeout))) PrintFormat("Не удалось задать busy_timeout, код ошибки: %d", GetLastError()); if(!DatabaseExecute(g_db, "PRAGMA journal_mode=WAL")) PrintFormat("Не удалось включить режим WAL, код ошибки: %d", GetLastError()); if(!EnsureSchema(g_db)) { DatabaseClose(g_db); g_db = INVALID_HANDLE; return false; } return true; } ``` Эти два PRAGMA нужны для того, чтобы несколько сервисов могли без проблем записывать данные в один и тот же файл. Первый из них, `journal_mode=WAL`, избавляет читателей и писателя от взаимных блокировок, поэтому веб-приложение сможет читать базу, пока сервисы в неё пишут. Это свойство самого файла: оно записывается в его заголовок один раз и сохраняется дальше, так что повторная установка при каждом запуске сервиса безвредна и просто подстраховывает на случай, если базу первым создаст другой процесс. Однако стоит иметь в виду, что WAL опирается на разделяемую память в пределах одной машины и не работает через сетевую папку. Поэтому, если распределённая схема из первой части когда-нибудь понадобится нам всерьёз, у каждого сервера должна быть своя база. Второй PRAGMA, `busy_timeout`, в отличие от `journal_mode`, относится не к файлу, а к соединению. Он не сохраняется, поэтому его нужно устанавливать заново при каждом открытии базы в каждом сервисе. Благодаря ему при попытке записи в занятую базу библиотека не сразу вернёт ошибку `SQLITE_BUSY`, а подождёт и повторит попытку. Режим WAL эту проблему не решает: писатель по-прежнему может быть только один, и сервисы просто становятся в очередь на запись, вместо того чтобы завершаться с ошибкой. ### Регистрация терминала Сведения о самом терминале (папка, путь, брокер и билд) меняются редко, поэтому записываем мы их в таблицу `terminals` не в основном цикле, а один раз при старте сервиса, и повторно, если базу пришлось переоткрывать. Функция `RegisterTerminal()` вставляет новую запись, а если запись об этом терминале уже есть, просто обновляет её: ```cpp int RegisterTerminal() { const string folder = InstanceFolder(); const string path = TerminalInfoString(TERMINAL_DATA_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` в самом запросе нумеруются с единицы. Поначалу это похоже на опечатку, но, судя по примеру в [документации по `DatabaseBind()`](https://www.mql5.com/ru/docs/database/databasebind), так и задумано: `?1` связывается вызовом `DatabaseBind(request, 0, ...)`. На этом легко споткнуться, если не знать заранее. ### Запись состояния Состояние терминала и торгового счёта собирает и записывает в таблицу `states` функция `StoreState()`. В её начале мы просто запрашиваем нужные показатели встроенными функциями MQL5: ```cpp //--- Состояние терминала const bool connected = (bool)TerminalInfoInteger(TERMINAL_CONNECTED); const int ping_last = (int)TerminalInfoInteger(TERMINAL_PING_LAST); //--- Состояние торгового счёта const long login = AccountInfoInteger(ACCOUNT_LOGIN); const double balance = AccountInfoDouble(ACCOUNT_BALANCE); const double equity = AccountInfoDouble(ACCOUNT_EQUITY); // ... const int positions = PositionsTotal(); const long updated_at = (long)TimeGMT(); ``` Затем, в зависимости от того, есть ли соединение с торговым сервером, выбираются код и текст статуса: ```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»), а префикс в тексте сразу показывает, кто именно сообщает о проблеме: сервис или менеджер. Единицу для успешного состояния мы выбрали не случайно: функция `mt5.last_error()` в модуле `MetaTrader5` возвращает `RES_S_OK`, равный единице, а ноль у нас уже занят статусами, которые расставляются вручную. Сохранив это соглашение, мы можем не менять логику отображения статуса на фронтенде. После этого данные записываются так же, как и при регистрации терминала, запросом `INSERT ... ON CONFLICT(terminal_id) DO UPDATE`, и всегда в рамках явной транзакции: ```cpp ResetLastError(); if(!DatabaseTransactionBegin(g_db)) { DatabaseFinalize(request); return false; } DatabaseRead(request); const int error = GetLastError(); DatabaseFinalize(request); if(error != 0 && error != ERR_DATABASE_NO_MORE_DATA) { DatabaseTransactionRollback(g_db); return false; } if(!DatabaseTransactionCommit(g_db)) return false; ``` Сейчас на каждой итерации у нас выполняется всего один оператор, поэтому транзакция здесь, по сути, не нужна: SQLite и сам обернёт одиночный `INSERT` в неявную транзакцию. Мы делаем её явной как заготовку на будущее, ведь в следующей части появится запись истории, и тогда за один цикл будет выполняться уже несколько операторов. Вот там транзакция принесёт пользу и в целостности данных, и в скорости. Насколько велика разница, показывает пример из [документации по `DatabaseTransactionBegin()`](https://www.mql5.com/ru/docs/database/databasetransactionbegin): вставка большого количества сделок внутри транзакции оказалась примерно в 500 раз быстрее, чем без неё. Отдельно стоит сказать про проверку результата. Функция `DatabaseRead()` на запросе, который не возвращает результата, а `INSERT` и `UPDATE` именно такие, завершается с кодом `ERR_DATABASE_NO_MORE_DATA` (5126). Согласно [документации по `DatabaseRead()`](https://www.mql5.com/ru/docs/database/databaseread), это не ошибка, а нормальное завершение, поэтому обе функции, которые пишут в базу, должны пропускать этот код, а не считать его неудачей. К чему приводит забытая проверка, мы увидим в разделе про первый запуск. ### Основной цикл Все отдельные части сервиса готовы, и теперь осталось собрать их вместе в функции `OnStart()`. Получилась она совсем небольшой, потому что почти вся работа вынесена в отдельные функции: ```cpp void OnStart() { PrintFormat("Запуск сервиса для экземпляра '%s'", InstanceFolder()); // Разброс интервала, чтобы записи разных сервисов не совпадали по фазе MathSrand((int)TimeLocal() + (int)GetTickCount()); const int interval_ms = InpIntervalSec * 1000; while(!IsStopped()) { // 1. Подключаемся к базе, если ещё не подключены if(g_db == INVALID_HANDLE) { if(!OpenDatabase()) { Sleep(interval_ms); continue; } g_terminal_id = -1; } // 2. Регистрируем терминал, если ещё не зарегистрирован if(g_terminal_id < 0) { g_terminal_id = RegisterTerminal(); if(g_terminal_id < 0) { Sleep(interval_ms); continue; } } // 3. Записываем текущее состояние if(!StoreState()) { g_skipped++; if(g_skipped % 10 == 0) PrintFormat("Пропущено записей: %d, последняя ошибка: %d", g_skipped, GetLastError()); } // 4. Ждём до следующей итерации, добавляя небольшой случайный разброс int pause = interval_ms; if(InpJitterMs > 0) pause += (int)(MathRand() % InpJitterMs); Sleep(pause); } if(g_db != INVALID_HANDLE) DatabaseClose(g_db); } ``` Пройдёмся по циклу. На каждой итерации мы сначала убеждаемся, что база открыта, затем что терминал зарегистрирован, и только после этого записываем состояние. Если на одном из первых двух шагов что-то не получилось, мы не пытаемся продолжать, а просто ждём один интервал и пробуем ещё раз. Благодаря этому сервис довольно спокойно переживает ситуацию, когда база временно недоступна, и для этого ему не нужна никакая отдельная логика. В конце каждой итерации стоит пауза, и здесь мы позволили себе одну небольшую хитрость. К базовому интервалу добавляется случайная величина в пределах `InpJitterMs`. Дело в том, что все сервисы стартуют почти одновременно, вместе со своими терминалами, и без такой добавки их циклы `Sleep()` могли бы довольно долго оставаться синхронными, а значит, записи приходились бы на один и тот же момент. Небольшой разброс размазывает их по времени и, скорее всего, заметно уменьшает число конфликтов на запись, хотя специальных замеров мы не проводили. Ещё одно решение касается ошибок записи. Если `StoreState()` не удалось, например потому, что база в этот момент занята другим сервисом, мы просто считаем такой пропуск и идём дальше, не пытаясь повторить запись внутри той же итерации. Пока в базе хранится только текущее состояние, это вполне допустимо: свежие данные придут через пару секунд и всё равно перезапишут предыдущие. Когда мы дойдём до истории показателей, пропуски превратятся в дыры в ряду данных, и к этому месту придётся вернуться. Чтобы они хотя бы не оставались незамеченными, каждый десятый пропуск попадает в журнал вместе с кодом последней ошибки. Когда сервис останавливают, `IsStopped()` начинает возвращать `true`, цикл завершается, и мы закрываем базу. О том, как именно ведёт себя остановка и почему нам не стоит бояться длинных пауз, поговорим чуть ниже. ### Остановка сервиса и грейс-период Раз `OnStart()` у нас представляет собой бесконечный цикл `while(!IsStopped())` со `Sleep()` внутри, стоит посмотреть, что происходит, когда сервис останавливают, например вручную или при закрытии терминала. [Документация по `IsStopped()`](https://www.mql5.com/ru/docs/check/isstopped) сообщает, что после команды на остановку программа должна завершить работу как можно быстрее, иначе через 3 секунды она будет завершена принудительно. А наша пауза при `InpIntervalSec = 2` и `InpJitterMs = 1000` в худшем случае доходит до 3000 мс, то есть как раз до этой границы. Опасаться этого не стоит. Как сказано в [документации по `Sleep()`](https://www.mql5.com/ru/docs/common/sleep), внутри этой функции каждые 0,1 секунды проверяется флаг остановки, а значит, пауза прерывается, не дожидаясь конца заданного интервала. Поэтому цикл реагирует на остановку в пределах десятой доли секунды, а разброс лишь увеличивает интервал между записями и никак не откладывает реакцию на команду. В этом нетрудно убедиться: если установить интервал в 30 секунд и закрыть терминал посередине паузы, сервис завершится примерно через треть секунды. ## Что показал первый запуск Первый запуск сервиса на реальных терминалах принёс несколько неожиданностей, и на каждой из них стоит остановиться. ### Код завершения 5126 Для проверки успешности запроса в `RegisterTerminal()` мы сначала использовали выражение, которое считало запрос успешным, если `DatabaseRead()` возвращала `true` или `GetLastError()` возвращала 0. Однако после запуска сервис раз за разом писал в журнал сообщение «Не удалось зарегистрировать терминал», значение `g_terminal_id` оставалось равным `-1`, и функция `StoreState()` не вызывалась вообще. При этом запись в `terminals` выполнялась успешно, и поле `last_seen` исправно обновлялось. Получилось, что база подтверждала успех, а сервис считал, что у него ничего не вышло. Выяснилось, что после `DatabaseRead()` на запросе без результата `GetLastError()` возвращает не 0, а 5126, и это тоже означает успешное завершение. В документации этот код описан как константа `ERR_DATABASE_NO_MORE_DATA`, которая «не является ошибкой». Мы добавили её в условие, и теперь обе функции проверяют `error != 0 && error != ERR_DATABASE_NO_MORE_DATA` одинаково. После этого в журнале сразу появилась успешная регистрация, а первая запись в `states` появилась уже на первой итерации. ### Время: `TimeLocal()` и `TimeGMT()` Для отметок времени мы сначала использовали `TimeLocal()`, но при сравнении с `time.time()` в Python выяснилось, что значения отличаются ровно на смещение часового пояса компьютера. У нас это GMT+3, то есть 10800 секунд, в чём мы убедились прямым вычитанием. Дело в том, что `TimeLocal()` возвращает местное время, а отметки в базе нужны в UTC, ведь именно с ним мы сравниваем возраст записи `updated_at` и порог свежести в 30 секунд. С местным временем такая проверка давала бы систематически неверный результат. Поэтому в обеих функциях сервиса мы заменили её на `TimeGMT()`, которая возвращает время по GMT и, в отличие от `TimeCurrent()`, не зависит от времени на сервере конкретного брокера. ### Схема базы не обновляется сама Самая неочевидная проблема проявилась уже после того, как всё, казалось бы, заработало. Страница менеджера показывала два запущенных терминала, но статус у обоих оставался `starting`, хотя сервисы исправно писали в базу. При этом ни в консоли веб-сервера, ни в журналах терминалов не было ни одной ошибки. Причина оказалась в том, что в схему таблицы `states` мы добавили два поля, `company` и `holder_name`, а файл базы к тому моменту уже существовал со старой структурой. Функция `EnsureSchema()` выполняет `CREATE TABLE IF NOT EXISTS`, и если таблица уже есть, она ничего не делает: SQLite не сверяет колонки и не добавляет недостающие. Поэтому мы продолжали работать со старой таблицей, а запрос на стороне Python, который выбирал новые поля, падал с ошибкой. Увидеть её мешал обработчик исключений в функции `read_state()`: ```python except sqlite3.OperationalError: return None ``` Мы рассчитывали, что так будут обрабатываться штатные ситуации, например отсутствие базы или её временная занятость, но `OperationalError` — довольно широкая категория. Любая ошибка превращалась в «данных пока нет», а интерфейс бесконечно показывал `starting`. Нашли мы проблему только тогда, когда выполнили упавший запрос вручную в SQLite: ``` sqlite3.OperationalError: no such column: s.company ``` Теперь обработчик перед возвратом `None` печатает текст исключения, так что следующая рассинхронизация схемы будет видна в консоли сразу. Из этой истории можно извлечь два вывода. Во-первых, не стоит молча глотать широкие классы исключений. Во-вторых, пока в базе лежит только текущее состояние, миграцию вполне можно заменить удалением файла базы (предварительно остановив все терминалы, которые держат его открытым, иначе в Windows файл не удалится), и сервис пересоздаст таблицы. Когда появится история, этот путь будет закрыт, и придётся вести версию схемы, например через `PRAGMA user_version`, и выполнять `ALTER TABLE ... ADD COLUMN`. ### Жёсткий перезапуск терминала Оставался открытым вопрос, как поведёт себя сервис при перезапуске терминала менеджером. Функция `stop_mt5()` останавливает терминал через `psutil.Process.terminate()`, а в Windows это вызов `TerminateProcess()`, то есть жёсткое завершение процесса, а не обычное закрытие окна. В документации по автозагрузке сервисов не сказано, нужно ли для неё штатное закрытие, поэтому мы проверили это вручную. Мы завершили процесс терминала командой `Stop-Process -Force`, что по сути то же самое, что делает `terminate()`, и запустили `terminal64.exe /portable` заново, как это делают `stop_mt5()` и `start_mt5()` в связке. Сервис стартовал сам, примерно через две секунды после запуска терминала и без обращения к Навигатору, а значение `states.updated_at` снова начало расти. Таким образом, жёсткое завершение автозагрузке сервиса не мешает. ### Автозапуск сервиса Остаётся вопрос, как добавить сервис в новый терминал, не создавая его вручную через Навигатор. Мы надеялись сделать это через конфигурационный файл запуска, как это делается для советников. Однако, согласно [справке по запуску платформы](https://www.metatrader5.com/ru/terminal/help/start_advanced/start), секция `[StartUp]` описывает только советники и скрипты, а про сервисы в ней ничего не сказано, и другого задокументированного способа нам найти не удалось. Можно было бы заменить сервис скриптом, запускаемым через `/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 as e: # База ещё не создана, занята или не совпадает схема - идём дальше # без данных, но причину печатаем, чтобы она не оставалась скрытой print(f'read_state({folder!r}): {e}') 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 и JavaScript. На стороне клиента функция `formatDateTime()` просто умножает полученное значение на 1000, прежде чем передать его в `new Date()`. ## Развёртывание через `config/services.ini` Сервис компилируется и раскладывается в папку `MQL5/Services` каждого терминала, а дальше, согласно документации, экземпляр сервиса нужно создать через Навигатор — вручную, по одному разу в каждом терминале. Для четырёх терминалов из наших примеров это не проблема, но масштабировать такое развёртывание дальше не хочется. Во время экспериментов нашёлся способ обойтись без этого шага. Если положить в папку `config` терминала файл `services.ini`, а исполняемый файл сервиса к моменту запуска терминала уже будет лежать по пути, указанному в этом файле, то сервис запустится сам, без единого обращения к Навигатору: ``` 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`, и такая кодировка типична для служебных файлов, которые терминал пишет и читает сам. Скорее всего, `services.ini` — это то, как терминал сохраняет сервисы, добавленные через Навигатор командой «Добавить сервис»: набор полей `name`, `path`, `expertmode`, `enabled` и блок `` с входными параметрами соответствует тому, что вводится в диалоге добавления. Заготовив такой файл заранее, мы фактически сообщаем терминалу, что сервис уже был добавлен вручную, а раз при запуске терминал загружает сервисы, которые работали на момент его остановки, он запускает и этот. Практически это делает развёртывание нового терминала воспроизводимым: достаточно скопировать скомпилированный `ServiceStateWriter.ex5` по нужному пути и положить в `config` заранее подготовленный `services.ini`. Но поскольку MetaQuotes нигде не описывает этот формат, к находке стоит относиться как к рабочему, но неофициальному приёму: сегодня он работает, а гарантий, что он переживёт будущие сборки терминала, у нас нет. ### Скрипт развёртывания Развёртывание по ходу работы пришлось повторять не раз: поправили схему — нужно пересобрать сервис и разложить его по терминалам, сменили параметры — переложить `services.ini`. Вручную это быстро становится источником ошибок (именно забытый устаревший `.ex5` привёл к истории со схемой, о которой шла речь выше), поэтому мы собрали все шаги в скрипт `deploy_service.py`. Он запускается так же, как `run.py`, и берёт список терминалов из того же `config.json`. Работает он в три этапа. Сначала скрипт обновляет исходный код на каждом терминале. Папка `MQL5\Shared Projects`, несмотря на название, в нашей установке (отдельные portable-копии терминала в `C:\MT5`) между терминалами не синхронизируется: у каждого терминала она своя, и на трёх из четырёх её поначалу вообще не было. Поэтому для каждого терминала скрипт либо клонирует репозиторий в `MQL5\Shared Projects\mt5-manager`, либо, если репозиторий там уже есть, выполняет `fetch`, `checkout` и `pull --ff-only` для той же ветки, с которой запущен он сам. При этом рабочая копия, из которой запускается менеджер, не трогается (это и есть источник), а папки с незакоммиченными изменениями пропускаются, чтобы не затереть чужую правку. Затем скрипт компилирует сервис и раскладывает `.ex5`. Компилятор MetaEditor можно вызвать из командной строки: ``` MetaEditor64.exe /compile:"путь\к\ServiceStateWriter.mq5" /log:"путь\к\compile.log" ``` Результат при этом попадает не рядом с исходником, а в зеркальную папку `MQL5\Services\Shared Projects\mt5-manager\ServiceStateWriter.ex5`, то есть туда, где его ищет Навигатор и откуда его будет брать `services.ini`. Однако на практике такой вызов оказался ненадёжным: после успешного первого раза те же команды стали завершаться с кодом 0, не создавая ни лога, ни нового файла. Судя по всему, запуску MetaEditor без участия пользователя не хватает интерактивной сессии рабочего стола, но это лишь предположение, так как документация эту ситуацию не описывает. Важнее причины вывод: код возврата MetaEditor не доказывает, что компиляция выполнена. Поэтому скрипт ведёт себя осторожно. Он компилирует сервис один раз, в терминале, где лежит рабочая копия исходника, и проверяет успех не по коду возврата, а по времени изменения `.ex5`. Если файл не обновился, скрипт сообщает об этом и просит открыть `ServiceStateWriter.mq5` в MetaEditor и нажать F7, а при повторном запуске видит, что `.ex5` уже новее исходника, пропускает компиляцию и продолжает с раскладки готового файла. Готовый `.ex5` копируется на остальные терминалы обычным копированием файла, и этот шаг ни разу нас не подвёл. Здесь есть допущение: один и тот же `.ex5` используется на всех терминалах, а сами терминалы бывают разных сборок (в нашем наборе это 5800, 6063 и 6292). Проблем с этим не возникло: сервис использует только обычные функции `AccountInfo*`, `TerminalInfo*` и `Database*`, и скомпилированный файл одинаково работает на всех трёх сборках. Наконец, канонический `services.ini` из репозитория копируется в папку `config` каждого терминала. Этот шаг не зависит от результата компиляции и выполняется всегда. После этого скрипт проверяет, какие терминалы сейчас запущены, и выводит их список с напоминанием о перезапуске: сервис, запущенный на момент замены файла, продолжает работать со старым кодом из памяти, поэтому новый `.ex5` вступит в силу только после перезапуска терминала или самого сервиса. Файлы самого менеджера скрипт не затрагивает, так что перезапускать `run.py` для обновления сервиса не нужно. ## Тестирование Теперь проверим, как всё это работает вместе. Для начала разложим сервис по терминалам скриптом `deploy_service.py` и запустим веб-приложение, как и раньше, командой `python run.py`. На странице менеджера показываются карточки всех терминалов из `config.json`, и пока ни один из них не запущен, у каждого статус «Stopped» (рис. 1). ![Рис. 1. Все терминалы остановлены](images/all-terminals-stopped.png) *Рис. 1. Все терминалы остановлены* Запустим терминалы кнопками на карточках. В трёх из них сервис стартует сам, и после загрузки терминала и первой записи в базу карточки показывают статус «Success» и данные торгового счёта: баланс, прибыль, средства и маржу. В первом терминале мы для опыта временно переименовали `services.ini`, чтобы сервис там не запускался. Процесс терминала уже есть, а свежих данных от сервиса нет, поэтому карточка показывает статус «Starting» (рис. 2). То же самое можно увидеть и после обычного запуска, пока сервис ещё не сделал первую запись, но продолжается это всего несколько секунд. ![Рис. 2. Три терминала работают, в первом сервис не запущен](images/service-not-running.png) *Рис. 2. Три терминала работают, в первом сервис не запущен* Вернём `services.ini` на место, остановим первый терминал кнопкой менеджера и запустим его снова. Сервис стартует сам, без обращения к Навигатору, и через несколько секунд карточка тоже переходит в состояние «Success» (рис. 3). Заметим, что менеджер останавливает терминал жёстко, так что это ещё одно подтверждение того, что на автозапуск сервиса такая остановка не влияет. ![Рис. 3. Сервис запустился сам, все терминалы работают](images/all-terminals-running.png) *Рис. 3. Сервис запустился сам, все терминалы работают* Убедиться, что данные приходят именно от сервиса, можно и напрямую, заглянув в базу, например таким запросом: ```sql SELECT t.folder, s.updated_at, s.balance, s.equity FROM states s JOIN terminals t ON t.id = s.terminal_id; ``` Значение `updated_at` у работающих терминалов должно постоянно увеличиваться с интервалом в несколько секунд. ## Заключение В этой части мы заменили опрос терминалов из Python на сервис, который работает внутри каждого терминала и сам записывает его состояние в общую базу SQLite. Веб-приложение теперь просто читает последние известные данные, а о том, жив ли процесс терминала, по-прежнему узнаёт через `psutil`. Благодаря этому из схемы ушли последовательные вызовы `mt5.initialize()` и `mt5.shutdown()` для каждого терминала, а интерфейс, написанный в предыдущей части, продолжил работать почти без изменений. Заодно мы познакомились с сервисом как с типом программ MQL5, нашли способ автоматически запускать его в новом терминале и написали скрипт для развёртывания. Для следующих частей у нас накопилось довольно много задач. Ближайшая из них — запись истории показателей в отдельную таблицу с более редким интервалом, вместе с журналом операций и построчным списком открытых позиций: сейчас в базе хранится только их количество. С появлением истории понадобится версионирование схемы базы, ведь удалить файл с накопленными данными уже будет нельзя. Кроме того, хотелось бы довести до конца автоматизацию развёртывания (установку нового терминала и компиляцию без участия MetaEditor), добавить редактирование конфигурации через браузер, авторизацию и HTTPS, а позже перейти к работе с несколькими серверами, как мы намечали в первой части, и к торговым операциям из браузера и управлению советниками. Спасибо за внимание и до новых встреч! > **Предупреждение.** Стоит подчеркнуть важность осторожности при использовании проекта. На данном этапе он находится в начальной стадии реализации, поэтому в нём никак не учитываются возможные угрозы утечки чувствительной информации при работе разрабатываемого веб-приложения в общедоступном режиме. В целях безопасности приложение должно запускаться только в защищённой или локальной среде. В текущем виде оно не предусматривает шифрования трафика, аутентификации пользователей или защиты от несанкционированного доступа. Поэтому пока обеспечение приватности передаваемой информации, как и любая прочая защита, ложится на плечи пользователя и обеспечивается внешними по отношению к проекту средствами. ## Содержание архива | # | Имя | Версия | Описание | Последние изменения | |---|---|---|---|---| | | **mt5-manager/** | | Рабочая папка проекта веб-сервера терминалов | | | 1 | ├─ config.py | 0.2.0 | Класс работы с конфигурацией приложения | [Часть 4](https://www.mql5.com/ru/articles/20150) | | 2 | ├─ deploy_service.py | 0.1.0 | Скрипт развёртывания сервиса по терминалам | [Часть 4](https://www.mql5.com/ru/articles/20150) | | 3 | ├─ main.py | 0.3.0 | Веб-приложение для веб-сервера терминалов | [Часть 4](https://www.mql5.com/ru/articles/20150) | | 4 | ├─ mt5_control.py | 0.3.0 | Логика управления запуском и остановкой терминалов, чтение состояния из базы | [Часть 4](https://www.mql5.com/ru/articles/20150) | | | ├─ run.py | 0.1.0 | Файл запуска веб-приложения для веб-сервера терминалов с заданной конфигурацией и параметрами | [Часть 3](https://www.mql5.com/ru/articles/19946) | | 5 | ├─ ServiceStateWriter.mq5 | 1.00 | Сервис, который записывает состояние терминала и торгового счёта в общую базу SQLite | [Часть 4](https://www.mql5.com/ru/articles/20150) | | | ├─ **templates/** | | | | | | │ └─ index.html | 0.2.0 | Шаблон основной страницы | [Часть 3](https://www.mql5.com/ru/articles/19946) | | | └─ **static/** | | | | | |      ├─ ... | | Дополнительные файлы для стилей и иконок | | | |      ├─ styles.css | 0.2.0 | CSS-стили | [Часть 3](https://www.mql5.com/ru/articles/19946) | | 6 |      ├─ script.js | 0.3.0 | JavaScript-код для главной страницы | [Часть 4](https://www.mql5.com/ru/articles/20150) | | 7 |      └─ services.ini | | Пример файла для автозапуска сервиса | [Часть 4](https://www.mql5.com/ru/articles/20150) | Также исходный код проекта доступен в репозитории [mt5-manager](https://forge.mql5.io/antekov/mt5-manager).