diff --git a/backend/services/database.py b/backend/services/database.py index 8b53d13..129dee5 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -274,6 +274,19 @@ def init_db(): horizon_days INTEGER, indicators_json TEXT DEFAULT '{}' )""", + # Instrument Analysis — per-instrument overrides (Saxo quote link, custom drivers) + # that must survive a deploy. backend/config/instruments.json is baked into the + # Docker image and gets reset to its git-tracked contents on every + # `docker compose up --build` (deploy/update.sh) — see project_saxo_quote_source + # memory. Merged on top of the static instruments.json catalog at read time by + # services/instrument_service.py, same pattern as instruments_watchlist's + # saxo_quote_symbol column (already SQLite-backed, already durable). + """CREATE TABLE IF NOT EXISTS instrument_overrides ( + instrument_id TEXT PRIMARY KEY, + saxo_quote_symbol TEXT, + drivers_json TEXT, + updated_at TEXT DEFAULT (datetime('now')) + )""", ]: try: c.execute(_sql) @@ -3388,6 +3401,49 @@ def rename_instrument_watchlist(ticker: str, name: str) -> bool: return changed +# ── Instrument Analysis overrides — Saxo link + drivers, kept in SQLite (survives a ── +# ── deploy rebuild, unlike backend/config/instruments.json which is baked into the image) + +def get_instrument_overrides() -> Dict[str, Dict]: + """All overrides keyed by instrument_id (upper), for merging onto instruments.json + at catalog-load time in services/instrument_service.py.""" + conn = get_conn() + rows = conn.execute("SELECT instrument_id, saxo_quote_symbol, drivers_json FROM instrument_overrides").fetchall() + conn.close() + out = {} + for r in rows: + out[r["instrument_id"]] = { + "saxo_quote_symbol": r["saxo_quote_symbol"], + "drivers": json.loads(r["drivers_json"]) if r["drivers_json"] else None, + } + return out + + +def set_instrument_override_saxo_symbol(instrument_id: str, saxo_symbol: Optional[str]) -> None: + instrument_id = instrument_id.upper() + saxo_symbol = (saxo_symbol or "").strip().upper() or None + conn = get_conn() + conn.execute( + "INSERT INTO instrument_overrides (instrument_id, saxo_quote_symbol, updated_at) VALUES (?, ?, datetime('now')) " + "ON CONFLICT(instrument_id) DO UPDATE SET saxo_quote_symbol = excluded.saxo_quote_symbol, updated_at = excluded.updated_at", + (instrument_id, saxo_symbol), + ) + conn.commit() + conn.close() + + +def set_instrument_override_drivers(instrument_id: str, drivers: List[Dict]) -> None: + instrument_id = instrument_id.upper() + conn = get_conn() + conn.execute( + "INSERT INTO instrument_overrides (instrument_id, drivers_json, updated_at) VALUES (?, ?, datetime('now')) " + "ON CONFLICT(instrument_id) DO UPDATE SET drivers_json = excluded.drivers_json, updated_at = excluded.updated_at", + (instrument_id, json.dumps(drivers)), + ) + conn.commit() + conn.close() + + # ── Wavelets — saved simulation/optimization runs ───────────────────────────── def save_wavelet_simulation(name: str, form: Dict, results: Optional[List[Dict]] = None, diff --git a/backend/services/instrument_service.py b/backend/services/instrument_service.py index d2a8926..7ee10b6 100644 --- a/backend/services/instrument_service.py +++ b/backend/services/instrument_service.py @@ -29,10 +29,29 @@ _narrative_cache: Dict[Tuple[str, str], str] = {} def _load_configs() -> None: + """instruments.json is the static, git-tracked catalog (baked into the Docker image — + reset to its git contents on every deploy rebuild). Saxo link + drivers are + user-editable at runtime, so they're kept in SQLite (services.database's + instrument_overrides table, on the persistent db_data volume) and merged on top here, + rather than written back to the JSON file where a deploy would silently discard them.""" global _configs with open(CONFIG_PATH, "r", encoding="utf-8") as f: data = json.load(f) _configs = {inst["id"]: inst for inst in data["instruments"]} + + from services.database import get_instrument_overrides + for uid, override in get_instrument_overrides().items(): + if uid not in _configs: + continue + # saxo_quote_symbol: the override row is the sole source of truth once it exists — + # apply it even when None (an explicit unlink from a previously-linked state). + _configs[uid]["saxo_quote_symbol"] = override.get("saxo_quote_symbol") + # drivers: only overwrite the catalog's own default when this instrument actually + # has a saved override (a row can exist purely for its saxo_quote_symbol, with + # drivers_json left NULL — that must NOT blank out the catalog's base drivers). + if override.get("drivers") is not None: + _configs[uid]["drivers"] = override["drivers"] + logger.info(f"[instrument_service] Loaded {len(_configs)} instrument configs") @@ -49,7 +68,9 @@ def get_instrument(instrument_id: str) -> Optional[Dict]: def update_instrument_drivers(instrument_id: str, drivers: List[Dict]) -> None: - """Persist updated drivers to instruments.json and refresh in-memory config.""" + """Persist updated drivers to the SQLite instrument_overrides table (NOT + instruments.json — that file is baked into the Docker image and gets reset to its + git-tracked contents on every deploy rebuild) and refresh in-memory config.""" global _configs if _configs is None: _load_configs() @@ -58,26 +79,18 @@ def update_instrument_drivers(instrument_id: str, drivers: List[Dict]) -> None: if uid not in _configs: raise ValueError(f"Instrument {uid} not found") - # Load raw JSON, update the matching instrument, save back - with open(CONFIG_PATH, "r", encoding="utf-8") as f: - raw = json.load(f) + from services.database import set_instrument_override_drivers + set_instrument_override_drivers(uid, drivers) - for inst in raw["instruments"]: - if inst["id"] == uid: - inst["drivers"] = drivers - break - - with open(CONFIG_PATH, "w", encoding="utf-8") as f: - json.dump(raw, f, ensure_ascii=False, indent=2) - - # Refresh in-memory cache _configs[uid]["drivers"] = drivers logger.info(f"[instrument_service] Updated drivers for {uid} ({len(drivers)} drivers)") def update_instrument_saxo_link(instrument_id: str, saxo_symbol: Optional[str]) -> None: - """Persist the Saxo quote-symbol link to instruments.json and refresh in-memory config. - saxo_symbol=None clears the link (falls back to yfinance).""" + """Persist the Saxo quote-symbol link to the SQLite instrument_overrides table (NOT + instruments.json — that file is baked into the Docker image and gets reset to its + git-tracked contents on every deploy rebuild, which is why this link kept disappearing) + and refresh in-memory config. saxo_symbol=None clears the link (falls back to yfinance).""" global _configs if _configs is None: _load_configs() @@ -86,16 +99,8 @@ def update_instrument_saxo_link(instrument_id: str, saxo_symbol: Optional[str]) if uid not in _configs: raise ValueError(f"Instrument {uid} not found") - with open(CONFIG_PATH, "r", encoding="utf-8") as f: - raw = json.load(f) - - for inst in raw["instruments"]: - if inst["id"] == uid: - inst["saxo_quote_symbol"] = saxo_symbol - break - - with open(CONFIG_PATH, "w", encoding="utf-8") as f: - json.dump(raw, f, ensure_ascii=False, indent=2) + from services.database import set_instrument_override_saxo_symbol + set_instrument_override_saxo_symbol(uid, saxo_symbol) _configs[uid]["saxo_quote_symbol"] = saxo_symbol logger.info(f"[instrument_service] Updated Saxo link for {uid}: {saxo_symbol}") diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 7d3cd4e..e1a3054 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -57,6 +57,17 @@ const IMPACT_DOT: Record = { // and a major G7/FX market alongside US/EUR/China. const ECO_CURRENCY_FILTERS = ['USD', 'EUR', 'CNY', 'GBP'] as const +// Instrument Analysis's catalog keys FX pairs with yfinance's "=X" suffix (EURUSD=X), +// while the Cockpit watchlist stores the plain ticker (EURUSD) — try both forms before +// concluding an instrument isn't registered there. +const resolveCatalogId = (ticker: string, catalogIds?: Set): string | undefined => { + if (!catalogIds) return undefined + const upper = ticker.toUpperCase() + if (catalogIds.has(upper)) return upper + if (catalogIds.has(`${upper}=X`)) return `${upper}=X` + return undefined +} + const formatDateShort = (dateStr: string) => { const d = new Date(dateStr + 'T00:00:00Z') return d.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) @@ -418,17 +429,17 @@ export default function Dashboard() { )}
{((watchlistQuotesData as any)?.items ?? []).map((it: any) => { - const inCatalog = (instrumentCatalogIds as Set | undefined)?.has(it.ticker.toUpperCase()) + const catalogId = resolveCatalogId(it.ticker, instrumentCatalogIds as Set | undefined) return (
{ if (inCatalog) navigate(`/instruments/${encodeURIComponent(it.ticker)}`) }} + onDoubleClick={() => { if (catalogId) navigate(`/instruments/${encodeURIComponent(catalogId)}`) }} className={clsx( 'flex items-center justify-between gap-1.5 text-[10px] whitespace-nowrap rounded px-1 -mx-1 py-0.5 transition-colors', it.ticker === activeWatchlistTicker ? 'bg-blue-900/20' : 'hover:bg-dark-700/40', - inCatalog && 'cursor-pointer' + catalogId && 'cursor-pointer' )} - title={inCatalog ? `Double-click to open ${it.name || it.ticker} in Instrument Analysis` : undefined} + title={catalogId ? `Double-click to open ${it.name || it.ticker} in Instrument Analysis` : undefined} >