# version: v11 # v11 — User-owned saves move to %USERPROFILE%\Documents\Quantum Terminal\ as # per-file stores (2026-05-13). Bug: a 4-key trimmed user_config.json # was being written when _load_user_config silently fell back to {} # on read errors; profiles/templates/presets were getting wiped on # every rebuild/update. Fix: (a) new per-file store with one JSON # per saved item under Documents/Quantum Terminal/{profiles,chart_templates, # chart_presets}/; (b) one-time migration from AppData-v2 (and # QuantumTerminal-X + v1 QuantumTerminal as best-effort fallback sources, read-only) # gated on a marker file; (c) _load_user_config renames a corrupt # file to .corrupt. instead of silently wiping it. Existing REST # routes /api/chart-templates, /api/chart-presets, /api/config/profiles # are unchanged on the wire — only the backing storage changes. # Spec: docs/specs/2026-05-13-user-data-store-rework-design.md. # # v10 — Module-level chart_templates API (get_chart_templates, # save_chart_template, delete_chart_template), mirroring the v7 # chart_presets layer. Default config skeleton gains a # "chart_templates": {} key; _save_user_config preserves it from # on-disk like chart_presets; _load_config defaults + backfills it. # Consumed via /api/chart-templates only — doesn't flow through the # merged config served to the frontend. (2026-05-11) # # v9 — _save_user_config now preserves chart_presets from on-disk state # before writing. This closes the residual race where ConfigManager # could overwrite a preset just saved via the module path (because the # instance's self._user_config is loaded once at init and doesn't # track chart_presets writes from the module path). Together with v8, # both write directions for chart_presets are now race-free. # # v8 — removed the v7 module-level `_cached_config` layer. The dual-layer # design (ConfigManager instance cache + module-level cache, both # writing to user_config.json) was a lost-write hazard: a chart-preset # PUT would update disk + module cache, but the ConfigManager instance # held a stale `self._user_config`; the next ConfigManager._save_user_config # (e.g. from a profile save or any /api/config PATCH) would overwrite # the file with its stale snapshot, silently dropping the just-saved # chart preset. v8 fixes this by making the chart-preset path # always-read-fresh-from-disk + atomic-write-via-tempfile, so the two # layers no longer race over a shared cache. Tests hook the path # resolver, not the cache, so dropping the cache is test-transparent. # v7 — added module-level chart_presets API (get_chart_presets, # save_chart_preset, delete_chart_preset) plus a module-level # load/save layer (_user_config_path, _load_config, _save_config, # _cached_config) that operates directly on user_config.json. This # is independent of the ConfigManager class instance — chart presets # are consumed via /api/chart-presets only and don't flow through # the merged config served to the frontend, so the two layers can # coexist without a stale-cache hazard. Default config skeleton now # includes a "chart_presets": {} key so first-write of a preset slots # cleanly into existing user configs. # v6 — user_config.json (profiles + universe + per-ticker preferences) # now lives in %APPDATA%\QuantumTerminal-v2\ instead of the install directory. # Reason: NSIS overwrites the install directory on every update, # which was wiping operator profiles on every reinstall. AppData # survives reinstalls. First-launch migration copies the legacy # PROJECT_ROOT/user_config.json into AppData if AppData is empty, # so existing users keep their profiles transparently. """ ================================================================================ Quantum Terminal — Configuration Manager v2 — Added get_broker_symbol() helper and auto-wires broker-symbol override callback into each provider after creation. Lets users override a broker's symbol naming (e.g. EURUSD → EURUSD_VV2) from the Settings panel and have the MT5 provider use that mapping at resolve time. ================================================================================ ================================================================================ Central config system for the terminal. Single source of truth. Architecture: server_config.py (static defaults, versioned in Git) ↓ merged with user_config.json (user runtime preferences, gitignored) ↓ produces ConfigManager.get_config() → unified config dict served to frontend What it manages: - Universe: which tickers are active, their metadata, display order - Providers: which data/execution sources are configured and active - Display: theme, chart height, visible panels, default timeframe - Modules: which quant modules are enabled (cones, bands, signals, etc.) - Profiles: save/load named terminal configurations Mutation: - PATCH /api/config → ConfigManager.update(patch_dict) → writes user_config.json - Frontend reads via GET /api/config on mount (useConfig hook) - Changes broadcast via WS event so all clients stay in sync Portability (Rule 3): - user_config.json lives at PROJECT_ROOT/user_config.json - No absolute paths in config — terminal_path comes from local_config.ini - Machine-specific values (credentials, paths) stay in local_config.ini Usage: from config_manager import ConfigManager cfg = ConfigManager() full_config = cfg.get_config() # Merged config dict cfg.update({"display": {"chart_height": 500}}) # Partial update ================================================================================ """ import json import copy import logging import os import tempfile import hashlib import re from pathlib import Path from typing import Dict, List, Optional, Any from datetime import datetime, timezone log = logging.getLogger("config_manager") # ── Project root (Rule 3) ── PROJECT_ROOT = Path(__file__).resolve().parent # v6: AppData dir — same env-var convention used by license_client.py so v1 # and v2 can run side by side without sharing user state. Default # "QuantumTerminal" preserves v1 behavior. APP_DIR = os.environ.get("MK_APP_DIR_NAME", "QuantumTerminal") def _appdata_user_config_path() -> Path: """Resolve %APPDATA%\\\\user_config.json. Used as the canonical storage path for user preferences + saved profiles. Survives reinstalls of the app (NSIS overwrites the install dir but leaves AppData alone).""" appdata = os.environ.get("APPDATA") if appdata: base = Path(appdata) / APP_DIR else: base = Path.home() / "AppData" / "Roaming" / APP_DIR base.mkdir(parents=True, exist_ok=True) return base / "user_config.json" # ════════════════════════════════════════════════════════════════ # v11: per-file user-data store (Documents\Quantum Terminal\) # ════════════════════════════════════════════════════════════════ # # Three classes of user-owned saves — profiles, chart_templates, # chart_presets — live as one JSON file per saved item under # %USERPROFILE%\Documents\Quantum Terminal\\. Decouples them from the # user_config.json trimming-on-save bug, and puts them in a folder users # can see/back up/copy. _USER_DATA_SUBDIRS = ("profiles", "chart_templates", "chart_presets") _MIGRATION_MARKER = ".migrated_v1" _README_TEXT = ( "Quantum Terminal — your saved settings live here.\n" "\n" " profiles/ — saved workspace profiles\n" " chart_templates/ — saved chart templates\n" " chart_presets/ — saved chart style presets\n" "\n" "Each .json file is one saved item. Safe to back up this whole folder.\n" "Copying these files to another machine restores your settings there.\n" ) def _user_data_dir() -> Path: """Resolve the root of the per-file user-data store. Defaults to %USERPROFILE%\\Documents\\Quantum Terminal\\. Overridable via MK_USER_DATA_DIR env var (used by tests + power users).""" override = os.environ.get("MK_USER_DATA_DIR", "").strip() if override: return Path(override) # Path.home() returns USERPROFILE on Windows. "Documents" is the # conventional name; if a user has redirected (OneDrive), Path.home() # still resolves under the OneDrive-managed Documents in practice. return Path.home() / "Documents" / "Quantum Terminal" def _user_data_subdir(kind: str) -> Path: """Resolve and create one subdir of the user-data store.""" if kind not in _USER_DATA_SUBDIRS: raise ValueError(f"unknown user-data subdir: {kind!r}") d = _user_data_dir() / kind d.mkdir(parents=True, exist_ok=True) return d def _ensure_user_data_root() -> Path: """Create Documents\\Quantum Terminal\\ + all subdirs + README. Idempotent.""" root = _user_data_dir() root.mkdir(parents=True, exist_ok=True) for sub in _USER_DATA_SUBDIRS: (root / sub).mkdir(parents=True, exist_ok=True) readme = root / "README.txt" if not readme.exists(): try: readme.write_text(_README_TEXT, encoding="utf-8") except OSError: pass return root _FILENAME_INVALID_RE = re.compile(r"[^A-Za-z0-9_\- ]") def _slugify_name(name: str) -> str: """Make a filename-safe slug from a user-supplied name. Replaces unsafe chars with `_`, collapses runs, trims, caps at 60 chars.""" s = _FILENAME_INVALID_RE.sub("_", (name or "").strip()) s = re.sub(r"_+", "_", s) s = s.strip("_ ") or "untitled" return s[:60] def _name_hash8(name: str) -> str: return hashlib.sha256((name or "").encode("utf-8")).hexdigest()[:8] def _user_data_filename(name: str) -> str: """`__.json` — deterministic, collision-safe.""" return f"{_slugify_name(name)}__{_name_hash8(name)}.json" def _user_data_atomic_write(path: Path, payload: Dict[str, Any]) -> None: """Atomic write via tempfile + os.replace.""" path.parent.mkdir(parents=True, exist_ok=True) fd, tmp_path = tempfile.mkstemp( prefix=path.stem + ".", suffix=".tmp", dir=str(path.parent) ) try: with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(payload, f, indent=2, ensure_ascii=False, sort_keys=True) os.replace(tmp_path, path) except Exception: if os.path.exists(tmp_path): try: os.unlink(tmp_path) except OSError: pass raise def _user_data_list(kind: str) -> Dict[str, Dict[str, Any]]: """Walk a subdir, parse each .json file, return {name: payload}. Each file's `name` field is the authoritative key — filename slug is cosmetic. Skips files that don't parse or don't carry a `name`.""" out: Dict[str, Dict[str, Any]] = {} d = _user_data_subdir(kind) for p in sorted(d.glob("*.json")): try: with open(p, "r", encoding="utf-8") as f: data = json.load(f) name = data.get("name") if not isinstance(name, str) or not name: # Fallback: try to recover the name from filename slug. # Shouldn't normally happen, but be lenient on read. continue payload = {k: v for k, v in data.items() if k != "name"} out[name] = payload except (OSError, json.JSONDecodeError): log.warning(f"Skipping unreadable user-data file: {p}") continue return out def _user_data_load(kind: str, name: str) -> Optional[Dict[str, Any]]: """Load one item by exact name. Returns None if not present.""" fn = _user_data_filename(name) p = _user_data_subdir(kind) / fn if not p.exists(): # Fallback: scan in case filename was hand-edited. all_items = _user_data_list(kind) return all_items.get(name) try: with open(p, "r", encoding="utf-8") as f: data = json.load(f) if data.get("name") == name: return {k: v for k, v in data.items() if k != "name"} # Hash collision (extremely unlikely) — fall back to scan all_items = _user_data_list(kind) return all_items.get(name) except (OSError, json.JSONDecodeError): return None def _user_data_save(kind: str, name: str, payload: Dict[str, Any]) -> None: """Write one item. Atomic. Overwrites if a file with the same slug+hash already exists (same name → same filename).""" if not name or not name.strip(): raise ValueError("name cannot be empty") # Function-arg `name` MUST win over any stray `name` key inside payload; # spread payload first, then set name last. body = {**payload, "name": name} if "saved_at" not in body: body["saved_at"] = datetime.now(timezone.utc).isoformat() p = _user_data_subdir(kind) / _user_data_filename(name) _user_data_atomic_write(p, body) def _user_data_delete(kind: str, name: str) -> bool: """Delete one item. Returns True if a file was removed.""" fn = _user_data_filename(name) p = _user_data_subdir(kind) / fn if p.exists(): try: p.unlink() return True except OSError: return False # Fallback: scan filenames in case slug rules changed. for cand in _user_data_subdir(kind).glob("*.json"): try: with open(cand, "r", encoding="utf-8") as f: if json.load(f).get("name") == name: cand.unlink() return True except (OSError, json.JSONDecodeError): continue return False # ════════════════════════════════════════════════════════════════ # v11: one-time migration from user_config.json → per-file store # ════════════════════════════════════════════════════════════════ # # Reads up to three legacy sources (live AppData v2, orphaned QuantumTerminal-X, # v1 QuantumTerminal — read-only) and writes each non-empty profiles[]/ # chart_presets[]/chart_templates[] entry to the new per-file store. # Existing per-file entries are NEVER overwritten — first-source-wins. # Gated on a marker file in the user-data root so this fires exactly once. def _migration_marker_path() -> Path: return _user_data_dir() / _MIGRATION_MARKER def _legacy_user_config_paths() -> List[Path]: """Return up to three paths to legacy user_config.json files, in priority order: live AppData v2 first, then any sibling dirs.""" appdata = os.environ.get("APPDATA") paths: List[Path] = [] if appdata: base = Path(appdata) # Live v2 first (most authoritative). paths.append(base / APP_DIR / "user_config.json") # QuantumTerminal-X — orphaned attempt with a typo'd env var. if APP_DIR != "QuantumTerminal-X": paths.append(base / "QuantumTerminal-X" / "user_config.json") # v1 — read-only fallback (Rule 8: never write). if APP_DIR != "QuantumTerminal": paths.append(base / "QuantumTerminal" / "user_config.json") return [p for p in paths if p.exists()] def _migrate_user_data_once() -> None: """Run the one-time migration. Idempotent — gated on marker file.""" _ensure_user_data_root() marker = _migration_marker_path() if marker.exists(): return # already migrated sources = _legacy_user_config_paths() migrated_counts = {"profiles": 0, "chart_templates": 0, "chart_presets": 0} for src in sources: try: with open(src, "r", encoding="utf-8") as f: legacy = json.load(f) except (OSError, json.JSONDecodeError) as e: log.warning(f"Migration skipping unreadable {src}: {e}") continue for kind in ("profiles", "chart_templates", "chart_presets"): section = legacy.get(kind) or {} if not isinstance(section, dict): continue existing = _user_data_list(kind) for name, payload in section.items(): if not isinstance(name, str) or not name: continue if name in existing: continue # never overwrite existing per-file entries if not isinstance(payload, dict): continue try: _user_data_save(kind, name, payload) migrated_counts[kind] += 1 log.info(f"Migrated {kind}[{name!r}] from {src}") except Exception as e: log.warning(f"Migration failed to write {kind}[{name!r}]: {e}") # Strip the migrated keys from the LIVE AppData v2 user_config.json # ONLY. Never touch QuantumTerminal-X or v1. live = _appdata_user_config_path() if live.exists(): try: with open(live, "r", encoding="utf-8") as f: cfg = json.load(f) stripped = False for kind in ("profiles", "chart_templates", "chart_presets"): if kind in cfg: del cfg[kind] stripped = True if stripped: _save_config(cfg) # module-level atomic writer (existing) log.info(f"Stripped legacy save dicts from {live}") except (OSError, json.JSONDecodeError) as e: log.warning(f"Could not strip legacy keys from {live}: {e}") try: marker.write_text("migrated 2026-05-13\n", encoding="utf-8") log.info( f"User-data migration complete: profiles={migrated_counts['profiles']}, " f"chart_templates={migrated_counts['chart_templates']}, " f"chart_presets={migrated_counts['chart_presets']}" ) except OSError as e: log.warning(f"Could not write migration marker: {e}") # ── Import static defaults ── from server_config import ( ASSET_UNIVERSE, ASSET_DECIMALS, ASSET_CLASSES, SYMBOL_ALIASES, MT5_TIMEFRAMES, ServerConfig, ) def _read_rithmic_config() -> Optional[Dict[str, str]]: """ Read Rithmic credentials from local_config.ini [rithmic] section. Returns dict with user, password, system_name, url — or None if missing. Machine-specific credentials stay in local_config.ini (Rule 3, gitignored). """ import configparser ini_path = PROJECT_ROOT / "local_config.ini" if not ini_path.exists(): return None try: parser = configparser.ConfigParser() parser.read(str(ini_path)) if "rithmic" not in parser: return None section = parser["rithmic"] cfg = {} for key in ("user", "password", "system_name", "url"): val = section.get(key, "").strip() if val and not val.startswith("YOUR_"): cfg[key] = val # Only return if we have at least user + password + url if cfg.get("user") and cfg.get("password") and cfg.get("url"): return cfg return None except Exception as e: log.warning(f"Failed to read [rithmic] from local_config.ini: {e}") return None # ════════════════════════════════════════════════════════════════ # Default user config schema # ════════════════════════════════════════════════════════════════ DEFAULT_USER_CONFIG = { "version": 1, # ── Universe ── "universe": { # Active tickers — derived from ASSET_UNIVERSE on first run # User can add/remove/reorder via settings panel "active_tickers": list(ASSET_UNIVERSE), # Custom tickers added by user (not in default universe) # Key = canonical name, value = metadata overrides "custom_tickers": {}, # Tickers to hide from display (still tracked, just not shown) "hidden_tickers": [], # Display order — if empty, uses active_tickers order "display_order": [], }, # ── Providers ── "providers": { # Which provider instance handles price feed "active_data": "mt5_default", # Which provider instance handles execution "active_execution": "mt5_default", # Configured accounts / data sources "accounts": { "mt5_default": { "id": "mt5_default", "type": "mt5", "label": "MetaTrader 5", "enabled": True, "terminal_path": None, # None = auto-detect, or read from local_config.ini "aliases": {}, # Per-account alias overrides (merged with defaults) # When True, the tick loop polls MT5 every 0.2s instead of 1.0s # so the chart's last candle flickers at MT5-style rates. # Default OFF — opt-in for users with capable hardware. "use_tick_data": False, }, "rithmic_default": { "id": "rithmic_default", "type": "rithmic", "label": "Rithmic — Paper Trading", "enabled": False, # User enables after entering credentials "user": "", "password": "", "system_name": "Rithmic Paper Trading", "url": "", "app_name": "Quantum Terminal", "app_version": "1.0", }, }, }, # ── Display ── "display": { "theme": "dark", "default_timeframe": "M15", "default_lookback_bars": 200, "chart_height": 420, "visible_panels": { "regime_bar": True, "signal_panel": True, "quant_panel": True, "asset_sidebar": True, }, "chart_layers": { "cones_gbm": True, "cones_mjd": True, "bands": True, "signals": True, "anchors": True, "volume": True, }, }, # ── Execution ── # ALL OFF by default — user must explicitly enable live trading. "execution": { "live_trading": False, # Master kill switch — no orders sent unless True "show_positions": False, # Show current open positions from broker "show_trade_history": False, # Show closed trades on chart / panel "confirm_orders": True, # Require confirmation dialog before sending "default_risk_pct": 1.0, # Default risk per trade (% of equity) "max_lots": 10.0, # Hard cap on lot size "magic_number": 202603, # MT5 magic number for Quantum Terminal orders }, # ── Modules ── "modules": { "enabled": [ "cones", "bands", "signals", "anchors", "regime", "garch", "ou", "wf", "kelly", ], }, # ── Saved profiles ── "profiles": { # "profile_name": { full config snapshot minus profiles } }, # ── Chart presets (named chart-style fragments, managed via /api/chart-presets) ── "chart_presets": { # "preset_name": { chart style fragment — see chartStyleDefaults.js for shape } }, # ── Chart templates (named full-chart-config snapshots, managed via /api/chart-templates) ── "chart_templates": { # "name": { "sourceTicker": str, "config": {...full chart config...}, "savedAt": ISO str } }, # ── Chart state (managed by MainChart, saved/restored with profiles) ── "_chart_state": {}, # ── Last used profile name (auto-loaded on startup) ── "_last_profile": None, } # ════════════════════════════════════════════════════════════════ # Asset metadata builder # ════════════════════════════════════════════════════════════════ # v4: pattern-based asset-class inference for dynamically-discovered tickers # that don't have a server_config.ASSET_CLASSES entry. Used by the settings # UI to group new assets correctly (FX / INDEX / COMMODITY / CRYPTO / # STOCK) instead of dumping them all under OTHER. _CRYPTO_PREFIXES = ("BTC", "ETH", "SOL", "XRP", "ADA", "LTC", "DOGE", "DOT", "AVAX", "LINK", "MATIC", "BNB", "TRX", "SHIB") _COMMODITY_PREFIXES = ("XAU", "XAG", "XPT", "XPD", "XTI") _COMMODITY_NAMES = frozenset({"BRENT", "WTI", "NGAS", "HG", "CL"}) _INDEX_NAMES = frozenset({ "US500", "USTEC", "US30", "GER40", "UK100", "FR40", "AUS200", "JP225", "HK50", "SPX", "NDX", "DJI", "DAX", "FTSE", "NI225", "ESTX50", "EU50", }) _CURRENCIES = frozenset({ "USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD", "SEK", "NOK", "DKK", "CNH", "MXN", "TRY", "ZAR", "SGD", "HKD", }) def _infer_asset_class(ticker: str) -> str: t = (ticker or "").upper() if not t: return "OTHER" # Crypto — exact prefix or known ticker if any(t.startswith(p) for p in _CRYPTO_PREFIXES): return "CRYPTO" # Commodity — metals/energy if any(t.startswith(p) for p in _COMMODITY_PREFIXES) or t in _COMMODITY_NAMES: return "COMMODITY" # Index — explicit list if t in _INDEX_NAMES: return "INDEX" # FX — 6-letter pair of known currencies if len(t) == 6 and t[:3] in _CURRENCIES and t[3:] in _CURRENCIES: return "FX" # Stock — 1-5 letters, pure alpha (AAPL, TSLA, MSFT, NVDA, …) if 1 <= len(t) <= 5 and t.isalpha(): return "STOCK" return "OTHER" def _build_asset_metadata() -> Dict[str, dict]: """ Build asset metadata dict from server_config static data. This is the server-side source of truth — replaces the hardcoded ASSET_META dict that was duplicated in the frontend. Returns: { "XAUUSD": { "class": "COMMODITY", "decimals": 2, "aliases": ["XAUUSD", "GOLD", "XAUUSD.cash"], }, ... } """ meta = {} for ticker in ASSET_UNIVERSE: meta[ticker] = { "class": ASSET_CLASSES.get(ticker, "OTHER"), "decimals": ASSET_DECIMALS.get(ticker, 2), "aliases": SYMBOL_ALIASES.get(ticker, [ticker]), } return meta # ════════════════════════════════════════════════════════════════ # ConfigManager # ════════════════════════════════════════════════════════════════ class ConfigManager: """ Central configuration manager. Lifecycle: 1. Load server_config.py defaults (static, versioned) 2. Load user_config.json if it exists (user overrides, gitignored) 3. Merge: user config wins where specified 4. Expose merged config via get_config() 5. Accept mutations via update() → writes back to user_config.json Provider management: - Creates provider instances from accounts config - Tracks which provider is active for data vs execution - Provides universe to active provider for symbol resolution """ def __init__(self, config_path: Optional[Path] = None): # v6: default storage moved from PROJECT_ROOT (install dir, wiped # on update) to AppData (survives reinstalls). Tests can still # override via the config_path argument. self._config_path = config_path or _appdata_user_config_path() self._user_config: Dict[str, Any] = {} self._asset_metadata: Dict[str, dict] = _build_asset_metadata() self._providers: Dict[str, Any] = {} # id → BaseProvider instance # v6: one-time migration — if AppData has no file yet but the legacy # install-dir file exists, copy it over so existing users keep # their profiles. Idempotent: only fires when AppData is empty. try: legacy_path = PROJECT_ROOT / "user_config.json" if ( config_path is None and not self._config_path.exists() and legacy_path.exists() and legacy_path.resolve() != self._config_path.resolve() ): self._config_path.parent.mkdir(parents=True, exist_ok=True) self._config_path.write_text( legacy_path.read_text(encoding="utf-8"), encoding="utf-8" ) log.info( f"Migrated legacy user_config from {legacy_path} → {self._config_path}" ) except Exception as e: log.warning(f"Legacy user_config migration failed (non-fatal): {e}") # Load persisted user config self._load_user_config() # v11: one-time per-file user-data migration. Idempotent (gated on # a marker file in Documents\Quantum Terminal\). Safe to call always. try: _migrate_user_data_once() # v11: in-memory mirror of the disk strip. _user_config was # loaded BEFORE migration and still carries any legacy # profiles/chart_templates/chart_presets dicts. If we leave # them in the cache, the next workspace auto-save would # write them back to disk and silently undo the strip. # Idempotent — pop is a no-op if the key isn't there. for _k in ("profiles", "chart_templates", "chart_presets"): self._user_config.pop(_k, None) except Exception as e: log.warning(f"User-data migration error (non-fatal): {e}") # ── Persistence ── def _load_user_config(self) -> None: """Load user_config.json. On parse/IO failure, rename the corrupt file to user_config.json.corrupt. so a transient error can't trigger the workspace-save wipe sequence, then start with an empty config. Per-file user-data store (profiles / chart_templates / chart_presets) is unaffected — those live in Documents\\Quantum Terminal\\ and are not read here.""" path = self._config_path if not path.exists(): log.info("No user_config.json found — using defaults (will create on first save)") self._user_config = {} return try: with open(path, "r", encoding="utf-8") as f: self._user_config = json.load(f) log.info(f"Loaded user config from {path.name}") return except (json.JSONDecodeError, IOError, OSError) as e: ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") backup = path.with_suffix(path.suffix + f".corrupt.{ts}") try: os.replace(str(path), str(backup)) log.error( f"user_config.json unreadable ({e}); preserved at {backup.name}. " f"Starting with empty config — saved profiles / templates / presets " f"in Documents\\Quantum Terminal\\ are unaffected." ) except OSError as e2: log.error(f"user_config.json unreadable and could not be backed up: {e2}") self._user_config = {} def _save_user_config(self) -> None: """Write current user config to disk. v9: Before writing, preserve `chart_presets` from on-disk state. The chart-presets module-level layer (see _load_config / _save_config below) writes presets directly to disk, bypassing this instance's in-memory cache. Without this preservation, the next instance write would silently clobber any preset added since the last instance load. """ try: preserved_chart_presets = None preserved_chart_templates = None if self._config_path.exists(): try: with open(self._config_path, "r", encoding="utf-8") as f: on_disk = json.load(f) preserved_chart_presets = on_disk.get("chart_presets") preserved_chart_templates = on_disk.get("chart_templates") except (OSError, json.JSONDecodeError): preserved_chart_presets = None preserved_chart_templates = None if preserved_chart_presets is not None: self._user_config["chart_presets"] = preserved_chart_presets if preserved_chart_templates is not None: self._user_config["chart_templates"] = preserved_chart_templates with open(self._config_path, "w", encoding="utf-8") as f: json.dump(self._user_config, f, indent=2, ensure_ascii=False) log.info(f"Saved user config to {self._config_path.name}") except IOError as e: log.error(f"Failed to save user config: {e}") # ── Config Access ── def get_config(self) -> Dict[str, Any]: """ Return the full merged config dict. This is what GET /api/config serves to the frontend. Shape: { "version": 1, "universe": { ... }, "providers": { ... }, "display": { ... }, "modules": { ... }, "profiles": { ... }, "asset_metadata": { ticker: {class, decimals, aliases} }, "available_timeframes": ["M1", "M5", ...], "available_provider_types": ["mt5", ...], "server": { host, port, uptime_seconds }, } """ merged = self._deep_merge( copy.deepcopy(DEFAULT_USER_CONFIG), copy.deepcopy(self._user_config), ) # v11: overlay user-owned saves from the per-file store. These keys # are no longer in self._user_config (post-migration); the new # store is authoritative. try: merged["profiles"] = _user_data_list("profiles") merged["chart_templates"] = _user_data_list("chart_templates") merged["chart_presets"] = _user_data_list("chart_presets") except Exception as e: log.warning(f"Failed to load user-data store entries: {e}") # leave whatever was already in merged (could be {} from defaults) # Inject computed / read-only fields merged["asset_metadata"] = self._get_full_asset_metadata(merged) # v3: the Settings Universe UI reads `universe.active_tickers` straight # out of this dict, so overwrite it with the dynamic union of # configured + cache-discovered tickers. Raw user_config on disk # stays untouched — the override is applied at read time only. try: dyn = self.get_active_universe() if dyn: merged.setdefault("universe", {})["active_tickers"] = dyn except Exception: log.warning("universe auto-discovery failed; falling back to configured list", exc_info=True) # v5: surface per-ticker data-type availability so the Settings UI # can paint the dot color (green = full, purple = quarterly-only, # grey = nothing). Empty dict if cache discovery failed. try: merged["quant_data_types"] = self._discover_tickers_data_types() except Exception: merged["quant_data_types"] = {} # Tickers that actually have synced quant data in the consumer's local cache. # Source of truth = data_sync_client.synced_tickers (what the VPS has shipped), # NOT the hardcoded ASSET_UNIVERSE constant (which can lag behind producer changes). # Falls back to ASSET_UNIVERSE only if the sync client is unavailable (PRO mode # or pre-first-sync). Custom user tickers are never in this list. try: from data_sync_client import get_sync_client synced = get_sync_client().synced_tickers merged["quant_supported_tickers"] = list(synced) if synced else list(ASSET_UNIVERSE) except Exception: merged["quant_supported_tickers"] = list(ASSET_UNIVERSE) merged["available_timeframes"] = list(MT5_TIMEFRAMES.keys()) from providers import list_provider_types merged["available_provider_types"] = list_provider_types() # Provider status merged["provider_status"] = {} for pid, prov in self._providers.items(): merged["provider_status"][pid] = { "connected": prov.connected, "type": prov.provider_type, "label": prov.label, "can_execute": prov.can_execute, } return merged # v3: discover tickers from the sync cache so new DC-shipped assets # auto-appear in the universe without user intervention. # Matches only per-ticker filenames ending in one of the known # data-type suffixes — this prevents GLOBAL-scope files like # `GLOBAL_macro_sector_flows.json` from being mistaken for a # "MACRO" ticker. Add suffixes here when DC ships new per-ticker # data types. _PER_TICKER_SUFFIXES = ( "bands", "cones", "historical_cones", "quarterly_cones", "scalp_bands", "options", "probfield", "bands_replay", ) # v4: aggregate / composite files produced by DC that match the per-ticker # filename pattern but aren't actually tickers (their content is a # cross-asset rollup). Ask DC to rename these to GLOBAL_* prefixes # so we don't need to maintain this list. _NON_TICKER_NAMES = frozenset({"SCORE", "COMPOSITE", "AGGREGATE"}) def _discover_tickers_data_types(self) -> Dict[str, List[str]]: """v5: return {TICKER: [data_types]} from cache scan. Single source of truth for (a) get_active_universe discovery and (b) per-ticker quant-data-availability diagnostics surfaced in /api/config. """ try: from data_sync_client import _get_cache_dir cache_dir = _get_cache_dir() except Exception: return {} if not cache_dir or not Path(cache_dir).is_dir(): return {} # v4: longest-first so `_quarterly_cones` beats `_cones` on # `GLOBAL_aapl_quarterly_cones.json` and AAPL survives. ordered = sorted(self._PER_TICKER_SUFFIXES, key=len, reverse=True) suffixes = tuple((f"_{s}", s) for s in ordered) result: Dict[str, set] = {} try: for f in Path(cache_dir).iterdir(): if not f.is_file() or f.suffix.lower() != ".json": continue name = f.stem if name.startswith("_"): continue match_sfx = match_type = None for sfx_full, sfx_bare in suffixes: if name.endswith(sfx_full): match_sfx, match_type = sfx_full, sfx_bare break if not match_sfx: continue prefix = name[:-len(match_sfx)] if prefix.startswith("GLOBAL_"): prefix = prefix[len("GLOBAL_"):] prefix = prefix.strip("_") if prefix and prefix.replace("-", "").isalnum(): up = prefix.upper() if up in self._NON_TICKER_NAMES: continue result.setdefault(up, set()).add(match_type) except Exception: log.warning("cache discovery failed", exc_info=True) return {} return {t: sorted(types) for t, types in sorted(result.items())} def _discover_tickers_from_cache(self) -> List[str]: # Kept for back-compat — thin wrapper over the richer discovery. return list(self._discover_tickers_data_types().keys()) def get_active_universe(self) -> List[str]: """ Return the current active ticker list. v3: union of (a) tickers the user explicitly added to their universe and (b) tickers discovered in the local sync cache. Discovery alone is usually enough, but the union keeps user-pinned tickers visible even when their data hasn't arrived yet. """ merged = self._deep_merge( copy.deepcopy(DEFAULT_USER_CONFIG.get("universe", {})), copy.deepcopy(self._user_config.get("universe", {})), ) configured = merged.get("active_tickers", list(ASSET_UNIVERSE)) discovered = self._discover_tickers_from_cache() if not discovered: return list(configured) # Union preserving order: configured first, then new discoveries. seen = set() out = [] for t in list(configured) + discovered: tu = t.upper() if tu not in seen: seen.add(tu) out.append(tu) return out def get_display_order(self) -> List[str]: """ Return tickers in display order, excluding hidden ones. Falls back to active_tickers order if no custom order set. v5: pulls the active list from get_active_universe() so dynamically discovered tickers (e.g. AAPL) flow through to /api/health and the chart's + menu. Previously it read the raw disk config, which bypassed discovery. """ merged_uni = self._deep_merge( copy.deepcopy(DEFAULT_USER_CONFIG.get("universe", {})), copy.deepcopy(self._user_config.get("universe", {})), ) active = self.get_active_universe() hidden = set(merged_uni.get("hidden_tickers", [])) order = merged_uni.get("display_order", []) if order: # Use custom order, but only include active non-hidden tickers active_set = set(active) - hidden ordered = [t for t in order if t in active_set] # Append any active tickers not in the custom order for t in active: if t not in hidden and t not in ordered: ordered.append(t) return ordered else: return [t for t in active if t not in hidden] def get_asset_meta(self, ticker: str) -> dict: """ Get metadata for a single ticker. Merges static defaults with any user custom_ticker overrides. """ base = self._asset_metadata.get(ticker, { "class": "OTHER", "decimals": 2, "aliases": [ticker], }) custom = ( self._user_config .get("universe", {}) .get("custom_tickers", {}) .get(ticker, {}) ) return {**base, **custom} def _get_full_asset_metadata(self, merged_config: dict) -> Dict[str, dict]: """ Build complete asset metadata for all active + custom tickers. Merges static server_config data with user custom_ticker overrides. """ result = {} active = merged_config.get("universe", {}).get("active_tickers", []) custom = merged_config.get("universe", {}).get("custom_tickers", {}) for ticker in active: # v4: if the ticker has no static metadata, infer its class from # the naming pattern (AAPL → STOCK, BTCUSD → CRYPTO, XAUUSD → # COMMODITY, …) so the Settings UI can group it properly. base = self._asset_metadata.get(ticker, { "class": _infer_asset_class(ticker), "decimals": 2, "aliases": [ticker], }) override = custom.get(ticker, {}) result[ticker] = {**base, **override} # Add custom tickers not in default universe for ticker, meta in custom.items(): if ticker not in result: result[ticker] = { "class": meta.get("class", "OTHER"), "decimals": meta.get("decimals", 2), "aliases": meta.get("aliases", [ticker]), **meta, } return result # ── Config Mutation ── def get_broker_symbol(self, ticker: str) -> Optional[str]: """v2: Return user-configured broker symbol override for a ticker. Looks up universe.custom_tickers[TICKER].broker_symbol; returns a non-empty trimmed string, or None if unset. """ if not ticker: return None cfg = self.get_config() custom = cfg.get("universe", {}).get("custom_tickers", {}) or {} entry = custom.get(ticker.upper()) or {} val = entry.get("broker_symbol") if isinstance(val, str): s = val.strip() if s: return s return None def update(self, patch: Dict[str, Any]) -> Dict[str, Any]: """ Apply a partial update to user config. Deep-merges the patch into existing user config, saves to disk. Args: patch: Partial config dict (same shape as user_config.json) Only specified fields are updated; others preserved. Returns: Updated full config (same as get_config()) """ self._user_config = self._deep_merge(self._user_config, patch) self._save_user_config() log.info(f"Config updated: {list(patch.keys())}") return self.get_config() # ── Universe Mutation Helpers ── def add_ticker(self, ticker: str, metadata: Optional[dict] = None) -> bool: """ Add a ticker to the active universe. If it's not in the default universe, it goes into custom_tickers. """ uni = self._user_config.setdefault("universe", {}) active = uni.setdefault("active_tickers", list(ASSET_UNIVERSE)) if ticker in active: log.info(f"Ticker {ticker} already in universe") return False active.append(ticker) # If not a default ticker, store metadata if ticker not in ASSET_UNIVERSE: custom = uni.setdefault("custom_tickers", {}) custom[ticker] = metadata or { "class": "OTHER", "decimals": 2, "aliases": [ticker], } self._save_user_config() log.info(f"Added ticker: {ticker}") return True def remove_ticker(self, ticker: str) -> bool: """Remove a ticker from the active universe.""" uni = self._user_config.get("universe", {}) active = uni.get("active_tickers", []) if ticker not in active: return False active.remove(ticker) # Also remove from custom_tickers and display_order custom = uni.get("custom_tickers", {}) custom.pop(ticker, None) order = uni.get("display_order", []) if ticker in order: order.remove(ticker) hidden = uni.get("hidden_tickers", []) if ticker in hidden: hidden.remove(ticker) self._save_user_config() log.info(f"Removed ticker: {ticker}") return True def toggle_ticker_visibility(self, ticker: str) -> bool: """Toggle a ticker between visible and hidden.""" uni = self._user_config.setdefault("universe", {}) hidden = uni.setdefault("hidden_tickers", []) if ticker in hidden: hidden.remove(ticker) visible = True else: hidden.append(ticker) visible = False self._save_user_config() log.info(f"Ticker {ticker} visibility: {'visible' if visible else 'hidden'}") return visible def reorder_tickers(self, order: List[str]) -> None: """Set custom display order for tickers.""" uni = self._user_config.setdefault("universe", {}) uni["display_order"] = order self._save_user_config() log.info(f"Display order updated: {len(order)} tickers") # ── Provider Management ── def init_providers(self) -> Dict[str, Any]: """ Instantiate and connect all enabled providers from config. Called once at server startup. Returns: Dict of provider_id → provider instance """ from providers import create_provider merged = self._deep_merge( copy.deepcopy(DEFAULT_USER_CONFIG.get("providers", {})), copy.deepcopy(self._user_config.get("providers", {})), ) accounts = merged.get("accounts", {}) self._providers.clear() for pid, acfg in accounts.items(): if not acfg.get("enabled", True): log.info(f"Provider {pid} disabled — skipping") continue try: # Merge default aliases with account-specific aliases acfg_with_aliases = {**acfg} merged_aliases = {**SYMBOL_ALIASES} merged_aliases.update(acfg.get("aliases", {})) acfg_with_aliases["aliases"] = merged_aliases # Read terminal_path from local_config.ini if not set if acfg.get("type") == "mt5" and not acfg.get("terminal_path"): from server_config import MT5_TERMINAL_PATH acfg_with_aliases["terminal_path"] = MT5_TERMINAL_PATH # Read Rithmic credentials from local_config.ini if not set if acfg.get("type") == "rithmic" and not acfg.get("user"): rithmic_cfg = _read_rithmic_config() if rithmic_cfg: acfg_with_aliases.update(rithmic_cfg) log.info(f"Rithmic credentials loaded from local_config.ini") provider = create_provider(acfg_with_aliases) self._providers[pid] = provider # v2: give provider a live lookup for user-configured broker # symbol overrides (Settings panel → custom_tickers[X].broker_symbol). if hasattr(provider, "set_broker_symbol_lookup"): try: provider.set_broker_symbol_lookup(self.get_broker_symbol) except Exception as e: log.warning(f"Provider {pid}: broker_symbol lookup wiring failed: {e}") log.info(f"Provider created: {pid} ({provider.provider_type})") except KeyError as e: log.error(f"Failed to create provider {pid}: {e}") return self._providers def get_provider(self, provider_id: Optional[str] = None) -> Optional[Any]: """ Get a provider by ID. If no ID given, returns the active data provider. """ if provider_id: return self._providers.get(provider_id) # Default: active data provider merged = self._deep_merge( copy.deepcopy(DEFAULT_USER_CONFIG.get("providers", {})), copy.deepcopy(self._user_config.get("providers", {})), ) active_id = merged.get("active_data", "mt5_default") return self._providers.get(active_id) def get_execution_provider(self) -> Optional[Any]: """Get the active execution provider.""" merged = self._deep_merge( copy.deepcopy(DEFAULT_USER_CONFIG.get("providers", {})), copy.deepcopy(self._user_config.get("providers", {})), ) active_id = merged.get("active_execution", "mt5_default") return self._providers.get(active_id) def get_all_providers(self) -> Dict[str, Any]: """Return all instantiated providers.""" return dict(self._providers) # ── Profile Management ── def save_profile(self, name: str) -> None: """v11: save profile as one file under Documents\\Quantum Terminal\\profiles\\.""" # Snapshot everything except profiles themselves AND the user-owned # saves (which now live in their own per-file stores). snapshot = { k: copy.deepcopy(v) for k, v in self._user_config.items() if k not in ("profiles", "chart_presets", "chart_templates") } _user_data_save("profiles", name, snapshot) log.info(f"Saved profile: {name}") def load_profile(self, name: str) -> bool: """v11: load profile from per-file store.""" payload = _user_data_load("profiles", name) if payload is None: log.warning(f"Profile '{name}' not found") return False snapshot = dict(payload) snapshot.pop("saved_at", None) # Replace runtime config (workspace / universe / providers / display) # but DO NOT touch the saves stores — they live in files now. self._user_config = copy.deepcopy(snapshot) self._save_user_config() log.info(f"Loaded profile: {name}") return True def delete_profile(self, name: str) -> bool: """v11: delete profile from per-file store.""" ok = _user_data_delete("profiles", name) if ok: log.info(f"Deleted profile: {name}") return ok def list_profiles(self) -> List[dict]: """v11: list profiles from per-file store.""" items = _user_data_list("profiles") result = [] for name, data in items.items(): result.append({ "name": name, "saved_at": data.get("saved_at", "unknown"), "ticker_count": len( (data.get("universe") or {}).get("active_tickers", []) ), }) return result # ── Utilities ── @staticmethod def _deep_merge(base: dict, override: dict) -> dict: """ Recursively merge override into base. Lists in override REPLACE base lists (not append). Dicts are merged recursively. Scalar values in override win. """ result = copy.deepcopy(base) for key, value in override.items(): if ( key in result and isinstance(result[key], dict) and isinstance(value, dict) ): result[key] = ConfigManager._deep_merge(result[key], value) else: result[key] = copy.deepcopy(value) return result # ════════════════════════════════════════════════════════════════ # Module-level chart_presets API # ════════════════════════════════════════════════════════════════ # # v7: chart presets are a small, self-contained slice of user_config.json # that the chart-settings UI reads/writes via /api/chart-presets. A thin # module-level layer (path resolver + load/save helpers) gives the routes # module a clean import-time API. # # v8: the v7 implementation kept its own `_cached_config` dict next to the # ConfigManager instance's `self._user_config` — both layers ultimately # wrote the same file. That was a lost-write race: a chart-preset write # refreshed the module cache, but the ConfigManager instance still held a # stale snapshot, and the next instance-side save would clobber the disk # copy and silently delete the just-saved preset. v8 removes the cache # entirely. Every read re-loads from disk; every write atomically replaces # the file via tempfile + os.replace. Slower (one disk read per preset # query) but correct under multi-writer use. # # Tests patch `_user_config_path()` to a temp file — see # tests/test_config_manager_chart_presets.py. def _user_config_path() -> Path: """Return the absolute path to user_config.json. Wraps `_appdata_user_config_path()` so tests can monkey-patch this single name without touching the AppData resolver used by ConfigManager.""" return _appdata_user_config_path() def _load_config() -> Dict[str, Any]: """Always read fresh from disk. No cache — see v8 header note for rationale (dual-layer cache hazard).""" path = _user_config_path() if not path.exists(): return {"profiles": {}, "chart_presets": {}, "chart_templates": {}, "_last_profile": None} try: with open(path, "r", encoding="utf-8") as f: data = json.load(f) except (OSError, json.JSONDecodeError): return {"profiles": {}, "chart_presets": {}, "chart_templates": {}, "_last_profile": None} if "chart_presets" not in data: data["chart_presets"] = {} if "chart_templates" not in data: data["chart_templates"] = {} return data def _save_config(cfg: Dict[str, Any]) -> None: """Atomically write user config to disk via tempfile + os.replace, so a crash mid-write can never leave a half-written user_config.json on disk. No cache to refresh — see v8 header note.""" path = _user_config_path() path.parent.mkdir(parents=True, exist_ok=True) fd, tmp_path = tempfile.mkstemp( prefix=path.stem + ".", suffix=".tmp", dir=str(path.parent) ) try: with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(cfg, f, indent=2, sort_keys=True) os.replace(tmp_path, path) except Exception: if os.path.exists(tmp_path): try: os.unlink(tmp_path) except OSError: pass raise def get_chart_presets() -> Dict[str, Any]: """Return the dict of named chart-style presets. {} if none.""" return _user_data_list("chart_presets") def save_chart_preset(name: str, style: Dict[str, Any]) -> None: """Create or replace a preset under `name`.""" _user_data_save("chart_presets", name, dict(style)) def delete_chart_preset(name: str) -> None: """Remove a preset. No-op if it doesn't exist.""" _user_data_delete("chart_presets", name) def get_chart_templates() -> Dict[str, Any]: """Return the dict of named chart templates. {} if none.""" return _user_data_list("chart_templates") def save_chart_template(name: str, template: Dict[str, Any]) -> None: """Create or replace a chart template under `name`. `template` is the full template object: {sourceTicker, config, savedAt}.""" _user_data_save("chart_templates", name, dict(template)) def delete_chart_template(name: str) -> None: """Remove a chart template. No-op if it doesn't exist.""" _user_data_delete("chart_templates", name)