313 lines
14 KiB
Python
313 lines
14 KiB
Python
# 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
|