feat: wavelets

This commit is contained in:
OpenSquared
2026-07-24 20:22:21 +02:00
parent 32f2d27782
commit 67d27eaeb6
5 changed files with 99 additions and 31 deletions

View File

@@ -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 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. with custom parameters, which does NOT overwrite this cache.
Looked up by the Cockpit Watchlist ticker the cache is keyed on, normalized via the Looked up by the Cockpit Watchlist ticker the cache is keyed on, resolved via
same yfinance-suffix stripping instrument_service uses elsewhere (EURUSD=X -> EURUSD) instrument_service.resolve_watchlist_ticker — handles both the yfinance-suffix case
— a quick-added instrument's id already equals its Watchlist ticker verbatim, but a (EURUSD=X -> EURUSD) and the alias case where the catalog id and the Watchlist ticker
curated one (EURUSD=X) doesn't.""" share no common substring at all (GC=F -> GOLD, ^GSPC -> SP500)."""
from services.instrument_service import _base_ticker from services.instrument_service import resolve_watchlist_ticker
from services.database import get_wavelet_decomposition_cache 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"): if not cached or not cached.get("decomposition"):
raise HTTPException(status_code=404, detail=f"Aucune décomposition wavelet en cache pour '{instrument_id}'") raise HTTPException(status_code=404, detail=f"Aucune décomposition wavelet en cache pour '{instrument_id}'")
return { return {

View File

@@ -3623,32 +3623,42 @@ def get_latest_wavelet_signals() -> List[Dict]:
def get_latest_wavelet_state_by_instrument() -> 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 """One row per Watchlist instrument, always — its own most recently scanned band state
run had one for it (any band), otherwise the fastest band's current slope/direction, so (a fired signal if that scan had one, otherwise the fastest band's slope/direction), or
every scanned instrument shows *something* here instead of only the ones with an active a data=None placeholder if it's never been scanned successfully at all. Looked up
trigger this run. get_latest_wavelet_signals() (fired-only) stays as-is for any other independently per ticker (its own MAX(computed_at)) rather than pinned to one shared
caller; this is specifically for the Dashboard's Wavelets Signal card, which should run_id, so an instrument that failed the latest scan (e.g. a transient Saxo hiccup)
reflect the latest state per instrument, not just a list of firings.""" still shows its last known state instead of silently disappearing from the list, and
run_id = _latest_wavelet_run_id() self-heals the next time it scans successfully. Feeds the Dashboard's Wavelets Signal
if not run_id: card, which should mirror the Watchlist exactly — every instrument represented, not
return [] just the ones with fresh data this run."""
conn = get_conn() conn = get_conn()
rows = conn.execute( rows = conn.execute(
"SELECT * FROM wavelet_watchlist_signals WHERE run_id=? AND band_label != 'ridge' " "SELECT w.* FROM wavelet_watchlist_signals w "
"ORDER BY ticker, (signal_kind IS NULL), period_low_days ASC", "JOIN (SELECT ticker, MAX(computed_at) AS max_computed_at FROM wavelet_watchlist_signals "
(run_id,), " 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() ).fetchall()
conn.close() conn.close()
by_ticker: Dict[str, Dict] = {} by_ticker: Dict[str, Dict] = {}
for r in rows: for r in rows:
d = dict(r) d = dict(r)
by_ticker.setdefault(d["ticker"], d) by_ticker.setdefault(d["ticker"], d)
out = list(by_ticker.values()) # No fired signal for this band -> direction is null; fall back to the band's own slope
# No fired signal this run for this band -> direction is null; fall back to the band's # sign so the card can still show an up/down arrow instead of a blank one.
# own slope sign so the card can still show an up/down arrow instead of a blank one. for d in by_ticker.values():
for d in out:
if d.get("direction") is None and d.get("slope") is not None: if d.get("direction") is None and d.get("slope") is not None:
d["direction"] = "up" if d["slope"] > 0 else "down" 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 return out

View File

@@ -18,6 +18,45 @@ def _base_ticker(t: str) -> str:
"""Normalize Yahoo Finance tickers for comparison: EURUSD=X → EURUSD, BZ=F → BZ, ^GSPC → GSPC.""" """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()) 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__) logger = logging.getLogger(__name__)
# ── Config loading ───────────────────────────────────────────────────────────── # ── Config loading ─────────────────────────────────────────────────────────────

View File

@@ -25,6 +25,18 @@ from typing import Dict, List, Optional
logger = logging.getLogger(__name__) 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]: def _compute_slope(series: List[float]) -> List[float]:
n = len(series) n = len(series)
@@ -256,12 +268,15 @@ def _fetch_close_series(ticker: str, saxo_symbol: Optional[str]):
from services.data_fetcher import get_historical from services.data_fetcher import get_historical
yf_ticker = ticker.upper() yf_ticker = ticker.upper()
# Bare 6-letter FX pairs (EURUSD, GBPUSD...) are a common Watchlist ticker convention if yf_ticker in _FRIENDLY_TO_YFINANCE:
# here but not a real yfinance symbol (needs the "=X" suffix) — without this, any # GOLD/CRUDE/BRENT/SP500... aren't real yfinance symbols either — without this, any
# Saxo-linked FX pair whose Saxo fetch fails falls through to a yfinance call that's # Saxo-linked commodity/index whose Saxo fetch fails falls through to a yfinance call
# guaranteed to return nothing, permanently keeping it out of the wavelet cache no # that's guaranteed to return nothing, permanently keeping it out of the wavelet
# matter how many refreshes run. # cache no matter how many refreshes run.
if len(yf_ticker) == 6 and yf_ticker.isalpha(): 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" yf_ticker += "=X"
hist = get_historical(yf_ticker, period="1y", interval="1d") hist = get_historical(yf_ticker, period="1y", interval="1d")
return [h["close"] for h in hist], [h["date"] for h in hist] return [h["close"] for h in hist], [h["date"] for h in hist]

View File

@@ -1067,13 +1067,17 @@ export default function Dashboard() {
title="Double-click to open in Instrument Analysis → Wavelets" 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" 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"
> >
<span className={clsx('shrink-0', s.direction === 'up' ? 'text-emerald-400' : 'text-red-400')}> <span className={clsx('shrink-0',
{s.direction === 'up' ? '↑' : '↓'} s.direction === 'up' ? 'text-emerald-400' : s.direction === 'down' ? 'text-red-400' : 'text-slate-700'
)}>
{s.direction === 'up' ? '↑' : s.direction === 'down' ? '↓' : '·'}
</span> </span>
<span className="text-slate-200 font-bold shrink-0 truncate max-w-[90px]" title={s.ticker}> <span className="text-slate-200 font-bold shrink-0 truncate max-w-[90px]" title={s.ticker}>
{nameByTicker[s.ticker] || s.ticker} {nameByTicker[s.ticker] || s.ticker}
</span> </span>
<span className="text-slate-500 truncate flex-1 text-[9px]">{s.band_label} · {s.signal_kind ?? 'veille'}</span> <span className="text-slate-500 truncate flex-1 text-[9px]">
{s.band_label ? `${s.band_label} · ${s.signal_kind ?? 'veille'}` : 'Pas de données'}
</span>
{s.computed_at && <span className="text-[8px] text-slate-700 shrink-0 font-mono">{s.computed_at.slice(0, 10)}</span>} {s.computed_at && <span className="text-[8px] text-slate-700 shrink-0 font-mono">{s.computed_at.slice(0, 10)}</span>}
</div> </div>
))} ))}