feat: cockpit
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -57,6 +57,17 @@ const IMPACT_DOT: Record<string, string> = {
|
||||
// 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>): 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() {
|
||||
)}
|
||||
<div className="mt-1.5 pt-1.5 border-t border-slate-700/30 space-y-0.5">
|
||||
{((watchlistQuotesData as any)?.items ?? []).map((it: any) => {
|
||||
const inCatalog = (instrumentCatalogIds as Set<string> | undefined)?.has(it.ticker.toUpperCase())
|
||||
const catalogId = resolveCatalogId(it.ticker, instrumentCatalogIds as Set<string> | undefined)
|
||||
return (
|
||||
<div
|
||||
key={it.ticker}
|
||||
onDoubleClick={() => { 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}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -453,9 +464,9 @@ export default function Dashboard() {
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{inCatalog && (
|
||||
{catalogId && (
|
||||
<Link
|
||||
to={`/instruments/${encodeURIComponent(it.ticker)}`}
|
||||
to={`/instruments/${encodeURIComponent(catalogId)}`}
|
||||
className="text-slate-600 hover:text-blue-400 shrink-0 transition-colors"
|
||||
title={`Open ${it.name || it.ticker} in Instrument Analysis`}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user