from __future__ import annotations import abc import asyncio import uuid from dataclasses import asdict, dataclass from datetime import datetime, timezone from typing import Any, Dict, List, Optional import httpx from app.config import settings try: # Optional dependency for MetaTrader5 import MetaTrader5 # type: ignore except ImportError: # pragma: no cover - optional MetaTrader5 = None # type: ignore class BrokerError(RuntimeError): """Raised when bridge operations fail.""" @dataclass class BrokerProvider: id: str name: str description: str docs_url: str latency_ms: int features: Dict[str, bool] supports_demo: bool = True BROKER_PROVIDERS: List[BrokerProvider] = [ BrokerProvider( id="mt5", name="MetaTrader 5", description="Direct bridge to a locally running MetaTrader 5 terminal.", docs_url="https://www.metatrader5.com/en/terminal/help", latency_ms=180, features={ "trailingStops": True, "partialCloses": True, "hedging": True, "streaming": True, }, ), BrokerProvider( id="oanda", name="OANDA v20", description="REST trading for FX/CFD (practice or live)", docs_url="https://developer.oanda.com/rest-live-v20/", latency_ms=230, features={ "trailingStops": True, "partialCloses": True, "hedging": False, "streaming": False, }, ), BrokerProvider( id="alpaca", name="Alpaca Trading", description="Equities/crypto order routing (paper or live)", docs_url="https://alpaca.markets/docs/api-references/trading-api/", latency_ms=120, features={ "trailingStops": False, "partialCloses": True, "hedging": False, "streaming": True, }, ), ] PROVIDER_LOOKUP = {provider.id: provider for provider in BROKER_PROVIDERS} def _iso_now() -> str: return datetime.now(timezone.utc).isoformat() def _demo_state(balance: Optional[float] = None) -> Dict[str, Any]: return { "mode": "demo", "token": f"demo-{uuid.uuid4()}", "balance": balance if balance is not None else settings.BROKER_SIM_BALANCE, "positions": [], } class BaseConnector(abc.ABC): provider_id: str @abc.abstractmethod async def connect(self, credentials: Dict[str, Any]) -> Dict[str, Any]: ... @abc.abstractmethod async def disconnect(self, state: Dict[str, Any]) -> None: ... @abc.abstractmethod async def place_order(self, state: Dict[str, Any], order: Dict[str, Any]) -> Dict[str, Any]: ... @abc.abstractmethod async def sync_positions(self, state: Dict[str, Any]) -> Dict[str, Any]: ... class MetaTraderConnector(BaseConnector): provider_id = "mt5" def __init__(self) -> None: self._lock = asyncio.Lock() def _client(self): if MetaTrader5 is None: raise BrokerError("MetaTrader5 python package is not installed") return MetaTrader5 async def connect(self, credentials: Dict[str, Any]) -> Dict[str, Any]: if credentials.get("demo", True): return _demo_state() mt5 = self._client() login = int(credentials["account_id"]) password = credentials["api_key"] server = credentials.get("server") or settings.MT5_SERVER async with self._lock: def _login(): if not mt5.initialize(): raise BrokerError(f"MetaTrader5 initialize failed: {mt5.last_error()}") if not mt5.login(login=login, password=password, server=server): raise BrokerError(f"MetaTrader5 login failed: {mt5.last_error()}") info = mt5.account_info() balance = float(info.balance) if info else None return { "mode": "live", "balance": balance, "token": f"mt5-{uuid.uuid4()}", } return await asyncio.to_thread(_login) async def disconnect(self, state: Dict[str, Any]) -> None: if state.get("mode") == "demo": return mt5 = self._client() async with self._lock: def _shutdown(): mt5.shutdown() await asyncio.to_thread(_shutdown) async def place_order(self, state: Dict[str, Any], order: Dict[str, Any]) -> Dict[str, Any]: if state.get("mode") == "demo": return { "remote_id": f"demo-{order['action']}-{uuid.uuid4().hex[:6]}", "filled": True, } mt5 = self._client() def _send(): request = { "action": mt5.TRADE_ACTION_DEAL, "symbol": order["symbol"], "type": mt5.ORDER_TYPE_BUY if order["action"] == "BUY" else mt5.ORDER_TYPE_SELL, "volume": float(order["quantity"]), "price": float(order["price"]), "type_filling": mt5.ORDER_FILLING_RETURN, "sl": order.get("stopLoss"), "tp": order.get("takeProfit"), } result = mt5.order_send(request) if result is None or result.retcode != mt5.TRADE_RETCODE_DONE: raise BrokerError(f"MetaTrader5 order failed: {mt5.last_error()}") return { "remote_id": str(result.order), "filled": True, } return await asyncio.to_thread(_send) async def sync_positions(self, state: Dict[str, Any]) -> Dict[str, Any]: if state.get("mode") == "demo": return {"positions": state.setdefault("positions", []), "balance": state.get("balance")} mt5 = self._client() def _fetch(): info = mt5.account_info() balance = float(info.balance) if info else None rows = mt5.positions_get() positions: List[Dict[str, Any]] = [] if rows: for row in rows: positions.append( { "symbol": row.symbol, "quantity": float(row.volume), "avgPrice": float(row.price_open), "lastPrice": float(row.price_current), "pnl": float(row.profit), "ticket": int(row.ticket), } ) return {"positions": positions, "balance": balance} return await asyncio.to_thread(_fetch) class OandaConnector(BaseConnector): provider_id = "oanda" async def connect(self, credentials: Dict[str, Any]) -> Dict[str, Any]: if credentials.get("demo", True) or not credentials.get("api_key"): return _demo_state() account_id = credentials["account_id"] headers = { "Authorization": f"Bearer {credentials['api_key']}", "Content-Type": "application/json", } base_url = settings.OANDA_BASE_URL.rstrip("/") async with httpx.AsyncClient(base_url=base_url, timeout=settings.BROKER_HTTP_TIMEOUT) as client: resp = await client.get(f"/v3/accounts/{account_id}", headers=headers) resp.raise_for_status() data = resp.json().get("account", {}) balance = float(data.get("balance", 0)) return { "mode": "live", "headers": headers, "account_id": account_id, "base_url": base_url, "balance": balance, "token": f"oanda-{uuid.uuid4()}", } async def disconnect(self, state: Dict[str, Any]) -> None: return None async def place_order(self, state: Dict[str, Any], order: Dict[str, Any]) -> Dict[str, Any]: if state.get("mode") == "demo": return { "remote_id": f"demo-{order['action']}-{uuid.uuid4().hex[:6]}", "filled": True, } payload = { "order": { "instrument": order["symbol"], "units": str(order["quantity"] if order["action"] == "BUY" else -order["quantity"]), "type": order.get("type", "MARKET"), "timeInForce": "FOK", "positionFill": "DEFAULT", } } if order.get("stopLoss"): payload["order"]["stopLossOnFill"] = {"price": str(order["stopLoss"])} if order.get("takeProfit"): payload["order"]["takeProfitOnFill"] = {"price": str(order["takeProfit"])} async with httpx.AsyncClient(base_url=state["base_url"], timeout=settings.BROKER_HTTP_TIMEOUT) as client: resp = await client.post( f"/v3/accounts/{state['account_id']}/orders", headers=state["headers"], json=payload, ) resp.raise_for_status() data = resp.json() return { "remote_id": data.get("orderFillTransaction", {}).get("orderID") or uuid.uuid4().hex, "filled": True, } async def sync_positions(self, state: Dict[str, Any]) -> Dict[str, Any]: if state.get("mode") == "demo": return {"positions": state.setdefault("positions", []), "balance": state.get("balance")} async with httpx.AsyncClient(base_url=state["base_url"], timeout=settings.BROKER_HTTP_TIMEOUT) as client: resp = await client.get( f"/v3/accounts/{state['account_id']}/openPositions", headers=state["headers"], ) resp.raise_for_status() payload = resp.json() positions: List[Dict[str, Any]] = [] for item in payload.get("positions", []): net = float(item.get("net", {}).get("units", 0)) if net == 0: continue avg_price = float(item.get("net", {}).get("averagePrice", 0)) positions.append( { "symbol": item.get("instrument"), "quantity": abs(net), "avgPrice": avg_price, "lastPrice": None, "pnl": None, } ) return {"positions": positions, "balance": state.get("balance")} class AlpacaConnector(BaseConnector): provider_id = "alpaca" async def connect(self, credentials: Dict[str, Any]) -> Dict[str, Any]: if credentials.get("demo", True) or not credentials.get("api_key"): return _demo_state() key_parts = credentials["api_key"].split(":", 1) if len(key_parts) != 2: raise BrokerError("Provide API_KEY:API_SECRET for Alpaca API key field") headers = { "APCA-API-KEY-ID": key_parts[0], "APCA-API-SECRET-KEY": key_parts[1], "Content-Type": "application/json", } base_url = settings.ALPACA_BASE_URL.rstrip("/") async with httpx.AsyncClient(base_url=base_url, timeout=settings.BROKER_HTTP_TIMEOUT) as client: resp = await client.get("/account", headers=headers) resp.raise_for_status() data = resp.json() return { "mode": "live", "headers": headers, "base_url": base_url, "account_id": data.get("id") or credentials.get("account_id"), "balance": float(data.get("cash", 0)), "token": f"alpaca-{uuid.uuid4()}", } async def disconnect(self, state: Dict[str, Any]) -> None: return None async def place_order(self, state: Dict[str, Any], order: Dict[str, Any]) -> Dict[str, Any]: if state.get("mode") == "demo": return { "remote_id": f"demo-{order['action']}-{uuid.uuid4().hex[:6]}", "filled": True, } payload = { "symbol": order["symbol"], "qty": order["quantity"], "side": "buy" if order["action"] == "BUY" else "sell", "type": order.get("type", "market").lower(), "time_in_force": "day", } if order.get("stopLoss") or order.get("takeProfit"): payload["order_class"] = "oto" payload["take_profit"] = {"limit_price": order.get("takeProfit")} payload["stop_loss"] = {"stop_price": order.get("stopLoss")} async with httpx.AsyncClient(base_url=state["base_url"], timeout=settings.BROKER_HTTP_TIMEOUT) as client: resp = await client.post("/orders", headers=state["headers"], json=payload) resp.raise_for_status() data = resp.json() return { "remote_id": data.get("id", uuid.uuid4().hex), "filled": data.get("status") == "filled", } async def sync_positions(self, state: Dict[str, Any]) -> Dict[str, Any]: if state.get("mode") == "demo": return {"positions": state.setdefault("positions", []), "balance": state.get("balance")} async with httpx.AsyncClient(base_url=state["base_url"], timeout=settings.BROKER_HTTP_TIMEOUT) as client: resp = await client.get("/positions", headers=state["headers"]) resp.raise_for_status() rows = resp.json() positions = [ { "symbol": row.get("symbol"), "quantity": float(row.get("qty", 0)), "avgPrice": float(row.get("avg_entry_price", 0)), "lastPrice": float(row.get("current_price", 0)), "pnl": float(row.get("unrealized_pl", 0)), } for row in rows ] return {"positions": positions, "balance": state.get("balance")} CONNECTORS: Dict[str, BaseConnector] = { "mt5": MetaTraderConnector(), "oanda": OandaConnector(), "alpaca": AlpacaConnector(), } class BrokerBridgeService: def __init__(self) -> None: self._session: Optional[Dict[str, Any]] = None self._lock = asyncio.Lock() def list_providers(self) -> List[Dict[str, Any]]: return [asdict(provider) for provider in BROKER_PROVIDERS] def get_session(self) -> Optional[Dict[str, Any]]: if not self._session: return None provider = PROVIDER_LOOKUP.get(self._session["provider_id"]) payload = {**self._session} payload["provider"] = asdict(provider) if provider else None return payload async def connect(self, provider_id: str, credentials: Dict[str, Any]) -> Dict[str, Any]: connector = CONNECTORS.get(provider_id) if not connector: raise BrokerError("Unsupported broker provider") state = await connector.connect(credentials) async with self._lock: self._session = { "provider_id": provider_id, "credentials": credentials, "state": state, "account_id": credentials.get("account_id"), "demo": credentials.get("demo", True), "last_heartbeat": _iso_now(), "balance": state.get("balance"), "positions": state.get("positions", []), } return self.get_session() # type: ignore[return-value] async def disconnect(self) -> None: if not self._session: return connector = CONNECTORS.get(self._session["provider_id"]) if connector: await connector.disconnect(self._session.get("state", {})) async with self._lock: self._session = None async def place_order(self, order: Dict[str, Any]) -> Dict[str, Any]: if not self._session: raise BrokerError("No active broker session") connector = CONNECTORS.get(self._session["provider_id"]) if not connector: raise BrokerError("Unsupported broker provider") result = await connector.place_order(self._session.get("state", {}), order) self._session["last_heartbeat"] = _iso_now() return result async def sync_positions(self) -> Dict[str, Any]: if not self._session: raise BrokerError("No active broker session") connector = CONNECTORS.get(self._session["provider_id"]) if not connector: raise BrokerError("Unsupported broker provider") snapshot = await connector.sync_positions(self._session.get("state", {})) self._session["last_heartbeat"] = _iso_now() self._session["positions"] = snapshot.get("positions", []) self._session["balance"] = snapshot.get("balance", self._session.get("balance")) return { "positions": self._session["positions"], "balance": self._session.get("balance"), "lastHeartbeat": self._session.get("last_heartbeat"), } broker_bridge_service = BrokerBridgeService()