145 lines
6.0 KiB
Python
145 lines
6.0 KiB
Python
# version: v1
|
|
"""
|
|
tradovate_routes.py — HTTP API shell for the Tradovate POC.
|
|
|
|
Routes mirror the MT5 management routes so the SettingsPanel can drive
|
|
both providers with the same UX pattern. Tradovate state is held in the
|
|
singleton TradovateProvider from providers/tradovate_provider.py.
|
|
|
|
GET /api/tradovate/config → read saved creds + status
|
|
PATCH /api/tradovate/config → save creds (env, app_id, cid, sec,
|
|
username, password)
|
|
POST /api/tradovate/connect → authenticate with current creds
|
|
POST /api/tradovate/disconnect → clear tokens
|
|
GET /api/tradovate/bars/{ticker}?timeframe=&count= → fetch bars
|
|
|
|
Reversibility: this module is standalone. Nothing in MT5 or the base
|
|
terminal references it. Dropping the include_router() line from
|
|
consumer_startup.py removes every route above in one edit.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
from providers.tradovate_provider import get_tradovate_provider
|
|
|
|
log = logging.getLogger("tradovate_routes")
|
|
|
|
|
|
class TradovateConfigBody(BaseModel):
|
|
env: Optional[str] = None # "demo" | "live"
|
|
app_id: Optional[str] = None
|
|
app_version:Optional[str] = None
|
|
cid: Optional[str] = None
|
|
sec: Optional[str] = None
|
|
username: Optional[str] = None
|
|
password: Optional[str] = None
|
|
device_id: Optional[str] = None
|
|
|
|
|
|
def create_tradovate_router(cfg_manager=None) -> APIRouter:
|
|
router = APIRouter(tags=["tradovate"])
|
|
|
|
def _cfg_store() -> dict:
|
|
"""Slice of user_config where Tradovate creds live. Uses
|
|
cfg_manager's internal _user_config dict (same pattern mt5_routes
|
|
uses) so edits persist to disk via _save_user_config()."""
|
|
if cfg_manager is None or not hasattr(cfg_manager, "_user_config"):
|
|
# Fallback: in-memory only (creds lost on restart)
|
|
if not hasattr(_cfg_store, "_mem"):
|
|
_cfg_store._mem = {}
|
|
store = _cfg_store._mem
|
|
else:
|
|
store = cfg_manager._user_config
|
|
providers = store.setdefault("providers", {}) if isinstance(store, dict) else {}
|
|
accounts = providers.setdefault("accounts", {}) if isinstance(providers, dict) else {}
|
|
tv = accounts.setdefault("tradovate_default", {
|
|
"id": "tradovate_default",
|
|
"type": "tradovate",
|
|
"label": "Tradovate",
|
|
"enabled": False,
|
|
"env": "demo",
|
|
"app_id": "",
|
|
"app_version": "1.0",
|
|
"cid": "",
|
|
"sec": "",
|
|
"username": "",
|
|
"password": "",
|
|
"device_id": "QuantumTerminal-consumer-poc",
|
|
})
|
|
return tv
|
|
|
|
def _masked(cfg: dict) -> dict:
|
|
"""Never return secrets to the UI — mask them so we don't leak."""
|
|
out = dict(cfg or {})
|
|
for k in ("sec", "password"):
|
|
if out.get(k):
|
|
out[k] = "••••••"
|
|
return out
|
|
|
|
# ── GET /api/tradovate/config ──────────────────────────────
|
|
@router.get("/api/tradovate/config")
|
|
async def get_config():
|
|
cfg = _cfg_store()
|
|
prov = get_tradovate_provider(cfg)
|
|
return {"config": _masked(cfg), "status": prov.status_dict}
|
|
|
|
# ── PATCH /api/tradovate/config ────────────────────────────
|
|
@router.patch("/api/tradovate/config")
|
|
async def patch_config(body: TradovateConfigBody):
|
|
cfg = _cfg_store()
|
|
updates = {k: v for k, v in body.model_dump().items() if v is not None and v != ""}
|
|
cfg.update(updates)
|
|
# Persist via the same internal save hook config_manager uses.
|
|
try:
|
|
if cfg_manager and hasattr(cfg_manager, "_save_user_config"):
|
|
cfg_manager._save_user_config()
|
|
except Exception as e:
|
|
log.warning(f"_save_user_config failed: {e}")
|
|
# Push new values into the live provider instance (clears token cache)
|
|
get_tradovate_provider().update_config(cfg)
|
|
return {"config": _masked(cfg), "status": get_tradovate_provider().status_dict}
|
|
|
|
# ── POST /api/tradovate/connect ────────────────────────────
|
|
@router.post("/api/tradovate/connect")
|
|
async def connect():
|
|
cfg = _cfg_store()
|
|
prov = get_tradovate_provider(cfg)
|
|
result = await prov.authenticate()
|
|
return result
|
|
|
|
# ── POST /api/tradovate/disconnect ─────────────────────────
|
|
@router.post("/api/tradovate/disconnect")
|
|
async def disconnect():
|
|
get_tradovate_provider().disconnect()
|
|
return {"success": True, "status": get_tradovate_provider().status_dict}
|
|
|
|
# ── GET /api/tradovate/bars/{ticker} ───────────────────────
|
|
@router.get("/api/tradovate/bars/{ticker}")
|
|
async def get_bars(ticker: str, timeframe: str = "M15", count: int = 200):
|
|
prov = get_tradovate_provider()
|
|
if not prov.connected:
|
|
raise HTTPException(503, {"error": "tradovate_not_connected",
|
|
"message": "Connect to Tradovate in Settings → Providers first."})
|
|
try:
|
|
bars = await prov.get_bars(ticker, timeframe, count)
|
|
except ValueError as e:
|
|
# Unmapped ticker — clean 404
|
|
raise HTTPException(404, {"error": "symbol_not_supported",
|
|
"message": str(e)})
|
|
except Exception as e:
|
|
raise HTTPException(500, {"error": "tradovate_bars_failed",
|
|
"message": str(e)})
|
|
return {
|
|
"ticker": ticker.upper(),
|
|
"timeframe": timeframe,
|
|
"bars": bars,
|
|
"delayed": prov.is_delayed,
|
|
"count": len(bars),
|
|
}
|
|
|
|
return router
|