# Часть 4 «Создание сервиса» — конспект проработки Рабочие заметки к статье и коду. Составлены до первой компиляции сервиса, поэтому часть утверждений помечена как требующая проверки. Ветка: `article-20150`. Репозиторий: https://forge.mql5.io/antekov/mt5-manager --- ## 1. Суть изменения Сейчас веб-приложение получает данные о терминалах **по запросу**: метод `MT5_Control.instance_info()` вызывает `mt5.initialize()` для конкретного экземпляра, забирает `terminal_info()` и `account_info()`, затем `mt5.shutdown()`. Становится **наоборот**: в каждом терминале работает сервис на MQL5, который раз в 2–3 секунды пишет своё состояние в общую базу SQLite. Веб-приложение только читает эту базу. ### Зачем - `mt5.initialize()` работает на уровне процесса — из одного процесса Python нельзя одновременно держать соединения с несколькими терминалами. Опрос строго последовательный, с `shutdown()` между итерациями. При четырёх терминалах терпимо, при пятнадцати цикл перестаёт укладываться в интервал обновления, а один «залипший» терминал тормозит очередь для всех. - Сервисы пишут параллельно, каждый в своём процессе. - С пути чтения уходит зависимость от модуля MetaTrader5, а с ней и ограничение «Python не выше 3.12» (частично: `create_mt5()` пока держит эту зависимость). - Появляется задел под историю и журнал операций, обещанные во 2-й части. --- ## 2. Принятые решения ### Расположение базы Файл в общей папке терминалов, открывается с флагом `DATABASE_OPEN_COMMON`. **Проверено на практике:** общая папка остаётся общей и в portable-режиме, разные экземпляры терминалов работают с одной базой корректно. Это стоит подчеркнуть в статье — интуиция подсказывает обратное. Следствие: путь к базе одинаков для всех экземпляров, настройка при развёртывании не нужна, один и тот же `.ex5` разложен по терминалам. ### Конкурентный доступ - **WAL** (`PRAGMA journal_mode=WAL`) — читатели и писатель перестают блокировать друг друга. Записывается в заголовок файла базы один раз и сохраняется. Файлы-спутники `-wal` и `-shm` рядом с базой — это нормально. Ограничение: работает только в пределах одной машины (нужна разделяемая память), по сетевой шаре не работает. Для распределённой архитектуры из 1-й части это означает, что у каждого сервера должна быть своя база. - **`PRAGMA busy_timeout=3000`** — вместо мгновенной ошибки `SQLITE_BUSY` библиотека сама ждёт и повторяет. WAL этого не решает: одновременный писатель по-прежнему ровно один, сервисы выстраиваются в очередь. **Важно:** это свойство *соединения*, а не файла. Не сохраняется, надо выставлять после каждого `DatabaseOpen()`, в каждом сервисе. На стороне Python — параметр `timeout` в `sqlite3.connect()` (в секундах, по умолчанию 5). - **Явные транзакции** вокруг записи (`DatabaseTransactionBegin` / `DatabaseTransactionCommit`). Без них каждый `INSERT` — отдельная транзакция со сбросом на диск, окно занятости базы растёт. - **Разброс интервала**: сервисы стартуют одновременно при запуске терминалов, и циклы `Sleep()` долго идут синхронно. Случайная добавка в пределах интервала размазывает записи во времени. - **Ошибку занятости игнорируем**, считая пропуски. Пока в базе только текущее состояние, пропуск безвреден — через две секунды приедет свежее значение. С появлением истории это станет дыркой в ряду данных. ### Схема Две таблицы. Таблица истории **отложена до следующей части** — там же обсудить более редкий интервал (5–10 минут) и чистку старых записей. ```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 ); ``` Пояснения к решениям: - Разделение на две таблицы: редко меняющееся (папка, брокер, билд) отдельно от постоянно меняющегося. Даёт список известных терминалов даже когда все выключены. - Размер базы не растёт со временем: 4 строки в каждой таблице. - Индексов сверх первичных ключей не нужно, на таких объёмах они мешают. - Время — `INTEGER`, секунды unix. Родной формат `datetime` в MQL5, кладётся напрямую из `TimeGMT()`. **Не `TimeCurrent()`** — последняя возвращает время сервера брокера, и терминалы у разных брокеров дадут расхождение в часы при вычислении свежести данных. **Не `TimeLocal()` тоже** — изначально был выбран именно он, но при первом прогоне обнаружилось: `TimeLocal()` возвращает цифры настенных часов машины, выданные за UTC-эпоху, а не настоящий UTC (проверено эмпирически, расхождение с `time.time()` в Python совпало с часовым поясом машины день в день). Сравнение с `time.time()` на стороне Python было бы systematически неверным на величину смещения пояса. `TimeGMT()` даёт настоящий UTC и сохраняет исходное преимущество перед `TimeCurrent()` — не зависит от брокера. Заменено в обеих функциях сервиса (`RegisterTerminal()`, `StoreState()`). - `margin_level` отдельным полем, хотя выводится из equity и margin: при нулевой марже не определено, пусть эту ветку обработает MQL5 один раз. - `pid` и `status` в схеме сознательно отсутствуют — сервис физически не может записать «я остановлен», это остаётся за `psutil`. - Открытые позиции построчно (символ, объём, прибыль) — **следующая часть**, сейчас только агрегат `positions`. ### Определение имени экземпляра Сервис берёт `TerminalInfoString(TERMINAL_PATH)` и отрезает последний компонент пути. Почему `TERMINAL_PATH`, а не `TERMINAL_DATA_PATH`: Python-сторона в `MT5_Control.instance_path()` собирает путь как `mt5_folder` + имя папки + `mt5_exe`, то есть оперирует местом установки. В portable-режиме обе константы совпадают, но без `/portable` `TERMINAL_DATA_PATH` даст `Terminal\<хеш>` вместо ожидаемого имени папки. Функции `StringFindLast()` в MQL5 нет, написана вручную через `StringFind()` в цикле. Открытые вопросы, отложенные осознанно: - **Регистр в путях.** Windows нечувствителен к регистру, `MetaTrader5.1` и `Metatrader5.1` — одна папка, но разные строки. Второстепенно: после реализации автоматической установки пользователь не будет вводить имя папки вручную в нескольких местах. В статье упомянуть, не заостряя. - **Папка, которой нет в конфигурации.** Пока ничего не делаем. В финальной реализации такого быть не должно. Вернуться в следующих частях. ### Способ записи `INSERT ... ON CONFLICT(terminal_id) DO UPDATE` каждый цикл — надёжнее связки «`UPDATE`, при нуле изменённых строк `INSERT`», переживает ручное удаление строки, не требует различать первый цикл и последующие. Регистрация в `terminals` вынесена из цикла — делается один раз при старте (и повторно при переподключении к базе). Гонять её каждые две секунды незачем. Идентификатор терминала после вставки забирается отдельным `SELECT`. `last_insert_rowid()` не годится: при срабатывании ветки обновления вставки не было. `RETURNING id` требует SQLite 3.35 — рискованнее, чем лишний запрос раз при старте. --- ## 3. Найденные нестыковки с текущим кодом Разобраны, требуют правки в коде проекта. ### Код успеха равен 1, а не 0 В `static/script.js`: - `last_error.code == 1` → зелёная галочка (работает) - `last_error.code == 0` → серый значок (Stopped / Starting) - всё остальное → красный восклицательный знак Это следствие того, что `mt5.last_error()` возвращает `RES_S_OK`, равный 1. Ноль занят под статусы, проставляемые вручную в `instance_info()`. Принятое соглашение для сервиса: - `1` / `"Success"` — есть связь с торговым сервером - `-2` / `"Service: No connection to trade server"` — терминал работает, связи нет Отрицательные коды продолжают ряд ошибок менеджера (`-1` — `"Manager: Terminal not found"`), префикс в тексте показывает источник жалобы. ### Формат времени Сейчас Python кладёт в `last_update` результат `str(datetime.now())`, JS парсит через `new Date()`. Строка вида `2026-07-25 12:34:56.789012` без буквы `T` — нестандартный формат, Chrome его переваривает, спецификация не гарантирует. **Решено:** миллисекундная точность не нужна, наружу отдаётся то же целое число секунд Unix, что лежит в `updated_at` (`mt5_control.py`), без умножения на 1000. `formatDateTime()` в `static/script.js` сам умножает полученное значение на 1000 перед `new Date()`. Сделано. ### Недостающие поля Фронтенд обращается к `data.account.company`. Добавлены `company` и `holder_name` (`ACCOUNT_COMPANY`, `ACCOUNT_NAME`) в `states`, чтобы словарь `account` собирался из базы без потерь. Учесть, что `company` у счёта и `company` у терминала — разные вещи, хотя обычно совпадают. ### Мелочи - `config.json`: у `MetaTrader5.4` логин совпадает с `MetaTrader5.1`. **Не баг** — так задумано намеренно (терминал 4 подключается к тому же счёту, что и терминал 1). Логины из конфигурации пока нигде не используются, начнём использовать в следующих частях — тогда и проверится, что дублирование обрабатывается корректно. - `script.js`: в расчёте `profitPct` нет защиты от нулевого баланса. - `mt5_control.py`: `self.instances = terminals.copy()` — поверхностная копия, запись `pid` и `status` меняет вложенные словари самой конфигурации. Сейчас безобидно, аукнется при появлении перечитывания конфига. **Исправлено:** заменено на `copy.deepcopy(terminals)`. - `mt5_control.py`: в именах `instanсes_paths` и `paths_instanсes` буква «с» в слове `instanсes` кириллическая. Код рабочий (используется последовательно), но поиск по латинскому написанию ничего не найдёт. **Исправлено:** переименовано на латиницу (`instances_paths`, `paths_instances`). - `create_mt5()` и `POST /create/{name}` выглядят заготовкой: внутри `mt5.initialize()` без пути, возвращается `{folder: folder}`. В статьях цикла этого нет. **Решено:** оставляем как есть, не трогаем и не упоминаем в 4-й части — дойдём до этого функционала в 5-й части. - `main.py`: в обработчике `index()` был закомментированный черновой код (`for name in instances: ... instances[name]['info'] = ...`). **Исправлено:** убран — эта информация приходит на главную страницу через ajax-запросы (`POST /instances/{name}`), а не при первом рендере. --- ## 4. Совместимость API Структура ответа `POST /instances/{name}` — словарь с ключами `terminal`, `account`, `last_update`, `last_error`, `status` — воспроизводится из базы один в один. Значит `templates/index.html` и `static/script.js` не требуют изменений, кроме формата времени. **Для статьи это сильный аргумент**: механизм получения данных меняется полностью, интерфейс подмены не замечает. Стоит вынести в отдельный подзаголовок. --- ## 5. Что проверить при первой компиляции Код сервиса написан без доступа к MetaTrader, ниже места с наибольшим риском расхождения. 1. **`PRAGMA` через `DatabaseExecute()`.** И `journal_mode`, и `busy_timeout` возвращают строку результата. `DatabaseExecute()` на таких запросах может вернуть ошибку. Запасной путь — `DatabasePrepare()` + `DatabaseRead()` + `DatabaseFinalize()`, он и использован в наброске. 2. **Выполнение `INSERT` через подготовленный запрос.** Возвращать нечего, `DatabaseRead()` вернёт `false` с кодом «нет данных». В коде результат `DatabaseRead()` игнорируется, проверяется `GetLastError()` с отсечением кода завершения выборки. **Константа 5126 приведена по памяти, сверить с фактическим поведением.** 3. **Поддержка `ON CONFLICT ... DO UPDATE`.** Требует SQLite 3.24+. Встроенная в MetaTrader библиотека почти наверняка новее, но проверить первым делом. Если нет — схема упрощается до `UPDATE`, при нуле изменённых строк `INSERT`. 4. **Нумерация в `DatabaseBind()` начинается с нуля**, а плейсхолдеры `?1`, `?2` в SQL — с единицы. Выглядит как ошибка, но так и задумано. В статье отметить явно, читатели будут спотыкаться. 5. **Поведение `DatabaseTransactionBegin()`** — какую форму `BEGIN` он использует. Документация умалчивает. Пока не критично: сервис только пишет, ничего не вычитывая в той же транзакции. Станет важно, если появится логика «прочитать предыдущее значение, сравнить, записать» — тогда нужен `BEGIN IMMEDIATE`, иначе `SQLITE_BUSY` возвращается немедленно, не выжидая таймаут. --- ## 6. Ограничения сервисов, которые стоит проговорить в статье - В сервисах **запрещены** `EventSetTimer()`, `EventSetMillisecondTimer()`, `EventKillTimer()`. Цикл строится на `Sleep()` с проверкой `IsStopped()`. - Сервисы **загружаются автоматически** после запуска терминала, если на момент его остановки были запущены. Сбор данных стартует сам, без участия менеджера. Хороший контраст с советником, которого пришлось бы вешать на график. - Каждый сервис работает в своём потоке, зацикленный сервис не мешает другим программам. - Экземпляр создаётся через Навигатор. --- ## 7. Развёртывание Сервис компилируется и раскладывается в `MQL5/Services` каждого терминала, экземпляр создаётся через Навигатор — вручную, четыре раза. Для статьи нормально, но честно назвать временным решением. Автоматизация установки логично ложится в следующую часть и смыкается с планом «загружать советника через браузер». --- ## 8. Что остаётся на стороне Python База скажет, что было в момент последней записи, но не скажет, что терминал упал. Определение «запущен / остановлен» через `psutil` никуда не уходит, архитектура становится гибридной. Комбинирование даёт **три статуса вместо нынешних двух**, и на странице их стоит различать визуально: | Процесс (psutil) | Свежесть `updated_at` | Статус | |---|---|---| | жив | свежее порога | работает | | жив | старше порога | запущен, данные не поступают | | нет процесса | — | остановлен | Второй случай покрывает загрузку терминала, потерю связи и незапустившийся сервис. Порог — порядка 30 секунд при интервале записи 2–3 секунды. Отдельно: терминал может работать, а связи с сервером брокера не быть — тогда цифры счёта в базе последние известные, а не актуальные. Это `error_code = -2`, веб-серверу стоит отрисовывать отдельно. --- ## 9. Незакрытые обещания из предыдущих частей Держать в виду при планировании, что войдёт в 4-ю часть, а что дальше: - SQLite как хранилище — закрывается этой частью (частично: только состояние) - авторизация и HTTPS — отложены - журнал операций — отложен вместе с историей - главный сервер + серверы терминалов (схема из 1-й части) — отложено - торговые операции из браузера, загрузка советников, сведение логов — из комментариев к 3-й части - автоматизация установки сервиса — **закрыта частично** находкой `config\services.ini` (раздел 10); программная раскладка самого `.ex5` по папкам новых терминалов и генерация `services.ini` под конкретную установку остаются на будущее --- ## 10. Результаты первого запуска Компиляция прошла без ошибок с первого раза. Ниже — что показал реальный прогон, по пунктам риска из раздела 5. ### Баг: `RegisterTerminal()` не учитывал код 5126 Код 5126 подтвердился на практике (пункт риска 2), но именно это вскрыло реальную ошибку: `RegisterTerminal()` проверял успех как `DatabaseRead(request) || GetLastError() == 0`, не зная про 5126 — в отличие от `StoreState()`, где это уже было учтено. В журнале (`MQL5\Logs`, не корневой `Logs` терминала — это два разных лога) сервис раз в цикл писал «Не удалось зарегистрировать терминал», `g_terminal_id` оставался `-1` навсегда, и `StoreState()` не вызывался вообще — при этом сама запись в `terminals` через `ON CONFLICT DO UPDATE` реально проходила, `last_seen` продвигался. То есть SQL отработал, а приложенческая проверка кода ошибки — нет. Хороший пример для статьи: база подтверждает успех, а сервис думает, что провалился. Исправлено: вынесена именованная константа `ERR_DB_NO_MORE_DATA = 5126`, обе функции проверяют `error != 0 && error != ERR_DB_NO_MORE_DATA` одинаково. После исправления и перекомпиляции лог показывает мгновенную успешную регистрацию и первую запись в `states` уже на первом цикле. Остальные пункты риска из раздела 5 закрыты без сюрпризов: `PRAGMA` через `DatabasePrepare`/`DatabaseRead` работает, `journal_mode=wal` подтверждён прямо в файле базы, `ON CONFLICT DO UPDATE` поддерживается, нумерация `DatabaseBind()` с нуля отработала верно. Поведение `DatabaseTransactionBegin()`/`Commit()` подтвердилось косвенно: как только починили баг регистрации, `StoreState()` начал доходить до транзакции и писать `states` без ошибок. ### Находка: `TimeLocal()` — не UTC Описано в разделе 2 (схема). Коротко: `TimeLocal()` вернул значение, отличающееся от `time.time()` в Python ровно на часовой пояс машины (GMT+3 → расхождение 10800 секунд, проверено вычитанием). Заменено на `TimeGMT()` в обеих функциях сервиса. ### Проверено: сервис переживает жёсткий рестарт терминала Открытый вопрос был в том, что `stop_mt5()` останавливает терминал через `psutil.Process.terminate()`, а на Windows это алиас `kill()` → `TerminateProcess()` — жёсткое завершение, не штатное закрытие через GUI. Утверждение «сервис загружается сам, если был запущен на момент остановки терминала» (раздел 1, из справки MetaTrader) не уточняет, требует ли это именно штатного закрытия. Проверено вручную: `Stop-Process -Force` по PID терминала (эквивалент `TerminateProcess`), затем повторный запуск `terminal64.exe /portable` — то есть буквально то, что делают `stop_mt5()`/`start_mt5()`. Сервис стартовал сам через ~2 секунды после запуска терминала, без обращения к Навигатору, и `states.updated_at` продолжил расти. Жёсткий килл не ломает автозагрузку сервиса. ### Решено: автодобавление сервиса через команду запуска — не реализуемо штатно Проверено по официальной документации (metatrader5.com/ru/terminal/help/start_advanced/start) и статье про сервисы на mql5.com: секция `[StartUp]` конфиг-файла поддерживает `Expert` и `Script`, но не `Service`. Программного или конфигурационного способа добавить сервис в Навигатор при первом развёртывании терминала не существует задокументированным способом. Рассмотренная альтернатива — перейти со `Service` на `Script`, запускаемый через `/config` + `[StartUp] Script=...` — отклонена: это возвращает тот самый минус советника, который сервис был выбран избежать (нужен график, скрипт умирает при его закрытии). Решение: разовое ручное добавление через Навигатор на каждый из терминалов при разворачивании, дальше сервис переживает перезапуски терминала сам (см. проверку выше) без участия Python. Честно назвать в статье шагом разворачивания, а не ограничением. **Пересмотрено ниже** — найден рабочий обходной путь, см. следующий раздел. ### Найдено: `config\services.ini` — недокументированный обходной путь Экспериментально найден способ добавить сервис в Навигатор без ручного шага, минуя вывод предыдущего раздела о принципиальной невозможности. Файл `config\services.ini` (пример — `static/services.ini` в репозитории) кладётся в папку `config` внутри **папки установки терминала** (не `Common`, не папку данных профиля). Если на момент запуска терминала по пути, указанному в файле, уже лежит скомпилированный `.ex5`, сервис стартует сам, без обращения к Навигатору вручную: ``` name=ServiceStateWriter path=Services\Shared Projects\mt5-manager\ServiceStateWriter.ex5 expertmode=0 enabled=1 InpDatabase=mt5-manager.sqlite InpIntervalSec=2 InpJitterMs=1000 InpBusyTimeout=3000 ``` **В официальной документации этого файла нет.** Проверено: страница «Файлы и папки» (metatrader5.com/ru/terminal/help/start_advanced/structure) перечисляет содержимое `config` явно — `accounts.dat`, `common.ini`, `metaeditor.ini`, `terminal.ini`, `servers.dat`, сертификаты — `services.ini` среди них нет. Страница про `[StartUp]` — тоже без него. Учебник, статья про сервисы и форум ничего не знают об этом файле. Косвенное подтверждение гипотезы о происхождении файла: `services.ini` закодирован в UTF-16LE (BOM `FF FE`), в отличие от читаемых текстовых конфигов вроде `common.ini`. Это типично для внутренних служебных файлов, которые терминал пишет и читает сам, а не для документированного пользовательского API. Похоже, это ровно то представление, в которое терминал сериализует состояние сервисов, добавленных через Навигатор командой «Добавить сервис» — то есть подложенный файл имитирует состояние «сервис уже был добавлен один раз вручную». Практический вывод: рабочий приём для развёртывания, но не задокументированное поведение платформы. В статье стоит подать с осторожной формулировкой — экспериментально найдено, воспроизводится, но может измениться в будущих билдах без предупреждения, раз MetaQuotes нигде не фиксирует этот формат как публичный контракт.