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 ? ( -