2025-10-14 16:04:37 +03:00 | | | '''
|
| | | File: mt5_control.py
|
| | | Description: Логика управления запуском и остановкой терминалов MetaTrader 5
|
| | | '''
|
| | |
|
2026-10-31 08:21:17 +03:00 | | | __version__ = '0.5.1'
|
2025-10-14 16:04:37 +03:00 | | |
|
2026-07-25 20:52:51 +03:00 | | | import copy
|
2026-10-29 14:39:39 +03:00 | | | import os
|
2026-07-25 20:52:51 +03:00 | | | import sqlite3
|
2025-10-10 10:16:59 +03:00 | | | import subprocess
|
2026-07-25 20:52:51 +03:00 | | | import time
|
2026-10-31 08:21:17 +03:00 | | | from pathlib import Path
|
2026-07-25 20:52:51 +03:00 | | |
|
2025-10-10 10:16:59 +03:00 | | | import psutil
|
2026-10-31 07:26:44 +03:00 | | | from config import instance_directory
|
2025-10-10 10:16:59 +03:00 | | |
|
| | |
|
2025-10-21 16:08:01 +03:00 | | | class MT5_Control:
|
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'):
|
2025-11-04 21:23:59 +03:00 | | | '''
|
| | | Инициализирует экземпляр класса MT5_Control.
|
| | |
|
| | | Args:
|
| | | terminals (dict): Словарь с информацией о терминалах.
|
| | | mt5_folder (str): Путь к папке с экземплярами терминалов.
|
2026-07-25 20:52:51 +03:00 | | | database (str): Путь к общей базе SQLite с состоянием терминалов.
|
2025-11-04 21:23:59 +03:00 | | | mt5_exe (str): Имя исполняемого файла терминала (по умолчанию 'terminal64.exe').
|
| | | '''
|
2025-11-11 12:15:50 +03:00 | | | # Путь к папке с экземплярами терминалов
|
2025-10-30 09:59:38 +03:00 | | | self.mt5_folder = mt5_folder
|
2025-11-11 12:15:50 +03:00 | | |
|
2026-07-25 20:52:51 +03:00 | | | # Путь к общей базе SQLite
|
| | | self.database = database
|
| | |
|
2025-11-11 12:15:50 +03:00 | | | # Имя исполняемого файла терминала
|
2025-10-30 09:59:38 +03:00 | | | self.mt5_exe = mt5_exe
|
2026-10-29 14:39:39 +03:00 | | | 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 = {
|
2026-10-31 07:26:44 +03:00 | | | folder: {**{key: value for key, value in record.items()
|
| | | if key in ('name', 'login', 'server')},
|
| | | 'path': str(instance_directory(mt5_folder, folder))}
|
2026-10-29 14:39:39 +03:00 | | | for folder, record in terminals.items()
|
| | | }
|
2025-10-30 09:59:38 +03:00 | | |
|
2025-11-17 21:13:30 +03:00 | | | # Список имён экземпляров
|
2025-10-30 09:59:38 +03:00 | | | self.instances_folders = list(terminals.keys())
|
2025-10-21 16:08:01 +03:00 | | |
|
| | | # Словарь соответствия имя -> путь
|
2026-07-25 20:52:51 +03:00 | | | self.instances_paths = {folder: self.instance_path(
|
2025-10-30 09:59:38 +03:00 | | | folder) for folder in self.instances_folders}
|
2025-10-21 16:08:01 +03:00 | | |
|
| | | # Словарь соответствия путь -> имя
|
2026-10-29 14:39:39 +03:00 | | | self.paths_instances = {self.normalized_path(self.instance_path(
|
| | | folder)): folder for folder in self.instances_folders}
|
2025-10-30 09:59:38 +03:00 | | |
|
2026-07-25 20:52:51 +03:00 | | | # Глубокая копия: instances хранит pid/status, не должен делить
|
| | | # вложенные словари с конфигурацией
|
2026-10-29 14:39:39 +03:00 | | | self.instances = copy.deepcopy(self.terminal_config)
|
2025-10-21 16:08:01 +03:00 | | |
|
2025-10-30 09:59:38 +03:00 | | | def instance_path(self, folder: str) -> str:
|
2025-10-21 16:08:01 +03:00 | | | '''
|
| | | Формирует полный путь по имени экземпляра терминала
|
| | |
|
| | | Args:
|
2025-10-30 09:59:38 +03:00 | | | folder (str): Имя экземпляра терминала.
|
2025-10-21 16:08:01 +03:00 | | |
|
| | | Returns:
|
2026-10-31 07:26:44 +03:00 | | | path (str): Полный путь к запускаемому файлу экземпляра терминала.
|
2025-10-21 16:08:01 +03:00 | | | '''
|
2026-10-31 07:26:44 +03:00 | | | return str(instance_directory(self.mt5_folder, folder) / self.mt5_exe)
|
2025-10-21 16:08:01 +03:00 | | |
|
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, если
|
| | | терминал ещё не регистрировался в базе (сервис ни разу не
|
| | | запускался) либо база недоступна.
|
| | | '''
|
2026-10-31 08:21:17 +03:00 | | | database = Path(self.database).resolve()
|
2026-07-25 20:52:51 +03:00 | | | try:
|
2026-10-31 08:21:17 +03:00 | | | # Создание файла и таблиц — задача MQL5-сервиса. Ошибка пути
|
| | | # менеджера не должна создавать вторую пустую базу.
|
| | | con = sqlite3.connect(database.as_uri() + '?mode=ro', uri=True, timeout=3)
|
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()
|
2026-07-27 13:41:03 +03:00 | | | except sqlite3.OperationalError as e:
|
| | | # База ещё не создана сервисом, временно занята, либо у неё
|
| | | # неожиданно не совпадает схема со STATE_QUERY (например, таблица
|
| | | # уже существовала до того, как в неё добавили колонки, а
|
| | | # CREATE TABLE IF NOT EXISTS не выполняет миграцию) - в любом из
|
| | | # этих случаев продолжаем без данных, но печатаем причину, чтобы
|
| | | # рассинхронизацию схемы не пришлось второй раз откапывать вручную
|
2026-10-31 08:21:17 +03:00 | | | 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}')
|
2026-07-25 20:52:51 +03:00 | | | return None
|
| | |
|
2025-10-30 09:59:38 +03:00 | | | def instance_info(self, folder: str) -> dict:
|
2025-11-17 21:13:30 +03:00 | | | '''
|
2026-07-25 20:52:51 +03:00 | | | Получает информацию о терминале по имени экземпляра терминала.
|
| | |
|
| | | Статус процесса определяется через psutil, данные счёта и терминала -
|
| | | из общей базы SQLite, которую пишет сервис ServiceStateWriter.
|
| | | Совмещение даёт три состояния вместо простого running/stopped:
|
| | | процесс жив и данные свежие - "running"; процесс жив, но данные
|
| | | устарели или их ещё нет (терминал грузится, сервис не запущен или
|
| | | завис) - "starting"; процесса нет - "stopped".
|
2025-11-17 21:13:30 +03:00 | | |
|
| | | Args:
|
| | | folder (str): Имя экземпляра терминала.
|
| | |
|
| | | Returns:
|
2026-07-25 20:52:51 +03:00 | | | info (dict): Информация о терминале или обнаруженных ошибках.
|
2025-11-17 21:13:30 +03:00 | | | '''
|
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)
|
2026-10-31 07:26:44 +03:00 | | | 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
|
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'],
|
| | | }
|
| | |
|
2026-07-27 12:34:18 +03:00 | | | # Секунды unix: сервис пишет TimeGMT(), миллисекундная точность не нужна
|
| | | info['last_update'] = row['updated_at']
|
2026-07-25 20:52:51 +03:00 | | | info['last_error'] = {'code': row['error_code'], 'description': row['error_text']}
|
| | | info['status'] = 'running'
|
2025-11-20 14:02:48 +03:00 | | |
|
2025-10-21 16:08:01 +03:00 | | | return info
|
| | |
|
| | | def find_instances(self) -> None:
|
2026-07-25 20:52:51 +03:00 | | | '''Получение информации о статусе экземпляров
|
2025-10-21 16:08:01 +03:00 | | | терминала MetaTrader 5 из заданных папок.
|
| | |
|
| | | Returns:
|
| | | instances (dict): Информация об экземплярах терминала.
|
| | | '''
|
| | |
|
| | | # Формируем словарь для всех экземпляров по списку имён
|
2025-10-30 09:59:38 +03:00 | | | for folder in self.instances_folders:
|
| | | self.instances[folder]['pid'] = 0
|
| | | self.instances[folder]['status'] = 'stopped'
|
2025-10-21 16:08:01 +03:00 | | |
|
| | | # Перебираем все запущенные процессы
|
| | | for proc in psutil.process_iter(['pid', 'name', 'exe']):
|
| | | try:
|
| | | # Получаем путь к запускаемому файлу процесса
|
2026-10-29 14:39:39 +03:00 | | | exe_path = self.normalized_path(proc.info['exe']) if proc.info['exe'] else ''
|
2025-10-10 10:16:59 +03:00 | | |
|
2025-10-21 16:08:01 +03:00 | | | # Если он есть среди путей экземпляров терминала
|
2026-07-25 20:52:51 +03:00 | | | if exe_path and exe_path in self.paths_instances:
|
2025-10-21 16:08:01 +03:00 | | | # Определяем имя экземпляра для данного пути
|
2026-07-25 20:52:51 +03:00 | | | folder = self.paths_instances[exe_path]
|
2025-10-14 14:13:43 +03:00 | | |
|
2025-10-21 16:08:01 +03:00 | | | # Берём нужный экземпляр
|
2025-10-30 09:59:38 +03:00 | | | instance = self.instances[folder]
|
2025-10-16 20:41:52 +03:00 | | |
|
2025-10-21 16:08:01 +03:00 | | | # Запоминаем идентификатор и статус процесса
|
| | | instance['pid'] = proc.info['pid']
|
2025-10-31 21:45:09 +03:00 | | | instance['status'] = 'starting'
|
2025-10-30 09:59:38 +03:00 | | |
|
2025-10-21 16:08:01 +03:00 | | | except (psutil.NoSuchProcess, psutil.AccessDenied):
|
| | | continue
|
2025-10-16 20:41:52 +03:00 | | |
|
2025-10-21 16:08:01 +03:00 | | | def load_instances(self) -> dict:
|
| | | self.find_instances()
|
2025-10-30 09:59:38 +03:00 | | | return self.instances
|
2025-10-16 20:41:52 +03:00 | | |
|
2025-10-30 09:59:38 +03:00 | | | def start_mt5(self, folder: str) -> dict:
|
2025-10-21 16:08:01 +03:00 | | | '''
|
| | | Запуск экземпляра терминала
|
2025-10-16 20:41:52 +03:00 | | |
|
2025-10-21 16:08:01 +03:00 | | | Args:
|
2025-10-30 09:59:38 +03:00 | | | folder (str): Имя экземпляра терминала.
|
2025-10-14 16:04:37 +03:00 | | |
|
2025-10-21 16:08:01 +03:00 | | | Returns:
|
| | | instance (dict): Информация о запущенном терминале.
|
| | | '''
|
2025-10-14 14:13:43 +03:00 | | |
|
2025-10-21 16:08:01 +03:00 | | | # Получаем информацию об экземплярах терминала
|
| | | instances = self.load_instances()
|
2025-10-14 14:13:43 +03:00 | | |
|
2025-10-21 16:08:01 +03:00 | | | # Если имя экземпляра есть среди имеющихся
|
2025-10-30 09:59:38 +03:00 | | | if folder in instances:
|
2025-10-21 16:08:01 +03:00 | | | # Берём нужный экземпляр
|
2025-10-30 09:59:38 +03:00 | | | instance = instances[folder]
|
2025-10-14 14:13:43 +03:00 | | |
|
2025-10-21 16:08:01 +03:00 | | | # Если для него нет запущенного процесса, то
|
| | | if not instance['pid']:
|
| | | # Запускаем новый процесс
|
| | | process = subprocess.Popen(
|
2026-07-25 20:52:51 +03:00 | | | [self.instances_paths[folder], '/portable'])
|
2025-10-14 16:04:37 +03:00 | | |
|
2025-10-21 16:08:01 +03:00 | | | # Идентификатор запущенного процесса
|
| | | pid = process.pid
|
2025-10-14 14:13:43 +03:00 | | |
|
| | | # Запоминаем идентификатор и статус процесса
|
2025-10-21 16:08:01 +03:00 | | | instance['pid'] = pid
|
2025-10-31 21:45:09 +03:00 | | | instance['status'] = 'starting'
|
2025-10-10 10:16:59 +03:00 | | |
|
2025-10-21 16:08:01 +03:00 | | | # Возвращаем результат - успешный запуск
|
2025-10-30 09:59:38 +03:00 | | | return {folder: instance}
|
2025-10-10 10:16:59 +03:00 | | |
|
2025-10-21 16:08:01 +03:00 | | | # Возвращаем результат - имя не найдено
|
2025-10-30 09:59:38 +03:00 | | | return {folder: {'status': 'not found'}}
|
2025-10-14 14:13:43 +03:00 | | |
|
2025-10-30 09:59:38 +03:00 | | | def stop_mt5(self, folder: str) -> dict:
|
2025-10-21 16:08:01 +03:00 | | | '''
|
| | | Остановка терминала
|
2025-10-14 14:13:43 +03:00 | | |
|
2025-10-21 16:08:01 +03:00 | | | Args:
|
2025-10-30 09:59:38 +03:00 | | | folder (str): Имя экземпляра терминала.
|
2025-10-14 14:13:43 +03:00 | | |
|
2025-10-21 16:08:01 +03:00 | | | Returns:
|
| | | instance (dict): Информация о запущенном терминале.
|
| | | '''
|
2025-10-14 14:13:43 +03:00 | | |
|
2025-10-21 16:08:01 +03:00 | | | # Получаем информацию об экземплярах терминала
|
| | | instances = self.load_instances()
|
2025-10-14 14:13:43 +03:00 | | |
|
2025-10-21 16:08:01 +03:00 | | | # Если имя экземпляра есть среди имеющихся
|
2025-10-30 09:59:38 +03:00 | | | if folder in instances:
|
2025-10-21 16:08:01 +03:00 | | | # Берём нужный экземпляр
|
2025-10-30 09:59:38 +03:00 | | | instance = instances[folder]
|
2025-10-14 14:13:43 +03:00 | | |
|
2025-10-21 16:08:01 +03:00 | | | # Берём идентификатор его процесса
|
| | | pid = instance['pid']
|
2025-10-10 10:16:59 +03:00 | | |
|
2025-10-21 16:08:01 +03:00 | | | # Если ранее терминал был запущен
|
| | | if pid:
|
| | | try:
|
| | | # Получаем объект процесса терминала
|
| | | p = psutil.Process(pid)
|
2025-10-10 10:16:59 +03:00 | | |
|
2025-10-21 16:08:01 +03:00 | | | # Останавливаем процесс
|
| | | p.terminate()
|
| | | p.wait(10)
|
| | | except psutil.NoSuchProcess:
|
| | | pass
|
2025-10-14 14:13:43 +03:00 | | |
|
2025-10-21 16:08:01 +03:00 | | | # Удаляем информацию о запущенном ранее процессе
|
| | | instance['pid'] = 0
|
| | | instance['status'] = 'stopped'
|
2025-10-10 10:16:59 +03:00 | | |
|
2025-10-21 16:08:01 +03:00 | | | # Возвращаем результат - успешная остановка
|
2025-10-30 09:59:38 +03:00 | | | return {folder: instance}
|
2025-10-16 20:41:52 +03:00 | | |
|
2025-10-21 16:08:01 +03:00 | | | # Возвращаем результат - процесс не найден
|
2025-10-30 09:59:38 +03:00 | | | return {folder: {'status': 'not found'}}
|