diff --git a/backend/routers/instruments.py b/backend/routers/instruments.py index 8f870e3..b70681a 100644 --- a/backend/routers/instruments.py +++ b/backend/routers/instruments.py @@ -81,13 +81,13 @@ def wavelet_cache(instrument_id: str) -> Dict[str, Any]: 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 + Looked up by the Cockpit Watchlist ticker the cache is keyed on, resolved via + instrument_service.resolve_watchlist_ticker — handles both the yfinance-suffix case + (EURUSD=X -> EURUSD) and the alias case where the catalog id and the Watchlist ticker + share no common substring at all (GC=F -> GOLD, ^GSPC -> SP500).""" + from services.instrument_service import resolve_watchlist_ticker from services.database import get_wavelet_decomposition_cache - cached = get_wavelet_decomposition_cache(_base_ticker(instrument_id)) + cached = get_wavelet_decomposition_cache(resolve_watchlist_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 { diff --git a/backend/services/database.py b/backend/services/database.py index 7cd7273..e6b52c0 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -3623,32 +3623,42 @@ def get_latest_wavelet_signals() -> List[Dict]: def get_latest_wavelet_state_by_instrument() -> List[Dict]: - """One row per Watchlist instrument from the most recent scan — a fired signal if this - run had one for it (any band), otherwise the fastest band's current slope/direction, so - every scanned instrument shows *something* here instead of only the ones with an active - trigger this run. get_latest_wavelet_signals() (fired-only) stays as-is for any other - caller; this is specifically for the Dashboard's Wavelets Signal card, which should - reflect the latest state per instrument, not just a list of firings.""" - run_id = _latest_wavelet_run_id() - if not run_id: - return [] + """One row per Watchlist instrument, always — its own most recently scanned band state + (a fired signal if that scan had one, otherwise the fastest band's slope/direction), or + a data=None placeholder if it's never been scanned successfully at all. Looked up + independently per ticker (its own MAX(computed_at)) rather than pinned to one shared + run_id, so an instrument that failed the latest scan (e.g. a transient Saxo hiccup) + still shows its last known state instead of silently disappearing from the list, and + self-heals the next time it scans successfully. Feeds the Dashboard's Wavelets Signal + card, which should mirror the Watchlist exactly — every instrument represented, not + just the ones with fresh data this run.""" conn = get_conn() rows = conn.execute( - "SELECT * FROM wavelet_watchlist_signals WHERE run_id=? AND band_label != 'ridge' " - "ORDER BY ticker, (signal_kind IS NULL), period_low_days ASC", - (run_id,), + "SELECT w.* FROM wavelet_watchlist_signals w " + "JOIN (SELECT ticker, MAX(computed_at) AS max_computed_at FROM wavelet_watchlist_signals " + " WHERE band_label != 'ridge' GROUP BY ticker) w2 " + " ON w.ticker = w2.ticker AND w.computed_at = w2.max_computed_at " + "WHERE w.band_label != 'ridge' " + "ORDER BY w.ticker, (w.signal_kind IS NULL), w.period_low_days ASC" ).fetchall() conn.close() by_ticker: Dict[str, Dict] = {} for r in rows: d = dict(r) by_ticker.setdefault(d["ticker"], d) - out = list(by_ticker.values()) - # No fired signal this run for this band -> direction is null; fall back to the band's - # own slope sign so the card can still show an up/down arrow instead of a blank one. - for d in out: + # No fired signal for this band -> direction is null; fall back to the band's own slope + # sign so the card can still show an up/down arrow instead of a blank one. + for d in by_ticker.values(): if d.get("direction") is None and d.get("slope") is not None: d["direction"] = "up" if d["slope"] > 0 else "down" + + out: List[Dict] = [] + for item in get_instruments_watchlist(): + ticker = item["ticker"].upper() + out.append(by_ticker.get(ticker) or { + "ticker": ticker, "band_label": None, "signal_kind": None, "direction": None, + "computed_at": None, "slope": None, "value": None, "energy": None, + }) return out diff --git a/backend/services/instrument_service.py b/backend/services/instrument_service.py index 2b03e2c..2130b24 100644 --- a/backend/services/instrument_service.py +++ b/backend/services/instrument_service.py @@ -18,6 +18,45 @@ def _base_ticker(t: str) -> str: """Normalize Yahoo Finance tickers for comparison: EURUSD=X → EURUSD, BZ=F → BZ, ^GSPC → GSPC.""" return re.sub(r'(=X|=F|=RR|-USD|\^)$', '', t.strip().upper()) + +# Reverse of the common "yfinance/futures ticker" an Instrument Analysis id (curated or +# quick-added) uses vs. the Cockpit Watchlist's own friendly ticker for the same underlying +# — needed because _base_ticker() only strips a SUFFIX (EURUSD=X -> EURUSD works since the +# base "EURUSD" already equals the Watchlist ticker), it can't turn "GC=F" into "GOLD" or +# "^GSPC" into "SP500" (nothing in common to strip; ^ is a prefix, not a suffix, so +# _base_ticker doesn't even touch it despite what its own docstring claims). Mirrors +# frontend/src/pages/Dashboard.tsx's UNDERLYING_ALIASES table (kept in sync manually — a +# small, stable list, not worth sharing across a Python/TS boundary). +_WAVELET_UNDERLYING_ALIASES = { + "^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", +} + + +def resolve_watchlist_ticker(instrument_id: str) -> str: + """Best-effort map from an Instrument Analysis id to the Cockpit Watchlist ticker it + represents — for looking up data keyed by the Watchlist ticker (wavelet cache in + particular). Tries an exact match, then the suffix-stripped form (EURUSD=X -> EURUSD), + then the alias table above (GC=F -> GOLD) — each checked against the tickers actually + in the Watchlist right now, not just "is this a known alias", so a curated catalog id + that happens to share a root with an alias but isn't Watchlist-linked doesn't falsely + resolve. Falls back to the suffix-stripped form if nothing matches.""" + uid = instrument_id.strip().upper() + base = _base_ticker(uid) + from services.database import get_instruments_watchlist + watchlist_tickers = {r["ticker"].upper() for r in get_instruments_watchlist()} + if uid in watchlist_tickers: + return uid + if base in watchlist_tickers: + return base + alias = _WAVELET_UNDERLYING_ALIASES.get(uid) or _WAVELET_UNDERLYING_ALIASES.get(base) + if alias and alias in watchlist_tickers: + return alias + return base + + logger = logging.getLogger(__name__) # ── Config loading ───────────────────────────────────────────────────────────── diff --git a/backend/services/wavelet_signals.py b/backend/services/wavelet_signals.py index 690e6f1..a011b10 100644 --- a/backend/services/wavelet_signals.py +++ b/backend/services/wavelet_signals.py @@ -25,6 +25,18 @@ from typing import Dict, List, Optional logger = logging.getLogger(__name__) +# Watchlist ticker -> a real yfinance symbol, for the fallback fetch when this instrument +# has no saxo_quote_symbol link (or its Saxo fetch fails) — mirrors +# instrument_service.py's _WAVELET_UNDERLYING_ALIASES (reverse direction: that one maps a +# yfinance/futures id back to the Watchlist ticker for cache lookups, this one maps the +# Watchlist ticker forward to a fetchable yfinance symbol). +_FRIENDLY_TO_YFINANCE = { + "GOLD": "GC=F", "SILVER": "SI=F", "COPPER": "HG=F", "PLATINUM": "PL=F", + "CRUDE": "CL=F", "BRENT": "BZ=F", "NATGAS": "NG=F", + "WHEAT": "ZW=F", "CORN": "ZC=F", "SOYBEANS": "ZS=F", + "SP500": "^GSPC", "NASDAQ": "^NDX", "DOW": "^DJI", "RUSSELL2000": "^RUT", +} + def _compute_slope(series: List[float]) -> List[float]: n = len(series) @@ -256,12 +268,15 @@ def _fetch_close_series(ticker: str, saxo_symbol: Optional[str]): from services.data_fetcher import get_historical yf_ticker = ticker.upper() - # Bare 6-letter FX pairs (EURUSD, GBPUSD...) are a common Watchlist ticker convention - # here but not a real yfinance symbol (needs the "=X" suffix) — without this, any - # Saxo-linked FX pair whose Saxo fetch fails falls through to a yfinance call that's - # guaranteed to return nothing, permanently keeping it out of the wavelet cache no - # matter how many refreshes run. - if len(yf_ticker) == 6 and yf_ticker.isalpha(): + if yf_ticker in _FRIENDLY_TO_YFINANCE: + # GOLD/CRUDE/BRENT/SP500... aren't real yfinance symbols either — without this, any + # Saxo-linked commodity/index whose Saxo fetch fails falls through to a yfinance call + # that's guaranteed to return nothing, permanently keeping it out of the wavelet + # cache no matter how many refreshes run. + yf_ticker = _FRIENDLY_TO_YFINANCE[yf_ticker] + elif len(yf_ticker) == 6 and yf_ticker.isalpha(): + # Bare 6-letter FX pairs (EURUSD, GBPUSD...) are a common Watchlist ticker + # convention here but not a real yfinance symbol (needs the "=X" suffix). yf_ticker += "=X" hist = get_historical(yf_ticker, period="1y", interval="1d") return [h["close"] for h in hist], [h["date"] for h in hist] diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 01bd5cd..c1c86de 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1067,13 +1067,17 @@ export default function Dashboard() { 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" > - - {s.direction === 'up' ? '↑' : '↓'} + + {s.direction === 'up' ? '↑' : s.direction === 'down' ? '↓' : '·'} {nameByTicker[s.ticker] || s.ticker} - {s.band_label} · {s.signal_kind ?? 'veille'} + + {s.band_label ? `${s.band_label} · ${s.signal_kind ?? 'veille'}` : 'Pas de données'} + {s.computed_at && {s.computed_at.slice(0, 10)}} ))}