2026-07-25 19:24:56 +03:00 | | | //+------------------------------------------------------------------+
|
| | | //| ServiceStateWriter.mq5 |
|
| | | //| Менеджер терминалов MetaTrader 5, ч. 4 |
|
| | | //| |
|
| | | //| Сервис периодически записывает состояние своего экземпляра |
|
| | | //| терминала и подключённого торгового счёта в общую базу SQLite, |
|
| | | //| расположенную в папке Common. Веб-приложение менеджера читает |
|
| | | //| эти данные из базы, не обращаясь к терминалам напрямую. |
|
| | | //+------------------------------------------------------------------+
|
| | | #property service
|
2026-07-25 20:52:51 +03:00 | | | #property version "1.00"
|
2026-07-25 19:24:56 +03:00 | | | #property description "Запись состояния терминала в общую базу SQLite"
|
| | |
|
| | | //--- Входные параметры экземпляра сервиса
|
| | | input string InpDatabase = "mt5-manager.sqlite"; // Имя файла базы данных (папка Common)
|
| | | input int InpIntervalSec = 2; // Интервал записи, секунд
|
| | | input int InpJitterMs = 1000; // Разброс интервала, мс (0 - выключить)
|
| | | input int InpBusyTimeout = 3000; // Таймаут занятости базы, мс
|
| | |
|
| | | //--- Глобальные переменные
|
| | | int g_db = INVALID_HANDLE; // Хэндл базы данных
|
| | | int g_terminal_id = -1; // Идентификатор записи этого терминала
|
| | | int g_skipped = 0; // Счётчик пропущенных записей
|
| | |
|
| | | //+------------------------------------------------------------------+
|
| | | //| Ищет последнее вхождение подстроки (в MQL5 нет StringFindLast) |
|
| | | //+------------------------------------------------------------------+
|
| | | 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;
|
| | | }
|
| | |
|
| | | //+------------------------------------------------------------------+
|
| | | //| Возвращает имя папки экземпляра терминала |
|
2026-10-28 21:13:30 +03:00 | | | //| В portable-режиме это последний компонент пути к данным, |
|
2026-07-25 19:24:56 +03:00 | | | //| например "MetaTrader5.1" для C:/MT5/MetaTrader5.1 |
|
| | | //+------------------------------------------------------------------+
|
| | | string InstanceFolder()
|
| | | {
|
2026-10-28 21:13:30 +03:00 | | | string path = TerminalInfoString(TERMINAL_DATA_PATH);
|
2026-07-25 19:24:56 +03:00 | | | 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;
|
| | | }
|
| | |
|
| | | //+------------------------------------------------------------------+
|
| | | //| Создаёт таблицы, если их ещё нет |
|
| | | //+------------------------------------------------------------------+
|
| | | bool EnsureSchema(const int db)
|
| | | {
|
| | | const string sql_terminals =
|
| | | "CREATE TABLE IF NOT EXISTS terminals ("
|
| | | " id INTEGER PRIMARY KEY,"
|
| | | " folder TEXT NOT NULL UNIQUE,"
|
| | | " path TEXT,"
|
| | | " company TEXT,"
|
| | | " build INTEGER,"
|
| | | " first_seen INTEGER,"
|
| | | " last_seen INTEGER)";
|
| | |
|
| | | const string sql_states =
|
| | | "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,"
|
2026-07-25 20:52:51 +03:00 | | | " company TEXT,"
|
| | | " holder_name TEXT,"
|
2026-07-25 19:24:56 +03:00 | | | " error_code INTEGER DEFAULT 0,"
|
| | | " error_text TEXT)";
|
| | |
|
| | | if(!DatabaseExecute(db, sql_terminals))
|
| | | {
|
| | | PrintFormat("Не удалось создать таблицу terminals, код ошибки: %d", GetLastError());
|
| | | return false;
|
| | | }
|
| | |
|
| | | if(!DatabaseExecute(db, sql_states))
|
| | | {
|
| | | PrintFormat("Не удалось создать таблицу states, код ошибки: %d", GetLastError());
|
| | | return false;
|
| | | }
|
| | |
|
| | | return true;
|
| | | }
|
| | |
|
| | | //+------------------------------------------------------------------+
|
| | | //| Открывает базу и подготавливает соединение |
|
| | | //+------------------------------------------------------------------+
|
| | | 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;
|
| | | }
|
| | |
|
| | | // Таймаут занятости - свойство соединения, задаём при каждом открытии
|
2026-10-28 21:13:30 +03:00 | | | if(!DatabaseExecute(g_db, StringFormat("PRAGMA busy_timeout=%d", InpBusyTimeout)))
|
| | | PrintFormat("Не удалось задать busy_timeout, код ошибки: %d", GetLastError());
|
2026-07-25 19:24:56 +03:00 | | |
|
| | | // Режим WAL запоминается в заголовке файла базы, но повторная
|
| | | // установка безвредна и страхует от создания базы другим клиентом
|
2026-10-28 21:13:30 +03:00 | | | if(!DatabaseExecute(g_db, "PRAGMA journal_mode=WAL"))
|
| | | PrintFormat("Не удалось включить режим WAL, код ошибки: %d", GetLastError());
|
2026-07-25 19:24:56 +03:00 | | |
|
| | | if(!EnsureSchema(g_db))
|
| | | {
|
| | | DatabaseClose(g_db);
|
| | | g_db = INVALID_HANDLE;
|
| | | return false;
|
| | | }
|
| | |
|
| | | return true;
|
| | | }
|
| | |
|
| | | //+------------------------------------------------------------------+
|
| | | //| Регистрирует терминал в таблице terminals и возвращает его id |
|
| | | //+------------------------------------------------------------------+
|
| | | int RegisterTerminal()
|
| | | {
|
| | | const string folder = InstanceFolder();
|
2026-10-28 21:13:30 +03:00 | | | const string path = TerminalInfoString(TERMINAL_DATA_PATH);
|
2026-07-25 19:24:56 +03:00 | | | const string company = TerminalInfoString(TERMINAL_COMPANY);
|
| | | const int build = (int)TerminalInfoInteger(TERMINAL_BUILD);
|
2026-07-25 20:52:51 +03:00 | | | // TimeGMT(), не TimeLocal(): последняя возвращает цифры настенных часов,
|
| | | // выданные за UTC-эпоху (смещение на часовой пояс машины), из-за чего
|
| | | // разъезжается с временем на стороне Python. TimeGMT() - настоящий UTC,
|
| | | // и при этом не зависит от времени сервера брокера, как TimeCurrent().
|
| | | const long now = (long)TimeGMT();
|
2026-07-25 19:24:56 +03:00 | | |
|
| | | 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";
|
| | |
|
| | | int request = DatabasePrepare(g_db, sql);
|
| | | if(request == INVALID_HANDLE)
|
| | | {
|
| | | PrintFormat("Не удалось подготовить регистрацию терминала, код ошибки: %d",
|
| | | GetLastError());
|
| | | return -1;
|
| | | }
|
| | |
|
| | | DatabaseBind(request, 0, folder);
|
| | | DatabaseBind(request, 1, path);
|
| | | DatabaseBind(request, 2, company);
|
| | | DatabaseBind(request, 3, build);
|
| | | DatabaseBind(request, 4, now);
|
| | |
|
| | | ResetLastError();
|
| | | DatabaseRead(request);
|
| | | const int error = GetLastError();
|
| | | DatabaseFinalize(request);
|
| | |
|
2026-10-28 21:13:30 +03:00 | | | if(error != 0 && error != ERR_DATABASE_NO_MORE_DATA)
|
2026-07-25 19:24:56 +03:00 | | | {
|
| | | PrintFormat("Не удалось зарегистрировать терминал '%s', код ошибки: %d",
|
| | | folder, error);
|
| | | return -1;
|
| | | }
|
| | |
|
| | | // Забираем присвоенный идентификатор
|
| | | int id = -1;
|
| | | request = DatabasePrepare(g_db, "SELECT id FROM terminals WHERE folder = ?1");
|
| | | if(request != INVALID_HANDLE)
|
| | | {
|
| | | DatabaseBind(request, 0, folder);
|
| | | if(DatabaseRead(request))
|
| | | DatabaseColumnInteger(request, 0, id);
|
| | | DatabaseFinalize(request);
|
| | | }
|
| | |
|
| | | if(id < 0)
|
| | | PrintFormat("Не удалось получить идентификатор терминала '%s'", folder);
|
| | | else
|
| | | PrintFormat("Терминал '%s' зарегистрирован с id=%d", folder, id);
|
| | |
|
| | | return id;
|
| | | }
|
| | |
|
| | | //+------------------------------------------------------------------+
|
| | | //| Собирает состояние и записывает его в таблицу states |
|
| | | //+------------------------------------------------------------------+
|
| | | bool StoreState()
|
| | | {
|
| | | //--- Состояние терминала
|
| | | const bool connected = (bool)TerminalInfoInteger(TERMINAL_CONNECTED);
|
| | | const int ping_last = (int)TerminalInfoInteger(TERMINAL_PING_LAST);
|
| | |
|
| | | //--- Состояние торгового счёта
|
| | | const long login = AccountInfoInteger(ACCOUNT_LOGIN);
|
| | | const string server = AccountInfoString(ACCOUNT_SERVER);
|
| | | const string currency = AccountInfoString(ACCOUNT_CURRENCY);
|
| | | const int leverage = (int)AccountInfoInteger(ACCOUNT_LEVERAGE);
|
| | | const int trade_mode = (int)AccountInfoInteger(ACCOUNT_TRADE_MODE);
|
| | | const int trade_allowed = (int)AccountInfoInteger(ACCOUNT_TRADE_ALLOWED);
|
| | |
|
| | | const double balance = AccountInfoDouble(ACCOUNT_BALANCE);
|
| | | const double equity = AccountInfoDouble(ACCOUNT_EQUITY);
|
| | | const double profit = AccountInfoDouble(ACCOUNT_PROFIT);
|
| | | const double margin = AccountInfoDouble(ACCOUNT_MARGIN);
|
| | | const double margin_free = AccountInfoDouble(ACCOUNT_MARGIN_FREE);
|
| | | const double margin_level = AccountInfoDouble(ACCOUNT_MARGIN_LEVEL);
|
| | | const double credit = AccountInfoDouble(ACCOUNT_CREDIT);
|
| | |
|
| | | const int positions = PositionsTotal();
|
2026-07-25 20:52:51 +03:00 | | | const long updated_at = (long)TimeGMT();
|
2026-07-25 19:24:56 +03:00 | | |
|
2026-07-25 20:52:51 +03:00 | | | //--- Данные о держателе счёта, для отображения в интерфейсе
|
| | | const string account_company = AccountInfoString(ACCOUNT_COMPANY);
|
| | | const string holder_name = AccountInfoString(ACCOUNT_NAME);
|
| | |
|
| | | //--- Признак готовности данных. 1/-2 продолжают ряд кодов менеджера
|
| | | // (-1 - "Manager: Terminal not found"), префикс в тексте показывает
|
| | | // источник жалобы
|
| | | const int error_code = connected ? 1 : -2;
|
| | | const string error_text = connected ? "Success" : "Service: No connection to trade server";
|
2026-07-25 19:24:56 +03:00 | | |
|
| | | const string sql =
|
| | | "INSERT INTO states ("
|
| | | " terminal_id, updated_at, connected, ping_last, login, server, currency,"
|
| | | " leverage, trade_mode, trade_allowed, balance, equity, profit, margin,"
|
2026-07-25 20:52:51 +03:00 | | | " margin_free, margin_level, credit, positions, company, holder_name,"
|
| | | " error_code, error_text) "
|
| | | "VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?22) "
|
2026-07-25 19:24:56 +03:00 | | | "ON CONFLICT(terminal_id) DO UPDATE SET "
|
| | | " updated_at = excluded.updated_at,"
|
| | | " connected = excluded.connected,"
|
| | | " ping_last = excluded.ping_last,"
|
| | | " login = excluded.login,"
|
| | | " server = excluded.server,"
|
| | | " currency = excluded.currency,"
|
| | | " leverage = excluded.leverage,"
|
| | | " trade_mode = excluded.trade_mode,"
|
| | | " trade_allowed = excluded.trade_allowed,"
|
| | | " balance = excluded.balance,"
|
| | | " equity = excluded.equity,"
|
| | | " profit = excluded.profit,"
|
| | | " margin = excluded.margin,"
|
| | | " margin_free = excluded.margin_free,"
|
| | | " margin_level = excluded.margin_level,"
|
| | | " credit = excluded.credit,"
|
| | | " positions = excluded.positions,"
|
2026-07-25 20:52:51 +03:00 | | | " company = excluded.company,"
|
| | | " holder_name = excluded.holder_name,"
|
2026-07-25 19:24:56 +03:00 | | | " error_code = excluded.error_code,"
|
| | | " error_text = excluded.error_text";
|
| | |
|
| | | int request = DatabasePrepare(g_db, sql);
|
| | | if(request == INVALID_HANDLE)
|
| | | return false;
|
| | |
|
| | | DatabaseBind(request, 0, g_terminal_id);
|
| | | DatabaseBind(request, 1, updated_at);
|
| | | DatabaseBind(request, 2, connected ? 1 : 0);
|
| | | DatabaseBind(request, 3, ping_last);
|
| | | DatabaseBind(request, 4, login);
|
| | | DatabaseBind(request, 5, server);
|
| | | DatabaseBind(request, 6, currency);
|
| | | DatabaseBind(request, 7, leverage);
|
| | | DatabaseBind(request, 8, trade_mode);
|
| | | DatabaseBind(request, 9, trade_allowed);
|
| | | DatabaseBind(request, 10, balance);
|
| | | DatabaseBind(request, 11, equity);
|
| | | DatabaseBind(request, 12, profit);
|
| | | DatabaseBind(request, 13, margin);
|
| | | DatabaseBind(request, 14, margin_free);
|
| | | DatabaseBind(request, 15, margin_level);
|
| | | DatabaseBind(request, 16, credit);
|
| | | DatabaseBind(request, 17, positions);
|
2026-07-25 20:52:51 +03:00 | | | DatabaseBind(request, 18, account_company);
|
| | | DatabaseBind(request, 19, holder_name);
|
| | | DatabaseBind(request, 20, error_code);
|
| | | DatabaseBind(request, 21, error_text);
|
2026-07-25 19:24:56 +03:00 | | |
|
| | | ResetLastError();
|
| | |
|
2026-10-28 21:13:30 +03:00 | | | // Сейчас оператор один, и транзакция фактически не нужна. Явная
|
| | | // транзакция - заготовка под запись истории, когда за один цикл
|
| | | // будет выполняться несколько операторов
|
2026-07-25 19:24:56 +03:00 | | | if(!DatabaseTransactionBegin(g_db))
|
| | | {
|
| | | DatabaseFinalize(request);
|
| | | return false;
|
| | | }
|
| | |
|
| | | DatabaseRead(request);
|
| | | const int error = GetLastError();
|
| | | DatabaseFinalize(request);
|
| | |
|
2026-10-28 21:13:30 +03:00 | | | // Код ERR_DATABASE_NO_MORE_DATA (5126) соответствует завершению выборки и ошибкой не является
|
| | | if(error != 0 && error != ERR_DATABASE_NO_MORE_DATA)
|
2026-07-25 19:24:56 +03:00 | | | {
|
| | | DatabaseTransactionRollback(g_db);
|
| | | return false;
|
| | | }
|
| | |
|
| | | if(!DatabaseTransactionCommit(g_db))
|
| | | return false;
|
| | |
|
| | | return true;
|
| | | }
|
| | |
|
| | | //+------------------------------------------------------------------+
|
| | | //| Точка входа сервиса |
|
| | | //+------------------------------------------------------------------+
|
| | | void OnStart()
|
| | | {
|
| | | PrintFormat("Запуск сервиса для экземпляра '%s'", InstanceFolder());
|
| | |
|
| | | // Разброс интервала, чтобы записи разных сервисов не совпадали по фазе
|
| | | MathSrand((int)TimeLocal() + (int)GetTickCount());
|
| | |
|
| | | const int interval_ms = InpIntervalSec * 1000;
|
| | |
|
| | | while(!IsStopped())
|
| | | {
|
2026-10-28 21:13:30 +03:00 | | | // 1. Подключаемся к базе, если ещё не подключены
|
2026-07-25 19:24:56 +03:00 | | | if(g_db == INVALID_HANDLE)
|
| | | {
|
| | | if(!OpenDatabase())
|
| | | {
|
| | | Sleep(interval_ms);
|
| | | continue;
|
| | | }
|
| | | g_terminal_id = -1;
|
| | | }
|
| | |
|
2026-10-28 21:13:30 +03:00 | | | // 2. Регистрируем терминал, если ещё не зарегистрирован
|
2026-07-25 19:24:56 +03:00 | | | if(g_terminal_id < 0)
|
| | | {
|
| | | g_terminal_id = RegisterTerminal();
|
| | | if(g_terminal_id < 0)
|
| | | {
|
| | | Sleep(interval_ms);
|
| | | continue;
|
| | | }
|
| | | }
|
| | |
|
2026-10-28 21:13:30 +03:00 | | | // 3. Записываем текущее состояние
|
2026-07-25 19:24:56 +03:00 | | | if(!StoreState())
|
| | | {
|
| | | g_skipped++;
|
| | | if(g_skipped % 10 == 0)
|
| | | PrintFormat("Пропущено записей: %d, последняя ошибка: %d",
|
| | | g_skipped, GetLastError());
|
| | | }
|
| | |
|
2026-10-28 21:13:30 +03:00 | | | // 4. Ждём до следующей итерации, добавляя небольшой случайный разброс
|
2026-07-25 19:24:56 +03:00 | | | int pause = interval_ms;
|
| | | if(InpJitterMs > 0)
|
| | | pause += (int)(MathRand() % InpJitterMs);
|
| | | Sleep(pause);
|
| | | }
|
| | |
|
| | | if(g_db != INVALID_HANDLE)
|
| | | DatabaseClose(g_db);
|
| | |
|
| | | PrintFormat("Остановка сервиса, всего пропущено записей: %d", g_skipped);
|
| | | }
|
| | | //+------------------------------------------------------------------+
|