Files
QuantumTerminal/backend/config_routes.py
T

203 lines
7.5 KiB
Python

# version: v2
"""
config_routes.py — Terminal Configuration API Routes
=====================================================
v2 — PATCH handler now detects broker_symbol changes in
universe.custom_tickers[*].broker_symbol and, for each affected ticker,
calls provider.reset_symbol() on every live provider so MT5 picks up
the new mapping at the next tick/bar fetch (no restart required).
Also triggers a data_sync_complete broadcast so the frontend refetches
bars for the affected tickers.
Serves GET /api/config and all mutation endpoints used by useConfig.js.
Endpoints:
GET /api/config → full merged config
PATCH /api/config → partial update
POST /api/config/tickers → add ticker
DELETE /api/config/tickers/{ticker} → remove ticker
POST /api/config/tickers/{ticker}/toggle → hide/show
PUT /api/config/tickers/order → reorder
POST /api/config/profiles → save profile
POST /api/config/profiles/{name}/load → load profile
DELETE /api/config/profiles/{name} → delete profile
This module does NOT introduce any calculation triggers.
"""
import logging
from typing import Callable, Optional, Set
from fastapi import APIRouter, Request, HTTPException
log = logging.getLogger("config_routes")
# v2 — helpers for broker_symbol hot-rewiring.
def _collect_broker_symbol_changes(patch: dict) -> Set[str]:
"""Walk a PATCH body and return the set of tickers whose broker_symbol
is being set (value may be null to clear). Safe for any patch shape."""
out = set()
try:
custom = (patch.get("universe", {}) or {}).get("custom_tickers", {}) or {}
for ticker, fields in custom.items():
if isinstance(fields, dict) and "broker_symbol" in fields:
out.add(str(ticker).upper())
except Exception as e:
log.warning(f"Failed to scan patch for broker_symbol changes: {e}")
return out
def _apply_broker_symbol_resets(cfg_manager, tickers: Set[str]) -> None:
"""For each live provider that supports it, evict and re-resolve each
ticker so the new broker_symbol override takes effect at the next fetch."""
try:
providers = cfg_manager.get_all_providers()
except Exception as e:
log.warning(f"get_all_providers failed: {e}")
return
for pid, prov in (providers or {}).items():
if not hasattr(prov, "reset_symbol"):
continue
for t in tickers:
try:
resolved = prov.reset_symbol(t)
log.info(f"[{pid}] reset_symbol({t}) → {resolved}")
except Exception as e:
log.warning(f"[{pid}] reset_symbol({t}) failed: {e}")
def create_config_router(cfg_manager, broadcast_event: Optional[Callable] = None) -> APIRouter:
"""
Create config API router wired to the given ConfigManager instance.
broadcast_event: async callable to push WS events on config change.
"""
router = APIRouter(prefix="/api/config", tags=["config"])
async def _notify():
"""Push config_update event to all WS clients."""
if broadcast_event:
try:
await broadcast_event({"type": "config_update"})
except Exception:
pass # Non-critical
# ── GET /api/config — full merged config ──
@router.get("")
async def get_config():
return cfg_manager.get_config()
# ── PATCH /api/config — partial update ──
@router.patch("")
async def patch_config(request: Request):
try:
patch = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body")
# v2: detect broker_symbol changes in custom_tickers so we can re-resolve
# the affected tickers on the live MT5 provider without a restart.
affected_tickers = _collect_broker_symbol_changes(patch)
cfg_manager.update(patch)
if affected_tickers:
_apply_broker_symbol_resets(cfg_manager, affected_tickers)
try:
if broadcast_event:
await broadcast_event({
"type": "data_sync_complete",
"source": "broker_symbol_change",
"tickers": sorted(affected_tickers),
})
except Exception:
pass # non-critical
await _notify()
return cfg_manager.get_config()
# ── POST /api/config/tickers — add ticker ──
@router.post("/tickers")
async def add_ticker(request: Request):
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body")
ticker = body.get("ticker", "").strip().upper()
if not ticker:
raise HTTPException(400, "ticker is required")
metadata = body.get("metadata")
success = cfg_manager.add_ticker(ticker, metadata)
if success:
await _notify()
return {"success": success, "ticker": ticker}
# ── DELETE /api/config/tickers/{ticker} — remove ticker ──
@router.delete("/tickers/{ticker}")
async def remove_ticker(ticker: str):
canonical = ticker.upper()
success = cfg_manager.remove_ticker(canonical)
if success:
await _notify()
return {"success": success, "ticker": canonical}
# ── POST /api/config/tickers/{ticker}/toggle — hide/show ──
@router.post("/tickers/{ticker}/toggle")
async def toggle_ticker(ticker: str):
canonical = ticker.upper()
visible = cfg_manager.toggle_ticker_visibility(canonical)
await _notify()
return {"ticker": canonical, "visible": visible}
# ── PUT /api/config/tickers/order — reorder display ──
@router.put("/tickers/order")
async def reorder_tickers(request: Request):
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body")
order = body.get("order", [])
if not isinstance(order, list):
raise HTTPException(400, "order must be a list of ticker strings")
cfg_manager.reorder_tickers(order)
await _notify()
return {"success": True, "order": order}
# ── POST /api/config/profiles — save profile ──
@router.post("/profiles")
async def save_profile(request: Request):
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body")
name = body.get("name", "").strip()
if not name:
raise HTTPException(400, "name is required")
cfg_manager.save_profile(name)
await _notify()
return {"success": True, "name": name}
# ── POST /api/config/profiles/{name}/load — load profile ──
@router.post("/profiles/{name}/load")
async def load_profile(name: str):
success = cfg_manager.load_profile(name)
if not success:
raise HTTPException(404, f"Profile '{name}' not found")
await _notify()
return {"success": True, "name": name}
# ── DELETE /api/config/profiles/{name} — delete profile ──
@router.delete("/profiles/{name}")
async def delete_profile(name: str):
success = cfg_manager.delete_profile(name)
if not success:
raise HTTPException(404, f"Profile '{name}' not found")
await _notify()
return {"success": True, "name": name}
return router