ad8dfa27d7
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
131 lines
3.5 KiB
Python
131 lines
3.5 KiB
Python
"""
|
|
MT5 broker client. Thin async wrapper around the synchronous MetaTrader5 API.
|
|
All blocking calls run in the default executor so the event loop stays free.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import Optional
|
|
|
|
import pandas as pd
|
|
|
|
from . import config
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
try:
|
|
import MetaTrader5 as mt5
|
|
_MT5_AVAILABLE = True
|
|
except ImportError:
|
|
_MT5_AVAILABLE = False
|
|
logger.warning("MetaTrader5 package not installed — MT5 client unavailable")
|
|
|
|
ORDER_TYPE_BUY = 0
|
|
ORDER_TYPE_SELL = 1
|
|
|
|
_TF_MAP = {
|
|
"M1": 1, "M5": 5, "M15": 15, "M30": 30,
|
|
"H1": 16385, "H4": 16388, "D1": 16408,
|
|
}
|
|
|
|
|
|
def _run(fn):
|
|
return asyncio.get_event_loop().run_in_executor(None, fn)
|
|
|
|
|
|
def connect() -> bool:
|
|
if not _MT5_AVAILABLE:
|
|
logger.error("MetaTrader5 not installed")
|
|
return False
|
|
if not mt5.initialize(
|
|
login=config.MT5_LOGIN,
|
|
password=config.MT5_PASSWORD,
|
|
server=config.MT5_SERVER,
|
|
):
|
|
logger.error("MT5 init failed: %s", mt5.last_error())
|
|
return False
|
|
info = mt5.terminal_info()
|
|
logger.info("MT5 connected: %s", info.name if info else "unknown")
|
|
return True
|
|
|
|
|
|
def disconnect():
|
|
if _MT5_AVAILABLE:
|
|
mt5.shutdown()
|
|
|
|
|
|
async def get_candles(symbol: str, timeframe: str, count: int) -> Optional[pd.DataFrame]:
|
|
if not _MT5_AVAILABLE:
|
|
return None
|
|
tf = _TF_MAP[timeframe]
|
|
rates = await _run(lambda: mt5.copy_rates_from_pos(symbol, tf, 0, count))
|
|
if rates is None or len(rates) == 0:
|
|
return None
|
|
df = pd.DataFrame(rates)
|
|
df["time"] = pd.to_datetime(df["time"], unit="s")
|
|
df = df.rename(columns={"tick_volume": "tick_volume"})
|
|
return df.set_index("time")
|
|
|
|
|
|
async def get_tick(symbol: str) -> Optional[dict]:
|
|
if not _MT5_AVAILABLE:
|
|
return None
|
|
tick = await _run(lambda: mt5.symbol_info_tick(symbol))
|
|
return tick._asdict() if tick else None
|
|
|
|
|
|
async def get_account_info() -> Optional[dict]:
|
|
if not _MT5_AVAILABLE:
|
|
return None
|
|
info = await _run(mt5.account_info)
|
|
return info._asdict() if info else None
|
|
|
|
|
|
async def get_open_positions(magic: int = 234001) -> list[dict]:
|
|
if not _MT5_AVAILABLE:
|
|
return []
|
|
positions = await _run(lambda: mt5.positions_get())
|
|
if positions is None:
|
|
return []
|
|
return [p._asdict() for p in positions if p.magic == magic]
|
|
|
|
|
|
async def get_symbol_info(symbol: str) -> Optional[dict]:
|
|
if not _MT5_AVAILABLE:
|
|
return None
|
|
info = await _run(lambda: mt5.symbol_info(symbol))
|
|
return info._asdict() if info else None
|
|
|
|
|
|
async def place_order(
|
|
symbol: str,
|
|
order_type: int,
|
|
volume: float,
|
|
price: float,
|
|
sl: float,
|
|
tp: float,
|
|
comment: str = "",
|
|
magic: int = 234001,
|
|
) -> Optional[dict]:
|
|
if not _MT5_AVAILABLE:
|
|
return None
|
|
request = {
|
|
"action": mt5.TRADE_ACTION_DEAL,
|
|
"symbol": symbol,
|
|
"volume": round(volume, 2),
|
|
"type": order_type,
|
|
"price": price,
|
|
"sl": sl,
|
|
"tp": tp,
|
|
"deviation": 20,
|
|
"magic": magic,
|
|
"comment": comment,
|
|
"type_time": mt5.ORDER_TIME_GTC,
|
|
"type_filling": mt5.ORDER_FILLING_IOC,
|
|
}
|
|
result = await _run(lambda: mt5.order_send(request))
|
|
if result is None or result.retcode != mt5.TRADE_RETCODE_DONE:
|
|
logger.error("Order failed %s: %s", symbol, result)
|
|
return None
|
|
return result._asdict()
|