Files
set50-system/backend/app/mt5_bridge.py
Kunthawat Greethong c3a1461932 [verified] Add capital-allocation simulation engine + MT5 bridge (both dry-run/gated)
- simulation.py: price-series loader (Yahoo snapshot) + allocate_capital 50/20/30 with min-100 shares, bucket3 excludes bucket1, cash fallback
- /api/v1/simulation POST: combines theme 60/40 score + Siamchart dividend + Yahoo price; labels output paper/backtest non-PIT (never validated)
- mt5_bridge.py: MT5 order interface, dry-run default; live dispatch needs MT5_SEND_ORDERS=1 AND approval (Windows-only MetaTrader5)
- 12 new tests (simulation/mt5/api); full suite 184 OK; live verified (1M -> buckets)
2026-08-25 16:24:22 +07:00

110 lines
3.4 KiB
Python

"""MT5 order bridge — interface only; LIVE dispatch is strictly gated off.
The forward test shares the backtest engine and may *optionally* dispatch orders
to MetaTrader 5. This module provides the bridge *interface* with a structural
kill switch: no real order path exists in code unless BOTH of these hold:
1. the environment flag MT5_ENABLE_ORDER=1, AND
2. explicit per-run approval is passed.
`MetaTrader5` is a Windows-only third-party module; it is imported lazily and
guarded so the rest of the platform (and tests) run fine without it. On non-
Windows (or when not installed) the bridge reports `unavailable` rather than
failing the platform.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Optional
class MT5UnavailableError(Exception):
"""Raised when MetaTrader5 is not available / connected."""
@dataclass
class MT5OrderRequest:
symbol: str
action: str # BUY / SELL
lots: float
price: Optional[float] = None
sl: Optional[float] = None
tp: Optional[float] = None
@dataclass
class MT5OrderResult:
symbol: str
ticket: int
executed: bool
dry_run: bool
message: str = ""
def _mt5():
"""Return the MetaTrader5 module or None (Windows-only + installed)."""
try:
import MetaTrader5 as mt5 # type: ignore
return mt5
except ImportError:
return None
def is_available() -> bool:
return _mt5() is not None
def enabled() -> bool:
"""Live dispatch is enabled only when MT5_SEND_ORDERS=1 is set explicitly."""
return os.environ.get("MT5_SEND_ORDERS", "").strip() == "1"
def place_order(req: MT5OrderRequest, approve: bool = False) -> MT5OrderResult:
"""Place an order.
- Without `enabled()` (flag) or explicit approval, this is a DRY-RUN: no
order is sent; we return an order-shaped result with executed=False.
- The caller (simulation forward mode) decides whether to actually call the
live path; default is dry-run. This module never self-authorizes.
"""
if not (enabled() and approve):
return MT5OrderResult(req.symbol, 0, False, dry_run=True,
message="live dispatch gated: MT5_SEND_ORDERS=1 AND approval required")
mt5 = _mt5()
if mt5 is None:
return MT5OrderResult(req.symbol, 0, False, dry_run=True,
message="MetaTrader5 not available (Windows-only module)")
if not mt5.initialize():
raise MT5UnavailableError(mt5.last_error())
symbol_info = mt5.symbol_info(req.symbol)
if symbol_info is None:
raise MT5UnavailableError(f"unknown symbol {req.symbol}")
request = {
"action": mt5.TRADE_ACTION_DEAL,
"symbol": req.symbol,
"volume": req.lots,
"type": mt5.ORDER_TYPE_BUY if req.action == "BUY" else mt5.ORDER_TYPE_SELL,
"price": req.price or symbol_info.ask if req.action == "BUY" else req.price or symbol_info.bid,
"sl": req.sl,
"tp": req.tp,
"deviation": 20,
"magic": 567890,
"comment": "SET50 alternative-data sim",
"type_time": mt5.ORDER_TIME_GTC,
"type_filling": mt5.ORDER_FILLING_IOC,
}
result = mt5.order_send(request)
return MT5OrderResult(
req.symbol,
ticket=result.order if result else 0,
executed=bool(result and result.retcode == mt5.TRADE_RETCODE_DONE),
dry_run=False,
message=str(result),
)