Files

313 lines
14 KiB
Python
Raw Permalink Normal View History

# 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