Files
Auto-Trader/agent/src/trading/connectors/mt5/reads.py
T

341 lines
13 KiB
Python

"""MT5 read operations: status, account, positions, orders, quote, history.
Account-bound reads use :func:`_client._session` and its identity guard.
Symbol search reads only metadata through :func:`_client._catalog_session`.
Both return a fail-closed ``{"status": "error", ...}`` envelope instead of
raising, so tools and CLI degrade cleanly when the SDK/terminal is absent.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from src.trading.connectors.mt5 import _client
from src.trading.connectors.mt5._client import (
GUARD_MARKER,
MT5Config,
MT5ConfigError,
MT5ConnectionError,
MT5DependencyError,
MT5ProfileMismatchError,
_missing_fields,
_public_config,
_resolve_symbol,
_usd_contract_value,
)
from src.trading.connectors.mt5.symbols import classify_mt5_symbol
_MT5_ERRORS = (MT5DependencyError, MT5ConfigError, MT5ConnectionError, MT5ProfileMismatchError)
#: Canonical period token → MetaTrader5 timeframe constant name. Resolved via
#: ``getattr`` on the module so fakes only need the integers. NOTE ``1m``
#: (minute) and ``1M`` (month) differ by case, matching the other connectors.
#: ``1H``/``4H``/``1D``/``1W`` alias the lowercase forms (project-style tokens).
_TIMEFRAME_MAP = {
"1m": "TIMEFRAME_M1", "5m": "TIMEFRAME_M5", "15m": "TIMEFRAME_M15",
"30m": "TIMEFRAME_M30",
"1h": "TIMEFRAME_H1", "1H": "TIMEFRAME_H1",
"4h": "TIMEFRAME_H4", "4H": "TIMEFRAME_H4",
"1d": "TIMEFRAME_D1", "1D": "TIMEFRAME_D1",
"1w": "TIMEFRAME_W1", "1W": "TIMEFRAME_W1",
"1M": "TIMEFRAME_MN1",
}
def _envelope(cfg: MT5Config, **extra: Any) -> dict[str, Any]:
"""Common payload header stamped on every read result."""
payload: dict[str, Any] = {
"status": "ok",
"profile": cfg.profile,
"is_demo": cfg.is_demo,
"paper_guard": GUARD_MARKER,
}
payload.update(extra)
return payload
def _error(cfg: MT5Config, message: str, **extra: Any) -> dict[str, Any]:
"""Fail-closed error payload carrying profile/guard context."""
payload = _envelope(cfg, **extra)
payload["status"] = "error"
payload["error"] = message
return payload
def check_status(config: MT5Config | None = None) -> dict[str, Any]:
"""Check SDK readiness, config completeness, and terminal/account identity."""
cfg = config or _client.load_config()
report: dict[str, Any] = {
"status": "ok",
"config": _public_config(cfg),
"sdk": {"package": "MetaTrader5", "installed": _client.mt5_available()},
"paper_guard": GUARD_MARKER,
}
missing = _missing_fields(cfg)
if missing:
report["status"] = "error"
report["error"] = f"MT5 connector not configured: missing {', '.join(missing)}."
return report
if not report["sdk"]["installed"]:
report["status"] = "error"
report["error"] = (
"Optional dependency missing: install with `pip install \"vibe-trading-ai[mt5]\"` "
"(Windows-only; requires a local MT5 terminal)."
)
return report
try:
with _client._session(cfg) as mt5:
account = mt5.account_info()
report["account"] = {
"login": int(getattr(account, "login", 0) or 0),
"server": getattr(account, "server", ""),
"currency": getattr(account, "currency", ""),
"leverage": getattr(account, "leverage", None),
"balance": getattr(account, "balance", None),
"equity": getattr(account, "equity", None),
"trade_mode": getattr(account, "trade_mode", None),
"is_demo": cfg.is_demo,
}
except _MT5_ERRORS as exc:
report["status"] = "error"
report["error"] = str(exc)
return report
def get_account_snapshot(config: MT5Config | None = None) -> dict[str, Any]:
"""Fetch account balance/equity/margin for the configured account."""
cfg = config or _client.load_config()
try:
with _client._session(cfg) as mt5:
account = mt5.account_info()
payload = {
"login": int(getattr(account, "login", 0) or 0),
"name": getattr(account, "name", ""),
"server": getattr(account, "server", ""),
"currency": getattr(account, "currency", ""),
"balance": getattr(account, "balance", None),
"equity": getattr(account, "equity", None),
"margin": getattr(account, "margin", None),
"margin_free": getattr(account, "margin_free", None),
"margin_level": getattr(account, "margin_level", None),
"leverage": getattr(account, "leverage", None),
"trade_mode": getattr(account, "trade_mode", None),
"is_demo": cfg.is_demo,
}
except _MT5_ERRORS as exc:
return _error(cfg, str(exc))
return _envelope(cfg, account=payload)
def get_positions(config: MT5Config | None = None) -> dict[str, Any]:
"""Fetch open positions with a USD ``market_value`` per row.
``market_value`` is ``None`` when the position cannot be priced in USD —
the live gate's exposure math treats that as unparseable and fails closed
rather than under-counting exposure.
"""
cfg = config or _client.load_config()
try:
with _client._session(cfg) as mt5:
sell_type = getattr(mt5, "POSITION_TYPE_SELL", 1)
rows = []
for pos in mt5.positions_get() or ():
symbol = getattr(pos, "symbol", "")
rows.append(
{
"ticket": getattr(pos, "ticket", None),
"symbol": symbol,
"side": "sell" if getattr(pos, "type", 0) == sell_type else "buy",
"volume": getattr(pos, "volume", None),
"price_open": getattr(pos, "price_open", None),
"price_current": getattr(pos, "price_current", None),
"sl": getattr(pos, "sl", None),
"tp": getattr(pos, "tp", None),
"swap": getattr(pos, "swap", None),
"profit": getattr(pos, "profit", None),
"time": getattr(pos, "time", None),
"market_value": _usd_contract_value(
mt5, cfg, symbol, float(getattr(pos, "volume", 0.0) or 0.0)
),
}
)
except _MT5_ERRORS as exc:
return _error(cfg, str(exc))
return _envelope(cfg, positions=rows)
def get_open_orders(config: MT5Config | None = None, *, include_executions: bool = False) -> dict[str, Any]:
"""Fetch pending orders (open positions are a separate read) and, optionally, recent deals."""
cfg = config or _client.load_config()
try:
with _client._session(cfg) as mt5:
rows = []
for order in mt5.orders_get() or ():
rows.append(
{
"order_id": str(getattr(order, "ticket", "")),
"symbol": getattr(order, "symbol", ""),
"order_type": getattr(order, "type", None),
"volume": getattr(order, "volume_initial", None),
"volume_current": getattr(order, "volume_current", None),
"price_open": getattr(order, "price_open", None),
"sl": getattr(order, "sl", None),
"tp": getattr(order, "tp", None),
"time_setup": getattr(order, "time_setup", None),
"state": getattr(order, "state", None),
}
)
executions = None
if include_executions:
now = datetime.now(timezone.utc)
deals = mt5.history_deals_get(now - timedelta(days=7), now) or ()
executions = [
{
"deal_id": str(getattr(deal, "ticket", "")),
"order_id": str(getattr(deal, "order", "")),
"symbol": getattr(deal, "symbol", ""),
"volume": getattr(deal, "volume", None),
"price": getattr(deal, "price", None),
"profit": getattr(deal, "profit", None),
"time": getattr(deal, "time", None),
}
for deal in deals
]
except _MT5_ERRORS as exc:
return _error(cfg, str(exc))
result = _envelope(cfg, open_orders=rows)
if include_executions:
result["executions"] = executions
return result
def get_quote(symbol: str, *, config: MT5Config | None = None, **_: Any) -> dict[str, Any]:
"""Fetch the current tick for ``symbol`` (suffix-aware broker resolution).
``last`` is omitted when the terminal reports 0 (common on forex ticks) so
the live gate's quote parser falls through to bid/ask instead of a zero.
"""
cfg = config or _client.load_config()
clean = str(symbol or "").strip().upper()
try:
with _client._session(cfg) as mt5:
name = _resolve_symbol(mt5, cfg, symbol)
tick = mt5.symbol_info_tick(name)
if tick is None:
return _error(cfg, f"no tick data for {name!r}", symbol=clean)
quote: dict[str, Any] = {
"bid": getattr(tick, "bid", None),
"ask": getattr(tick, "ask", None),
"time": getattr(tick, "time", None),
}
last = float(getattr(tick, "last", 0.0) or 0.0)
if last > 0:
quote["last"] = last
info = mt5.symbol_info(name)
if info is not None:
quote["spread"] = getattr(info, "spread", None)
except _MT5_ERRORS as exc:
return _error(cfg, str(exc), symbol=clean)
return _envelope(cfg, symbol=clean, resolved_symbol=name, quote=quote)
def search_instruments(
query: str,
*,
config: MT5Config | None = None,
limit: int = 10,
) -> dict[str, Any]:
"""Resolve one explicit symbol against the configured terminal catalog."""
cfg = config or _client.load_config()
try:
bounded_limit = max(1, min(int(limit), 50))
except (TypeError, ValueError, OverflowError):
bounded_limit = 10
try:
with _client._catalog_session(cfg) as mt5:
name = _client._catalog_symbol(mt5, cfg, str(query or ""))
info = mt5.symbol_info(name)
except _MT5_ERRORS as exc:
return {"status": "error", "error": str(exc), "instruments": [], "catalog_only": True}
instruments = []
if info is not None:
name = str(getattr(info, "name", "") or "").strip()
if name:
instrument_type, _ = classify_mt5_symbol(name)
instruments.append(
{
"symbol": name,
"native_symbol": name,
"market": "fx" if instrument_type.value == "forex" else "mt5",
"type": instrument_type.value,
"exchange": cfg.server or "MT5",
"venue": cfg.server or "MT5",
}
)
return {"status": "ok", "query": query, "instruments": instruments[:bounded_limit], "catalog_only": True}
def get_historical_bars(
symbol: str,
*,
config: MT5Config | None = None,
period: str = "1d",
limit: int = 90,
**_: Any,
) -> dict[str, Any]:
"""Fetch recent OHLCV bars from the terminal's local history."""
cfg = config or _client.load_config()
clean = str(symbol or "").strip().upper()
timeframe_name = _TIMEFRAME_MAP.get(period.strip(), "TIMEFRAME_D1")
try:
with _client._session(cfg) as mt5:
name = _resolve_symbol(mt5, cfg, symbol)
timeframe = getattr(mt5, timeframe_name)
rates = mt5.copy_rates_from_pos(name, timeframe, 0, int(limit))
bars = [_rate_to_dict(rate) for rate in (rates if rates is not None else ())]
except _MT5_ERRORS as exc:
return _error(cfg, str(exc), symbol=clean)
return _envelope(
cfg, symbol=clean, resolved_symbol=name, period=period,
timeframe=timeframe_name, bars=bars,
)
def _int_or_none(value: Any) -> int | None:
"""Cast *value* to a plain Python int (handles numpy scalars). Return None for None."""
if value is None:
return None
return int(value)
def _float_or_none(value: Any) -> float | None:
"""Cast *value* to a plain Python float (handles numpy scalars). Return None for None."""
if value is None:
return None
return float(value)
def _rate_to_dict(rate: Any) -> dict[str, Any]:
"""Map one rates row (numpy structured row, mapping, or object) defensively."""
def _get(key: str) -> Any:
try:
return rate[key] # numpy structured rows and mappings
except (TypeError, KeyError, IndexError, ValueError):
return getattr(rate, key, None)
volume = _get("tick_volume")
return {
"time": _int_or_none(_get("time")),
"open": _float_or_none(_get("open")),
"high": _float_or_none(_get("high")),
"low": _float_or_none(_get("low")),
"close": _float_or_none(_get("close")),
"volume": _int_or_none(volume),
"spread": _int_or_none(_get("spread")),
"real_volume": _int_or_none(_get("real_volume")),
}