Files
DinQuant/backend_api_python/app/services/mt5_trading/client.py
T
TIANHE b7451c63fb fix: Fix trading precision issues and improve error handling
- 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
2026-03-12 00:02:53 +08:00

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