# version: v3 """ execution_routes.py — MT5 trade execution HTTP layer (v3). v1 was a stub (PRO-only). v2 exposes the provider's execution methods as HTTP endpoints so the consumer OrderTicket / ExposureStrip / quick-trade buttons can act on the user's live MT5 account. v3 adds the pending-order lifecycle: list / modify-price-or-SLTP / cancel. Endpoints: GET /api/positions — list open positions POST /api/orders — place a market/limit/stop order POST /api/positions/{ticket}/close — close (full or partial) a position PATCH /api/positions/{ticket} — modify SL / TP on an open position GET /api/orders/pending — list resting pending orders (v3) PATCH /api/orders/{ticket} — modify pending-order price / SL / TP (v3) DELETE /api/orders/{ticket} — cancel a pending order (v3) All endpoints are thin wrappers over `provider.place_order` / `get_positions` / `close_position` / `modify_position`. Results flow back as JSON. Consumer-safety notes: - Every request goes through the cached MT5 provider — no fresh connect. - provider None or disconnected → returns a structured error, not 500. - Magic number defaults to 777 (Quantum Terminal tag) unless overridden. """ import logging from typing import Optional from fastapi import APIRouter, HTTPException from pydantic import BaseModel from models import OrderRequest log = logging.getLogger("execution_routes") MK_MAGIC = 777 # Quantum Terminal tag on broker side for attribution / filtering class OrderBody(BaseModel): ticker: str direction: str # "BUY" | "SELL" order_type: str = "MARKET" # "MARKET" | "LIMIT" | "STOP" lots: float price: Optional[float] = None # required for LIMIT/STOP stop_loss: Optional[float] = None take_profit: Optional[float] = None comment: Optional[str] = "Quantum Terminal" # Signal attribution (optional — logged only) signal_id: Optional[str] = None signal_type: Optional[str] = None signal_confidence: Optional[float] = None class ClosePositionBody(BaseModel): lots: Optional[float] = None # None = full close class ModifyPositionBody(BaseModel): stop_loss: Optional[float] = None take_profit: Optional[float] = None class ModifyOrderBody(BaseModel): """v3: Modify a pending order — any field left None is preserved server-side.""" price: Optional[float] = None stop_loss: Optional[float] = None take_profit: Optional[float] = None def _pending_to_dict(o) -> dict: """Normalize a PendingOrder object to the JSON shape the frontend expects.""" def g(obj, *names, default=None): for n in names: v = getattr(obj, n, None) if v is not None and v != 0: return v return default return { "ticket": str(g(o, "ticket") or ""), "ticker": g(o, "ticker", "symbol", default=""), "direction": g(o, "direction", default=""), "order_type": g(o, "order_type", default=""), "lots": float(g(o, "lots", "volume", default=0) or 0), "price": float(g(o, "price", "price_open", default=0) or 0), "stop_loss": float(g(o, "stop_loss", "sl", default=0) or 0), "take_profit": float(g(o, "take_profit", "tp", default=0) or 0), "comment": str(getattr(o, "comment", "") or ""), "time_setup": str(g(o, "time_setup", "time_open") or ""), } def _position_to_dict(p) -> dict: """Normalize a Position object to the JSON shape the frontend expects.""" def g(obj, *names, default=None): for n in names: v = getattr(obj, n, None) if v is not None and v != 0: return v return default return { "ticket": str(g(p, "ticket") or ""), "ticker": g(p, "ticker", "symbol", default=""), "direction": g(p, "direction", default=""), "lots": float(g(p, "lots", "volume", default=0) or 0), "open_price": float(g(p, "open_price", "price_open", default=0) or 0), "current_price":float(g(p, "current_price", "price_current", default=0) or 0), "stop_loss": float(g(p, "stop_loss", "sl", default=0) or 0), "take_profit": float(g(p, "take_profit", "tp", default=0) or 0), "profit": float(getattr(p, "profit", 0) or 0), "swap": float(getattr(p, "swap", 0) or 0), "commission": float(getattr(p, "commission", 0) or 0), "open_time": str(g(p, "open_time") or getattr(p, "time_open", "") or ""), "comment": str(getattr(p, "comment", "") or ""), } def create_execution_router(cfg_manager=None, app=None, broadcast_event=None) -> APIRouter: router = APIRouter(tags=["execution"]) def _provider(): if cfg_manager is None: return None try: return cfg_manager.get_provider("mt5_default") except Exception: return None # ── GET /api/positions ───────────────────────────────────────────── @router.get("/api/positions") async def get_positions(): import asyncio prov = _provider() if prov is None or not getattr(prov, "connected", False): return {"positions": [], "mt5_connected": False} try: positions = await asyncio.to_thread(prov.get_positions) except Exception as e: log.warning(f"get_positions failed: {e}") return {"positions": [], "mt5_connected": True, "error": str(e)} return { "positions": [_position_to_dict(p) for p in (positions or [])], "mt5_connected": True, } # ── POST /api/orders ─────────────────────────────────────────────── @router.post("/api/orders") async def place_order(body: OrderBody): import asyncio prov = _provider() if prov is None or not getattr(prov, "connected", False): raise HTTPException(503, {"error": "mt5_disconnected", "message": "MT5 is not connected. Connect in Settings → Providers."}) # Build the provider's OrderRequest. FlexModel accepts extra fields. req = OrderRequest( ticker=body.ticker.upper(), direction=body.direction.upper(), order_type=body.order_type.upper(), lots=float(body.lots), volume=float(body.lots), # dual-name for safety price=body.price or 0, stop_loss=body.stop_loss, take_profit=body.take_profit, sl=float(body.stop_loss) if body.stop_loss else 0, tp=float(body.take_profit) if body.take_profit else 0, comment=body.comment or "Quantum Terminal", magic=MK_MAGIC, ) log.info( f"ORDER {req.direction} {req.lots} {req.ticker} @ " f"{body.price or 'market'} SL={body.stop_loss} TP={body.take_profit}" + (f" signal={body.signal_id}" if body.signal_id else "") ) try: result = await asyncio.to_thread(prov.place_order, req) except Exception as e: log.error(f"place_order exception: {e}") raise HTTPException(500, {"error": "place_order_failed", "message": str(e)}) # Normalize response — mt5_provider returns OrderResult (FlexModel). ok = bool(getattr(result, "success", False)) payload = { "success": ok, "order_id": str(getattr(result, "order_id", "") or ""), "price": float(getattr(result, "price", 0) or 0), "ticker": getattr(result, "ticker", req.ticker) or req.ticker, "direction": getattr(result, "direction", req.direction) or req.direction, "lots": float(getattr(result, "lots", req.lots) or req.lots), } if not ok: payload["error"] = str( getattr(result, "error", None) or getattr(result, "message", None) or "Order rejected" ) return payload # ── POST /api/positions/{ticket}/close ───────────────────────────── @router.post("/api/positions/{ticket}/close") async def close_position(ticket: str, body: Optional[ClosePositionBody] = None): import asyncio prov = _provider() if prov is None or not getattr(prov, "connected", False): raise HTTPException(503, {"error": "mt5_disconnected"}) lots = body.lots if body else None log.info(f"CLOSE position {ticket} lots={lots or 'full'}") try: result = await asyncio.to_thread(prov.close_position, ticket, lots) except Exception as e: log.error(f"close_position exception: {e}") raise HTTPException(500, {"error": "close_failed", "message": str(e)}) ok = bool(getattr(result, "success", False)) return { "success": ok, "ticket": ticket, "price": float(getattr(result, "price", 0) or 0), **({"error": str(getattr(result, "error", None) or "")} if not ok else {}), } # ── PATCH /api/positions/{ticket} ────────────────────────────────── @router.patch("/api/positions/{ticket}") async def modify_position(ticket: str, body: ModifyPositionBody): import asyncio prov = _provider() if prov is None or not getattr(prov, "connected", False): raise HTTPException(503, {"error": "mt5_disconnected"}) if not hasattr(prov, "modify_position"): raise HTTPException(501, {"error": "modify_unsupported"}) log.info(f"MODIFY position {ticket} SL={body.stop_loss} TP={body.take_profit}") try: result = await asyncio.to_thread( prov.modify_position, ticket, body.stop_loss, body.take_profit ) except Exception as e: log.error(f"modify_position exception: {e}") raise HTTPException(500, {"error": "modify_failed", "message": str(e)}) # modify_position returns a dict per mt5_provider if isinstance(result, dict): return {"success": bool(result.get("success", False)), **result} return {"success": True} # ── GET /api/orders/pending ────────────────────────────────── v3 ── @router.get("/api/orders/pending") async def list_pending_orders(): import asyncio prov = _provider() if prov is None or not getattr(prov, "connected", False): return {"orders": [], "mt5_connected": False} try: orders = await asyncio.to_thread(prov.get_pending_orders) except Exception as e: log.warning(f"get_pending_orders failed: {e}") return {"orders": [], "mt5_connected": True, "error": str(e)} return { "orders": [_pending_to_dict(o) for o in (orders or [])], "mt5_connected": True, } # ── PATCH /api/orders/{ticket} ─────────────────────────────── v3 ── @router.patch("/api/orders/{ticket}") async def modify_pending_order(ticket: str, body: ModifyOrderBody): import asyncio prov = _provider() if prov is None or not getattr(prov, "connected", False): raise HTTPException(503, {"error": "mt5_disconnected"}) if not hasattr(prov, "modify_order"): raise HTTPException(501, {"error": "modify_order_unsupported"}) log.info(f"MODIFY order {ticket} price={body.price} SL={body.stop_loss} TP={body.take_profit}") try: result = await asyncio.to_thread( prov.modify_order, ticket, body.price, body.stop_loss, body.take_profit ) except Exception as e: log.error(f"modify_order exception: {e}") raise HTTPException(500, {"error": "modify_order_failed", "message": str(e)}) ok = bool(getattr(result, "success", False)) payload = {"success": ok, "ticket": ticket} if not ok: payload["error"] = str(getattr(result, "error", None) or "Modify rejected") return payload # ── DELETE /api/orders/{ticket} ────────────────────────────── v3 ── @router.delete("/api/orders/{ticket}") async def cancel_pending_order(ticket: str): import asyncio prov = _provider() if prov is None or not getattr(prov, "connected", False): raise HTTPException(503, {"error": "mt5_disconnected"}) if not hasattr(prov, "cancel_order"): raise HTTPException(501, {"error": "cancel_order_unsupported"}) log.info(f"CANCEL order {ticket}") try: result = await asyncio.to_thread(prov.cancel_order, ticket) except Exception as e: log.error(f"cancel_order exception: {e}") raise HTTPException(500, {"error": "cancel_order_failed", "message": str(e)}) ok = bool(getattr(result, "success", False)) payload = {"success": ok, "ticket": ticket} if not ok: payload["error"] = str(getattr(result, "error", None) or "Cancel rejected") return payload return router