Files
QuantumTerminal/backend/providers/mt5_provider.py
T

1039 lines
39 KiB
Python

# version: v6
"""
================================================================================
Quantum Terminal — MetaTrader 5 Provider
v6 — Auto-link broker symbol via fuzzy match. Brokers wrap canonical names
in arbitrary suffixes / case variants — XAUUSD might be stored as
`xauusd`, `XAUUSD.x`, `XAUUSD.cash`, `XAUUSDi`, `XAUUSD!`, `XAUUSD-cfd`
etc.; common-name swaps like USTEC ↔ NAS100 / US100, US30 ↔ DOW are
also widespread. Previously these required manual entry in Settings →
Custom Tickers → broker_symbol. Now `resolve_symbol()` enumerates the
broker's symbol catalog (`mt5.symbols_get()`), normalizes each name
(lowercased, alphanumeric-only), and matches against the canonical
plus a known-alias list. First hit wins, gets cached, logs an INFO
line so the operator can see the auto-link. User overrides from
Settings still take precedence.
v2 — Added broker-symbol override hook. set_broker_symbol_lookup(fn) lets
the ConfigManager wire a runtime lookup for user-configured overrides
(Settings panel → custom_tickers[X].broker_symbol). resolve_symbol()
checks the override FIRST before the canonical / alias chain. New
reset_symbol(canonical) method evicts a ticker's cached mapping so the
next resolve picks up a changed override without requiring a restart.
================================================================================
================================================================================
Implements BaseProvider for MetaTrader 5.
This is the refactored MT5Adapter from data_server.py. Same proven logic:
- Symbol alias resolution (XAUUSD → GOLD, GER40 → DE40, etc.)
- Tick polling, bar fetching, new bar detection
- Synchronous API wrapped by callers in asyncio.to_thread()
New in provider version:
- Implements BaseProvider interface (swappable)
- Account info reporting
- Execution methods (place_order, get_positions, close_position)
- Symbol info extraction from MT5 symbol_info()
- Reads config from account dict, not ServerConfig
Usage:
from providers.mt5_provider import MT5Provider
provider = MT5Provider({
"id": "mt5_primary",
"label": "MT5 — CFI (Live)",
"terminal_path": None, # auto-detect
"aliases": { "GER40": ["GER40", "DE40", "DAX40"] },
})
provider.connect()
ticks = provider.get_latest_ticks(["XAUUSD", "EURUSD"])
================================================================================
"""
import logging
from datetime import datetime, timezone
from typing import Dict, List, Optional, Any
from models import (
TickData, BarData, AccountInfo, SymbolInfo,
OrderRequest, OrderResult, Position, PendingOrder,
)
from providers.base_provider import BaseProvider
log = logging.getLogger("provider.mt5")
# ── MT5 imported lazily — provider works in degraded mode if not installed ──
try:
import MetaTrader5 as mt5
MT5_AVAILABLE = True
except ImportError:
mt5 = None
MT5_AVAILABLE = False
# ── MT5 timeframe constants ──
MT5_TIMEFRAMES = {
"M1": 1,
"M5": 5,
"M15": 15,
"M30": 30,
"H1": 16385,
"H4": 16388,
"D1": 16408,
"W1": 32769,
}
# ── MT5 order type mapping ──
MT5_ORDER_TYPES = {
"MARKET_BUY": None, # Populated after mt5 import check
"MARKET_SELL": None,
"LIMIT_BUY": None,
"LIMIT_SELL": None,
"STOP_BUY": None,
"STOP_SELL": None,
}
if MT5_AVAILABLE:
MT5_ORDER_TYPES.update({
"MARKET_BUY": mt5.ORDER_TYPE_BUY,
"MARKET_SELL": mt5.ORDER_TYPE_SELL,
"LIMIT_BUY": mt5.ORDER_TYPE_BUY_LIMIT,
"LIMIT_SELL": mt5.ORDER_TYPE_SELL_LIMIT,
"STOP_BUY": mt5.ORDER_TYPE_BUY_STOP,
"STOP_SELL": mt5.ORDER_TYPE_SELL_STOP,
})
# v6: known-alias table for the auto-link fuzzy match. When a canonical
# ticker's exact name isn't found at the broker, resolve_symbol() will
# look for any of these names too, in addition to fuzzy-normalizing both
# sides (lowercased, alphanumeric-only) so suffix/case quirks
# (XAUUSD.x, XAUUSDi, xauusd, XAUUSD-cfd, etc.) auto-resolve.
# Keep entries CASE-INSENSITIVE — they're normalized on lookup. The
# canonical itself doesn't need to be repeated here.
KNOWN_ALIASES: Dict[str, List[str]] = {
"US500": ["SPX500", "SP500", "USA500", "ES500", "WS500", "S&P500", "USA500IDX"],
"USTEC": ["NAS100", "US100", "USTECH100", "USTEC100", "NDX", "NQ100", "NAS100IDX"],
"US30": ["DOW", "DJ30", "WS30", "INDU", "DJ30IDX", "US30Cash"],
"GER40": ["DE40", "GER30", "DAX", "DAX40", "DE30", "GER30Cash"],
"UK100": ["FTSE100", "FTSE", "UK100Cash"],
"JP225": ["NIK225", "NIKKEI", "NIKKEI225", "JP225Cash"],
"FRA40": ["CAC40", "CAC"],
"ESP35": ["IBEX35", "IBEX"],
"AUS200": ["ASX200", "AU200"],
"HK50": ["HKG33", "HKG50", "HSI"],
"XAUUSD": ["GOLD", "XAU", "XAUUSDX"],
"XAGUSD": ["SILVER", "XAG", "XAGUSDX"],
"XPTUSD": ["PLATINUM"],
"XPDUSD": ["PALLADIUM"],
"XTIUSD": ["WTI", "USOIL", "OIL", "CRUDE", "WTIUSD", "OILUSD"],
"BRENT": ["UKOIL", "BCOUSD", "BRENTUSD", "UKOIL.cash"],
"BTCUSD": ["BITCOIN", "BTC"],
"ETHUSD": ["ETHEREUM", "ETH"],
"NATGAS": ["NGAS", "NATURALGAS", "GAS"],
"COPPER": ["XCUUSD", "HG"],
}
class MT5Provider(BaseProvider):
"""
MetaTrader 5 data and execution provider.
Config dict keys:
id: str — unique provider instance ID (e.g., "mt5_primary")
label: str — display name (e.g., "MT5 — CFI (Live)")
terminal_path: str or None — path to terminal64.exe (None = auto-detect)
aliases: dict — canonical → [broker_names] override map
"""
def __init__(self, account_config: Dict[str, Any]):
self._id = account_config.get("id", "mt5_default")
self._label = account_config.get("label", "MetaTrader 5")
self._terminal_path = account_config.get("terminal_path", None)
self._aliases = account_config.get("aliases", {})
self._connected = False
# Symbol resolution caches
self._symbol_map: Dict[str, str] = {} # canonical → broker name
self._reverse_map: Dict[str, str] = {} # broker name → canonical
self._resolved_symbols: List[str] = [] # canonical names that resolved
# v6: lazy normalized index of the broker's full symbol catalog so
# resolve_symbol() can do an alphanumeric-only case-insensitive
# match. Built on first use after connect; cleared on disconnect.
self._broker_symbols_norm: Dict[str, str] = {} # normalized → real broker name
# Bar tracking for check_new_bars
self._last_bar_times: Dict[str, int] = {} # "TICKER_TF" → epoch
# v2: runtime broker-symbol override lookup (wired by ConfigManager).
# Callable(canonical: str) -> Optional[str].
self._broker_symbol_lookup = None
# ── v6: fuzzy auto-link helpers ──
@staticmethod
def _normalize_symbol_name(name: str) -> str:
"""Lower-case, alphanumeric-only. So `XAUUSD.x`, `xauusd!`,
`XAUUSD-cfd`, `xau usd` all collapse to `xauusd`."""
if not name:
return ""
return "".join(ch.lower() for ch in str(name) if ch.isalnum())
def _ensure_broker_index(self) -> None:
"""Lazy-build the normalized → real-name lookup over every symbol
the broker exposes. Cheap (< 50ms for 1000+ symbols) and runs only
once per connection. Cleared on disconnect / reset."""
if self._broker_symbols_norm or not self._connected:
return
try:
all_syms = mt5.symbols_get()
except Exception as e:
log.warning(f"[symbol auto-link] symbols_get() failed: {e}")
return
if not all_syms:
return
idx = {}
for s in all_syms:
try:
key = self._normalize_symbol_name(s.name)
if key and key not in idx:
idx[key] = s.name
except Exception:
continue
self._broker_symbols_norm = idx
log.info(f"[symbol auto-link] indexed {len(idx)} broker symbols")
def _fuzzy_resolve(self, canonical: str) -> Optional[str]:
"""Match `canonical` (and KNOWN_ALIASES[canonical]) against the
broker's full catalog with normalize-and-compare. First hit wins."""
self._ensure_broker_index()
if not self._broker_symbols_norm:
return None
candidates: List[str] = [canonical] + KNOWN_ALIASES.get(canonical.upper(), [])
seen_keys = set()
for c in candidates:
key = self._normalize_symbol_name(c)
if not key or key in seen_keys:
continue
seen_keys.add(key)
real = self._broker_symbols_norm.get(key)
if real:
return real
return None
# ── Identity ──
@property
def provider_type(self) -> str:
return "mt5"
@property
def provider_id(self) -> str:
return self._id
@property
def label(self) -> str:
return self._label
# ── Capabilities ──
@property
def can_execute(self) -> bool:
return True # MT5 supports order execution
@property
def supported_timeframes(self) -> List[str]:
return list(MT5_TIMEFRAMES.keys())
# ── Connection ──
@property
def connected(self) -> bool:
return self._connected
def connect(self) -> bool:
"""Initialize MT5 connection. Returns True on success."""
if not MT5_AVAILABLE:
log.warning("MetaTrader5 package not installed — provider unavailable")
return False
if self._connected:
return True # Idempotent
init_kwargs = {}
if self._terminal_path:
init_kwargs["path"] = self._terminal_path
log.info(f"MT5 terminal path: {self._terminal_path}")
else:
log.info("MT5 terminal path: auto-detect")
if not mt5.initialize(**init_kwargs):
log.error(f"MT5 initialize() failed: {mt5.last_error()}")
return False
info = mt5.terminal_info()
if info is None:
log.error("MT5 terminal_info() returned None")
mt5.shutdown()
return False
self._connected = True
log.info(
f"MT5 connected: {info.name} | "
f"Company: {info.company} | "
f"Build: {info.build}"
)
return True
def disconnect(self) -> None:
"""Shutdown MT5 connection."""
if self._connected and MT5_AVAILABLE:
mt5.shutdown()
self._connected = False
self._symbol_map.clear()
self._reverse_map.clear()
self._resolved_symbols.clear()
self._broker_symbols_norm.clear() # v6: drop broker-specific index on disconnect
log.info("MT5 disconnected")
def heartbeat(self) -> bool:
"""
Lightweight MT5 health check.
Calls mt5.terminal_info() — fast and read-only.
Returns False and marks disconnected if MT5 is unresponsive.
"""
if not self._connected or not MT5_AVAILABLE:
return False
try:
info = mt5.terminal_info()
if info is None:
self._connected = False
log.warning("MT5 heartbeat failed — terminal_info() returned None")
return False
return True
except Exception as e:
self._connected = False
log.warning(f"MT5 heartbeat exception: {e}")
return False
# ── Symbol Resolution ──
def set_broker_symbol_lookup(self, fn) -> None:
"""v2: Register a callable(canonical) -> Optional[str] that returns
the user-configured broker-symbol override for a ticker, if any.
Called on every cache miss in resolve_symbol so runtime config
changes take effect without provider restart."""
self._broker_symbol_lookup = fn
def reset_symbol(self, canonical: str) -> Optional[str]:
"""v2: Evict a ticker's cached mapping and re-resolve. Call this
after the user changes broker_symbol in Settings so the next
tick/bar request uses the new mapping."""
canonical = canonical.upper()
old = self._symbol_map.pop(canonical, None)
if old is not None:
self._reverse_map.pop(old, None)
# Also drop cached bar time so the next poll re-seeds.
for k in list(self._last_bar_times.keys()):
if k.startswith(canonical + "_"):
self._last_bar_times.pop(k, None)
return self.resolve_symbol(canonical)
def resolve_symbol(self, canonical: str) -> Optional[str]:
"""
Map canonical name to broker symbol.
Order:
0. user override (Settings panel) via broker_symbol_lookup callable — v2
1. cache
2. canonical name itself
3. provider-level aliases (from account_config["aliases"])
Caches result in _symbol_map for fast lookups.
"""
if not self._connected:
return None
# v2: (0) user override takes precedence over cache — so changing
# broker_symbol in Settings and calling reset_symbol() picks it up.
if self._broker_symbol_lookup is not None:
try:
override = self._broker_symbol_lookup(canonical)
except Exception as e:
log.warning(f"broker_symbol_lookup failed for {canonical}: {e}")
override = None
if override:
sym = mt5.symbol_info(override)
if sym is not None:
if not sym.visible:
mt5.symbol_select(override, True)
self._symbol_map[canonical] = override
self._reverse_map[override] = canonical
log.info(f"Symbol resolved via user override: {canonical}{override}")
return override
else:
log.warning(f"User override {canonical}{override} not found in MT5; falling back")
# (1) cache
if canonical in self._symbol_map:
return self._symbol_map[canonical]
# (2) canonical name directly
sym = mt5.symbol_info(canonical)
if sym is not None:
if not sym.visible:
mt5.symbol_select(canonical, True)
self._symbol_map[canonical] = canonical
self._reverse_map[canonical] = canonical
return canonical
# v6: (2.5) fuzzy auto-link — handle suffix/case variants and known
# common aliases (USTEC ↔ NAS100/US100, US30 ↔ DOW, etc.) without
# the user needing to set Settings → broker_symbol manually.
auto = self._fuzzy_resolve(canonical)
if auto is not None:
sym = mt5.symbol_info(auto)
if sym is not None:
if not sym.visible:
mt5.symbol_select(auto, True)
self._symbol_map[canonical] = auto
self._reverse_map[auto] = canonical
log.info(f"[symbol auto-link] {canonical}{auto}")
return auto
# (3) provider aliases
aliases = self._aliases.get(canonical, [])
for alias in aliases:
if alias == canonical:
continue
sym = mt5.symbol_info(alias)
if sym is not None:
if not sym.visible:
mt5.symbol_select(alias, True)
self._symbol_map[canonical] = alias
self._reverse_map[alias] = canonical
log.info(f"Symbol resolved: {canonical}{alias}")
return alias
log.warning(f"Symbol {canonical} not found in MT5 (tried {len(aliases)} aliases)")
return None
def resolve_universe(self, universe: List[str]) -> List[str]:
"""
Resolve a full universe of canonical tickers.
Returns list of canonical names that successfully resolved.
Populates internal symbol maps.
"""
self._symbol_map.clear()
self._reverse_map.clear()
self._resolved_symbols.clear()
for canonical in universe:
broker_name = self.resolve_symbol(canonical)
if broker_name is not None:
self._resolved_symbols.append(canonical)
if broker_name != canonical:
log.info(f" ✓ {canonical}{broker_name}")
else:
log.info(f" ✓ {canonical}")
else:
log.warning(f" ✗ {canonical} — not found")
log.info(f"Resolved {len(self._resolved_symbols)}/{len(universe)} symbols")
return list(self._resolved_symbols)
def get_available_symbols(self) -> List[str]:
"""Return canonical names that have been successfully resolved."""
return list(self._resolved_symbols)
def _broker_symbol(self, canonical: str) -> str:
"""Quick lookup: canonical → broker symbol (assumes already resolved)."""
return self._symbol_map.get(canonical, canonical)
# ── Market Data ──
def get_latest_ticks(self, symbols: List[str]) -> Dict[str, TickData]:
"""Fetch latest tick for each symbol."""
if not self._connected:
return {}
ticks = {}
for canonical in symbols:
broker_sym = self._broker_symbol(canonical)
tick = mt5.symbol_info_tick(broker_sym)
if tick is None:
continue
sym_info = mt5.symbol_info(broker_sym)
dec = sym_info.digits if sym_info else 5
ticks[canonical] = TickData(
ticker=canonical,
bid=round(tick.bid, dec),
ask=round(tick.ask, dec),
last=round(tick.last or tick.bid, dec),
time=datetime.fromtimestamp(
tick.time, tz=timezone.utc
).strftime("%Y-%m-%dT%H:%M:%S"),
spread=round(tick.ask - tick.bid, dec),
)
return ticks
def get_bars(
self, ticker: str, timeframe: str = "M15", count: int = 200
) -> List[BarData]:
"""Fetch recent OHLCV bars for a canonical ticker."""
if not self._connected:
return []
tf_value = MT5_TIMEFRAMES.get(timeframe)
if tf_value is None:
log.error(f"Unknown timeframe: {timeframe}")
return []
broker_sym = self._broker_symbol(ticker)
rates = mt5.copy_rates_from_pos(broker_sym, tf_value, 0, count)
if rates is None or len(rates) == 0:
return []
sym_info = mt5.symbol_info(broker_sym)
dec = sym_info.digits if sym_info else 5
bars = []
for r in rates:
bars.append(BarData(
time=datetime.fromtimestamp(
r[0], tz=timezone.utc
).strftime("%Y-%m-%dT%H:%M:%S"),
open=round(float(r[1]), dec),
high=round(float(r[2]), dec),
low=round(float(r[3]), dec),
close=round(float(r[4]), dec),
volume=int(r[5]),
))
return bars
def get_bars_range(
self, ticker: str, timeframe: str, from_dt: datetime, to_dt: datetime,
) -> List[BarData]:
"""v5: Fetch OHLCV bars between two UTC datetimes via copy_rates_range.
Used by the Stress Lab last-signal panel to replay historical trades."""
if not self._connected:
return []
tf_value = MT5_TIMEFRAMES.get(timeframe)
if tf_value is None:
log.error(f"Unknown timeframe: {timeframe}")
return []
broker_sym = self._broker_symbol(ticker)
# Ensure tz-aware UTC for the MT5 call
if from_dt.tzinfo is None: from_dt = from_dt.replace(tzinfo=timezone.utc)
if to_dt.tzinfo is None: to_dt = to_dt.replace(tzinfo=timezone.utc)
rates = mt5.copy_rates_range(broker_sym, tf_value, from_dt, to_dt)
if rates is None or len(rates) == 0:
return []
sym_info = mt5.symbol_info(broker_sym)
dec = sym_info.digits if sym_info else 5
bars = []
for r in rates:
bars.append(BarData(
time=datetime.fromtimestamp(r[0], tz=timezone.utc)
.strftime("%Y-%m-%dT%H:%M:%S"),
open=round(float(r[1]), dec),
high=round(float(r[2]), dec),
low=round(float(r[3]), dec),
close=round(float(r[4]), dec),
volume=int(r[5]),
))
return bars
def check_new_bars(
self, symbols: List[str], timeframe: str = "M15"
) -> List[dict]:
"""
Detect newly closed bars since last check.
Returns list of bar event dicts for broadcasting.
"""
if not self._connected:
return []
tf_value = MT5_TIMEFRAMES.get(timeframe)
if tf_value is None:
return []
new_bars = []
for canonical in symbols:
broker_sym = self._broker_symbol(canonical)
rates = mt5.copy_rates_from_pos(broker_sym, tf_value, 0, 2)
if rates is None or len(rates) < 2:
continue
# Second-to-last bar = most recently CLOSED bar
closed_bar = rates[-2]
bar_epoch = int(closed_bar[0])
cache_key = f"{canonical}_{timeframe}"
if cache_key in self._last_bar_times:
if bar_epoch > self._last_bar_times[cache_key]:
sym_info = mt5.symbol_info(broker_sym)
dec = sym_info.digits if sym_info else 5
bar = BarData(
time=datetime.fromtimestamp(
bar_epoch, tz=timezone.utc
).strftime("%Y-%m-%dT%H:%M:%S"),
open=round(float(closed_bar[1]), dec),
high=round(float(closed_bar[2]), dec),
low=round(float(closed_bar[3]), dec),
close=round(float(closed_bar[4]), dec),
volume=int(closed_bar[5]),
)
new_bars.append({
"type": "bar",
"ticker": canonical,
"timeframe": timeframe,
"bar": bar.to_dict(),
})
self._last_bar_times[cache_key] = bar_epoch
return new_bars
# ── Symbol Info ──
def get_symbol_info(self, ticker: str) -> Optional[SymbolInfo]:
"""Extract full symbol metadata from MT5."""
if not self._connected:
return None
broker_sym = self._broker_symbol(ticker)
sym = mt5.symbol_info(broker_sym)
if sym is None:
return None
# Determine asset class from symbol path or properties
asset_class = self._classify_symbol(sym)
return SymbolInfo(
ticker=ticker,
broker_symbol=broker_sym,
asset_class=asset_class,
decimals=sym.digits,
description=sym.description or ticker,
trade_allowed=sym.trade_mode != 0,
min_lot=sym.volume_min,
max_lot=sym.volume_max,
lot_step=sym.volume_step,
contract_size=sym.trade_contract_size,
currency_profit=sym.currency_profit,
currency_margin=sym.currency_margin,
tick_size=sym.trade_tick_size,
tick_value=sym.trade_tick_value,
)
def _classify_symbol(self, sym) -> str:
"""Guess asset class from MT5 symbol properties."""
path = (sym.path or "").lower()
desc = (sym.description or "").lower()
if "forex" in path or "currencies" in path:
return "FX"
if "index" in path or "indices" in path:
return "INDEX"
if "commodit" in path or "metals" in path or "energy" in path:
return "COMMODITY"
if "crypto" in path:
return "CRYPTO"
if "stock" in path or "equit" in path:
return "STOCK"
# Heuristic fallbacks
if "gold" in desc or "silver" in desc or "oil" in desc:
return "COMMODITY"
if sym.currency_profit == sym.currency_margin and sym.digits >= 4:
return "FX"
return "OTHER"
# ── Account Info ──
def get_account_info(self) -> Optional[AccountInfo]:
"""Get MT5 account snapshot."""
if not self._connected:
return None
info = mt5.account_info()
if info is None:
return None
term = mt5.terminal_info()
return AccountInfo(
provider_type="mt5",
account_id=str(info.login),
label=self._label,
broker=info.company,
server=info.server,
currency=info.currency,
balance=info.balance,
equity=info.equity,
margin_free=info.margin_free,
leverage=info.leverage,
connected=True,
)
# ── Execution ──
def place_order(self, order: OrderRequest) -> OrderResult:
"""Place a trade order through MT5."""
if not self._connected:
return OrderResult(success=False, error="MT5 not connected")
broker_sym = self._broker_symbol(order.ticker)
sym_info = mt5.symbol_info(broker_sym)
if sym_info is None:
return OrderResult(
success=False,
error=f"Symbol {order.ticker} not found",
)
# Determine MT5 order type
type_key = f"{order.order_type}_{order.direction}"
mt5_type = MT5_ORDER_TYPES.get(type_key)
if mt5_type is None:
return OrderResult(
success=False,
error=f"Unsupported order type: {type_key}",
)
# Build request
request = {
"action": mt5.TRADE_ACTION_DEAL if order.order_type == "MARKET" else mt5.TRADE_ACTION_PENDING,
"symbol": broker_sym,
"volume": order.lots,
"type": mt5_type,
"magic": order.magic,
"comment": order.comment or "Quantum Terminal",
"type_time": mt5.ORDER_TIME_GTC,
"type_filling": mt5.ORDER_FILLING_IOC,
}
if order.price is not None:
request["price"] = order.price
elif order.order_type == "MARKET":
# Use current ask/bid for market orders
tick = mt5.symbol_info_tick(broker_sym)
if tick:
request["price"] = tick.ask if order.direction == "BUY" else tick.bid
if order.stop_loss is not None:
request["sl"] = order.stop_loss
if order.take_profit is not None:
request["tp"] = order.take_profit
# Send order
result = mt5.order_send(request)
if result is None:
return OrderResult(
success=False,
error=f"MT5 order_send returned None: {mt5.last_error()}",
)
if result.retcode != mt5.TRADE_RETCODE_DONE:
return OrderResult(
success=False,
error=f"MT5 order rejected: {result.retcode}{result.comment}",
raw=result._asdict() if hasattr(result, '_asdict') else None,
)
return OrderResult(
success=True,
order_id=str(result.deal),
ticker=order.ticker,
direction=order.direction,
lots=order.lots,
price=result.price,
)
def get_positions(self) -> List[Position]:
"""Get all open positions."""
if not self._connected:
return []
positions = mt5.positions_get()
if positions is None:
return []
result = []
for pos in positions:
# Map broker symbol back to canonical
canonical = self._reverse_map.get(pos.symbol, pos.symbol)
result.append(Position(
ticket=str(pos.ticket),
ticker=canonical,
direction="BUY" if pos.type == 0 else "SELL",
lots=pos.volume,
open_price=pos.price_open,
current_price=pos.price_current,
stop_loss=pos.sl if pos.sl != 0 else None,
take_profit=pos.tp if pos.tp != 0 else None,
profit=pos.profit,
swap=pos.swap,
open_time=datetime.fromtimestamp(
pos.time, tz=timezone.utc
).strftime("%Y-%m-%dT%H:%M:%S"),
))
return result
def close_position(
self, ticket: str, lots: Optional[float] = None
) -> OrderResult:
"""Close a position by ticket (full or partial)."""
if not self._connected:
return OrderResult(success=False, error="MT5 not connected")
# Find the position
positions = mt5.positions_get(ticket=int(ticket))
if not positions or len(positions) == 0:
return OrderResult(
success=False,
error=f"Position {ticket} not found",
)
pos = positions[0]
close_lots = lots if lots is not None else pos.volume
# Determine close direction (opposite of position)
close_type = mt5.ORDER_TYPE_SELL if pos.type == 0 else mt5.ORDER_TYPE_BUY
tick = mt5.symbol_info_tick(pos.symbol)
price = tick.bid if pos.type == 0 else tick.ask
request = {
"action": mt5.TRADE_ACTION_DEAL,
"symbol": pos.symbol,
"volume": close_lots,
"type": close_type,
"position": pos.ticket,
"price": price,
"comment": "Quantum Terminal close",
"type_time": mt5.ORDER_TIME_GTC,
"type_filling": mt5.ORDER_FILLING_IOC,
}
result = mt5.order_send(request)
if result is None:
return OrderResult(
success=False,
error=f"Close failed: {mt5.last_error()}",
)
if result.retcode != mt5.TRADE_RETCODE_DONE:
return OrderResult(
success=False,
error=f"Close rejected: {result.retcode}{result.comment}",
)
canonical = self._reverse_map.get(pos.symbol, pos.symbol)
return OrderResult(
success=True,
order_id=str(result.deal),
ticker=canonical,
direction="SELL" if pos.type == 0 else "BUY",
lots=close_lots,
price=result.price,
)
def modify_position(
self, ticket: str, stop_loss=None, take_profit=None
) -> dict:
"""Modify SL/TP on an open position."""
if not self._connected:
return {"success": False, "error": "MT5 not connected"}
positions = mt5.positions_get(ticket=int(ticket))
if not positions or len(positions) == 0:
return {"success": False, "error": f"Position {ticket} not found"}
pos = positions[0]
tick = mt5.symbol_info_tick(pos.symbol)
if not tick:
return {"success": False, "error": f"No tick data for {pos.symbol}"}
# v3: Preserve the other side when only one is being modified.
# MT5 treats sl=0.0 / tp=0.0 as "clear the level", so passing None
# through as 0 would silently remove whichever side the caller
# omitted. Read the live pos.sl / pos.tp and pass them when the
# corresponding parameter is None.
request = {
"action": mt5.TRADE_ACTION_SLTP,
"symbol": pos.symbol,
"position": pos.ticket,
"sl": float(stop_loss) if stop_loss is not None else float(getattr(pos, "sl", 0.0) or 0.0),
"tp": float(take_profit) if take_profit is not None else float(getattr(pos, "tp", 0.0) or 0.0),
}
result = mt5.order_send(request)
if result is None:
return {"success": False, "error": f"Modify failed: {mt5.last_error()}"}
if result.retcode != mt5.TRADE_RETCODE_DONE:
return {"success": False, "error": f"Modify rejected: {result.retcode}{result.comment}"}
return {
"success": True,
"ticket": ticket,
"stop_loss": stop_loss,
"take_profit": take_profit,
}
# ── Pending orders (LIMIT / STOP) — v4 ────────────────────────────
def get_pending_orders(self) -> List[PendingOrder]:
"""List all resting pending orders (LIMIT / STOP) on this account."""
if not self._connected:
return []
orders = mt5.orders_get()
if orders is None:
return []
out = []
for o in orders:
t = o.type
if t == mt5.ORDER_TYPE_BUY_LIMIT: direction, kind = "BUY", "LIMIT"
elif t == mt5.ORDER_TYPE_SELL_LIMIT: direction, kind = "SELL", "LIMIT"
elif t == mt5.ORDER_TYPE_BUY_STOP: direction, kind = "BUY", "STOP"
elif t == mt5.ORDER_TYPE_SELL_STOP: direction, kind = "SELL", "STOP"
else:
continue # skip stop-limit / market / closed types
canonical = self._reverse_map.get(o.symbol, o.symbol)
out.append(PendingOrder(
ticket=str(o.ticket),
symbol=o.symbol,
ticker=canonical,
direction=direction,
order_type=kind,
lots=o.volume_current,
price=o.price_open,
stop_loss=o.sl if o.sl != 0 else None,
take_profit=o.tp if o.tp != 0 else None,
comment=o.comment or "",
time_setup=datetime.fromtimestamp(
o.time_setup, tz=timezone.utc
).strftime("%Y-%m-%dT%H:%M:%S"),
))
return out
def cancel_order(self, ticket: str) -> OrderResult:
"""Remove (cancel) a resting pending order."""
if not self._connected:
return OrderResult(success=False, error="MT5 not connected")
orders = mt5.orders_get(ticket=int(ticket))
if not orders:
return OrderResult(success=False, error=f"Order {ticket} not found")
req = {"action": mt5.TRADE_ACTION_REMOVE, "order": int(ticket)}
result = mt5.order_send(req)
if result is None:
return OrderResult(success=False, error=f"Cancel failed: {mt5.last_error()}")
if result.retcode != mt5.TRADE_RETCODE_DONE:
return OrderResult(
success=False,
error=f"Cancel rejected: {result.retcode}{result.comment}",
)
return OrderResult(success=True, order_id=int(ticket))
def modify_order(
self, ticket: str,
price: Optional[float] = None,
stop_loss: Optional[float] = None,
take_profit: Optional[float] = None,
) -> OrderResult:
"""
Modify a resting pending order's trigger price and/or SL/TP.
Parameters left as None are preserved from the existing order so
the caller can patch a single field without clobbering the rest.
"""
if not self._connected:
return OrderResult(success=False, error="MT5 not connected")
orders = mt5.orders_get(ticket=int(ticket))
if not orders:
return OrderResult(success=False, error=f"Order {ticket} not found")
o = orders[0]
req = {
"action": mt5.TRADE_ACTION_MODIFY,
"order": int(ticket),
"symbol": o.symbol,
"price": float(price) if price is not None else float(o.price_open),
"sl": float(stop_loss) if stop_loss is not None else float(o.sl or 0.0),
"tp": float(take_profit) if take_profit is not None else float(o.tp or 0.0),
"type_time": o.type_time,
"type_filling": o.type_filling,
}
result = mt5.order_send(req)
if result is None:
return OrderResult(success=False, error=f"Modify failed: {mt5.last_error()}")
if result.retcode != mt5.TRADE_RETCODE_DONE:
return OrderResult(
success=False,
error=f"Modify rejected: {result.retcode}{result.comment}",
)
return OrderResult(success=True, order_id=int(ticket))
def get_trade_history(self, days: int = 7) -> list:
"""
Get closed trade history from MT5 using history_deals_get().
Groups DEAL_ENTRY_IN + DEAL_ENTRY_OUT by position_id into ClosedTrade records.
"""
from models import ClosedTrade
from datetime import timedelta
if not self._connected:
return []
utc_to = datetime.now(timezone.utc)
utc_from = utc_to - timedelta(days=days)
deals = mt5.history_deals_get(utc_from, utc_to)
if deals is None or len(deals) == 0:
return []
# Group deals by position_id
positions = {} # position_id -> {"in": deal, "out": deal}
for deal in deals:
pid = deal.position_id
if pid == 0:
continue # Balance/correction operations
if pid not in positions:
positions[pid] = {"in": None, "out": None}
# entry=0 is DEAL_ENTRY_IN, entry=1 is DEAL_ENTRY_OUT
if deal.entry == 0:
positions[pid]["in"] = deal
elif deal.entry == 1:
positions[pid]["out"] = deal
# Build ClosedTrade records (only complete round-trips)
trades = []
for pid, pair in positions.items():
entry = pair["in"]
exit_deal = pair["out"]
if entry is None or exit_deal is None:
continue # Partial — still open or missing leg
# Resolve canonical ticker from broker symbol
canonical = entry.symbol
for canon, aliases in self._aliases.items():
if entry.symbol in aliases:
canonical = canon
break
direction = "BUY" if entry.type == 0 else "SELL" # DEAL_TYPE_BUY=0
trades.append(ClosedTrade(
ticket=str(pid),
ticker=canonical,
direction=direction,
lots=entry.volume,
open_price=entry.price,
close_price=exit_deal.price,
profit=exit_deal.profit,
commission=round((entry.commission or 0) + (exit_deal.commission or 0), 2),
swap=round((entry.swap or 0) + (exit_deal.swap or 0), 2),
open_time=datetime.fromtimestamp(entry.time, tz=timezone.utc).isoformat(),
close_time=datetime.fromtimestamp(exit_deal.time, tz=timezone.utc).isoformat(),
comment=entry.comment or "",
magic=entry.magic,
))
# Sort newest first
trades.sort(key=lambda t: t.close_time, reverse=True)
return trades