# 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