diff --git a/backend/routers/instruments_watchlist.py b/backend/routers/instruments_watchlist.py index 5399a78..0c18149 100644 --- a/backend/routers/instruments_watchlist.py +++ b/backend/routers/instruments_watchlist.py @@ -66,6 +66,16 @@ def watchlist_quotes(): return {"items": items} +@router.get("/curve-regimes") +def watchlist_curve_regimes(): + """Cached Curve Regime classification (services.curve_regime) per Watchlist instrument — + refreshed on the same cadence as the wavelet cache (services.wavelet_scheduler), not a + live call. Every Watchlist ticker is represented, with a null-regime placeholder for any + never successfully classified yet.""" + from services.database import get_all_curve_regime_cache + return {"items": get_all_curve_regime_cache()} + + _HISTORY_PERIODS = { "1w": {"yf": "5d", "days": 7}, "1m": {"yf": "1mo", "days": 30}, diff --git a/backend/services/curve_regime.py b/backend/services/curve_regime.py index 4dce71f..4579f05 100644 --- a/backend/services/curve_regime.py +++ b/backend/services/curve_regime.py @@ -395,3 +395,34 @@ _CATEGORY_TO_ASSET_BIAS_KEY = { def _macro_bias_for_category(asset_bias: Dict[str, str], category: Optional[str]) -> Optional[str]: key = _CATEGORY_TO_ASSET_BIAS_KEY.get(category or "") return asset_bias.get(key) if key else None + + +async def refresh_watchlist_curve_regimes() -> int: + """Classify every Cockpit Watchlist instrument and cache the result — called from + services.wavelet_scheduler's periodic refresh pass, same cadence as the wavelet cache, + so the Dashboard's Curve Regime card never needs a live classify call per page view and + always agrees with what Instrument Analysis's Curve Regime tab would compute right now. + Auto quick-adds a Watchlist ticker into the Instrument Analysis catalog first if it + isn't there yet (services.instrument_service.quick_add_instrument_from_watchlist) — + classify_instrument_regime needs a full snapshot (price history + indicators), which + only exists for catalog-registered instruments. Returns the number classified.""" + from services.database import get_instruments_watchlist, save_curve_regime_cache + from services.instrument_service import quick_add_instrument_from_watchlist, get_snapshot + + count = 0 + for item in get_instruments_watchlist(): + ticker = item["ticker"] + try: + added = quick_add_instrument_from_watchlist(ticker) + snapshot = await get_snapshot(added["id"], period="1y", interval="1d") + if "error" in snapshot: + logger.warning(f"[curve_regime] Skipping '{ticker}': snapshot error: {snapshot['error']}") + continue + classification = classify_instrument_regime(added["id"], snapshot) + save_curve_regime_cache(ticker, classification) + count += 1 + except Exception as e: + logger.warning(f"[curve_regime] Skipping '{ticker}': {e}") + continue + logger.info(f"[curve_regime] Refreshed {count}/{len(get_instruments_watchlist())} Watchlist instruments") + return count diff --git a/backend/services/database.py b/backend/services/database.py index e6b52c0..deffeef 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -310,6 +310,20 @@ def init_db(): "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", + # Curve Regime — one row per Watchlist ticker, overwritten every refresh pass (see + # services/wavelet_scheduler.py, same cadence as the wavelet cache). Instrument-level + # regime (services.curve_regime), NOT the global macro regime shown elsewhere. Feeds + # the Dashboard's "Curve Regime" card so it never needs a live classify call per view. + """CREATE TABLE IF NOT EXISTS curve_regime_cache ( + ticker TEXT PRIMARY KEY, + computed_at TEXT DEFAULT (datetime('now')), + regime_key TEXT, + regime_label TEXT, + score REAL, + direction TEXT, + profile_json TEXT, + has_options_data INTEGER DEFAULT 0 + )""", ]: try: c.execute(_sql) @@ -3721,6 +3735,48 @@ def get_wavelet_decomposition_cache(ticker: str) -> Optional[Dict]: return d +def save_curve_regime_cache(ticker: str, classification: Dict) -> None: + top = classification.get("top_match") or {} + conn = get_conn() + conn.execute( + "INSERT INTO curve_regime_cache (ticker, computed_at, regime_key, regime_label, score, direction, profile_json, has_options_data) " + "VALUES (?, datetime('now'), ?, ?, ?, ?, ?, ?) " + "ON CONFLICT(ticker) DO UPDATE SET computed_at=excluded.computed_at, regime_key=excluded.regime_key, " + "regime_label=excluded.regime_label, score=excluded.score, direction=excluded.direction, " + "profile_json=excluded.profile_json, has_options_data=excluded.has_options_data", + ( + ticker.upper(), top.get("key"), top.get("label"), top.get("score"), + (classification.get("profile") or {}).get("direction"), + json.dumps(classification), 1 if classification.get("has_options_data") else 0, + ), + ) + conn.commit() + conn.close() + + +def get_all_curve_regime_cache() -> List[Dict]: + """One row per Watchlist ticker, in Watchlist sort order — a data=None placeholder for + any ticker never successfully classified yet, same "always show every Watchlist + instrument" convention as get_latest_wavelet_state_by_instrument().""" + conn = get_conn() + rows = conn.execute("SELECT * FROM curve_regime_cache").fetchall() + conn.close() + by_ticker = {r["ticker"]: dict(r) for r in rows} + out: List[Dict] = [] + for item in get_instruments_watchlist(): + ticker = item["ticker"].upper() + row = by_ticker.get(ticker) + if row: + row["profile"] = json.loads(row.pop("profile_json")).get("profile") if row.get("profile_json") else None + out.append(row) + else: + out.append({ + "ticker": ticker, "computed_at": None, "regime_key": None, "regime_label": None, + "score": None, "direction": None, "profile": None, "has_options_data": 0, + }) + return out + + # ── AI Chat widget — persisted conversation ──────────────────────────────────── def save_chat_message(session_id: str, role: str, content: str) -> None: diff --git a/backend/services/wavelet_engine.py b/backend/services/wavelet_engine.py index 1fd2a07..6e901b9 100644 --- a/backend/services/wavelet_engine.py +++ b/backend/services/wavelet_engine.py @@ -249,10 +249,12 @@ def rolling_causal_bands( n = len(values) out_dates: list[str] = [] out_original: list[float] = [] + out_mean: list[float] = [] band_series: list[list[float]] = [[] for _ in range(num_levels)] band_labels = [f"bande {i + 1}" for i in range(num_levels)] band_failed = [False] * num_levels last_tip: list[float] | None = None + last_tip_mean = 0.0 recomputations = 0 for t in range(start_idx, n): @@ -264,12 +266,21 @@ def rolling_causal_bands( window_dates = dates[window_start:t + 1] result = band_decompose(window_values, window_dates, num_levels, wavelet) last_tip = [band["series"][-1] for band in result["bands"]] + # band_decompose de-means each trailing window before decomposing it (see its + # own x_mean/xc) — reconstructing a day's price needs THAT window's mean added + # back, not one fixed constant for the whole causal run (unlike band_decompose's + # own single-window "mean" key): a lookback window slides forward every step, so + # its mean drifts along with any real trend. Losing this here (never captured) + # meant the frontend's mean+sum(bands) reconstruction was silently missing the + # entire price level — bands.sum() alone centers on ~0, not the real price. + last_tip_mean = result["mean"] band_labels = [band["label"] for band in result["bands"]] for i, band in enumerate(result["bands"]): band_failed[i] = band_failed[i] or band.get("reconstruction_failed", False) recomputations += 1 out_dates.append(dates[t]) out_original.append(values[t]) + out_mean.append(last_tip_mean) for i in range(num_levels): band_series[i].append(last_tip[i]) @@ -288,6 +299,9 @@ def rolling_causal_bands( return { "dates": out_dates, "original": out_original, + "mean": out_mean, # per-day series (the trailing window's mean drifts) — NOT a + # single scalar like band_decompose's own "mean" key; callers + # must add mean[i] (not a bare `mean`) to bands.sum() per day "bands": bands, "wavelet": wavelet, "lookback": lookback, @@ -442,6 +456,7 @@ def rolling_causal_bands_ssq( n = len(values) out_dates: list[str] = [] out_original: list[float] = [] + out_mean: list[float] = [] band_series: list[list[float]] = [[] for _ in range(num_levels)] energy_series: list[list[float]] = [[] for _ in range(num_levels)] ridge_series: list[float | None] = [] @@ -450,6 +465,7 @@ def rolling_causal_bands_ssq( last_tip: list[float] | None = None last_energy_tip: list[float] | None = None last_ridge_tip: float | None = None + last_tip_mean = 0.0 recomputations = 0 for t in range(start_idx, n): @@ -463,12 +479,17 @@ def rolling_causal_bands_ssq( last_tip = [band["series"][-1] for band in result["bands"]] last_energy_tip = [band["energy"][-1] for band in result["bands"]] last_ridge_tip = result["ridge_period_days"][-1] + # See rolling_causal_bands's identical fix for why this is a per-day series, + # not a single scalar: each trailing window is de-meaned independently before + # decomposition, and that mean drifts as the window slides forward. + last_tip_mean = result["mean"] band_labels = [band["label"] for band in result["bands"]] for i, band in enumerate(result["bands"]): band_failed[i] = band_failed[i] or band.get("reconstruction_failed", False) recomputations += 1 out_dates.append(dates[t]) out_original.append(values[t]) + out_mean.append(last_tip_mean) for i in range(num_levels): band_series[i].append(last_tip[i]) energy_series[i].append(last_energy_tip[i]) @@ -490,6 +511,7 @@ def rolling_causal_bands_ssq( return { "dates": out_dates, "original": out_original, + "mean": out_mean, # per-day series — see rolling_causal_bands "bands": bands, "wavelet": wavelet, "lookback": lookback, diff --git a/backend/services/wavelet_scheduler.py b/backend/services/wavelet_scheduler.py index 94755f6..f8c01ea 100644 --- a/backend/services/wavelet_scheduler.py +++ b/backend/services/wavelet_scheduler.py @@ -39,18 +39,27 @@ def set_settings(enabled: bool, refresh_minutes: float) -> None: def run_refresh_pass() -> int: """One pass over the whole Watchlist: Saxo-first price fetch + wavelet recompute (see - services.wavelet_signals.scan_watchlist_wavelet_signals) — the same work the daily cycle's - own wavelet step does, just callable on its own cadence. Shared by the periodic loop and - the manual 'refresh now' button. Returns the number of signal rows written.""" + services.wavelet_signals.scan_watchlist_wavelet_signals), then Curve Regime + classification per instrument (services.curve_regime.refresh_watchlist_curve_regimes, + reusing that same wavelet cache) — the same work the daily cycle's own wavelet step + does, just callable on its own cadence. Shared by the periodic loop and the manual + 'refresh now' button. Returns the number of signal rows written.""" + import asyncio from .wavelet_signals import compute_and_save_wavelet_signals + from .curve_regime import refresh_watchlist_curve_regimes run_id = f"refresh-{uuid.uuid4().hex[:10]}" try: results = compute_and_save_wavelet_signals(run_id) logger.info(f"[Wavelet Scheduler] Refresh pass complete: {len(results)} signal rows ({run_id})") - return len(results) except Exception as e: - logger.warning(f"[Wavelet Scheduler] Refresh pass failed: {e}") - return 0 + logger.warning(f"[Wavelet Scheduler] Wavelet refresh failed: {e}") + results = [] + try: + n_regimes = asyncio.run(refresh_watchlist_curve_regimes()) + logger.info(f"[Wavelet Scheduler] Curve Regime refresh complete: {n_regimes} instruments") + except Exception as e: + logger.warning(f"[Wavelet Scheduler] Curve Regime refresh failed: {e}") + return len(results) def _loop(stop: threading.Event): diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 02bed33..74b8ba8 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1164,6 +1164,16 @@ export const useWaveletWatchlistSignals = () => staleTime: 5 * 60_000, }) +// Instrument-level Curve Regime (services.curve_regime) per Watchlist instrument — cached +// on the same refresh cadence as the wavelet cache (services.wavelet_scheduler), NOT the +// global macro regime. Feeds the Dashboard's "Curve Regime" card. +export const useWatchlistCurveRegimes = () => + useQuery({ + queryKey: ['watchlist-curve-regimes'], + queryFn: () => api.get('/watchlist/curve-regimes').then(r => r.data as { items: any[] }), + staleTime: 5 * 60_000, + }) + export const useLogCycles = () => useQuery({ queryKey: ['log-cycles'], diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index ae542b7..aad8208 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -6,7 +6,7 @@ import { useRiskDashboard, useRiskRadar, useGeoNews, useCycleStatus, useInstrumentsWatchlist, useInstrumentsWatchlistQuotes, useWatchlistHistory, useQuickAddInstrument, useSaxoIvWatchlist, useLatestCycleReport, - useWaveletWatchlistSignals, + useWatchlistCurveRegimes, } from '../hooks/useApi' import { Clock, Globe, ArrowUpRight, Newspaper, Waves, Link2 } from 'lucide-react' import { Link, useNavigate } from 'react-router-dom' @@ -132,7 +132,7 @@ export default function Dashboard() { const activeWatchlistName = ((watchlistItems as any) ?? []).find((w: any) => w.ticker === activeWatchlistTicker)?.name || activeWatchlistTicker const { data: watchlistHistoryData, isLoading: watchlistHistoryLoading } = useWatchlistHistory(activeWatchlistTicker, watchlistChartPeriod) const { data: latestCycleReportData } = useLatestCycleReport() - const { data: waveletSignalsData } = useWaveletWatchlistSignals() + const { data: curveRegimesData } = useWatchlistCurveRegimes() const { data: saxoIvWatchlistData } = useSaxoIvWatchlist() // Double-click on a Watchlist row opens it in Instrument Analysis. Always quick-adds @@ -1013,50 +1013,51 @@ export default function Dashboard() { - {/* Wavelets Signal — latest per-ticker signal from the watchlist scan (computed each - cycle). Single click on the card = Wavelets Simulation overview; double-click a - row = that instrument's Analysis page, Wavelets panel open directly. */} + {/* Curve Regime — instrument-level regime classification (services.curve_regime), + cached per-instrument on the same refresh cadence as the wavelet cache. Distinct + from the global macro regime shown elsewhere. Double-click a row = that + instrument's Analysis page, Curve Regime tab open directly. */} {(() => { - const signals: any[] = waveletSignalsData?.signals ?? [] + const regimes: any[] = curveRegimesData?.items ?? [] const nameByTicker: Record = {} for (const w of ((watchlistItems as any[]) ?? [])) nameByTicker[w.ticker] = w.name || w.ticker + const scoreColor = (s: number | null) => s == null ? 'text-slate-600' : s >= 0.75 ? 'text-emerald-400' : s >= 0.5 ? 'text-amber-400' : 'text-slate-400' return (
- +
- Wavelets Signal + Curve Regime - - - {signals.length > 0 ? ( +
+ {regimes.length > 0 ? (
- {signals.map((s: any, i: number) => ( + {regimes.map((r: any, i: number) => (
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), + onDoubleClick={() => quickAddInstrument.mutate(r.ticker, { + onSuccess: ({ id }) => navigate(`/instruments/${encodeURIComponent(id)}?tab=regime`), + onError: (e) => console.error(`[CurveRegime] failed to open ${r.ticker} in Instrument Analysis:`, e), })} - title="Double-click to open in Instrument Analysis → Wavelets" + title="Double-click to open in Instrument Analysis → Curve Regime" 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" > - {s.direction === 'up' ? '↑' : s.direction === 'down' ? '↓' : '·'} + {r.direction === 'up' ? '↑' : r.direction === 'down' ? '↓' : '·'} - - {nameByTicker[s.ticker] || s.ticker} + + {nameByTicker[r.ticker] || r.ticker} - - {s.band_label ? `${s.band_label} · ${s.signal_kind ?? 'veille'}` : 'Pas de données'} + + {r.regime_label ?? 'Pas de données'} - {s.computed_at && {s.computed_at.slice(0, 10)}} + {r.computed_at && {r.computed_at.slice(0, 10)}}
))}
) : ( -
No wavelet signal detected — le prochain cycle en calculera
+
Aucun régime calculé — le prochain rafraîchissement s'en chargera
)}
) diff --git a/frontend/src/pages/InstrumentDashboard.tsx b/frontend/src/pages/InstrumentDashboard.tsx index 910da2f..fc997ca 100644 --- a/frontend/src/pages/InstrumentDashboard.tsx +++ b/frontend/src/pages/InstrumentDashboard.tsx @@ -1799,9 +1799,16 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i const waveletChartData = useMemo(() => { if (!waveletData) return [] const { dates, original, bands, mean } = waveletData + // mean is a single scalar for a whole-range decomposition (/wavelet/analyze), but a + // per-day series for the causal/rolling one (/wavelet/rolling and the cached decomposition + // this tab loads by default) — each trailing window is de-meaned independently before + // decomposing it, and that mean drifts as the window slides forward, so there's no single + // constant that reconstructs every day correctly. Without picking the right one per day, + // the reconstruction (and the shared price axis it's plotted on) comes out silently + // missing the entire price level, not just slightly off. return dates.map((d: string, i: number) => { const row: any = { date: d.slice(0, 10), original: original[i] } - let recon = mean ?? 0 + let recon = Array.isArray(mean) ? (mean[i] ?? 0) : (mean ?? 0) for (const b of bands) { row[b.label] = b.series[i]; recon += b.series[i] } row.reconstruction = recon return row