633 lines
19 KiB
Python
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
|
|
}
|