Supports MT5 and forex trading.

Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
This commit is contained in:
TIANHE
2026-01-13 04:01:42 +08:00
parent 7527d73f25
commit e095f9be7e
21 changed files with 2717 additions and 51 deletions
+5 -3
View File
@@ -1,11 +1,11 @@
"""
API 路由模块
API Routes Module
"""
from flask import Flask
def register_routes(app: Flask):
"""注册所有 API 路由蓝图"""
"""Register all API route blueprints"""
from app.routes.kline import kline_bp
from app.routes.analysis import analysis_bp
from app.routes.backtest import backtest_bp
@@ -20,9 +20,10 @@ def register_routes(app: Flask):
from app.routes.settings import settings_bp
from app.routes.portfolio import portfolio_bp
from app.routes.ibkr import ibkr_bp
from app.routes.mt5 import mt5_bp
app.register_blueprint(health_bp)
app.register_blueprint(auth_bp, url_prefix='/api/user') # 兼容前端 /api/user/login
app.register_blueprint(auth_bp, url_prefix='/api/user')
app.register_blueprint(kline_bp, url_prefix='/api/indicator')
app.register_blueprint(analysis_bp, url_prefix='/api/analysis')
app.register_blueprint(backtest_bp, url_prefix='/api/indicator')
@@ -35,4 +36,5 @@ def register_routes(app: Flask):
app.register_blueprint(settings_bp, url_prefix='/api/settings')
app.register_blueprint(portfolio_bp, url_prefix='/api/portfolio')
app.register_blueprint(ibkr_bp, url_prefix='/api/ibkr')
app.register_blueprint(mt5_bp, url_prefix='/api/mt5')
+386
View File
@@ -0,0 +1,386 @@
"""
MetaTrader 5 Trading API Routes
Provides REST API for MT5 trading operations.
"""
from flask import Blueprint, request, jsonify
from app.utils.logger import get_logger
logger = get_logger(__name__)
mt5_bp = Blueprint("mt5", __name__)
# Lazy import MT5 client to avoid errors if not installed
MT5Client = None
MT5Config = None
_client = None
def _ensure_mt5_imports():
"""Ensure MT5 modules are imported."""
global MT5Client, MT5Config
if MT5Client is None or MT5Config is None:
try:
from app.services.mt5_trading import MT5Client as _MT5Client, MT5Config as _MT5Config
MT5Client = _MT5Client
MT5Config = _MT5Config
except ImportError as e:
raise ImportError(
"MT5 trading requires MetaTrader5 library. "
"Run: pip install MetaTrader5\n"
"Note: This library only works on Windows."
) from e
def _get_client():
"""Get or create MT5 client instance."""
global _client
if _client is None:
_ensure_mt5_imports()
_client = MT5Client()
return _client
# ==================== Connection Management ====================
@mt5_bp.route("/status", methods=["GET"])
def get_status():
"""Get MT5 connection status."""
try:
_ensure_mt5_imports()
client = _get_client()
status = client.get_connection_status()
return jsonify(status)
except ImportError as e:
return jsonify({
"connected": False,
"error": str(e),
"hint": "MetaTrader5 library is not installed or not on Windows"
})
except Exception as e:
logger.error(f"Get MT5 status failed: {e}")
return jsonify({"connected": False, "error": str(e)})
@mt5_bp.route("/connect", methods=["POST"])
def connect():
"""
Connect to MT5 terminal.
Request body:
{
"login": 12345678, // MT5 account number
"password": "xxx", // MT5 password
"server": "ICMarkets-Demo", // Broker server
"terminal_path": "" // Optional: path to terminal64.exe
}
"""
global _client
try:
_ensure_mt5_imports()
data = request.get_json() or {}
login = data.get("login") or data.get("mt5_login")
password = data.get("password") or data.get("mt5_password")
server = data.get("server") or data.get("mt5_server")
terminal_path = data.get("terminal_path") or data.get("mt5_terminal_path") or ""
if not login or not password or not server:
return jsonify({
"success": False,
"error": "Missing required fields: login, password, server"
}), 400
config = MT5Config(
login=int(login),
password=str(password),
server=str(server),
terminal_path=str(terminal_path),
)
# Create new client with config
_client = MT5Client(config)
if _client.connect():
account_info = _client.get_account_info()
return jsonify({
"success": True,
"message": "Connected to MT5",
"account": account_info
})
else:
return jsonify({
"success": False,
"error": "Failed to connect to MT5. Check credentials and ensure terminal is running."
}), 400
except ImportError as e:
return jsonify({
"success": False,
"error": str(e)
}), 500
except Exception as e:
logger.error(f"MT5 connect failed: {e}")
return jsonify({
"success": False,
"error": str(e)
}), 500
@mt5_bp.route("/disconnect", methods=["POST"])
def disconnect():
"""Disconnect from MT5 terminal."""
global _client
try:
if _client is not None:
_client.disconnect()
_client = None
return jsonify({"success": True, "message": "Disconnected"})
except Exception as e:
logger.error(f"MT5 disconnect failed: {e}")
return jsonify({"success": False, "error": str(e)}), 500
# ==================== Account Queries ====================
@mt5_bp.route("/account", methods=["GET"])
def get_account():
"""Get account information."""
try:
client = _get_client()
if not client.connected:
return jsonify({"success": False, "error": "Not connected to MT5"}), 400
info = client.get_account_info()
return jsonify(info)
except Exception as e:
logger.error(f"Get MT5 account failed: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@mt5_bp.route("/positions", methods=["GET"])
def get_positions():
"""Get open positions."""
try:
client = _get_client()
if not client.connected:
return jsonify({"success": False, "error": "Not connected to MT5"}), 400
symbol = request.args.get("symbol")
positions = client.get_positions(symbol=symbol)
return jsonify({"success": True, "positions": positions})
except Exception as e:
logger.error(f"Get MT5 positions failed: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@mt5_bp.route("/orders", methods=["GET"])
def get_orders():
"""Get pending orders."""
try:
client = _get_client()
if not client.connected:
return jsonify({"success": False, "error": "Not connected to MT5"}), 400
symbol = request.args.get("symbol")
orders = client.get_orders(symbol=symbol)
return jsonify({"success": True, "orders": orders})
except Exception as e:
logger.error(f"Get MT5 orders failed: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@mt5_bp.route("/symbols", methods=["GET"])
def get_symbols():
"""Get available symbols."""
try:
client = _get_client()
if not client.connected:
return jsonify({"success": False, "error": "Not connected to MT5"}), 400
group = request.args.get("group", "*")
symbols = client.get_symbols(group=group)
return jsonify({"success": True, "symbols": symbols})
except Exception as e:
logger.error(f"Get MT5 symbols failed: {e}")
return jsonify({"success": False, "error": str(e)}), 500
# ==================== Trading ====================
@mt5_bp.route("/order", methods=["POST"])
def place_order():
"""
Place an order.
Request body:
{
"symbol": "EURUSD",
"side": "buy", // "buy" or "sell"
"volume": 0.1, // Lot size
"orderType": "market", // "market" or "limit"
"price": 1.0800 // Required for limit orders
}
"""
try:
client = _get_client()
if not client.connected:
return jsonify({"success": False, "error": "Not connected to MT5"}), 400
data = request.get_json() or {}
symbol = data.get("symbol")
side = data.get("side")
volume = data.get("volume") or data.get("quantity")
order_type = data.get("orderType", "market").lower()
price = data.get("price")
comment = data.get("comment", "QuantDinger")
if not symbol or not side or not volume:
return jsonify({
"success": False,
"error": "Missing required fields: symbol, side, volume"
}), 400
if order_type == "limit":
if not price:
return jsonify({
"success": False,
"error": "Limit order requires price"
}), 400
result = client.place_limit_order(
symbol=symbol,
side=side,
volume=float(volume),
price=float(price),
comment=comment,
)
else:
result = client.place_market_order(
symbol=symbol,
side=side,
volume=float(volume),
comment=comment,
)
if result.success:
return jsonify({
"success": True,
"order_id": result.order_id,
"deal_id": result.deal_id,
"filled": result.filled,
"price": result.price,
"status": result.status,
"message": result.message,
})
else:
return jsonify({
"success": False,
"error": result.message
}), 400
except Exception as e:
logger.error(f"MT5 place order failed: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@mt5_bp.route("/close", methods=["POST"])
def close_position():
"""
Close a position.
Request body:
{
"ticket": 123456789, // Position ticket
"volume": 0.1 // Optional: partial close volume
}
"""
try:
client = _get_client()
if not client.connected:
return jsonify({"success": False, "error": "Not connected to MT5"}), 400
data = request.get_json() or {}
ticket = data.get("ticket")
volume = data.get("volume")
if not ticket:
return jsonify({
"success": False,
"error": "Missing required field: ticket"
}), 400
result = client.close_position(
ticket=int(ticket),
volume=float(volume) if volume else None,
)
if result.success:
return jsonify({
"success": True,
"order_id": result.order_id,
"deal_id": result.deal_id,
"filled": result.filled,
"price": result.price,
"message": result.message,
})
else:
return jsonify({
"success": False,
"error": result.message
}), 400
except Exception as e:
logger.error(f"MT5 close position failed: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@mt5_bp.route("/order/<int:ticket>", methods=["DELETE"])
def cancel_order(ticket: int):
"""Cancel a pending order."""
try:
client = _get_client()
if not client.connected:
return jsonify({"success": False, "error": "Not connected to MT5"}), 400
if client.cancel_order(ticket):
return jsonify({"success": True, "message": f"Order {ticket} cancelled"})
else:
return jsonify({"success": False, "error": "Failed to cancel order"}), 400
except Exception as e:
logger.error(f"MT5 cancel order failed: {e}")
return jsonify({"success": False, "error": str(e)}), 500
# ==================== Market Data ====================
@mt5_bp.route("/quote", methods=["GET"])
def get_quote():
"""
Get real-time quote.
Query params:
- symbol: Trading symbol (e.g., EURUSD)
"""
try:
client = _get_client()
if not client.connected:
return jsonify({"success": False, "error": "Not connected to MT5"}), 400
symbol = request.args.get("symbol")
if not symbol:
return jsonify({"success": False, "error": "Missing symbol parameter"}), 400
quote = client.get_quote(symbol)
return jsonify(quote)
except Exception as e:
logger.error(f"MT5 get quote failed: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@@ -61,7 +61,7 @@ def load_strategy_configs(strategy_id: int) -> Dict[str, Any]:
cur = db.cursor()
cur.execute(
"""
SELECT id, exchange_config, trading_config, market_type, leverage, execution_mode
SELECT id, exchange_config, trading_config, market_type, leverage, execution_mode, market_category
FROM qd_strategies_trading
WHERE id = %s
""",
@@ -76,6 +76,7 @@ def load_strategy_configs(strategy_id: int) -> Dict[str, Any]:
market_type = (row.get("market_type") or exchange_config.get("market_type") or "swap").strip()
leverage = float(row.get("leverage") or trading_config.get("leverage") or exchange_config.get("leverage") or 1.0)
execution_mode = (row.get("execution_mode") or "signal").strip().lower()
market_category = (row.get("market_category") or "Crypto").strip()
return {
"strategy_id": int(strategy_id),
@@ -84,6 +85,7 @@ def load_strategy_configs(strategy_id: int) -> Dict[str, Any]:
"market_type": market_type,
"leverage": leverage,
"execution_mode": execution_mode,
"market_category": market_category,
}
@@ -4,6 +4,7 @@ Translate a strategy signal into a direct-exchange order call.
Supports:
- Crypto exchanges: Binance, OKX, Bitget, Bybit, Coinbase, Kraken, KuCoin, Gate, Bitfinex
- Traditional brokers: Interactive Brokers (IBKR) for US/HK stocks
- Forex brokers: MetaTrader 5 (MT5)
"""
from __future__ import annotations
@@ -28,6 +29,9 @@ from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativ
# Lazy import IBKR
IBKRClient = None
# Lazy import MT5
MT5Client = None
def _signal_to_sides(signal_type: str) -> Tuple[str, str, bool]:
"""
@@ -169,6 +173,24 @@ def place_order_from_signal(
exchange_config=exchange_config,
)
# Check for MT5 client (lazy import to avoid circular dependency)
global MT5Client
if MT5Client is None:
try:
from app.services.mt5_trading import MT5Client as _MT5Client
MT5Client = _MT5Client
except ImportError:
pass
if MT5Client is not None and isinstance(client, MT5Client):
return _place_mt5_order(
client=client,
signal_type=signal_type,
symbol=symbol,
amount=qty,
exchange_config=exchange_config,
)
raise LiveTradingError(f"Unsupported client type: {type(client)}")
@@ -228,3 +250,56 @@ def _place_ibkr_order(
)
def _place_mt5_order(
client,
*,
signal_type: str,
symbol: str,
amount: float,
exchange_config: Optional[Dict[str, Any]] = None,
) -> LiveOrderResult:
"""
Place order via MT5 for forex trading.
Signal mapping for forex:
- open_long / add_long -> BUY
- close_long / reduce_long -> SELL
- open_short / add_short -> SELL
- close_short / reduce_short -> BUY
"""
sig = (signal_type or "").strip().lower()
# Determine action based on signal
if sig in ("open_long", "add_long"):
action = "buy"
elif sig in ("close_long", "reduce_long"):
action = "sell"
elif sig in ("open_short", "add_short"):
action = "sell"
elif sig in ("close_short", "reduce_short"):
action = "buy"
else:
raise LiveTradingError(f"Unsupported signal_type for MT5: {signal_type}")
# Place market order
result = client.place_market_order(
symbol=symbol,
side=action,
volume=amount,
comment="QuantDinger",
)
# Convert MT5Client result to LiveOrderResult format
return LiveOrderResult(
success=result.success,
exchange_order_id=str(result.order_id) if result.order_id else "",
filled=result.filled,
avg_price=result.price,
raw={
"status": result.status,
"message": result.message,
"deal_id": result.deal_id,
"raw": result.raw,
},
)
@@ -4,6 +4,7 @@ Factory for direct exchange clients.
Supports:
- Crypto exchanges: Binance, OKX, Bitget, Bybit, Coinbase, Kraken, KuCoin, Gate, Bitfinex
- Traditional brokers: Interactive Brokers (IBKR) for US/HK stocks
- Forex brokers: MetaTrader 5 (MT5)
"""
from __future__ import annotations
@@ -28,6 +29,10 @@ from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativ
IBKRClient = None
IBKRConfig = None
# Lazy import MT5 to avoid ImportError if MetaTrader5 not installed
MT5Client = None
MT5Config = None
def _get(cfg: Dict[str, Any], *keys: str) -> str:
for k in keys:
@@ -109,10 +114,18 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
return BitfinexClient(api_key=api_key, secret_key=secret_key, base_url=base_url)
return BitfinexDerivativesClient(api_key=api_key, secret_key=secret_key, base_url=base_url)
# Traditional brokers (IBKR for US/HK stocks)
# Traditional brokers (IBKR for US/HK stocks only)
if exchange_id == "ibkr":
# Note: Market category validation should be done at the caller level
# This factory only creates clients based on exchange_id
return create_ibkr_client(exchange_config)
# Forex brokers (MT5 for Forex only)
if exchange_id == "mt5":
# Note: Market category validation should be done at the caller level
# This factory only creates clients based on exchange_id
return create_mt5_client(exchange_config)
raise LiveTradingError(f"Unsupported exchange_id: {exchange_id}")
@@ -159,3 +172,56 @@ def create_ibkr_client(exchange_config: Dict[str, Any]):
return client
def create_mt5_client(exchange_config: Dict[str, Any]):
"""
Create MT5 client for forex trading.
exchange_config should contain:
- mt5_login: MT5 account number
- mt5_password: MT5 password
- mt5_server: Broker server name (e.g., "ICMarkets-Demo")
- mt5_terminal_path: Optional path to terminal64.exe
"""
global MT5Client, MT5Config
# Lazy import to avoid ImportError if MetaTrader5 not installed
if MT5Client is None or MT5Config is None:
try:
from app.services.mt5_trading import MT5Client as _MT5Client, MT5Config as _MT5Config
MT5Client = _MT5Client
MT5Config = _MT5Config
except ImportError:
raise LiveTradingError(
"MT5 trading requires MetaTrader5 library. Run: pip install MetaTrader5\n"
"Note: This library only works on Windows."
)
login = int(exchange_config.get("mt5_login") or 0)
password = str(exchange_config.get("mt5_password") or "").strip()
server = str(exchange_config.get("mt5_server") or "").strip()
terminal_path = str(exchange_config.get("mt5_terminal_path") or "").strip()
if not login or not password or not server:
raise LiveTradingError("MT5 requires login, password, and server")
config = MT5Config(
login=login,
password=password,
server=server,
terminal_path=terminal_path,
)
client = MT5Client(config)
# Connect immediately
if not client.connect():
raise LiveTradingError(
"Failed to connect to MT5 terminal. Please check:\n"
"1. MT5 terminal is running\n"
"2. Credentials are correct\n"
"3. You are on Windows"
)
return client
@@ -0,0 +1,173 @@
# MetaTrader 5 Trading Module
Supports forex and CFD trading via MetaTrader 5 terminal.
## Requirements
- **Windows platform** (MT5 Python library is Windows-only)
- MetaTrader 5 terminal installed
- MT5 account with a broker
## Installation
```bash
pip install MetaTrader5
```
Or the dependency is already in `requirements.txt`.
## MT5 Terminal Configuration
1. Download and install MetaTrader 5 from your broker or [official website](https://www.metatrader5.com/)
2. Login to your trading account
3. Go to **Tools****Options****Expert Advisors**
4. Enable:
- ✅ Allow algorithmic trading
- ✅ Allow DLL imports (optional, may be needed for some features)
5. Click OK
## Strategy Configuration
When creating a forex strategy, configure the MT5 connection in the "Live Trading" section:
| Field | Description | Example |
|-------|-------------|---------|
| **Broker** | Select "MetaTrader 5" | - |
| **Server** | Broker server name | `ICMarkets-Demo` |
| **Account** | MT5 login number | `12345678` |
| **Password** | MT5 password | `****` |
## Symbol Format
| Market | Format | Examples |
|--------|--------|----------|
| Forex | Currency pair | `EURUSD`, `GBPUSD`, `USDJPY` |
| Metals | XAU/XAG pairs | `XAUUSD`, `XAGUSD` |
| Indices | CFD symbols | `US30`, `US500`, `DE40` |
| Crypto | Symbol pairs | `BTCUSD`, `ETHUSD` |
> **Note**: Symbol names may vary by broker. Some brokers use suffixes like `EURUSDm`, `EURUSD.raw`, etc.
## Lot Size Reference
| Type | Units | Example |
|------|-------|---------|
| Standard Lot | 100,000 | 1.0 lot = 100,000 EUR |
| Mini Lot | 10,000 | 0.1 lot = 10,000 EUR |
| Micro Lot | 1,000 | 0.01 lot = 1,000 EUR |
## API Endpoints
### Connection Management
```
GET /api/mt5/status # Get connection status
POST /api/mt5/connect # Connect to MT5 terminal
POST /api/mt5/disconnect # Disconnect
```
### Account Queries
```
GET /api/mt5/account # Account information
GET /api/mt5/positions # Open positions
GET /api/mt5/orders # Pending orders
GET /api/mt5/symbols # Available symbols
```
### Trading
```
POST /api/mt5/order # Place order
POST /api/mt5/close # Close position
DELETE /api/mt5/order/<id> # Cancel pending order
```
### Market Data
```
GET /api/mt5/quote?symbol=EURUSD
```
## Usage Examples
### Connect
```bash
curl -X POST http://localhost:5000/api/mt5/connect \
-H "Content-Type: application/json" \
-d '{"login": 12345678, "password": "your_password", "server": "ICMarkets-Demo"}'
```
### Place Market Order
```bash
# Buy 0.1 lot EURUSD
curl -X POST http://localhost:5000/api/mt5/order \
-H "Content-Type: application/json" \
-d '{"symbol": "EURUSD", "side": "buy", "volume": 0.1}'
# Sell 0.5 lot XAUUSD
curl -X POST http://localhost:5000/api/mt5/order \
-H "Content-Type: application/json" \
-d '{"symbol": "XAUUSD", "side": "sell", "volume": 0.5}'
```
### Place Limit Order
```bash
curl -X POST http://localhost:5000/api/mt5/order \
-H "Content-Type: application/json" \
-d '{"symbol": "EURUSD", "side": "buy", "volume": 0.1, "orderType": "limit", "price": 1.0800}'
```
### Close Position
```bash
curl -X POST http://localhost:5000/api/mt5/close \
-H "Content-Type: application/json" \
-d '{"ticket": 123456789}'
```
## Important Notes
1. **MT5 terminal must be running**: The terminal must be open and logged in
2. **Windows only**: The MetaTrader5 Python library only works on Windows
3. **Broker symbol names**: Symbol names vary by broker, check your broker's symbol list
4. **Demo account first**: Test with a demo account before using real funds
5. **Market hours**: Forex trades 24/5, check specific market hours for other instruments
## Troubleshooting
| Error | Cause | Solution |
|-------|-------|----------|
| ImportError | MetaTrader5 not installed | `pip install MetaTrader5` |
| ImportError | Not on Windows | Use a Windows machine or VM |
| Connection failed | Terminal not running | Start MT5 and login |
| Connection failed | Wrong credentials | Verify login/password/server |
| Symbol not found | Invalid symbol | Check broker's symbol list |
| Trade not allowed | Trading disabled | Enable algo trading in MT5 options |
## Security Recommendations
- Use a dedicated trading account
- Test with demo account first
- Set appropriate lot sizes and risk limits
- Monitor positions regularly
- Keep MT5 terminal updated
## Removing This Module
To remove this module, delete:
```
backend_api_python/app/services/mt5_trading/ # Entire directory
backend_api_python/app/routes/mt5.py # Route file
```
Then remove the related import and registration code in `app/routes/__init__.py`.
## See Also
- [Python Strategy Development Guide](../../docs/STRATEGY_DEV_GUIDE.md)
- [MetaTrader 5 Python Documentation](https://www.mql5.com/en/docs/python_metatrader5)
@@ -0,0 +1,17 @@
"""
MetaTrader 5 Trading Module
Provides forex trading capabilities via MT5 terminal.
Requires Windows platform and MetaTrader5 Python library.
"""
from app.services.mt5_trading.client import MT5Client, MT5Config, OrderResult
from app.services.mt5_trading.symbols import normalize_symbol, parse_symbol
__all__ = [
"MT5Client",
"MT5Config",
"OrderResult",
"normalize_symbol",
"parse_symbol",
]
@@ -0,0 +1,783 @@
"""
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}"
)
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
# Prepare order request
request = {
"action": mt5.TRADE_ACTION_DEAL,
"symbol": symbol,
"volume": float(volume),
"type": order_type,
"price": price,
"deviation": deviation,
"magic": self.config.magic_number,
"comment": comment,
"type_time": mt5.ORDER_TIME_GTC,
"type_filling": mt5.ORDER_FILLING_IOC,
}
# 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 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
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": mt5.ORDER_FILLING_IOC,
}
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
@@ -0,0 +1,144 @@
"""
Symbol Mapping and Conversion for MT5
Handles forex symbol normalization and parsing.
"""
from typing import Tuple, Optional
# Common forex pairs with their typical MT5 symbol format
FOREX_PAIRS = {
# Major pairs
"EURUSD", "GBPUSD", "USDJPY", "USDCHF", "AUDUSD", "USDCAD", "NZDUSD",
# Cross pairs
"EURGBP", "EURJPY", "EURCHF", "EURAUD", "EURCAD", "EURNZD",
"GBPJPY", "GBPCHF", "GBPAUD", "GBPCAD", "GBPNZD",
"AUDJPY", "AUDCHF", "AUDCAD", "AUDNZD",
"NZDJPY", "NZDCHF", "NZDCAD",
"CADJPY", "CADCHF",
"CHFJPY",
# Exotic pairs
"USDMXN", "USDZAR", "USDTRY", "USDHKD", "USDSGD", "USDNOK", "USDSEK", "USDDKK",
"EURTRY", "EURMXN", "EURNOK", "EURSEK", "EURDKK", "EURPLN", "EURHUF", "EURCZK",
# Metals
"XAUUSD", "XAGUSD", "XAUEUR",
# Indices (CFD)
"US30", "US500", "USTEC", "UK100", "DE40", "JP225", "AU200",
# Crypto (if broker supports)
"BTCUSD", "ETHUSD", "LTCUSD", "XRPUSD",
}
def normalize_symbol(symbol: str, broker_suffix: str = "") -> str:
"""
Normalize symbol to MT5 format.
Different brokers may use different suffixes:
- No suffix: "EURUSD"
- With suffix: "EURUSDm", "EURUSD.raw", "EURUSD-ECN"
Args:
symbol: Symbol code (e.g., "EUR/USD", "EURUSD", "eurusd")
broker_suffix: Broker-specific suffix (e.g., "m", ".raw", "-ECN")
Returns:
Normalized MT5 symbol
"""
# Remove common separators and convert to uppercase
normalized = (symbol or "").strip().upper()
normalized = normalized.replace("/", "").replace("-", "").replace("_", "").replace(" ", "")
# Add broker suffix if provided
if broker_suffix:
# Check if symbol already has the suffix
if not normalized.endswith(broker_suffix.upper()):
normalized = normalized + broker_suffix
return normalized
def parse_symbol(symbol: str) -> Tuple[str, Optional[str]]:
"""
Parse symbol and extract base/quote currencies.
Args:
symbol: MT5 symbol (e.g., "EURUSD", "EURUSDm")
Returns:
(clean_symbol, market_type)
"""
clean = (symbol or "").strip().upper()
# Remove common broker suffixes
for suffix in ["M", ".RAW", "-ECN", ".STD", ".PRO", ".", "#"]:
if clean.endswith(suffix):
clean = clean[:-len(suffix)]
# Determine market type based on symbol pattern
if clean in FOREX_PAIRS or (len(clean) == 6 and clean.isalpha()):
return clean, "forex"
if clean.startswith("XAU") or clean.startswith("XAG"):
return clean, "metal"
if clean.startswith("BTC") or clean.startswith("ETH") or clean.startswith("LTC"):
return clean, "crypto"
if any(idx in clean for idx in ["US30", "US500", "USTEC", "UK100", "DE40", "JP225"]):
return clean, "index"
# Default to forex
return clean, "forex"
def get_lot_size_info(symbol: str) -> dict:
"""
Get lot size information for a symbol.
Standard forex lot sizes:
- Standard lot: 100,000 units
- Mini lot: 10,000 units
- Micro lot: 1,000 units
- Nano lot: 100 units
Args:
symbol: MT5 symbol
Returns:
Dict with lot size information
"""
clean, market_type = parse_symbol(symbol)
if market_type == "forex":
return {
"standard_lot": 100000,
"min_lot": 0.01,
"lot_step": 0.01,
"max_lot": 100.0,
}
if market_type == "metal":
# Gold/Silver typically uses oz
return {
"standard_lot": 100, # 100 oz
"min_lot": 0.01,
"lot_step": 0.01,
"max_lot": 50.0,
}
if market_type == "index":
return {
"standard_lot": 1,
"min_lot": 0.1,
"lot_step": 0.1,
"max_lot": 100.0,
}
# Default
return {
"standard_lot": 1,
"min_lot": 0.01,
"lot_step": 0.01,
"max_lot": 100.0,
}
@@ -42,6 +42,9 @@ from app.utils.logger import get_logger
# Lazy import IBKR to avoid ImportError if ib_insync not installed
IBKRClient = None
# Lazy import MT5 to avoid ImportError if MetaTrader5 not installed
MT5Client = None
logger = get_logger(__name__)
@@ -348,6 +351,32 @@ class PendingOrderWorker:
except Exception:
continue
# Check for MT5 client (forex)
if MT5Client is None:
try:
from app.services.mt5_trading import MT5Client as _MT5Client
MT5Client = _MT5Client
except ImportError:
pass
if MT5Client is not None and isinstance(client, MT5Client):
# MT5 forex positions
positions = client.get_positions()
if isinstance(positions, list):
for p in positions:
if not isinstance(p, dict):
continue
sym = str(p.get("symbol") or "").strip()
pos_type = str(p.get("type") or "").strip().lower()
try:
vol = float(p.get("volume") or 0.0)
except Exception:
vol = 0.0
if not sym or vol <= 0:
continue
# MT5: type "buy" = long, "sell" = short
side = "long" if pos_type == "buy" else "short"
exch_size.setdefault(sym, {"long": 0.0, "short": 0.0})[side] = float(vol)
# Continue to reconciliation logic below
else:
# Spot reconciliation is optional; skip for now (keeps self-check low-risk).
logger.debug(f"position sync: skip unsupported market/client: sid={sid}, cfg={safe_cfg}, market_type={market_type}, client={type(client)}")
@@ -681,6 +710,40 @@ class PendingOrderWorker:
exchange_config = resolve_exchange_config(cfg.get("exchange_config") or {})
safe_cfg = safe_exchange_config_for_log(exchange_config)
exchange_id = str(exchange_config.get("exchange_id") or "").strip().lower()
market_category = str(cfg.get("market_category") or "Crypto").strip()
# Validate market category and exchange_id combination for live trading
# AShare and Futures do not support live trading
if market_category in ("AShare", "Futures"):
self._mark_failed(order_id=order_id, error=f"live_trading_not_supported_for_{market_category.lower()}")
_console_print(f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} {market_category} does not support live trading")
_notify_live_best_effort(status="failed", error=f"live_trading_not_supported_for_{market_category.lower()}")
return
# Validate IBKR only for USStock/HShare
if exchange_id == "ibkr":
if market_category not in ("USStock", "HShare"):
self._mark_failed(order_id=order_id, error=f"ibkr_only_supports_usstock_hshare_got_{market_category.lower()}")
_console_print(f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} IBKR only supports USStock/HShare, got {market_category}")
_notify_live_best_effort(status="failed", error=f"ibkr_only_supports_usstock_hshare_got_{market_category.lower()}")
return
# Validate MT5 only for Forex
if exchange_id == "mt5":
if market_category != "Forex":
self._mark_failed(order_id=order_id, error=f"mt5_only_supports_forex_got_{market_category.lower()}")
_console_print(f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} MT5 only supports Forex, got {market_category}")
_notify_live_best_effort(status="failed", error=f"mt5_only_supports_forex_got_{market_category.lower()}")
return
# Validate crypto exchanges only for Crypto market
crypto_exchanges = ["binance", "okx", "bitget", "bybit", "coinbaseexchange", "kraken", "kucoin", "gate", "bitfinex"]
if exchange_id in crypto_exchanges:
if market_category != "Crypto":
self._mark_failed(order_id=order_id, error=f"crypto_exchange_only_supports_crypto_got_{market_category.lower()}")
_console_print(f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} {exchange_id} only supports Crypto, got {market_category}")
_notify_live_best_effort(status="failed", error=f"crypto_exchange_only_supports_crypto_got_{market_category.lower()}")
return
market_type = (payload.get("market_type") or order_row.get("market_type") or cfg.get("market_type") or exchange_config.get("market_type") or "swap")
market_type = str(market_type or "swap").strip().lower()
@@ -719,6 +782,52 @@ class PendingOrderWorker:
)
return
# Check if this is an MT5 client (Forex)
global MT5Client
if MT5Client is None:
try:
from app.services.mt5_trading import MT5Client as _MT5Client
MT5Client = _MT5Client
except ImportError:
pass
if MT5Client is not None and isinstance(client, MT5Client):
# Execute MT5 order (separate flow for forex)
self._execute_mt5_order(
order_id=order_id,
order_row=order_row,
payload=payload,
client=client,
strategy_id=strategy_id,
exchange_config=exchange_config,
_notify_live_best_effort=_notify_live_best_effort,
_console_print=_console_print,
)
return
# Check if this is an MT5 client (Forex)
global MT5Client
if MT5Client is None:
try:
from app.services.mt5_trading import MT5Client as _MT5Client
MT5Client = _MT5Client
except ImportError:
pass
if MT5Client is not None and isinstance(client, MT5Client):
# Execute MT5 order (separate flow for forex)
self._execute_mt5_order(
order_id=order_id,
order_row=order_row,
payload=payload,
client=client,
strategy_id=strategy_id,
exchange_config=exchange_config,
_notify_live_best_effort=_notify_live_best_effort,
_console_print=_console_print,
)
return
def _make_client_oid(phase: str = "") -> str:
"""
Build a client order id.
@@ -1702,6 +1811,131 @@ class PendingOrderWorker:
_console_print(f"[worker] IBKR order exception: strategy_id={strategy_id} pending_id={order_id} err={e}")
_notify_live_best_effort(status="failed", error=str(e))
def _execute_mt5_order(
self,
*,
order_id: int,
order_row: Dict[str, Any],
payload: Dict[str, Any],
client, # MT5Client instance
strategy_id: int,
exchange_config: Dict[str, Any],
_notify_live_best_effort,
_console_print,
) -> None:
"""
Execute order via MetaTrader 5 for forex trading.
Simplified flow compared to crypto (no maker->market fallback):
- Place market order directly
- Wait for fill
- Record trade
"""
signal_type = payload.get("signal_type") or order_row.get("signal_type")
symbol = payload.get("symbol") or order_row.get("symbol")
amount = float(payload.get("amount") or order_row.get("amount") or 0.0)
ref_price = float(payload.get("ref_price") or payload.get("price") or order_row.get("price") or 0.0)
sig = str(signal_type or "").strip().lower()
# Map signal to action
if sig in ("open_long", "add_long"):
action = "buy"
elif sig in ("close_long", "reduce_long"):
action = "sell"
elif sig in ("open_short", "add_short"):
action = "sell"
elif sig in ("close_short", "reduce_short"):
action = "buy"
else:
self._mark_failed(order_id=order_id, error=f"mt5_unsupported_signal:{signal_type}")
_console_print(f"[worker] MT5 order rejected: strategy_id={strategy_id} pending_id={order_id} unsupported signal {signal_type}")
_notify_live_best_effort(status="failed", error=f"mt5_unsupported_signal:{signal_type}")
return
try:
# Place market order via MT5
result = client.place_market_order(
symbol=symbol,
side=action,
volume=amount,
comment="QuantDinger",
)
if not result.success:
self._mark_failed(order_id=order_id, error=f"mt5_order_failed:{result.message}")
_console_print(f"[worker] MT5 order failed: strategy_id={strategy_id} pending_id={order_id} err={result.message}")
_notify_live_best_effort(status="failed", error=f"mt5_order_failed:{result.message}")
return
filled = float(result.filled or 0.0)
avg_price = float(result.price or 0.0)
exchange_order_id = str(result.order_id or "")
# Use ref_price if avg_price not available
if avg_price <= 0 and ref_price > 0:
avg_price = ref_price
if filled <= 0:
filled = amount
executed_at = int(time.time())
# Mark order as sent
self._mark_sent(
order_id=order_id,
note="mt5_order_sent",
exchange_id="mt5",
exchange_order_id=exchange_order_id,
exchange_response_json=json.dumps(result.raw or {}, ensure_ascii=False),
filled=filled,
avg_price=avg_price,
executed_at=executed_at,
)
_console_print(f"[worker] MT5 order sent: strategy_id={strategy_id} pending_id={order_id} order_id={exchange_order_id} filled={filled} avg={avg_price}")
# Record trade and update position
try:
if filled > 0 and avg_price > 0:
logger.info(
f"MT5 record begin: pending_id={order_id} strategy_id={strategy_id} symbol={symbol} "
f"signal={signal_type} filled={filled} avg_price={avg_price}"
)
profit, _pos = apply_fill_to_local_position(
strategy_id=strategy_id,
symbol=str(symbol),
signal_type=str(signal_type),
filled=filled,
avg_price=avg_price,
)
record_trade(
strategy_id=strategy_id,
symbol=str(symbol),
trade_type=str(signal_type),
price=avg_price,
amount=filled,
commission=0.0, # MT5 commission is complex, skip for now
commission_ccy="USD",
profit=profit,
)
logger.info(f"MT5 record done: pending_id={order_id} strategy_id={strategy_id} symbol={symbol}")
except Exception as e:
logger.warning(f"MT5 record_trade/update_position failed: pending_id={order_id}, err={e}")
# Notify success
_notify_live_best_effort(
status="sent",
exchange_id="mt5",
exchange_order_id=exchange_order_id,
price_hint=avg_price,
amount_hint=filled,
)
except Exception as e:
logger.error(f"MT5 order execution failed: pending_id={order_id}, strategy_id={strategy_id}, err={e}")
self._mark_failed(order_id=order_id, error=f"mt5_exception:{e}")
_console_print(f"[worker] MT5 order exception: strategy_id={strategy_id} pending_id={order_id} err={e}")
_notify_live_best_effort(status="failed", error=str(e))
def _mark_sent(
self,
order_id: int,