mt5-manager/mt5_control.py
348 lines
16 KiB
Python
Yuriy Bykov
2025-10-14 16:04:37 +03:00
'''
File: mt5_control.py
Description: Логика управления запуском и остановкой терминалов MetaTrader 5
'''
__version__ = '0.5.1'
Yuriy Bykov
2025-10-14 16:04:37 +03:00
Yuriy Bykov
2026-07-25 20:52:51 +03:00
import copy
import os
Yuriy Bykov
2026-07-25 20:52:51 +03:00
import sqlite3
Yuriy Bykov
2025-10-10 10:16:59 +03:00
import subprocess
Yuriy Bykov
2026-07-25 20:52:51 +03:00
import time
from pathlib import Path
Yuriy Bykov
2026-07-25 20:52:51 +03:00
Yuriy Bykov
2025-10-10 10:16:59 +03:00
import psutil
from config import instance_directory
Yuriy Bykov
2025-10-10 10:16:59 +03:00
class MT5_Control:
Yuriy Bykov
2026-07-25 20:52:51 +03:00
# Сколько секунд с последней записи в states считается "свежими" данными.
# Сервис пишет раз в 2-3 секунды, порог даёт запас на редкие пропуски.
FRESHNESS_THRESHOLD_SEC = 30
STATE_QUERY = '''
SELECT
t.path AS terminal_path,
t.company AS terminal_company,
t.build AS terminal_build,
s.updated_at, s.connected, s.ping_last,
s.login, s.server, s.currency, s.leverage,
s.trade_mode, s.trade_allowed,
s.balance, s.equity, s.profit, s.margin, s.margin_free,
s.margin_level, s.credit, s.positions,
s.company AS account_company, s.holder_name,
s.error_code, s.error_text
FROM terminals t
LEFT JOIN states s ON s.terminal_id = t.id
WHERE t.folder = ?
'''
def __init__(self, terminals: dict, mt5_folder: str, database: str,
mt5_exe: str = 'terminal64.exe'):
Yuriy Bykov
2025-11-04 21:23:59 +03:00
'''
Инициализирует экземпляр класса MT5_Control.
Args:
terminals (dict): Словарь с информацией о терминалах.
mt5_folder (str): Путь к папке с экземплярами терминалов.
Yuriy Bykov
2026-07-25 20:52:51 +03:00
database (str): Путь к общей базе SQLite с состоянием терминалов.
Yuriy Bykov
2025-11-04 21:23:59 +03:00
mt5_exe (str): Имя исполняемого файла терминала (по умолчанию 'terminal64.exe').
'''
Yuriy Bykov
2025-11-11 12:15:50 +03:00
# Путь к папке с экземплярами терминалов
Yuriy Bykov
2025-10-30 09:59:38 +03:00
self.mt5_folder = mt5_folder
Yuriy Bykov
2025-11-11 12:15:50 +03:00
Yuriy Bykov
2026-07-25 20:52:51 +03:00
# Путь к общей базе SQLite
self.database = database
Yuriy Bykov
2025-11-11 12:15:50 +03:00
# Имя исполняемого файла терминала
Yuriy Bykov
2025-10-30 09:59:38 +03:00
self.mt5_exe = mt5_exe
self.configure(terminals, mt5_folder, database)
@staticmethod
def normalized_path(path: str) -> str:
return os.path.normcase(os.path.abspath(path))
def configure(self, terminals: dict, mt5_folder: str, database: str) -> None:
self.mt5_folder = mt5_folder
self.database = database
# Пароли остаются в ConfigStore; словари карточек содержат только
# разрешённые для выдачи поля конфигурации и состояние процесса.
self.terminal_config = {
folder: {**{key: value for key, value in record.items()
if key in ('name', 'login', 'server')},
'path': str(instance_directory(mt5_folder, folder))}
for folder, record in terminals.items()
}
Yuriy Bykov
2025-10-30 09:59:38 +03:00
Yuriy Bykov
2025-11-17 21:13:30 +03:00
# Список имён экземпляров
Yuriy Bykov
2025-10-30 09:59:38 +03:00
self.instances_folders = list(terminals.keys())
# Словарь соответствия имя -> путь
Yuriy Bykov
2026-07-25 20:52:51 +03:00
self.instances_paths = {folder: self.instance_path(
Yuriy Bykov
2025-10-30 09:59:38 +03:00
folder) for folder in self.instances_folders}
# Словарь соответствия путь -> имя
self.paths_instances = {self.normalized_path(self.instance_path(
folder)): folder for folder in self.instances_folders}
Yuriy Bykov
2025-10-30 09:59:38 +03:00
Yuriy Bykov
2026-07-25 20:52:51 +03:00
# Глубокая копия: instances хранит pid/status, не должен делить
# вложенные словари с конфигурацией
self.instances = copy.deepcopy(self.terminal_config)
Yuriy Bykov
2025-10-30 09:59:38 +03:00
def instance_path(self, folder: str) -> str:
'''
Формирует полный путь по имени экземпляра терминала
Args:
Yuriy Bykov
2025-10-30 09:59:38 +03:00
folder (str): Имя экземпляра терминала.
Returns:
path (str): Полный путь к запускаемому файлу экземпляра терминала.
'''
return str(instance_directory(self.mt5_folder, folder) / self.mt5_exe)
Yuriy Bykov
2026-07-25 20:52:51 +03:00
def read_state(self, folder: str) -> sqlite3.Row | None:
'''
Читает последнее известное состояние терминала из общей базы.
Args:
folder (str): Имя экземпляра терминала.
Returns:
row (sqlite3.Row | None): Строка состояния, или None, если
терминал ещё не регистрировался в базе (сервис ни разу не
запускался) либо база недоступна.
'''
database = Path(self.database).resolve()
Yuriy Bykov
2026-07-25 20:52:51 +03:00
try:
# Создание файла и таблиц — задача MQL5-сервиса. Ошибка пути
# менеджера не должна создавать вторую пустую базу.
con = sqlite3.connect(database.as_uri() + '?mode=ro', uri=True, timeout=3)
Yuriy Bykov
2026-07-25 20:52:51 +03:00
con.row_factory = sqlite3.Row
try:
return con.execute(self.STATE_QUERY, (folder,)).fetchone()
finally:
con.close()
except sqlite3.OperationalError as e:
# База ещё не создана сервисом, временно занята, либо у неё
# неожиданно не совпадает схема со STATE_QUERY (например, таблица
# уже существовала до того, как в неё добавили колонки, а
# CREATE TABLE IF NOT EXISTS не выполняет миграцию) - в любом из
# этих случаев продолжаем без данных, но печатаем причину, чтобы
# рассинхронизацию схемы не пришлось второй раз откапывать вручную
message = str(e)
if 'no such table:' in message:
hint = (' В этой базе ещё нет схемы сервиса. Проверьте его запуск, '
'журнал и совпадение database с Common/Files/InpDatabase.')
elif not database.is_file():
hint = (' Файл базы ещё не создан сервисом или неверно задан database. '
'Менеджер не создаёт его при чтении.')
else:
hint = ''
print(f'read_state({folder!r}): {message}; database={database}.{hint}')
Yuriy Bykov
2026-07-25 20:52:51 +03:00
return None
Yuriy Bykov
2025-10-30 09:59:38 +03:00
def instance_info(self, folder: str) -> dict:
Yuriy Bykov
2025-11-17 21:13:30 +03:00
'''
Yuriy Bykov
2026-07-25 20:52:51 +03:00
Получает информацию о терминале по имени экземпляра терминала.
Статус процесса определяется через psutil, данные счёта и терминала -
из общей базы SQLite, которую пишет сервис ServiceStateWriter.
Совмещение даёт три состояния вместо простого running/stopped:
процесс жив и данные свежие - "running"; процесс жив, но данные
устарели или их ещё нет (терминал грузится, сервис не запущен или
завис) - "starting"; процесса нет - "stopped".
Yuriy Bykov
2025-11-17 21:13:30 +03:00
Args:
folder (str): Имя экземпляра терминала.
Returns:
Yuriy Bykov
2026-07-25 20:52:51 +03:00
info (dict): Информация о терминале или обнаруженных ошибках.
Yuriy Bykov
2025-11-17 21:13:30 +03:00
'''
Yuriy Bykov
2026-07-25 20:52:51 +03:00
# Если терминала с таким именем нет в конфигурации
if folder not in self.instances:
return {
'last_error': {'code': -1, 'description': 'Manager: Terminal not found'},
'status': 'not found',
}
info = self.instances[folder]
# Если для терминала нет запущенного процесса
if not info.get('pid'):
info['last_error'] = {'code': 0, 'description': 'Stopped'}
info['status'] = 'stopped'
return info
row = self.read_state(folder)
if row is not None:
expected = os.path.dirname(self.instance_path(folder))
if not row['terminal_path'] or self.normalized_path(row['terminal_path']) != self.normalized_path(expected):
row = None
Yuriy Bykov
2026-07-25 20:52:51 +03:00
fresh = (
row is not None
and row['updated_at'] is not None
and (time.time() - row['updated_at']) < self.FRESHNESS_THRESHOLD_SEC
)
if row is not None:
info['terminal'] = {
'path': row['terminal_path'],
'company': row['terminal_company'],
'build': row['terminal_build'],
'connected': bool(row['connected']),
'ping_last': row['ping_last'],
}
if not fresh:
# Терминал грузится, сервис ещё не написал данные, либо завис
info['last_error'] = {'code': 0, 'description': 'Starting'}
info['status'] = 'starting'
return info
info['account'] = {
'login': row['login'],
'server': row['server'],
'currency': row['currency'],
'leverage': row['leverage'],
'trade_mode': row['trade_mode'],
'trade_allowed': bool(row['trade_allowed']),
'balance': row['balance'],
'equity': row['equity'],
'profit': row['profit'],
'margin': row['margin'],
'margin_free': row['margin_free'],
'margin_level': row['margin_level'],
'credit': row['credit'],
'positions': row['positions'],
'company': row['account_company'],
'holder_name': row['holder_name'],
}
# Секунды unix: сервис пишет TimeGMT(), миллисекундная точность не нужна
info['last_update'] = row['updated_at']
Yuriy Bykov
2026-07-25 20:52:51 +03:00
info['last_error'] = {'code': row['error_code'], 'description': row['error_text']}
info['status'] = 'running'
Yuriy Bykov
2025-11-20 14:02:48 +03:00
return info
def find_instances(self) -> None:
Yuriy Bykov
2026-07-25 20:52:51 +03:00
'''Получение информации о статусе экземпляров
терминала MetaTrader 5 из заданных папок.
Returns:
instances (dict): Информация об экземплярах терминала.
'''
# Формируем словарь для всех экземпляров по списку имён
Yuriy Bykov
2025-10-30 09:59:38 +03:00
for folder in self.instances_folders:
self.instances[folder]['pid'] = 0
self.instances[folder]['status'] = 'stopped'
# Перебираем все запущенные процессы
for proc in psutil.process_iter(['pid', 'name', 'exe']):
try:
# Получаем путь к запускаемому файлу процесса
exe_path = self.normalized_path(proc.info['exe']) if proc.info['exe'] else ''
Yuriy Bykov
2025-10-10 10:16:59 +03:00
# Если он есть среди путей экземпляров терминала
Yuriy Bykov
2026-07-25 20:52:51 +03:00
if exe_path and exe_path in self.paths_instances:
# Определяем имя экземпляра для данного пути
Yuriy Bykov
2026-07-25 20:52:51 +03:00
folder = self.paths_instances[exe_path]
Yuriy Bykov
2025-10-14 14:13:43 +03:00
# Берём нужный экземпляр
Yuriy Bykov
2025-10-30 09:59:38 +03:00
instance = self.instances[folder]
Yuriy Bykov
2025-10-16 20:41:52 +03:00
# Запоминаем идентификатор и статус процесса
instance['pid'] = proc.info['pid']
Yuriy Bykov
2025-10-31 21:45:09 +03:00
instance['status'] = 'starting'
Yuriy Bykov
2025-10-30 09:59:38 +03:00
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
Yuriy Bykov
2025-10-16 20:41:52 +03:00
def load_instances(self) -> dict:
self.find_instances()
Yuriy Bykov
2025-10-30 09:59:38 +03:00
return self.instances
Yuriy Bykov
2025-10-16 20:41:52 +03:00
Yuriy Bykov
2025-10-30 09:59:38 +03:00
def start_mt5(self, folder: str) -> dict:
'''
Запуск экземпляра терминала
Yuriy Bykov
2025-10-16 20:41:52 +03:00
Args:
Yuriy Bykov
2025-10-30 09:59:38 +03:00
folder (str): Имя экземпляра терминала.
Yuriy Bykov
2025-10-14 16:04:37 +03:00
Returns:
instance (dict): Информация о запущенном терминале.
'''
Yuriy Bykov
2025-10-14 14:13:43 +03:00
# Получаем информацию об экземплярах терминала
instances = self.load_instances()
Yuriy Bykov
2025-10-14 14:13:43 +03:00
# Если имя экземпляра есть среди имеющихся
Yuriy Bykov
2025-10-30 09:59:38 +03:00
if folder in instances:
# Берём нужный экземпляр
Yuriy Bykov
2025-10-30 09:59:38 +03:00
instance = instances[folder]
Yuriy Bykov
2025-10-14 14:13:43 +03:00
# Если для него нет запущенного процесса, то
if not instance['pid']:
# Запускаем новый процесс
process = subprocess.Popen(
Yuriy Bykov
2026-07-25 20:52:51 +03:00
[self.instances_paths[folder], '/portable'])
Yuriy Bykov
2025-10-14 16:04:37 +03:00
# Идентификатор запущенного процесса
pid = process.pid
Yuriy Bykov
2025-10-14 14:13:43 +03:00
# Запоминаем идентификатор и статус процесса
instance['pid'] = pid
Yuriy Bykov
2025-10-31 21:45:09 +03:00
instance['status'] = 'starting'
Yuriy Bykov
2025-10-10 10:16:59 +03:00
# Возвращаем результат - успешный запуск
Yuriy Bykov
2025-10-30 09:59:38 +03:00
return {folder: instance}
Yuriy Bykov
2025-10-10 10:16:59 +03:00
# Возвращаем результат - имя не найдено
Yuriy Bykov
2025-10-30 09:59:38 +03:00
return {folder: {'status': 'not found'}}
Yuriy Bykov
2025-10-14 14:13:43 +03:00
Yuriy Bykov
2025-10-30 09:59:38 +03:00
def stop_mt5(self, folder: str) -> dict:
'''
Остановка терминала
Yuriy Bykov
2025-10-14 14:13:43 +03:00
Args:
Yuriy Bykov
2025-10-30 09:59:38 +03:00
folder (str): Имя экземпляра терминала.
Yuriy Bykov
2025-10-14 14:13:43 +03:00
Returns:
instance (dict): Информация о запущенном терминале.
'''
Yuriy Bykov
2025-10-14 14:13:43 +03:00
# Получаем информацию об экземплярах терминала
instances = self.load_instances()
Yuriy Bykov
2025-10-14 14:13:43 +03:00
# Если имя экземпляра есть среди имеющихся
Yuriy Bykov
2025-10-30 09:59:38 +03:00
if folder in instances:
# Берём нужный экземпляр
Yuriy Bykov
2025-10-30 09:59:38 +03:00
instance = instances[folder]
Yuriy Bykov
2025-10-14 14:13:43 +03:00
# Берём идентификатор его процесса
pid = instance['pid']
Yuriy Bykov
2025-10-10 10:16:59 +03:00
# Если ранее терминал был запущен
if pid:
try:
# Получаем объект процесса терминала
p = psutil.Process(pid)
Yuriy Bykov
2025-10-10 10:16:59 +03:00
# Останавливаем процесс
p.terminate()
p.wait(10)
except psutil.NoSuchProcess:
pass
Yuriy Bykov
2025-10-14 14:13:43 +03:00
# Удаляем информацию о запущенном ранее процессе
instance['pid'] = 0
instance['status'] = 'stopped'
Yuriy Bykov
2025-10-10 10:16:59 +03:00
# Возвращаем результат - успешная остановка
Yuriy Bykov
2025-10-30 09:59:38 +03:00
return {folder: instance}
Yuriy Bykov
2025-10-16 20:41:52 +03:00
# Возвращаем результат - процесс не найден
Yuriy Bykov
2025-10-30 09:59:38 +03:00
return {folder: {'status': 'not found'}}