From 7cb81cbd659ea8e36136ccc4b940fccdc279e9d0 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Fri, 24 Jul 2026 15:52:58 +0200 Subject: [PATCH] feat: cockpit --- backend/routers/instruments.py | 28 ++++++++++ backend/services/database.py | 50 +++++++++++++++++ backend/services/wavelet_signals.py | 47 ++++++++++++---- frontend/src/components/TradeRankList.tsx | 10 ++-- frontend/src/pages/Dashboard.tsx | 32 ++++++++++- frontend/src/pages/InstrumentDashboard.tsx | 63 ++++++++++++++++++++-- 6 files changed, 211 insertions(+), 19 deletions(-) diff --git a/backend/routers/instruments.py b/backend/routers/instruments.py index 9fef995..8f870e3 100644 --- a/backend/routers/instruments.py +++ b/backend/routers/instruments.py @@ -72,6 +72,34 @@ async def instrument_snapshot( return snapshot +@router.get("/{instrument_id}/wavelet-cache") +def wavelet_cache(instrument_id: str) -> Dict[str, Any]: + """Latest full band decomposition for this instrument, from the same per-cycle scan + that feeds the Dashboard's Wavelets Signal card (services.wavelet_signals. + scan_watchlist_wavelet_signals) — NOT a live recompute. Instrument Analysis's Wavelet + tab reads this by default so opening it never triggers its own analysis and always + agrees with the Signal card; "Start analyse" is still there for an on-demand live run + with custom parameters, which does NOT overwrite this cache. + + Looked up by the Cockpit Watchlist ticker the cache is keyed on, normalized via the + same yfinance-suffix stripping instrument_service uses elsewhere (EURUSD=X -> EURUSD) + — a quick-added instrument's id already equals its Watchlist ticker verbatim, but a + curated one (EURUSD=X) doesn't.""" + from services.instrument_service import _base_ticker + from services.database import get_wavelet_decomposition_cache + cached = get_wavelet_decomposition_cache(_base_ticker(instrument_id)) + if not cached or not cached.get("decomposition"): + raise HTTPException(status_code=404, detail=f"Aucune décomposition wavelet en cache pour '{instrument_id}'") + return { + "computed_at": cached["computed_at"], + "method": cached["method"], + "wavelet": cached["wavelet"], + "num_levels": cached["num_levels"], + "lookback_days": cached["lookback_days"], + **cached["decomposition"], + } + + @router.post("/{instrument_id}/narrative") async def generate_narrative( instrument_id: str, diff --git a/backend/services/database.py b/backend/services/database.py index dbe869e..41aca5e 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -223,6 +223,22 @@ def init_db(): price_at_signal REAL )""", "ALTER TABLE cycle_runs ADD COLUMN wavelet_signals_count INTEGER DEFAULT 0", + # Wavelets — full band-decomposition series from the same per-cycle watchlist scan + # (see wavelet_watchlist_signals above, which only keeps the latest scalar + # slope/energy/signal per band). One row per ticker, overwritten every cycle — this + # is what Instrument Analysis's Wavelet tab reads by default so opening it doesn't + # trigger a live recompute (services.wavelet_signals.scan_watchlist_wavelet_signals + # writes both tables together from the exact same decomposition run). + """CREATE TABLE IF NOT EXISTS wavelet_decomposition_cache ( + ticker TEXT PRIMARY KEY, + run_id TEXT, + computed_at TEXT DEFAULT (datetime('now')), + method TEXT, + wavelet TEXT, + num_levels INTEGER, + lookback_days REAL, + decomposition_json TEXT + )""", # AI Chat widget — persisted conversation turns, one growing thread per session_id """CREATE TABLE IF NOT EXISTS ai_chat_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -3631,6 +3647,40 @@ def get_wavelet_signals_history(ticker: str, days: int = 30) -> List[Dict]: return [dict(r) for r in rows] +def save_wavelet_decomposition_cache( + ticker: str, run_id: Optional[str], method: str, wavelet: str, + num_levels: int, lookback_days: float, decomposition: Dict, +) -> None: + """Overwrites this ticker's cached full band-decomposition — one row per ticker, not a + history (services.wavelet_signals.scan_watchlist_wavelet_signals calls this once per + cycle, right after computing the same decomposition object it also flattens into + wavelet_watchlist_signals).""" + conn = get_conn() + conn.execute( + "INSERT INTO wavelet_decomposition_cache (ticker, run_id, computed_at, method, wavelet, num_levels, lookback_days, decomposition_json) " + "VALUES (?, ?, datetime('now'), ?, ?, ?, ?, ?) " + "ON CONFLICT(ticker) DO UPDATE SET run_id=excluded.run_id, computed_at=excluded.computed_at, method=excluded.method, " + "wavelet=excluded.wavelet, num_levels=excluded.num_levels, lookback_days=excluded.lookback_days, " + "decomposition_json=excluded.decomposition_json", + (ticker.upper(), run_id, method, wavelet, num_levels, lookback_days, json.dumps(decomposition)), + ) + conn.commit() + conn.close() + + +def get_wavelet_decomposition_cache(ticker: str) -> Optional[Dict]: + conn = get_conn() + row = conn.execute( + "SELECT * FROM wavelet_decomposition_cache WHERE ticker = ? COLLATE NOCASE", (ticker,) + ).fetchone() + conn.close() + if not row: + return None + d = dict(row) + d["decomposition"] = json.loads(d.pop("decomposition_json")) if d.get("decomposition_json") else None + return d + + # ── AI Chat widget — persisted conversation ──────────────────────────────────── def save_chat_message(session_id: str, role: str, content: str) -> None: diff --git a/backend/services/wavelet_signals.py b/backend/services/wavelet_signals.py index 04936f3..2afac08 100644 --- a/backend/services/wavelet_signals.py +++ b/backend/services/wavelet_signals.py @@ -231,7 +231,33 @@ def _technical_desk_wavelet_config() -> Dict: return (desk.get("config") or {}).get("signals") or {} -def scan_watchlist_wavelet_signals() -> List[Dict]: +def _fetch_close_series(ticker: str, saxo_symbol: Optional[str]): + """Saxo-first when this watchlist instrument has a saxo_quote_symbol link, yfinance + otherwise or as a silent fallback on any Saxo failure — same pattern as + routers/wavelet.py's _fetch_history, duplicated locally rather than importing across a + router boundary. Needed because several Watchlist instruments (BRENT, COPPER...) have no + real yfinance ticker at all — get_historical(ticker, ...) always failed for them, which + silently dropped them out of the per-cycle scan entirely (one bad ticker just gets + skipped, see the try/except around the caller) — that's why the Wavelets Signal card + only ever showed the yfinance-recognized subset of the Watchlist.""" + if saxo_symbol: + try: + from services.database import get_saxo_catalog_by_symbol + from services.saxo_client import get_price_history + entry = get_saxo_catalog_by_symbol(saxo_symbol) + asset_type = entry["asset_type"] if entry else "FxSpot" + bars = get_price_history(saxo_symbol, asset_type, days=400) + return [b["close"] for b in bars], [b["date"] for b in bars] + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"[wavelet_signals] Saxo fetch failed for '{saxo_symbol}', falling back to yfinance: {e}") + + from services.data_fetcher import get_historical + hist = get_historical(ticker, period="1y", interval="1d") + return [h["close"] for h in hist], [h["date"] for h in hist] + + +def scan_watchlist_wavelet_signals(run_id: Optional[str] = None) -> List[Dict]: """Compute a causal (no-look-ahead) band decomposition for each watchlist instrument. Every (ticker, band) gets a row every cycle — current slope/ value/energy state always, plus signal_kind/direction/params_json when one @@ -239,9 +265,13 @@ def scan_watchlist_wavelet_signals() -> List[Dict]: wins, evaluated extremum -> level_threshold -> trend_flatten -> acceleration -> band_cross -> energy_threshold). ridge_shift is evaluated once per ticker (not per band — the ridge is a single track for the whole - decomposition) and stored as an extra band_label="ridge" row.""" - from services.database import get_instruments_watchlist - from services.data_fetcher import get_historical + decomposition) and stored as an extra band_label="ridge" row. + + Also caches the full (untruncated) decomposition per ticker in + wavelet_decomposition_cache — Instrument Analysis's Wavelet tab reads that instead of + running its own live decomposition on open, so it always agrees with the Watchlist + Signal card and never needs a click just to show the current state.""" + from services.database import get_instruments_watchlist, save_wavelet_decomposition_cache from services.wavelet_engine import rolling_causal_bands, rolling_causal_bands_ssq sig_cfg = _technical_desk_wavelet_config() @@ -267,11 +297,9 @@ def scan_watchlist_wavelet_signals() -> List[Dict]: for item in get_instruments_watchlist(): ticker = item["ticker"] try: - hist = get_historical(ticker, period="1y", interval="1d") - if len(hist) < lookback + 32: + values, dates = _fetch_close_series(ticker, item.get("saxo_quote_symbol")) + if len(values) < lookback + 32: continue - values = [h["close"] for h in hist] - dates = [h["date"] for h in hist] start_idx = max(lookback, len(values) - 60) decomposed = decomposer( values, dates, @@ -280,6 +308,7 @@ def scan_watchlist_wavelet_signals() -> List[Dict]: ) if not decomposed["dates"]: continue + save_wavelet_decomposition_cache(ticker, run_id, method, wavelet, num_levels, lookback, decomposed) price_at_signal = decomposed["original"][-1] bands = decomposed["bands"] @@ -378,6 +407,6 @@ def scan_watchlist_wavelet_signals() -> List[Dict]: def compute_and_save_wavelet_signals(run_id: str) -> List[Dict]: from services.database import save_wavelet_signals - results = scan_watchlist_wavelet_signals() + results = scan_watchlist_wavelet_signals(run_id) save_wavelet_signals(run_id, results) return results diff --git a/frontend/src/components/TradeRankList.tsx b/frontend/src/components/TradeRankList.tsx index 089460a..af5651e 100644 --- a/frontend/src/components/TradeRankList.tsx +++ b/frontend/src/components/TradeRankList.tsx @@ -3,12 +3,13 @@ import { Link } from 'react-router-dom' import { ArrowUpRight } from 'lucide-react' import clsx from 'clsx' -export default function TradeRankList({ trades, mode, title, linkTo, toggle }: { +export default function TradeRankList({ trades, mode, title, linkTo, toggle, tickerLabel }: { trades: any[] mode: 'top' | 'worst' | 'recent' title: string linkTo: string toggle?: ReactNode + tickerLabel?: (ticker: string) => string }) { const sorted = [...trades] .sort((a, b) => mode === 'recent' @@ -16,7 +17,6 @@ export default function TradeRankList({ trades, mode, title, linkTo, toggle }: { : mode === 'top' ? (b.pnl_pct ?? -999) - (a.pnl_pct ?? -999) : (a.pnl_pct ?? 999) - (b.pnl_pct ?? 999)) - .slice(0, 5) return ( @@ -28,7 +28,7 @@ export default function TradeRankList({ trades, mode, title, linkTo, toggle }: { {sorted.length > 0 ? ( -
+
{sorted.map((t: any, i: number) => { const pnl: number | null = t.pnl_pct ?? null const isBear = t.strategy?.toLowerCase().includes('put') || t.strategy?.toLowerCase().includes('bear') @@ -37,7 +37,9 @@ export default function TradeRankList({ trades, mode, title, linkTo, toggle }: {
{i + 1} {isBear ? '🐻' : '🐂'} - {t.underlying ?? '—'} + + {t.underlying ? (tickerLabel ? tickerLabel(t.underlying) : t.underlying) : '—'} + {t.strategy ?? ''} {entryDate && {entryDate}} {pnl !== null ? ( diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index e54adc5..0306802 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -213,6 +213,31 @@ export default function Dashboard() { const lastCycle = (cycleStatusData as any)?.last_cycle ?? null const openPositions: any[] = (openPositionsData as any) ?? [] + // Trades Overview shows each position's trading-side underlying (yfinance/futures + // convention: ^GSPC, ^NDX, GC=F, HG=F, EURUSD=X...), which reads as noise next to the + // Cockpit's own friendly Watchlist names for the exact same instrument (SP500, NASDAQ, + // GOLD, COPPER, EURUSD). Rename to the Watchlist ticker when one plausibly matches — + // suffix-stripped (EURUSD=X -> EURUSD) or via a small curated root-ticker alias table — + // and only if that name is actually in the current Watchlist (not just "known"), so this + // stays tied to what's configured rather than a static list. + const UNDERLYING_ALIASES: Record = { + '^GSPC': 'SP500', '^NDX': 'NASDAQ', '^DJI': 'DOW', '^RUT': 'RUSSELL2000', + 'GC=F': 'GOLD', 'SI=F': 'SILVER', 'HG=F': 'COPPER', 'PL=F': 'PLATINUM', + 'CL=F': 'CRUDE', 'BZ=F': 'BRENT', 'NG=F': 'NATGAS', + 'ZW=F': 'WHEAT', 'ZC=F': 'CORN', 'ZS=F': 'SOYBEANS', + } + const underlyingDisplayName = (underlying: string): string => { + if (!underlying) return underlying + const upper = underlying.toUpperCase() + const watchlistTickers = new Set(((watchlistItems as any[]) ?? []).map(w => w.ticker.toUpperCase())) + const baseTicker = upper.replace(/(=X|=F)$/, '') + if (watchlistTickers.has(upper)) return upper + if (watchlistTickers.has(baseTicker)) return baseTicker + const alias = UNDERLYING_ALIASES[upper] + if (alias && watchlistTickers.has(alias)) return alias + return underlying + } + // Patterns from the last cycle only (filter by created_at >= cycle started_at) const cyclePatterns = useMemo(() => { const started = lastCycle?.started_at ?? null @@ -1013,7 +1038,7 @@ export default function Dashboard() { {/* ── Command Center: Intelligence & Contexte ── */}
- + {/* Wavelets Signal — latest per-ticker signal from the watchlist scan (computed each cycle). Single click on the card = Wavelets Simulation overview; double-click a @@ -1035,7 +1060,10 @@ export default function Dashboard() { {signals.slice(0, 5).map((s: any, i: number) => (
navigate(`/instruments/${encodeURIComponent(s.ticker)}?tab=wavelets`)} + onDoubleClick={() => quickAddInstrument.mutate(s.ticker, { + onSuccess: ({ id }) => navigate(`/instruments/${encodeURIComponent(id)}?tab=wavelets`), + onError: (e) => console.error(`[WaveletsSignal] failed to open ${s.ticker} in Instrument Analysis:`, e), + })} title="Double-click to open in Instrument Analysis → Wavelets" className="flex items-center gap-1.5 text-[10px] rounded px-1 -mx-1 py-0.5 cursor-pointer hover:bg-dark-700/40 transition-colors" > diff --git a/frontend/src/pages/InstrumentDashboard.tsx b/frontend/src/pages/InstrumentDashboard.tsx index 4ff3efd..47b6ff6 100644 --- a/frontend/src/pages/InstrumentDashboard.tsx +++ b/frontend/src/pages/InstrumentDashboard.tsx @@ -1456,6 +1456,12 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i const [waveletCausal, setWaveletCausal] = useState(false) const [waveletLookback, setWaveletLookback] = useState(260) const [waveletData, setWaveletData] = useState(null) + // 'cached' = last per-cycle scan result (services.wavelet_signals, same source as the + // Dashboard's Wavelets Signal card) — loaded automatically. 'live' = a fresh on-demand + // run from the "Start analyse" button, with whatever params are set above; not persisted + // back into the cache, so it can diverge from what the Signal card shows until the next cycle. + const [waveletSource, setWaveletSource] = useState<'cached' | 'live' | null>(null) + const [waveletCachedAt, setWaveletCachedAt] = useState(null) const [loadingWavelet, setLoadingWavelet] = useState(false) const [waveletReliability, setWaveletReliability] = useState(null) const [loadingReliability, setLoadingReliability] = useState(false) @@ -1655,6 +1661,10 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i const dateLabel = effectiveDate ? fmtDateFR(effectiveDate) : '—' const displayPrice = dateTrend?.current_price ?? snapshot?.current_price + // On-demand LIVE recompute — the "Start analyse" button only. Does not touch + // wavelet_decomposition_cache, so it can show a different result (custom params, more + // recent tick) than the cached view without disturbing what the Dashboard's Wavelets + // Signal card / other instruments' cached tabs show until the next scheduled cycle. const runWaveletAnalysis = async () => { if (!selected) return setLoadingWavelet(true) @@ -1665,15 +1675,47 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i if (waveletCausal) params.lookback = waveletLookback const { data } = await api.get(path, { params }) setWaveletData(data) + setWaveletSource('live') + setWaveletCachedAt(null) setHiddenBands(new Set()) } catch (e) { console.error('Wavelet analysis failed', e) setWaveletData(null) + setWaveletSource(null) } finally { setLoadingWavelet(false) } } + // Load the cached decomposition once per instrument, the first time the Wavelet tab + // becomes active for it — NOT a live call. Same data the per-cycle Watchlist scan writes + // (services.wavelet_signals.scan_watchlist_wavelet_signals), so this tab always agrees + // with the Dashboard's Wavelets Signal card and never needs a click just to show the + // current state. 404 (no cycle has scanned this ticker yet, or it's not Watchlist-linked) + // just leaves the empty state up — "Start analyse" still runs a live one on demand. + // Keyed by instrumentId in a ref so tweaking levels/family/method for a live run + // afterward doesn't silently re-trigger this. + const waveletAutoFetchKey = useRef(null) + useEffect(() => { + if (tabUnder !== 'wavelets' || !selected) return + if (waveletAutoFetchKey.current === instrumentId) return + waveletAutoFetchKey.current = instrumentId + setWaveletData(null) + setWaveletSource(null) + setWaveletCachedAt(null) + setLoadingWavelet(true) + api.get(`/instruments/${encodeURIComponent(instrumentId)}/wavelet-cache`) + .then(({ data }) => { + setWaveletData(data) + setWaveletSource('cached') + setWaveletCachedAt(data.computed_at ?? null) + setHiddenBands(new Set()) + }) + .catch(() => { /* no cache yet — expected, empty state stays up */ }) + .finally(() => setLoadingWavelet(false)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tabUnder, instrumentId, selected]) + const runWaveletReliability = async () => { if (!selected) return setLoadingReliability(true) @@ -2080,7 +2122,7 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i {([ { key: 'counters', label: 'Compteurs' }, { key: 'analyse', label: 'Analyse de la courbe' }, - { key: 'wavelets', label: 'Ondelettes' }, + { key: 'wavelets', label: 'Wavelet' }, ] as const).map(t => (