from __future__ import annotations """MetaTrader-powered price feed (scaffold). The actual MT5 integration lives here so we can keep the rest of the app agnostic. Implement the ``_connect`` and ``_fetch`` routines once MetaTrader 5 is available on the host machine. """ import asyncio from dataclasses import dataclass from datetime import datetime from typing import Any, Dict, Tuple try: import MetaTrader5 as mt5 # type: ignore except Exception: # pragma: no cover - MetaTrader library not always installed mt5 = None from app.streaming.live_store import live_store @dataclass(frozen=True) class MTKey: symbol: str timeframe: str class MetaTraderFeed: def __init__(self) -> None: self._tasks: Dict[MTKey, asyncio.Task] = {} self._pinned: set[MTKey] = set() self._lock = asyncio.Lock() self._active_counts: Dict[MTKey, int] = {} def get_status(self) -> list[dict]: out: list[dict] = [] for key, task in self._tasks.items(): hist = live_store.get_history(key.symbol, key.timeframe) last_ts = hist[-1]["time"] if hist else None last_iso = None if isinstance(last_ts, (int, float)): last_iso = datetime.utcfromtimestamp(int(last_ts)).isoformat() + "Z" out.append( { "symbol": key.symbol, "timeframe": key.timeframe, "subscribers": self._active_counts.get(key, 0), "last_event_time": last_iso, "connected": mt5 is not None and mt5.terminal_info() is not None, "poller_running": not task.done(), } ) return out async def subscribe(self, symbol: str, timeframe: str = "1m") -> Tuple[asyncio.Queue, Any]: key = MTKey(symbol.upper().replace("/", ""), timeframe) queue: asyncio.Queue = asyncio.Queue(maxsize=100) live_store.subscribe(key.symbol, key.timeframe, queue) async with self._lock: self._active_counts[key] = self._active_counts.get(key, 0) + 1 if key not in self._tasks: self._tasks[key] = asyncio.create_task(self._run_mt_poller(key)) async def _unsubscribe() -> None: live_store.unsubscribe(key.symbol, key.timeframe, queue) async with self._lock: self._active_counts[key] = max(0, self._active_counts.get(key, 0) - 1) if self._active_counts.get(key, 0) == 0 and key not in self._pinned: task = self._tasks.pop(key, None) if task: task.cancel() self._active_counts.pop(key, None) return queue, _unsubscribe async def ensure_stream(self, symbol: str, timeframe: str = "1m") -> None: key = MTKey(symbol.upper().replace("/", ""), timeframe) async with self._lock: self._pinned.add(key) self._active_counts.setdefault(key, 0) if key not in self._tasks: self._tasks[key] = asyncio.create_task(self._run_mt_poller(key)) async def release_stream(self, symbol: str, timeframe: str = "1m") -> None: """Allow external callers to drop pinning once no longer needed.""" key = MTKey(symbol.upper().replace("/", ""), timeframe) async with self._lock: self._pinned.discard(key) if self._active_counts.get(key, 0) == 0: task = self._tasks.pop(key, None) if task: task.cancel() self._active_counts.pop(key, None) async def _run_mt_poller(self, key: MTKey) -> None: if mt5 is None: raise RuntimeError("MetaTrader5 package not installed. Install to enable MT feed.") if not mt5.initialize(): raise RuntimeError(f"Unable to initialize MetaTrader5: {mt5.last_error()}") interval = self._resolve_timeframe(key.timeframe) last_ts: int | None = None poll_interval = 5 while True: try: rates = mt5.copy_rates_from_pos(key.symbol, interval, 0, 1) if not rates: await asyncio.sleep(poll_interval) continue row = rates[0] tsec = int(row["time"]) if last_ts is not None and tsec <= last_ts: await asyncio.sleep(poll_interval) continue last_ts = tsec evt = { "symbol": key.symbol, "timeframe": key.timeframe, "open_time": datetime.utcfromtimestamp(tsec).isoformat(), "close_time": datetime.utcfromtimestamp(tsec).isoformat(), "open": float(row["open"]), "high": float(row["high"]), "low": float(row["low"]), "close": float(row["close"]), "volume": float(row.get("tick_volume", 0.0)), "is_closed": True, "source": "metatrader", } live_store.ingest_bar( symbol=key.symbol, timeframe=key.timeframe, bar={ "time": tsec, "open": evt["open"], "high": evt["high"], "low": evt["low"], "close": evt["close"], "volume": evt["volume"], }, ) await asyncio.sleep(poll_interval) except asyncio.CancelledError: break except Exception: await asyncio.sleep(poll_interval) def _resolve_timeframe(self, name: str): if mt5 is None: raise RuntimeError("MetaTrader5 package not available") mapping = { "1m": mt5.TIMEFRAME_M1, "5m": mt5.TIMEFRAME_M5, "15m": mt5.TIMEFRAME_M15, "30m": mt5.TIMEFRAME_M30, "60m": mt5.TIMEFRAME_H1, "1h": mt5.TIMEFRAME_H1, } return mapping.get(name.lower(), mt5.TIMEFRAME_M1) metatrader_feed = MetaTraderFeed()