Files
QuantumTerminal/backend/config_manager.py
T

1413 lines
59 KiB
Python

# 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.<ts> 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%\\<APP_DIR>\\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\<class>\. 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:
"""`<slug>__<hash8>.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.<UTC-timestamp> 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)