mt5-manager/ServiceStateWriter.mq5

421 lines
17 KiB
MQL5
Raw Permalink Normal View History

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; // Счётчик пропущенных записей
// DatabaseRead() на запросе без результата (INSERT/UPDATE) завершается
// этим кодом - это конец выборки, а не ошибка. Проверено на практике.
#define ERR_DB_NO_MORE_DATA 5126
//+------------------------------------------------------------------+
//| Ищет последнее вхождение подстроки (в 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;
}
//+------------------------------------------------------------------+
//| Возвращает имя папки экземпляра терминала |
//| В portable-режиме это последний компонент пути запуска, |
//| например "MetaTrader5.1" для C:/MT5/MetaTrader5.1 |
//+------------------------------------------------------------------+
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;
}
//+------------------------------------------------------------------+
//| Выполняет PRAGMA, возвращающий строку результата |
//| DatabaseExecute() на таких запросах может вернуть ошибку, |
//| поэтому используем подготовленный запрос с чтением результата |
//+------------------------------------------------------------------+
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;
}
//+------------------------------------------------------------------+
//| Создаёт таблицы, если их ещё нет |
//+------------------------------------------------------------------+
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;
}
// Таймаут занятости - свойство соединения, задаём при каждом открытии
ExecutePragma(g_db, StringFormat("PRAGMA busy_timeout=%d", InpBusyTimeout));
// Режим WAL запоминается в заголовке файла базы, но повторная
// установка безвредна и страхует от создания базы другим клиентом
ExecutePragma(g_db, "PRAGMA journal_mode=WAL");
if(!EnsureSchema(g_db))
{
DatabaseClose(g_db);
g_db = INVALID_HANDLE;
return false;
}
return true;
}
//+------------------------------------------------------------------+
//| Регистрирует терминал в таблице terminals и возвращает его id |
//+------------------------------------------------------------------+
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);
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);
if(error != 0 && error != ERR_DB_NO_MORE_DATA)
{
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();
// Оборачиваем запись в явную транзакцию: без неё каждая операция
// становится отдельной транзакцией со сбросом на диск
if(!DatabaseTransactionBegin(g_db))
{
DatabaseFinalize(request);
return false;
}
DatabaseRead(request);
const int error = GetLastError();
DatabaseFinalize(request);
// Код ERR_DB_NO_MORE_DATA соответствует завершению выборки и ошибкой не является
if(error != 0 && error != ERR_DB_NO_MORE_DATA)
{
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())
{
// Подключение к базе - при неудаче пробуем снова на следующем витке
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);
PrintFormat("Остановка сервиса, всего пропущено записей: %d", g_skipped);
}
//+------------------------------------------------------------------+