- Add advanced metrics dashboard with trade analytics - Add new trading components (EntryTypeAnalysis, MultiDayPositionTracker, NewsEventTracker, etc.) - Add strategy mode selector and trend confirmation - Add risk automation panel and slippage correlation analysis - Add daily trading plan enhancements with modal components - Add custom hooks (useApi, useLocalStorage, useAdvancedTradeMetrics) - Add broker service integration and trading API - Add test setup and vitest configuration - Include parquet data files for live market data - Add comprehensive documentation in docs/ folder
169 lines
6.2 KiB
Python
169 lines
6.2 KiB
Python
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()
|