From 35a00f43c30b752fc6ecf151af41d0b72060a4ee Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Fri, 24 Jul 2026 11:46:37 +0200 Subject: [PATCH] feat: cockpit --- backend/routers/instruments.py | 15 +++++ backend/services/database.py | 38 +++++++++++- backend/services/instrument_service.py | 72 +++++++++++++++++++++- frontend/src/hooks/useApi.ts | 10 +++ frontend/src/pages/Dashboard.tsx | 9 --- frontend/src/pages/InstrumentDashboard.tsx | 70 ++++++++++++++++++++- 6 files changed, 201 insertions(+), 13 deletions(-) diff --git a/backend/routers/instruments.py b/backend/routers/instruments.py index 20a79ba..9fef995 100644 --- a/backend/routers/instruments.py +++ b/backend/routers/instruments.py @@ -16,6 +16,7 @@ from services.instrument_service import ( get_narrative, update_instrument_drivers, update_instrument_saxo_link, + quick_add_instrument_from_watchlist, ) class DriverUpdate(BaseModel): @@ -25,6 +26,10 @@ class DriverUpdate(BaseModel): class SaxoLinkBody(BaseModel): saxo_symbol: Optional[str] = None + +class QuickAddBody(BaseModel): + ticker: str + router = APIRouter(prefix="/api/instruments", tags=["instruments"]) @@ -36,6 +41,16 @@ def list_instruments() -> List[Dict[str, Any]]: return get_all_instruments() +@router.post("/quick-add") +def quick_add(body: QuickAddBody) -> Dict[str, Any]: + """Bring a Cockpit Watchlist ticker into Instrument Analysis on the fly, copying over + its saxo_quote_symbol link automatically — see instrument_service.quick_add_instrument_from_watchlist.""" + try: + return quick_add_instrument_from_watchlist(body.ticker) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + @router.get("/{instrument_id}/snapshot") async def instrument_snapshot( instrument_id: str, diff --git a/backend/services/database.py b/backend/services/database.py index 129dee5..dbe869e 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -287,6 +287,13 @@ def init_db(): drivers_json TEXT, updated_at TEXT DEFAULT (datetime('now')) )""", + # "Quick add from Watchlist" (Instrument Analysis picker) — name/yf_ticker/category + # let a row stand in for a full instruments.json entry when the instrument doesn't + # have one yet, instead of only overriding an existing one. See + # services.instrument_service._load_configs()'s second merge pass. + "ALTER TABLE instrument_overrides ADD COLUMN name TEXT", + "ALTER TABLE instrument_overrides ADD COLUMN yf_ticker TEXT", + "ALTER TABLE instrument_overrides ADD COLUMN category TEXT", ]: try: c.execute(_sql) @@ -3406,15 +3413,20 @@ def rename_instrument_watchlist(ticker: str, name: str) -> bool: 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.""" + at catalog-load time in services/instrument_service.py. name/yf_ticker/category are + only set for "quick add from Watchlist" rows (see set_instrument_override_quick_add) — + they let a row stand in for a whole instruments.json entry that doesn't exist yet.""" conn = get_conn() - rows = conn.execute("SELECT instrument_id, saxo_quote_symbol, drivers_json FROM instrument_overrides").fetchall() + rows = conn.execute( + "SELECT instrument_id, saxo_quote_symbol, drivers_json, name, yf_ticker, category 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, + "name": r["name"], "yf_ticker": r["yf_ticker"], "category": r["category"], } return out @@ -3444,6 +3456,28 @@ def set_instrument_override_drivers(instrument_id: str, drivers: List[Dict]) -> conn.close() +def set_instrument_override_quick_add(instrument_id: str, name: str, yf_ticker: str, category: str, saxo_quote_symbol: Optional[str]) -> None: + """"Quick add from Watchlist" — instruments_watchlist has a ticker with no + instruments.json counterpart, so the user picked it straight from Instrument + Analysis's picker instead of hand-authoring a catalog entry. name/yf_ticker/category + make this row stand in for a full entry (services.instrument_service._load_configs() + synthesizes one with generic chart/drivers defaults when it finds these set on an id + that isn't in instruments.json).""" + instrument_id = instrument_id.upper() + saxo_quote_symbol = (saxo_quote_symbol or "").strip().upper() or None + conn = get_conn() + conn.execute( + "INSERT INTO instrument_overrides (instrument_id, name, yf_ticker, category, saxo_quote_symbol, updated_at) " + "VALUES (?, ?, ?, ?, ?, datetime('now')) " + "ON CONFLICT(instrument_id) DO UPDATE SET name = excluded.name, yf_ticker = excluded.yf_ticker, " + "category = excluded.category, saxo_quote_symbol = COALESCE(instrument_overrides.saxo_quote_symbol, excluded.saxo_quote_symbol), " + "updated_at = excluded.updated_at", + (instrument_id, name, yf_ticker, category, saxo_quote_symbol), + ) + 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 7ee10b6..9f64369 100644 --- a/backend/services/instrument_service.py +++ b/backend/services/instrument_service.py @@ -28,6 +28,28 @@ _configs: Optional[Dict[str, Any]] = None _narrative_cache: Dict[Tuple[str, str], str] = {} +# "Quick add from Watchlist" — instruments_watchlist's asset_class vocabulary (see +# routers/instruments_watchlist.py's _QUOTE_TYPE_TO_ASSET_CLASS) mapped onto instruments.json's +# own category vocabulary (CATEGORY_ORDER in frontend/src/pages/InstrumentDashboard.tsx). +# Approximate on purpose — this only decides which group heading a quick-added instrument +# is filed under in the picker, it doesn't affect chart/Saxo link/drivers. +_ASSET_CLASS_TO_CATEGORY = { + "forex": "fx", "energy": "energy", "indices": "equity_index", + "etfs": "stock", "equities": "stock", "unknown": "stock", +} + +# Generic chart/driver defaults for a quick-added instrument — instruments.json entries are +# hand-curated (custom drivers, ai_context, related_assets...); a quick add has none of that +# yet, just enough to render a chart and price. The user can flesh it out later via the +# existing Drivers editor (update_instrument_drivers), same as any other instrument. +_QUICK_ADD_DEFAULTS = { + "chart": {"ma_periods": [20, 50, 200], "bollinger_period": 20, "bollinger_std": 2, "show_volume": True}, + "drivers": [], "regime_labels": [], "event_keywords": [], + "related_assets": [], "correlation_instruments": [], "ai_context": "", + "currency": "USD", "description": "", +} + + 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 @@ -40,7 +62,8 @@ def _load_configs() -> None: _configs = {inst["id"]: inst for inst in data["instruments"]} from services.database import get_instrument_overrides - for uid, override in get_instrument_overrides().items(): + overrides = get_instrument_overrides() + for uid, override in overrides.items(): if uid not in _configs: continue # saxo_quote_symbol: the override row is the sole source of truth once it exists — @@ -52,6 +75,22 @@ def _load_configs() -> None: if override.get("drivers") is not None: _configs[uid]["drivers"] = override["drivers"] + # "Quick add from Watchlist" — an override row with `name` set but no matching + # instruments.json entry stands in for a whole catalog entry (see + # set_instrument_override_quick_add / quick_add_instrument_from_watchlist below). + for uid, override in overrides.items(): + if uid in _configs or not override.get("name"): + continue + _configs[uid] = { + "id": uid, + "name": override["name"], + "yf_ticker": override.get("yf_ticker") or uid, + "category": override.get("category") or "stock", + "saxo_quote_symbol": override.get("saxo_quote_symbol"), + "drivers": override.get("drivers") or [], + **{k: v for k, v in _QUICK_ADD_DEFAULTS.items() if k not in ("drivers",)}, + } + logger.info(f"[instrument_service] Loaded {len(_configs)} instrument configs") @@ -106,6 +145,37 @@ def update_instrument_saxo_link(instrument_id: str, saxo_symbol: Optional[str]) logger.info(f"[instrument_service] Updated Saxo link for {uid}: {saxo_symbol}") +def quick_add_instrument_from_watchlist(ticker: str) -> Dict[str, Any]: + """Bring a Cockpit watchlist instrument (services.database.instruments_watchlist) into + Instrument Analysis without hand-authoring an instruments.json entry — used by the + picker's "Depuis la Watchlist" section for tickers that don't already have a catalog + entry. Its saxo_quote_symbol (already linked in Config -> Instruments Watchlist) is + copied over automatically — no need to re-link it a second time in Instrument Analysis. + Idempotent: if instrument_id already resolves (either a real catalog entry or an + earlier quick add), returns it unchanged rather than overwriting name/category.""" + global _configs + if _configs is None: + _load_configs() + + uid = ticker.strip().upper() + if uid in _configs: + return {"id": uid, "created": False} + + from services.database import get_instruments_watchlist, set_instrument_override_quick_add + row = next((r for r in get_instruments_watchlist() if r["ticker"].upper() == uid), None) + if row is None: + raise ValueError(f"'{uid}' n'est pas dans la Watchlist du Cockpit") + + category = _ASSET_CLASS_TO_CATEGORY.get(row.get("asset_class") or "unknown", "stock") + set_instrument_override_quick_add( + uid, name=row.get("name") or uid, yf_ticker=uid, category=category, + saxo_quote_symbol=row.get("saxo_quote_symbol"), + ) + _load_configs() + logger.info(f"[instrument_service] Quick-added {uid} from Watchlist (category={category})") + return {"id": uid, "created": True} + + # ── DataFrame helpers ────────────────────────────────────────────────────────── def _ohlcv_to_df(records: List[Dict]) -> pd.DataFrame: diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 633f0ed..8c62a27 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -219,6 +219,16 @@ export const useSetInstrumentSaxoLink = () => api.put(`/instruments/${encodeURIComponent(instrumentId)}/saxo-link`, { saxo_symbol: saxoSymbol }).then(r => r.data), }) +// Brings a Cockpit Watchlist ticker into Instrument Analysis without hand-authoring an +// instruments.json entry — its saxo_quote_symbol (already linked in Config -> Instruments +// Watchlist) is copied over automatically. Idempotent: a ticker that already resolves +// (real catalog entry or an earlier quick add) is returned unchanged. +export const useQuickAddInstrument = () => + useMutation({ + mutationFn: (ticker: string) => + api.post('/instruments/quick-add', { ticker }).then(r => r.data as { id: string; created: boolean }), + }) + // Free-form display name — no longer tied to yfinance's long_name lookup, since an // instrument can now be entirely Saxo-sourced. export const useRenameWatchlistInstrument = () => { diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 861dc0e..51905c6 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -482,15 +482,6 @@ export default function Dashboard() { )} - {catalogId && ( - - - - )} ) diff --git a/frontend/src/pages/InstrumentDashboard.tsx b/frontend/src/pages/InstrumentDashboard.tsx index ff6307b..83cdb2f 100644 --- a/frontend/src/pages/InstrumentDashboard.tsx +++ b/frontend/src/pages/InstrumentDashboard.tsx @@ -9,7 +9,7 @@ import clsx from 'clsx' import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' import InstrumentChart, { TheoPoint } from '../components/InstrumentChart' import SaxoLinkPicker from '../components/SaxoLinkPicker' -import { useSetInstrumentSaxoLink } from '../hooks/useApi' +import { useSetInstrumentSaxoLink, useInstrumentsWatchlist, useQuickAddInstrument } from '../hooks/useApi' import { fmtPrice } from '../lib/format' const api = axios.create({ baseURL: '/api' }) @@ -197,6 +197,18 @@ const CATEGORY_LABELS: Record = { fx: 'Forex', volatility: 'Volatilité', stock: 'Actions', crypto: 'Crypto', } +// 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 a watchlist instrument has no catalog entry yet (same logic as Dashboard.tsx's +// resolveCatalogId, duplicated here since the two pages don't share a components module). +function resolveWatchlistCatalogId(ticker: string, catalog: { id: string }[]): string | undefined { + const upper = ticker.toUpperCase() + const ids = new Set(catalog.map(i => i.id.toUpperCase())) + if (ids.has(upper)) return upper + if (ids.has(`${upper}=X`)) return `${upper}=X` + return undefined +} + function regimeColor(label: string): string { const l = label.toLowerCase() if (/bull|expan|recov|rally|strength|risk.on|inflow|demand|falling|weakness/i.test(l)) return 'emerald' @@ -1424,6 +1436,9 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i const [chartStyle, setChartStyle] = useState<'candles' | 'line'>('candles') const [instruments, setInstruments] = useState([]) const setSaxoLink = useSetInstrumentSaxoLink() + const { data: watchlistItems } = useInstrumentsWatchlist() + const quickAdd = useQuickAddInstrument() + const [addingTicker, setAddingTicker] = useState(null) const [snapshot, setSnapshot] = useState(null) const [narrative, setNarrative] = useState('') const [loading, setLoading] = useState(false) @@ -1635,6 +1650,12 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i items: instruments.filter(i => i.category === cat), })).filter(g => g.items.length > 0) + // Cockpit Watchlist tickers that don't already resolve to a catalog entry — offered as + // "quick add" picks (see resolveWatchlistCatalogId below for the resolution itself). + // Excludes anything already resolvable so picking it never creates a duplicate id. + const watchlistCandidates = ((watchlistItems as any[]) ?? []) + .filter(w => !resolveWatchlistCatalogId(w.ticker, instruments)) + const selected = instruments.find(i => i.id === instrumentId) const isLastDate = effectiveDate === sortedDates[sortedDates.length - 1] const dateLabel = effectiveDate ? fmtDateFR(effectiveDate) : '—' @@ -1692,6 +1713,32 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i }) } + // Picking a Watchlist instrument that has no catalog entry yet: quick-add it (copies + // its saxo_quote_symbol over automatically, see services.instrument_service. + // quick_add_instrument_from_watchlist) then navigate straight there. Already-resolvable + // tickers (e.g. EURUSD -> EURUSD=X) skip the round-trip and navigate directly. + const handleSelectWatchlist = (ticker: string) => { + const existing = resolveWatchlistCatalogId(ticker, instruments) + if (existing) { + navigate(`/instruments/${encodeURIComponent(existing)}`) + setSelectorOpen(false) + return + } + setAddingTicker(ticker) + quickAdd.mutate(ticker, { + onSuccess: ({ id }) => { + api.get('/instruments').then(r => setInstruments(r.data)).catch(() => {}) + navigate(`/instruments/${encodeURIComponent(id)}`) + setSelectorOpen(false) + setAddingTicker(null) + }, + onError: (e) => { + console.error('Quick-add from watchlist failed', e) + setAddingTicker(null) + }, + }) + } + const waveletChartData = useMemo(() => { if (!waveletData) return [] const { dates, original, bands, mean } = waveletData @@ -1917,6 +1964,27 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i ))} + {watchlistCandidates.length > 0 && ( +
+
Depuis la Watchlist
+
+ {watchlistCandidates.map((w: any) => ( + + ))} +
+
+ )} )}