Supports MT5 and forex trading.
Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
This commit is contained in:
@@ -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')
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user