Spaces:
Sleeping
Sleeping
| """ | |
| MT5 local trade execution engine. | |
| Handles opening and closing trades with safety checks. | |
| """ | |
| import asyncio | |
| import logging | |
| from datetime import datetime, timezone | |
| from database import db as database | |
| from config import MT5_LOGIN, MT5_PASSWORD, MT5_SERVER, MT5_SYMBOL_MAP | |
| logger = logging.getLogger("gap_system.execution.mt5") | |
| def _broker_symbol(asset: str) -> str: | |
| """Map internal asset name to broker MT5 symbol (e.g. XAUUSD → GOLD for XM).""" | |
| return MT5_SYMBOL_MAP.get(asset, asset) | |
| _mt5_available = False | |
| try: | |
| import MetaTrader5 as mt5 | |
| _mt5_available = True | |
| except ImportError: | |
| mt5 = None # type: ignore | |
| logger.warning("MetaTrader5 package not installed — MT5 execution disabled") | |
| async def initialize() -> bool: | |
| """Initialise MT5 connection.""" | |
| if not _mt5_available: | |
| logger.warning("MT5 not available") | |
| return False | |
| try: | |
| result = await asyncio.to_thread(mt5.initialize) | |
| if not result: | |
| logger.error("MT5 initialise failed: %s", mt5.last_error()) | |
| return False | |
| if MT5_LOGIN and MT5_LOGIN != "your_login": | |
| login_result = await asyncio.to_thread( | |
| mt5.login, | |
| int(MT5_LOGIN), | |
| password=MT5_PASSWORD, | |
| server=MT5_SERVER, | |
| ) | |
| if not login_result: | |
| logger.error("MT5 login failed: %s", mt5.last_error()) | |
| return False | |
| logger.info("MT5 initialised and logged in") | |
| return True | |
| except Exception as e: | |
| logger.error("MT5 init error: %s", e) | |
| return False | |
| async def get_spread(symbol: str) -> float | None: | |
| """Get current spread in points.""" | |
| if not _mt5_available: | |
| return None | |
| try: | |
| info = await asyncio.to_thread(mt5.symbol_info, symbol) | |
| if info is None: | |
| return None | |
| return float(info.spread) | |
| except Exception: | |
| return None | |
| async def execute_trade( | |
| symbol: str, | |
| direction: str, | |
| lot_size: float, | |
| hold_seconds: int, | |
| ) -> dict: | |
| """ | |
| Execute a trade on MT5: open, wait hold_seconds, then close. | |
| Returns result dict with entry/exit prices and P&L. | |
| """ | |
| if not _mt5_available: | |
| return {"status": "failed", "error": "MT5 not available"} | |
| # Map internal name to broker symbol (e.g. "XAUUSD" → "GOLD" for XM) | |
| mt5_symbol = _broker_symbol(symbol) | |
| logger.info("Trading %s (broker symbol: %s)", symbol, mt5_symbol) | |
| # Safety check: spread | |
| spread = await get_spread(mt5_symbol) | |
| normal_spread = 30 # typical for gold | |
| if spread and spread > normal_spread * 3: | |
| error = f"Spread too high: {spread} (max {normal_spread * 3})" | |
| logger.warning(error) | |
| return {"status": "skipped", "error": error} | |
| try: | |
| # Get price | |
| tick = await asyncio.to_thread(mt5.symbol_info_tick, mt5_symbol) | |
| if tick is None: | |
| return {"status": "failed", "error": f"Could not get tick data for {mt5_symbol}"} | |
| if direction == "BULLISH": | |
| order_type = mt5.ORDER_TYPE_BUY | |
| price = tick.ask | |
| else: | |
| order_type = mt5.ORDER_TYPE_SELL | |
| price = tick.bid | |
| # Open trade | |
| request = { | |
| "action": mt5.TRADE_ACTION_DEAL, | |
| "symbol": mt5_symbol, | |
| "volume": lot_size, | |
| "type": order_type, | |
| "price": price, | |
| "deviation": 20, | |
| "magic": 99887766, | |
| "comment": f"GapAI_{direction}_{hold_seconds}s", | |
| "type_time": mt5.ORDER_TIME_GTC, | |
| "type_filling": mt5.ORDER_FILLING_IOC, | |
| } | |
| result = await asyncio.to_thread(mt5.order_send, request) | |
| if result is None or result.retcode != mt5.TRADE_RETCODE_DONE: | |
| error = f"Order failed: {result.comment if result else 'null result'}" | |
| logger.error(error) | |
| return {"status": "failed", "error": error} | |
| trade_id = await database.insert_trade( | |
| asset=symbol, direction=direction, lot_size=lot_size, | |
| hold_seconds=hold_seconds, entry_price=price, | |
| ) | |
| logger.info("Trade opened: %s %s %.2f @ %.5f (hold %ds)", | |
| symbol, direction, lot_size, price, hold_seconds) | |
| # Wait hold_seconds | |
| await asyncio.sleep(hold_seconds) | |
| # Close trade | |
| close_result = await _close_position(mt5_symbol, result.order, direction) | |
| if close_result["status"] == "closed": | |
| profit_pips = close_result.get("profit_pips", 0) | |
| profit_usd = close_result.get("profit_usd", 0) | |
| await database.close_trade(trade_id, close_result["exit_price"], | |
| profit_pips, profit_usd) | |
| return { | |
| "status": "closed", | |
| "entry_price": price, | |
| "exit_price": close_result.get("exit_price", 0), | |
| "profit_pips": close_result.get("profit_pips", 0), | |
| "profit_usd": close_result.get("profit_usd", 0), | |
| "hold_seconds": hold_seconds, | |
| "trade_id": trade_id, | |
| } | |
| except Exception as e: | |
| logger.error("Trade execution error: %s", e) | |
| return {"status": "failed", "error": str(e)} | |
| async def _close_position(symbol: str, ticket: int, direction: str) -> dict: | |
| """Close an open position by ticket.""" | |
| try: | |
| tick = await asyncio.to_thread(mt5.symbol_info_tick, symbol) | |
| if tick is None: | |
| return {"status": "failed", "error": "No tick data for close"} | |
| if direction == "BULLISH": | |
| close_type = mt5.ORDER_TYPE_SELL | |
| close_price = tick.bid | |
| else: | |
| close_type = mt5.ORDER_TYPE_BUY | |
| close_price = tick.ask | |
| request = { | |
| "action": mt5.TRADE_ACTION_DEAL, | |
| "symbol": symbol, | |
| "volume": 0.0, # close full position | |
| "type": close_type, | |
| "position": ticket, | |
| "price": close_price, | |
| "deviation": 20, | |
| "magic": 99887766, | |
| "comment": "GapAI_close", | |
| "type_time": mt5.ORDER_TIME_GTC, | |
| "type_filling": mt5.ORDER_FILLING_IOC, | |
| } | |
| # Get position volume — MUST succeed or we assume position already closed | |
| positions = await asyncio.to_thread(mt5.positions_get, ticket=ticket) | |
| if positions and len(positions) > 0: | |
| request["volume"] = positions[0].volume | |
| else: | |
| # Position not found — it was already closed (SL/TP/manual) | |
| logger.info("Position %d not found — already closed", ticket) | |
| return { | |
| "status": "closed", | |
| "exit_price": close_price, | |
| "profit_pips": 0, | |
| "profit_usd": 0, | |
| } | |
| result = await asyncio.to_thread(mt5.order_send, request) | |
| if result and result.retcode == mt5.TRADE_RETCODE_DONE: | |
| logger.info("Trade closed @ %.5f", close_price) | |
| return { | |
| "status": "closed", | |
| "exit_price": close_price, | |
| "profit_pips": 0, # calculated by caller | |
| "profit_usd": 0, | |
| } | |
| else: | |
| return {"status": "failed", | |
| "error": f"Close failed: {result.comment if result else '?'}"} | |
| except Exception as e: | |
| logger.error("Close position error: %s", e) | |
| return {"status": "failed", "error": str(e)} | |