b7451c63fb
- Fix quantity precision calculation for Binance, OKX, Bybit, Bitget, Deepcoin exchanges - Improve OpenRouter API error handling with detailed error messages - Add SECRET_KEY validation in Docker deployment entrypoint - Fix K-line chart measurement tool click issue - Adapt billing page text colors for dark theme - Update frontend build files
828 lines
27 KiB
Python
828 lines
27 KiB
Python
"""
|
|
MetaTrader 5 Trading Client
|
|
|
|
Uses official MetaTrader5 Python library to connect to MT5 terminal for trading.
|
|
Note: Requires Windows platform and MT5 terminal installed.
|
|
"""
|
|
|
|
import time
|
|
import threading
|
|
from dataclasses import dataclass, field
|
|
from typing import Optional, Dict, Any, List
|
|
from datetime import datetime
|
|
|
|
from app.utils.logger import get_logger
|
|
from app.services.mt5_trading.symbols import normalize_symbol, parse_symbol
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
# Lazy import MetaTrader5 to allow other features to work without it installed
|
|
mt5 = None
|
|
|
|
|
|
def _ensure_mt5():
|
|
"""Ensure MetaTrader5 is imported."""
|
|
global mt5
|
|
if mt5 is None:
|
|
try:
|
|
import MetaTrader5 as _mt5
|
|
mt5 = _mt5
|
|
except ImportError:
|
|
raise ImportError(
|
|
"MetaTrader5 is not installed. Run: pip install MetaTrader5\n"
|
|
"Note: This library only works on Windows with MT5 terminal installed."
|
|
)
|
|
return mt5
|
|
|
|
|
|
@dataclass
|
|
class MT5Config:
|
|
"""MT5 connection configuration."""
|
|
login: int = 0 # MT5 account number
|
|
password: str = "" # MT5 password
|
|
server: str = "" # Broker server name (e.g., "ICMarkets-Demo")
|
|
terminal_path: str = "" # Optional: path to terminal64.exe
|
|
timeout: int = 60000 # Connection timeout in milliseconds
|
|
magic_number: int = 123456 # EA magic number for identifying orders
|
|
|
|
|
|
@dataclass
|
|
class OrderResult:
|
|
"""Order execution result."""
|
|
success: bool
|
|
order_id: int = 0
|
|
deal_id: int = 0
|
|
filled: float = 0.0
|
|
price: float = 0.0
|
|
status: str = ""
|
|
message: str = ""
|
|
raw: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
class MT5Client:
|
|
"""
|
|
MetaTrader 5 Trading Client
|
|
|
|
Usage:
|
|
config = MT5Config(
|
|
login=12345678,
|
|
password="your_password",
|
|
server="ICMarkets-Demo"
|
|
)
|
|
client = MT5Client(config)
|
|
|
|
if client.connect():
|
|
# Place order
|
|
result = client.place_market_order("EURUSD", "buy", 0.1)
|
|
|
|
# Get positions
|
|
positions = client.get_positions()
|
|
|
|
client.disconnect()
|
|
"""
|
|
|
|
def __init__(self, config: Optional[MT5Config] = None):
|
|
self.config = config or MT5Config()
|
|
self._connected = False
|
|
self._lock = threading.Lock()
|
|
|
|
@property
|
|
def connected(self) -> bool:
|
|
"""Check if connected to MT5 terminal."""
|
|
if not self._connected:
|
|
return False
|
|
try:
|
|
_ensure_mt5()
|
|
info = mt5.terminal_info()
|
|
return info is not None and info.connected
|
|
except Exception:
|
|
return False
|
|
|
|
def connect(self) -> bool:
|
|
"""
|
|
Connect to MT5 terminal.
|
|
|
|
Returns:
|
|
True if connected successfully
|
|
"""
|
|
with self._lock:
|
|
if self.connected:
|
|
return True
|
|
|
|
try:
|
|
_ensure_mt5()
|
|
|
|
# Initialize MT5 connection
|
|
init_params = {}
|
|
|
|
if self.config.terminal_path:
|
|
init_params["path"] = self.config.terminal_path
|
|
|
|
if self.config.login and self.config.password and self.config.server:
|
|
init_params["login"] = self.config.login
|
|
init_params["password"] = self.config.password
|
|
init_params["server"] = self.config.server
|
|
init_params["timeout"] = self.config.timeout
|
|
|
|
logger.info(f"Connecting to MT5: server={self.config.server}, login={self.config.login}")
|
|
|
|
if init_params:
|
|
initialized = mt5.initialize(**init_params)
|
|
else:
|
|
# Connect to already running terminal
|
|
initialized = mt5.initialize()
|
|
|
|
if not initialized:
|
|
error = mt5.last_error()
|
|
logger.error(f"MT5 initialization failed: {error}")
|
|
return False
|
|
|
|
self._connected = True
|
|
|
|
# Log account info
|
|
account_info = mt5.account_info()
|
|
if account_info:
|
|
logger.info(f"MT5 connected: account={account_info.login}, "
|
|
f"server={account_info.server}, balance={account_info.balance}")
|
|
else:
|
|
logger.warning("MT5 connected but account info not available")
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"MT5 connection failed: {e}")
|
|
self._connected = False
|
|
return False
|
|
|
|
def disconnect(self):
|
|
"""Disconnect from MT5 terminal."""
|
|
with self._lock:
|
|
if self._connected:
|
|
try:
|
|
_ensure_mt5()
|
|
mt5.shutdown()
|
|
except Exception as e:
|
|
logger.warning(f"MT5 disconnect exception: {e}")
|
|
finally:
|
|
self._connected = False
|
|
logger.info("MT5 disconnected")
|
|
|
|
def _ensure_connected(self):
|
|
"""Ensure connection is established."""
|
|
if not self.connected:
|
|
if not self.connect():
|
|
raise ConnectionError("Cannot connect to MT5 terminal")
|
|
|
|
# ==================== Order Methods ====================
|
|
|
|
def place_market_order(
|
|
self,
|
|
symbol: str,
|
|
side: str,
|
|
volume: float,
|
|
deviation: int = 20,
|
|
comment: str = "QuantDinger",
|
|
) -> OrderResult:
|
|
"""
|
|
Place a market order.
|
|
|
|
Args:
|
|
symbol: Trading symbol (e.g., "EURUSD")
|
|
side: Direction ("buy" or "sell")
|
|
volume: Lot size (e.g., 0.1 = 1 mini lot)
|
|
deviation: Maximum price deviation in points
|
|
comment: Order comment
|
|
|
|
Returns:
|
|
OrderResult
|
|
"""
|
|
try:
|
|
self._ensure_connected()
|
|
_ensure_mt5()
|
|
|
|
# Normalize symbol
|
|
symbol = normalize_symbol(symbol)
|
|
|
|
# Get symbol info
|
|
symbol_info = mt5.symbol_info(symbol)
|
|
if symbol_info is None:
|
|
return OrderResult(
|
|
success=False,
|
|
message=f"Symbol not found: {symbol}"
|
|
)
|
|
|
|
# Validate volume against symbol constraints
|
|
volume_float = float(volume)
|
|
if volume_float < symbol_info.volume_min:
|
|
return OrderResult(
|
|
success=False,
|
|
message=f"Volume {volume_float} is less than minimum {symbol_info.volume_min}"
|
|
)
|
|
if volume_float > symbol_info.volume_max:
|
|
return OrderResult(
|
|
success=False,
|
|
message=f"Volume {volume_float} exceeds maximum {symbol_info.volume_max}"
|
|
)
|
|
# Round volume to lot step
|
|
volume_step = symbol_info.volume_step
|
|
if volume_step > 0:
|
|
volume_float = round(volume_float / volume_step) * volume_step
|
|
|
|
if not symbol_info.visible:
|
|
# Enable symbol in Market Watch
|
|
if not mt5.symbol_select(symbol, True):
|
|
return OrderResult(
|
|
success=False,
|
|
message=f"Failed to select symbol: {symbol}"
|
|
)
|
|
|
|
# Get current price
|
|
tick = mt5.symbol_info_tick(symbol)
|
|
if tick is None:
|
|
return OrderResult(
|
|
success=False,
|
|
message=f"Failed to get tick for: {symbol}"
|
|
)
|
|
|
|
# Determine order type and price
|
|
if side.lower() == "buy":
|
|
order_type = mt5.ORDER_TYPE_BUY
|
|
price = tick.ask
|
|
else:
|
|
order_type = mt5.ORDER_TYPE_SELL
|
|
price = tick.bid
|
|
|
|
# Determine filling mode based on symbol properties
|
|
# Different brokers support different filling modes
|
|
filling_mode = mt5.ORDER_FILLING_IOC # Default
|
|
if symbol_info.filling_mode & mt5.ORDER_FILLING_IOC:
|
|
filling_mode = mt5.ORDER_FILLING_IOC
|
|
elif symbol_info.filling_mode & mt5.ORDER_FILLING_FOK:
|
|
filling_mode = mt5.ORDER_FILLING_FOK
|
|
elif symbol_info.filling_mode & mt5.ORDER_FILLING_RETURN:
|
|
filling_mode = mt5.ORDER_FILLING_RETURN
|
|
|
|
# Prepare order request
|
|
request = {
|
|
"action": mt5.TRADE_ACTION_DEAL,
|
|
"symbol": symbol,
|
|
"volume": volume_float, # Use validated and rounded volume
|
|
"type": order_type,
|
|
"price": price,
|
|
"deviation": deviation,
|
|
"magic": self.config.magic_number,
|
|
"comment": comment,
|
|
"type_time": mt5.ORDER_TIME_GTC,
|
|
"type_filling": filling_mode,
|
|
}
|
|
|
|
# Send order
|
|
result = mt5.order_send(request)
|
|
|
|
if result is None:
|
|
error = mt5.last_error()
|
|
return OrderResult(
|
|
success=False,
|
|
message=f"Order send failed: {error}"
|
|
)
|
|
|
|
if result.retcode != mt5.TRADE_RETCODE_DONE:
|
|
return OrderResult(
|
|
success=False,
|
|
order_id=result.order if hasattr(result, 'order') else 0,
|
|
status=str(result.retcode),
|
|
message=f"Order rejected: {result.comment}",
|
|
raw=result._asdict() if hasattr(result, '_asdict') else {}
|
|
)
|
|
|
|
return OrderResult(
|
|
success=True,
|
|
order_id=result.order,
|
|
deal_id=result.deal,
|
|
filled=result.volume,
|
|
price=result.price,
|
|
status="filled",
|
|
message="Order executed",
|
|
raw=result._asdict() if hasattr(result, '_asdict') else {}
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Market order failed: {e}")
|
|
return OrderResult(
|
|
success=False,
|
|
message=str(e)
|
|
)
|
|
|
|
def place_limit_order(
|
|
self,
|
|
symbol: str,
|
|
side: str,
|
|
volume: float,
|
|
price: float,
|
|
comment: str = "QuantDinger",
|
|
) -> OrderResult:
|
|
"""
|
|
Place a pending limit order.
|
|
|
|
Args:
|
|
symbol: Trading symbol
|
|
side: Direction ("buy" or "sell")
|
|
volume: Lot size
|
|
price: Limit price
|
|
comment: Order comment
|
|
|
|
Returns:
|
|
OrderResult
|
|
"""
|
|
try:
|
|
self._ensure_connected()
|
|
_ensure_mt5()
|
|
|
|
symbol = normalize_symbol(symbol)
|
|
|
|
symbol_info = mt5.symbol_info(symbol)
|
|
if symbol_info is None:
|
|
return OrderResult(
|
|
success=False,
|
|
message=f"Symbol not found: {symbol}"
|
|
)
|
|
|
|
if not symbol_info.visible:
|
|
mt5.symbol_select(symbol, True)
|
|
|
|
tick = mt5.symbol_info_tick(symbol)
|
|
if tick is None:
|
|
return OrderResult(
|
|
success=False,
|
|
message=f"Failed to get tick for: {symbol}"
|
|
)
|
|
|
|
# Determine order type based on side and price relative to market
|
|
if side.lower() == "buy":
|
|
if price < tick.ask:
|
|
order_type = mt5.ORDER_TYPE_BUY_LIMIT
|
|
else:
|
|
order_type = mt5.ORDER_TYPE_BUY_STOP
|
|
else:
|
|
if price > tick.bid:
|
|
order_type = mt5.ORDER_TYPE_SELL_LIMIT
|
|
else:
|
|
order_type = mt5.ORDER_TYPE_SELL_STOP
|
|
|
|
request = {
|
|
"action": mt5.TRADE_ACTION_PENDING,
|
|
"symbol": symbol,
|
|
"volume": float(volume),
|
|
"type": order_type,
|
|
"price": price,
|
|
"magic": self.config.magic_number,
|
|
"comment": comment,
|
|
"type_time": mt5.ORDER_TIME_GTC,
|
|
}
|
|
|
|
result = mt5.order_send(request)
|
|
|
|
if result is None:
|
|
error = mt5.last_error()
|
|
return OrderResult(
|
|
success=False,
|
|
message=f"Order send failed: {error}"
|
|
)
|
|
|
|
if result.retcode != mt5.TRADE_RETCODE_DONE:
|
|
return OrderResult(
|
|
success=False,
|
|
status=str(result.retcode),
|
|
message=f"Order rejected: {result.comment}",
|
|
)
|
|
|
|
return OrderResult(
|
|
success=True,
|
|
order_id=result.order,
|
|
price=price,
|
|
status="pending",
|
|
message="Pending order placed",
|
|
raw=result._asdict() if hasattr(result, '_asdict') else {}
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Limit order failed: {e}")
|
|
return OrderResult(
|
|
success=False,
|
|
message=str(e)
|
|
)
|
|
|
|
def close_position(
|
|
self,
|
|
ticket: int,
|
|
volume: Optional[float] = None,
|
|
deviation: int = 20,
|
|
comment: str = "QuantDinger close",
|
|
) -> OrderResult:
|
|
"""
|
|
Close an open position.
|
|
|
|
Args:
|
|
ticket: Position ticket number
|
|
volume: Volume to close (None = close all)
|
|
deviation: Maximum price deviation
|
|
comment: Order comment
|
|
|
|
Returns:
|
|
OrderResult
|
|
"""
|
|
try:
|
|
self._ensure_connected()
|
|
_ensure_mt5()
|
|
|
|
# Get position info
|
|
position = mt5.positions_get(ticket=ticket)
|
|
if not position:
|
|
return OrderResult(
|
|
success=False,
|
|
message=f"Position not found: {ticket}"
|
|
)
|
|
|
|
pos = position[0]
|
|
symbol = pos.symbol
|
|
|
|
# Get symbol info for filling mode
|
|
symbol_info = mt5.symbol_info(symbol)
|
|
if symbol_info is None:
|
|
return OrderResult(
|
|
success=False,
|
|
message=f"Symbol not found: {symbol}"
|
|
)
|
|
|
|
# Get tick
|
|
tick = mt5.symbol_info_tick(symbol)
|
|
if tick is None:
|
|
return OrderResult(
|
|
success=False,
|
|
message=f"Failed to get tick for: {symbol}"
|
|
)
|
|
|
|
# Determine close direction and price
|
|
if pos.type == mt5.POSITION_TYPE_BUY:
|
|
order_type = mt5.ORDER_TYPE_SELL
|
|
price = tick.bid
|
|
else:
|
|
order_type = mt5.ORDER_TYPE_BUY
|
|
price = tick.ask
|
|
|
|
close_volume = volume if volume else pos.volume
|
|
|
|
# Determine filling mode based on symbol properties
|
|
filling_mode = mt5.ORDER_FILLING_IOC # Default
|
|
if symbol_info.filling_mode & mt5.ORDER_FILLING_IOC:
|
|
filling_mode = mt5.ORDER_FILLING_IOC
|
|
elif symbol_info.filling_mode & mt5.ORDER_FILLING_FOK:
|
|
filling_mode = mt5.ORDER_FILLING_FOK
|
|
elif symbol_info.filling_mode & mt5.ORDER_FILLING_RETURN:
|
|
filling_mode = mt5.ORDER_FILLING_RETURN
|
|
|
|
request = {
|
|
"action": mt5.TRADE_ACTION_DEAL,
|
|
"symbol": symbol,
|
|
"volume": float(close_volume),
|
|
"type": order_type,
|
|
"position": ticket,
|
|
"price": price,
|
|
"deviation": deviation,
|
|
"magic": self.config.magic_number,
|
|
"comment": comment,
|
|
"type_time": mt5.ORDER_TIME_GTC,
|
|
"type_filling": filling_mode,
|
|
}
|
|
|
|
result = mt5.order_send(request)
|
|
|
|
if result is None or result.retcode != mt5.TRADE_RETCODE_DONE:
|
|
return OrderResult(
|
|
success=False,
|
|
message=f"Close failed: {result.comment if result else 'Unknown error'}"
|
|
)
|
|
|
|
return OrderResult(
|
|
success=True,
|
|
order_id=result.order,
|
|
deal_id=result.deal,
|
|
filled=result.volume,
|
|
price=result.price,
|
|
status="closed",
|
|
message="Position closed",
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Close position failed: {e}")
|
|
return OrderResult(
|
|
success=False,
|
|
message=str(e)
|
|
)
|
|
|
|
def cancel_order(self, ticket: int) -> bool:
|
|
"""
|
|
Cancel a pending order.
|
|
|
|
Args:
|
|
ticket: Order ticket number
|
|
|
|
Returns:
|
|
True if cancelled successfully
|
|
"""
|
|
try:
|
|
self._ensure_connected()
|
|
_ensure_mt5()
|
|
|
|
request = {
|
|
"action": mt5.TRADE_ACTION_REMOVE,
|
|
"order": ticket,
|
|
}
|
|
|
|
result = mt5.order_send(request)
|
|
|
|
if result is None or result.retcode != mt5.TRADE_RETCODE_DONE:
|
|
logger.warning(f"Cancel order failed: {result.comment if result else 'Unknown'}")
|
|
return False
|
|
|
|
logger.info(f"Order {ticket} cancelled")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Cancel order failed: {e}")
|
|
return False
|
|
|
|
# ==================== Query Methods ====================
|
|
|
|
def get_account_info(self) -> Dict[str, Any]:
|
|
"""
|
|
Get account information.
|
|
|
|
Returns:
|
|
Account info dictionary
|
|
"""
|
|
try:
|
|
self._ensure_connected()
|
|
_ensure_mt5()
|
|
|
|
info = mt5.account_info()
|
|
if info is None:
|
|
return {"success": False, "error": "Failed to get account info"}
|
|
|
|
return {
|
|
"success": True,
|
|
"login": info.login,
|
|
"server": info.server,
|
|
"name": info.name,
|
|
"currency": info.currency,
|
|
"balance": info.balance,
|
|
"equity": info.equity,
|
|
"margin": info.margin,
|
|
"margin_free": info.margin_free,
|
|
"margin_level": info.margin_level,
|
|
"profit": info.profit,
|
|
"leverage": info.leverage,
|
|
"trade_allowed": info.trade_allowed,
|
|
"trade_expert": info.trade_expert,
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Get account info failed: {e}")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
def get_positions(self, symbol: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
"""
|
|
Get open positions.
|
|
|
|
Args:
|
|
symbol: Filter by symbol (optional)
|
|
|
|
Returns:
|
|
List of positions
|
|
"""
|
|
try:
|
|
self._ensure_connected()
|
|
_ensure_mt5()
|
|
|
|
if symbol:
|
|
positions = mt5.positions_get(symbol=normalize_symbol(symbol))
|
|
else:
|
|
positions = mt5.positions_get()
|
|
|
|
if positions is None:
|
|
return []
|
|
|
|
result = []
|
|
for pos in positions:
|
|
result.append({
|
|
"ticket": pos.ticket,
|
|
"symbol": pos.symbol,
|
|
"type": "buy" if pos.type == mt5.POSITION_TYPE_BUY else "sell",
|
|
"volume": pos.volume,
|
|
"price_open": pos.price_open,
|
|
"price_current": pos.price_current,
|
|
"sl": pos.sl,
|
|
"tp": pos.tp,
|
|
"profit": pos.profit,
|
|
"swap": pos.swap,
|
|
"magic": pos.magic,
|
|
"comment": pos.comment,
|
|
"time": datetime.fromtimestamp(pos.time).isoformat(),
|
|
})
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"Get positions failed: {e}")
|
|
return []
|
|
|
|
def get_orders(self, symbol: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
"""
|
|
Get pending orders.
|
|
|
|
Args:
|
|
symbol: Filter by symbol (optional)
|
|
|
|
Returns:
|
|
List of orders
|
|
"""
|
|
try:
|
|
self._ensure_connected()
|
|
_ensure_mt5()
|
|
|
|
if symbol:
|
|
orders = mt5.orders_get(symbol=normalize_symbol(symbol))
|
|
else:
|
|
orders = mt5.orders_get()
|
|
|
|
if orders is None:
|
|
return []
|
|
|
|
result = []
|
|
for order in orders:
|
|
order_type_map = {
|
|
mt5.ORDER_TYPE_BUY_LIMIT: "buy_limit",
|
|
mt5.ORDER_TYPE_SELL_LIMIT: "sell_limit",
|
|
mt5.ORDER_TYPE_BUY_STOP: "buy_stop",
|
|
mt5.ORDER_TYPE_SELL_STOP: "sell_stop",
|
|
}
|
|
|
|
result.append({
|
|
"ticket": order.ticket,
|
|
"symbol": order.symbol,
|
|
"type": order_type_map.get(order.type, str(order.type)),
|
|
"volume_initial": order.volume_initial,
|
|
"volume_current": order.volume_current,
|
|
"price_open": order.price_open,
|
|
"price_current": order.price_current,
|
|
"sl": order.sl,
|
|
"tp": order.tp,
|
|
"magic": order.magic,
|
|
"comment": order.comment,
|
|
"time_setup": datetime.fromtimestamp(order.time_setup).isoformat(),
|
|
})
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"Get orders failed: {e}")
|
|
return []
|
|
|
|
def get_quote(self, symbol: str) -> Dict[str, Any]:
|
|
"""
|
|
Get real-time quote.
|
|
|
|
Args:
|
|
symbol: Symbol code
|
|
|
|
Returns:
|
|
Quote data
|
|
"""
|
|
try:
|
|
self._ensure_connected()
|
|
_ensure_mt5()
|
|
|
|
symbol = normalize_symbol(symbol)
|
|
|
|
# Select symbol
|
|
symbol_info = mt5.symbol_info(symbol)
|
|
if symbol_info is None:
|
|
return {"success": False, "error": f"Symbol not found: {symbol}"}
|
|
|
|
if not symbol_info.visible:
|
|
mt5.symbol_select(symbol, True)
|
|
|
|
tick = mt5.symbol_info_tick(symbol)
|
|
if tick is None:
|
|
return {"success": False, "error": f"Failed to get tick: {symbol}"}
|
|
|
|
return {
|
|
"success": True,
|
|
"symbol": symbol,
|
|
"bid": tick.bid,
|
|
"ask": tick.ask,
|
|
"last": tick.last,
|
|
"volume": tick.volume,
|
|
"time": datetime.fromtimestamp(tick.time).isoformat(),
|
|
"spread": round((tick.ask - tick.bid) / symbol_info.point, 1),
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Get quote failed: {e}")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
def get_symbols(self, group: str = "*") -> List[Dict[str, Any]]:
|
|
"""
|
|
Get available symbols.
|
|
|
|
Args:
|
|
group: Filter by group pattern (e.g., "*USD*", "Forex*")
|
|
|
|
Returns:
|
|
List of symbol info
|
|
"""
|
|
try:
|
|
self._ensure_connected()
|
|
_ensure_mt5()
|
|
|
|
symbols = mt5.symbols_get(group=group)
|
|
if symbols is None:
|
|
return []
|
|
|
|
result = []
|
|
for s in symbols:
|
|
result.append({
|
|
"name": s.name,
|
|
"description": s.description,
|
|
"path": s.path,
|
|
"currency_base": s.currency_base,
|
|
"currency_profit": s.currency_profit,
|
|
"digits": s.digits,
|
|
"point": s.point,
|
|
"trade_mode": s.trade_mode,
|
|
"volume_min": s.volume_min,
|
|
"volume_max": s.volume_max,
|
|
"volume_step": s.volume_step,
|
|
})
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"Get symbols failed: {e}")
|
|
return []
|
|
|
|
def get_connection_status(self) -> Dict[str, Any]:
|
|
"""Get connection status."""
|
|
try:
|
|
_ensure_mt5()
|
|
terminal_info = mt5.terminal_info() if self._connected else None
|
|
account_info = mt5.account_info() if self._connected else None
|
|
|
|
return {
|
|
"connected": self.connected,
|
|
"login": self.config.login,
|
|
"server": self.config.server,
|
|
"account_login": account_info.login if account_info else None,
|
|
"account_server": account_info.server if account_info else None,
|
|
"terminal_connected": terminal_info.connected if terminal_info else False,
|
|
"trade_allowed": terminal_info.trade_allowed if terminal_info else False,
|
|
}
|
|
except Exception as e:
|
|
return {
|
|
"connected": False,
|
|
"error": str(e),
|
|
}
|
|
|
|
|
|
# Global singleton (optional)
|
|
_global_client: Optional[MT5Client] = None
|
|
_global_lock = threading.Lock()
|
|
|
|
|
|
def get_mt5_client(config: Optional[MT5Config] = None) -> MT5Client:
|
|
"""
|
|
Get global MT5 client singleton.
|
|
|
|
Args:
|
|
config: Configuration (only effective on first call)
|
|
|
|
Returns:
|
|
MT5Client instance
|
|
"""
|
|
global _global_client
|
|
|
|
with _global_lock:
|
|
if _global_client is None:
|
|
_global_client = MT5Client(config)
|
|
return _global_client
|
|
|
|
|
|
def reset_mt5_client():
|
|
"""Reset global client (disconnect and clear instance)."""
|
|
global _global_client
|
|
|
|
with _global_lock:
|
|
if _global_client is not None:
|
|
_global_client.disconnect()
|
|
_global_client = None
|