trading-platform-data-service/src/providers/mt4_client.py

633 lines
19 KiB
Python

"""
MetaTrader 4 Direct Server Connection Client
OrbiQuant IA Trading Platform
Provides direct connection to MT4 server without requiring MT4 terminal.
Uses the MT4 Manager API protocol or alternative open protocols.
Options for MT4 connection:
1. dwx-zeromq-connector: Uses ZeroMQ bridge with MT4 EA
2. mt4-server-api: Direct TCP connection using reverse-engineered protocol
3. metaapi.cloud: Cloud service for MT4/MT5 API access
4. ctrader-fix: FIX protocol for cTrader (alternative)
"""
import asyncio
import struct
import socket
import hashlib
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any, Callable
from dataclasses import dataclass, field
from enum import Enum, IntEnum
import logging
logger = logging.getLogger(__name__)
class OrderType(IntEnum):
"""MT4 Order Types"""
OP_BUY = 0
OP_SELL = 1
OP_BUYLIMIT = 2
OP_SELLLIMIT = 3
OP_BUYSTOP = 4
OP_SELLSTOP = 5
class TradeOperation(IntEnum):
"""MT4 Trade Operations"""
OPEN = 1
CLOSE = 2
MODIFY = 3
DELETE = 4
@dataclass
class MT4Tick:
"""Real-time tick data."""
symbol: str
timestamp: datetime
bid: float
ask: float
spread: float = field(init=False)
def __post_init__(self):
self.spread = self.ask - self.bid
@dataclass
class MT4Order:
"""MT4 Order information."""
ticket: int
symbol: str
order_type: OrderType
lots: float
open_price: float
sl: float
tp: float
open_time: datetime
close_price: Optional[float] = None
close_time: Optional[datetime] = None
profit: float = 0.0
swap: float = 0.0
commission: float = 0.0
magic: int = 0
comment: str = ""
@dataclass
class MT4AccountInfo:
"""MT4 Account information."""
login: int
name: str
server: str
currency: str
leverage: int
balance: float
equity: float
margin: float
free_margin: float
margin_level: float
profit: float
@dataclass
class MT4Symbol:
"""MT4 Symbol specification."""
symbol: str
digits: int
point: float
spread: int
stops_level: int
contract_size: float
tick_value: float
tick_size: float
min_lot: float
max_lot: float
lot_step: float
swap_long: float
swap_short: float
class MT4ConnectionError(Exception):
"""MT4 Connection error."""
pass
class MT4TradeError(Exception):
"""MT4 Trade execution error."""
pass
class MT4Client:
"""
MetaTrader 4 Client using direct server connection.
This implementation uses a hybrid approach:
1. For real-time data: WebSocket/TCP connection to a bridge service
2. For trading: REST API through MetaAPI or similar service
For full direct connection without any bridge, you would need:
- Reverse-engineered MT4 protocol (complex, may violate ToS)
- Or: MT4 Manager API license from MetaQuotes (expensive)
"""
def __init__(
self,
server: str,
login: int,
password: str,
investor_mode: bool = False,
timeout: int = 30
):
self.server = server
self.login = login
self.password = password
self.investor_mode = investor_mode
self.timeout = timeout
self._connected = False
self._socket: Optional[socket.socket] = None
self._account_info: Optional[MT4AccountInfo] = None
self._symbols: Dict[str, MT4Symbol] = {}
# Callbacks for real-time events
self._tick_callbacks: List[Callable[[MT4Tick], None]] = []
self._order_callbacks: List[Callable[[MT4Order], None]] = []
@property
def is_connected(self) -> bool:
return self._connected
async def connect(self) -> bool:
"""
Connect to MT4 server.
Note: Direct MT4 server connection requires proprietary protocol.
This implementation assumes a bridge service or MetaAPI.
"""
try:
# Parse server address
if ":" in self.server:
host, port = self.server.rsplit(":", 1)
port = int(port)
else:
host = self.server
port = 443
logger.info(f"Connecting to MT4 server {host}:{port}")
# For direct connection, we would establish TCP socket here
# However, MT4 uses proprietary encryption
# Instead, we'll use a bridge pattern
self._connected = True
logger.info(f"Connected to MT4 as {self.login}")
# Load account info
await self._load_account_info()
return True
except Exception as e:
logger.error(f"Failed to connect to MT4: {e}")
self._connected = False
raise MT4ConnectionError(f"Connection failed: {e}")
async def disconnect(self):
"""Disconnect from MT4 server."""
if self._socket:
self._socket.close()
self._socket = None
self._connected = False
logger.info("Disconnected from MT4")
async def _load_account_info(self):
"""Load account information."""
# In real implementation, this would query the server
# Placeholder for now
pass
async def get_account_info(self) -> MT4AccountInfo:
"""Get current account information."""
if not self._connected:
raise MT4ConnectionError("Not connected")
# Would query server for live data
return self._account_info
async def get_symbol_info(self, symbol: str) -> Optional[MT4Symbol]:
"""Get symbol specification."""
if symbol in self._symbols:
return self._symbols[symbol]
# Query server for symbol info
# Placeholder - would parse server response
return None
async def get_tick(self, symbol: str) -> Optional[MT4Tick]:
"""Get current tick for symbol."""
if not self._connected:
raise MT4ConnectionError("Not connected")
# Query current prices
# In real implementation, this uses the MarketInfo command
return None
async def subscribe_ticks(
self,
symbols: List[str],
callback: Callable[[MT4Tick], None]
):
"""Subscribe to real-time tick updates."""
self._tick_callbacks.append(callback)
# In real implementation, send subscription request to server
logger.info(f"Subscribed to ticks for {symbols}")
async def get_spread(self, symbol: str) -> float:
"""Get current spread for symbol in points."""
tick = await self.get_tick(symbol)
if tick:
symbol_info = await self.get_symbol_info(symbol)
if symbol_info:
return (tick.ask - tick.bid) / symbol_info.point
return 0.0
async def get_spread_in_price(self, symbol: str) -> float:
"""Get current spread as price difference."""
tick = await self.get_tick(symbol)
return tick.spread if tick else 0.0
# ==========================================
# Trading Operations
# ==========================================
async def open_order(
self,
symbol: str,
order_type: OrderType,
lots: float,
price: float = 0,
sl: float = 0,
tp: float = 0,
slippage: int = 3,
magic: int = 0,
comment: str = ""
) -> Optional[int]:
"""
Open a new order.
Args:
symbol: Trading symbol
order_type: Type of order (buy, sell, etc.)
lots: Trade volume
price: Order price (0 for market orders)
sl: Stop loss price
tp: Take profit price
slippage: Maximum slippage in points
magic: Expert Advisor magic number
comment: Order comment
Returns:
Order ticket number or None if failed
"""
if not self._connected:
raise MT4ConnectionError("Not connected")
if self.investor_mode:
raise MT4TradeError("Cannot trade in investor mode")
logger.info(f"Opening {order_type.name} order for {symbol}, {lots} lots")
# Build trade request
# In real implementation, this sends TradeTransaction command
# Placeholder return
return None
async def close_order(
self,
ticket: int,
lots: Optional[float] = None,
price: float = 0,
slippage: int = 3
) -> bool:
"""
Close an existing order.
Args:
ticket: Order ticket number
lots: Volume to close (None = close all)
price: Close price (0 for market)
slippage: Maximum slippage
Returns:
True if successful
"""
if not self._connected:
raise MT4ConnectionError("Not connected")
logger.info(f"Closing order {ticket}")
# Build close request
# In real implementation, sends TradeTransaction close command
return False
async def modify_order(
self,
ticket: int,
price: Optional[float] = None,
sl: Optional[float] = None,
tp: Optional[float] = None
) -> bool:
"""
Modify an existing order.
Args:
ticket: Order ticket
price: New order price (for pending orders)
sl: New stop loss
tp: New take profit
Returns:
True if successful
"""
if not self._connected:
raise MT4ConnectionError("Not connected")
logger.info(f"Modifying order {ticket}")
return False
async def get_orders(self, symbol: Optional[str] = None) -> List[MT4Order]:
"""Get all open orders."""
if not self._connected:
raise MT4ConnectionError("Not connected")
# Query open orders from server
return []
async def get_history(
self,
start_time: datetime,
end_time: datetime,
symbol: Optional[str] = None
) -> List[MT4Order]:
"""Get order history."""
if not self._connected:
raise MT4ConnectionError("Not connected")
# Query history from server
return []
class MetaAPIClient(MT4Client):
"""
MT4/MT5 Client using MetaAPI.cloud service.
MetaAPI provides REST/WebSocket API for MT4/MT5 without requiring
the terminal or proprietary protocols.
Requires MetaAPI account and token.
"""
METAAPI_URL = "https://mt-client-api-v1.agiliumtrade.agiliumtrade.ai"
def __init__(
self,
token: str,
account_id: str,
server: str = "",
login: int = 0,
password: str = "",
**kwargs
):
super().__init__(server, login, password, **kwargs)
self.token = token
self.account_id = account_id
self._session = None
async def connect(self) -> bool:
"""Connect via MetaAPI."""
import aiohttp
self._session = aiohttp.ClientSession(
headers={"auth-token": self.token}
)
try:
# Deploy account if needed
async with self._session.get(
f"{self.METAAPI_URL}/users/current/accounts/{self.account_id}"
) as resp:
if resp.status == 200:
data = await resp.json()
self._connected = True
logger.info(f"Connected to MetaAPI account {self.account_id}")
return True
else:
raise MT4ConnectionError(f"MetaAPI error: {resp.status}")
except Exception as e:
logger.error(f"MetaAPI connection failed: {e}")
raise MT4ConnectionError(str(e))
async def disconnect(self):
"""Disconnect from MetaAPI."""
if self._session:
await self._session.close()
self._connected = False
async def get_tick(self, symbol: str) -> Optional[MT4Tick]:
"""Get current tick via MetaAPI."""
if not self._session:
return None
try:
async with self._session.get(
f"{self.METAAPI_URL}/users/current/accounts/{self.account_id}/symbols/{symbol}/current-price"
) as resp:
if resp.status == 200:
data = await resp.json()
return MT4Tick(
symbol=symbol,
timestamp=datetime.fromisoformat(data["time"].replace("Z", "+00:00")),
bid=data["bid"],
ask=data["ask"]
)
except Exception as e:
logger.error(f"Error getting tick: {e}")
return None
async def open_order(
self,
symbol: str,
order_type: OrderType,
lots: float,
price: float = 0,
sl: float = 0,
tp: float = 0,
slippage: int = 3,
magic: int = 0,
comment: str = ""
) -> Optional[int]:
"""Open order via MetaAPI."""
if not self._session:
raise MT4ConnectionError("Not connected")
action_type = "ORDER_TYPE_BUY" if order_type == OrderType.OP_BUY else "ORDER_TYPE_SELL"
payload = {
"symbol": symbol,
"actionType": action_type,
"volume": lots,
"stopLoss": sl if sl > 0 else None,
"takeProfit": tp if tp > 0 else None,
"comment": comment
}
try:
async with self._session.post(
f"{self.METAAPI_URL}/users/current/accounts/{self.account_id}/trade",
json=payload
) as resp:
if resp.status == 200:
data = await resp.json()
return data.get("orderId")
else:
error = await resp.text()
raise MT4TradeError(f"Trade failed: {error}")
except Exception as e:
logger.error(f"Order execution error: {e}")
raise MT4TradeError(str(e))
class SpreadTracker:
"""
Tracks and analyzes spreads for trading cost calculations.
"""
def __init__(self, db_pool):
self.db = db_pool
self._spread_cache: Dict[str, Dict[str, float]] = {}
async def record_spread(
self,
account_id: int,
ticker_id: int,
bid: float,
ask: float,
timestamp: datetime
):
"""Record a spread observation."""
spread_points = ask - bid
spread_pct = (spread_points / ((bid + ask) / 2)) * 100 if bid > 0 else 0
async with self.db.acquire() as conn:
await conn.execute(
"""
INSERT INTO broker_integration.broker_prices
(account_id, ticker_id, timestamp, bid, ask, spread_points, spread_pct)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (account_id, ticker_id, timestamp) DO NOTHING
""",
account_id, ticker_id, timestamp, bid, ask, spread_points, spread_pct
)
async def calculate_spread_statistics(
self,
account_id: int,
ticker_id: int,
period_hours: int = 24
) -> Dict[str, float]:
"""Calculate spread statistics for a period."""
async with self.db.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT
AVG(spread_points) as avg_spread,
MIN(spread_points) as min_spread,
MAX(spread_points) as max_spread,
STDDEV(spread_points) as std_spread,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY spread_points) as median_spread,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY spread_points) as p95_spread,
COUNT(*) as sample_count
FROM broker_integration.broker_prices
WHERE account_id = $1
AND ticker_id = $2
AND timestamp > NOW() - INTERVAL '%s hours'
""" % period_hours,
account_id, ticker_id
)
return dict(row) if row else {}
async def get_session_spread(
self,
ticker_id: int,
session: str = "london"
) -> float:
"""Get average spread for a trading session."""
async with self.db.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT avg_spread
FROM broker_integration.spread_statistics
WHERE ticker_id = $1 AND session_type = $2
ORDER BY period_start DESC
LIMIT 1
""",
ticker_id, session
)
return row["avg_spread"] if row else 0.0
def calculate_spread_adjusted_rr(
self,
entry_price: float,
stop_loss: float,
take_profit: float,
spread: float,
is_long: bool
) -> Dict[str, float]:
"""
Calculate spread-adjusted risk/reward ratio.
For LONG trades:
- Entry is at ASK (higher), exit at BID (lower)
- Effective entry = entry_price + spread/2
- Risk increases, reward decreases
For SHORT trades:
- Entry is at BID (lower), exit at ASK (higher)
- Same adjustment applies
"""
if is_long:
effective_entry = entry_price + spread / 2
risk = effective_entry - stop_loss
reward = take_profit - effective_entry
else:
effective_entry = entry_price - spread / 2
risk = stop_loss - effective_entry
reward = effective_entry - take_profit
gross_rr = abs(take_profit - entry_price) / abs(entry_price - stop_loss)
net_rr = reward / risk if risk > 0 else 0
spread_cost_pct = (spread / entry_price) * 100
return {
"gross_rr": gross_rr,
"net_rr": net_rr,
"effective_entry": effective_entry,
"adjusted_risk": risk,
"adjusted_reward": reward,
"spread_cost_pct": spread_cost_pct,
"rr_reduction": gross_rr - net_rr
}