diff --git a/backend/routers/instruments.py b/backend/routers/instruments.py index b70681a..3f20ad7 100644 --- a/backend/routers/instruments.py +++ b/backend/routers/instruments.py @@ -100,6 +100,29 @@ def wavelet_cache(instrument_id: str) -> Dict[str, Any]: } +@router.get("/{instrument_id}/curve-regime") +async def curve_regime( + instrument_id: str, + period: str = Query(default="1y"), + interval: str = Query(default="1d"), +) -> Dict[str, Any]: + """Instrument-level regime classification (Direction/Dynamique/Volatilité/Structure IV, + matched against the 15-regime reference table) — distinct from the global macro regime + already shown elsewhere. See services.curve_regime module docstring for the full + methodology and its data-availability caveats (no cross-asset correlation source, + structure_iv only available when this instrument's options chain is Saxo-linked).""" + config = get_instrument(instrument_id) + if not config: + raise HTTPException(status_code=404, detail=f"Instrument '{instrument_id}' not found") + + snapshot = await get_snapshot(instrument_id, period=period, interval=interval) + if "error" in snapshot: + raise HTTPException(status_code=500, detail=snapshot["error"]) + + from services.curve_regime import classify_instrument_regime + return classify_instrument_regime(instrument_id, snapshot) + + @router.post("/{instrument_id}/narrative") async def generate_narrative( instrument_id: str, diff --git a/backend/services/curve_regime.py b/backend/services/curve_regime.py new file mode 100644 index 0000000..4dce71f --- /dev/null +++ b/backend/services/curve_regime.py @@ -0,0 +1,397 @@ +""" +Instrument-level "Curve Regime" — distinct from the global macro regime +(services.data_fetcher.score_macro_scenarios / snapshot.macro_regime, one dominant +scenario for the whole market). This classifies a SINGLE instrument's current price/vol +state into one of 15 canonical regimes (spec below, provided by the user as an analyst +reference table), combining: + + - direction / dynamique: from the instrument's wavelet decomposition — the SAME cache + services.wavelet_signals writes and the Wavelet tab reads (services.database. + get_wavelet_decomposition_cache), so this tab can never disagree with the Wavelet tab + it sits next to — plus the trend/regime signals instrument_service already computes + (ATR ratio, momentum, MA slopes) for confirmation. + - volatilite: realized-vol level (ATR vs its own 3-month average) + the direction that + level has been moving in over the last ~10 sessions (rising/falling fast). + - structure_iv / liquidite: Saxo options IV/skew/term-structure (services.saxo_iv_engine), + only when this instrument has a saxo_option_symbol linked (Config -> Instruments + Watchlist -> "Option") — otherwise both axes are "n/a" and excluded from scoring + rather than guessed at. Only a single 25-delta skew point + term-structure shape exist + today (see saxo_iv_engine.py), not a full smile/butterfly curve — so structure_iv only + distinguishes flat / skew_put / skew_put_fort / degraded, not the finer smile/BF/RR + categories some regime rows reference; those regimes fall back to their other axes. + - facteur_dominant: the global macro regime's asset-class bias (already computed per + snapshot) — shown alongside the match for context, NOT used as a hard scoring + criterion, since instrument category -> macro asset-class is an approximate mapping. + +Cross-asset correlation (the spec table's "Corrélation cross-actifs" column) has no +existing data source anywhere in this codebase (checked: only a portfolio-scoped pairwise +correlation exists, for the Risk radar) — always "n/a" here, not scored. A real version +would need a new rolling-correlation-vs-benchmark fetch; flagged as a known gap rather than +approximated. + +This is a transparent rule-based scorer, not a black box: classify_instrument_regime() +returns every candidate regime's per-axis score breakdown, not just the winner, so a wrong +call is inspectable and the thresholds/weights can be tuned without re-deriving the whole +design. +""" +import logging +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +# ── Regime reference table (digitized from the user-provided spec) ──────────────────── +# Each entry's axis values are what THAT regime expects to see. `None` means "this axis +# doesn't discriminate this regime" (not scored against it). volatilite accepts either a +# level bucket (faible/moyenne/elevee/tres_elevee/explosive) or a trend word +# (hausse/baisse_rapide) — matched against the corresponding computed signal, see +# _score_regime() below. +REGIME_SPECS: List[Dict[str, Any]] = [ + {"key": "bull_trend", "label": "Bull Trend", + "direction": "up", "dynamique": "progressive", "volatilite": "faible", "structure_iv": "flat", + "facteur_dominant": "Croissance / BCE", + "declencheurs": "BCE plus hawkish que Fed, croissance Europe", + "strategies": "Bull Call Spread, Bull Put Spread", "exemple": "+120 pips, IV stable → +60 à 90%"}, + {"key": "bear_trend", "label": "Bear Trend", + "direction": "down", "dynamique": "progressive", "volatilite": "faible", "structure_iv": "skew_put", + "facteur_dominant": "USD / Diff. de taux", + "declencheurs": "Fed hawkish, différentiel de taux", + "strategies": "Bear Put Spread", "exemple": "-120 pips → +60 à 90%"}, + {"key": "bull_breakout", "label": "Bull Breakout", + "direction": "up", "dynamique": "explosive", "volatilite": "elevee", "structure_iv": "smile", + "facteur_dominant": "Événement", + "declencheurs": "BCE surprise, cassure technique", + "strategies": "Long Call, Call Backspread", "exemple": "+150 à 250%"}, + {"key": "bear_breakout", "label": "Bear Breakout", + "direction": "down", "dynamique": "explosive", "volatilite": "elevee", "structure_iv": "skew_put_fort", + "facteur_dominant": "Événement", + "declencheurs": "Fed surprise, cassure, crise localisée", + "strategies": "Long Put, Put Backspread", "exemple": "+150 à 250%"}, + {"key": "compression", "label": "Compression", + "direction": "flat", "dynamique": "progressive", "volatilite": "faible", "structure_iv": "flat", + "facteur_dominant": "Attente", + "declencheurs": "Vacances, avant FOMC/BCE", + "strategies": "Calendar, Gamma positif", "exemple": "Préparer une cassure"}, + {"key": "iv_crush", "label": "IV Crush", + "direction": "flat", "dynamique": "progressive", "volatilite": "tres_elevee", "structure_iv": "flat", + "facteur_dominant": "Décroissance du risque", + "declencheurs": "Après BCE/Fed/NFP sans surprise", + "strategies": "Iron Condor, Butterfly", "exemple": "+30 à 70%"}, + {"key": "whipsaw", "label": "Whipsaw", + "direction": "flat", "dynamique": "chaotique", "volatilite": "elevee", "structure_iv": "smile", + "facteur_dominant": "Flux contradictoires", + "declencheurs": "Faux breakouts, marché sans conviction", + "strategies": "Petite taille, Gamma contrôlé", "exemple": "Peu de stratégies robustes"}, + {"key": "stress_prolonge", "label": "Stress prolongé", + "direction": "mixed", "dynamique": "progressive", "volatilite": "elevee", "structure_iv": "skew_put", + "facteur_dominant": "Inflation / Taux / EM", + "declencheurs": "Brexit, crise EM, inflation durable", + "strategies": "Calendar directionnel, Diagonal", "exemple": "+40 à 80%"}, + {"key": "explosion_incertitude", "label": "Explosion d'incertitude", + "direction": "mixed", "dynamique": "explosive", "volatilite": "explosive", "structure_iv": "skew_put_fort", + "facteur_dominant": "Géopolitique / Banque centrale", + "declencheurs": "Guerre, surprise extrême, banques", + "strategies": "Long Straddle, Reverse Iron Condor", "exemple": "IV 5→8 : +50 à 120%"}, + {"key": "flash_crash", "label": "Flash Crash / Crise de liquidité", + "direction": "mixed", "dynamique": "explosive", "volatilite": "explosive", "structure_iv": "degradee", + "facteur_dominant": "Déleveraging", + "declencheurs": "Vente forcée, défaut, crise systémique", + "strategies": "Hedges existants uniquement", "exemple": "Priorité = survie et exécution"}, + {"key": "retour_normale", "label": "Retour à la normale", + "direction": "flat", "dynamique": "progressive", "volatilite": "baisse_rapide", "structure_iv": "flat", + "facteur_dominant": "Dissipation du risque", + "declencheurs": "Cessez-le-feu, marché rassuré", + "strategies": "Vente Vega", "exemple": "+40 à 80%"}, + {"key": "transition_regime", "label": "Transition de régime", + "direction": "mixed", "dynamique": "accelere", "volatilite": "hausse", "structure_iv": "skew_put", + "facteur_dominant": "Changement macro", + "declencheurs": "Brent, COT, Fed/BCE changent progressivement", + "strategies": "Calendar, Diagonal, structures hybrides", "exemple": "+40 à 90%"}, + {"key": "risk_on", "label": "Risk-On", + "direction": "up", "dynamique": "progressive", "volatilite": "faible", "structure_iv": "flat", + "facteur_dominant": "Sentiment", + "declencheurs": "QE, détente géopolitique", + "strategies": "Bull Spread", "exemple": "+50 à 80%"}, + {"key": "risk_off", "label": "Risk-Off", + "direction": "down", "dynamique": "accelere", "volatilite": "elevee", "structure_iv": "skew_put", + "facteur_dominant": "Sentiment", + "declencheurs": "Flight to Quality", + "strategies": "Bear Put, Long Put", "exemple": "+70 à 150%"}, + {"key": "dispersion", "label": "Dispersion / Décorrélation", + "direction": "mixed", "dynamique": "variable", "volatilite": "moyenne", "structure_iv": None, + "facteur_dominant": "Rotation sectorielle", + "declencheurs": "Corrélations qui cassent", + "strategies": "Paniers, dispersion, relative value", + "exemple": "Plus adapté au multi-actifs qu'au single instrument"}, +] + +# Per-axis scoring weight — direction/dynamique/volatilite are the axes we can compute with +# real confidence (wavelets + price data, always available); structure_iv only weighs in +# when options data exists, so it's naturally excluded (not zero-penalized) otherwise. +_AXIS_WEIGHTS = {"direction": 3.0, "dynamique": 2.5, "volatilite": 2.0, "structure_iv": 1.5} + + +# ── Axis 1+2: direction & dynamique, from the wavelet cache + trend/regime signals ───── + +def _wavelet_band_slopes(decomposition: Optional[Dict]) -> List[float]: + """Last-point slope of each band's series (today vs yesterday) — same math as + services.wavelet_signals._compute_slope, applied directly to whatever's already in the + cached decomposition rather than recomputing anything.""" + if not decomposition: + return [] + bands = decomposition.get("bands") or [] + slopes = [] + for band in bands: + series = band.get("series") or [] + if len(series) >= 2 and series[-1] is not None and series[-2] is not None: + slopes.append(float(series[-1]) - float(series[-2])) + return slopes + + +def _axis_direction(trend: Dict, regime_signals: Dict, wavelet_slopes: List[float]) -> str: + """'up'/'down' on a clear, consistent trend; 'flat' when momentum is negligible; + 'mixed' when the fast (wavelet) and slow (momentum/MA) signals disagree — that + disagreement IS the "?"/"↑ ou ↓" state several regimes in the spec are built around + (stress prolongé, explosion d'incertitude, transition, flash crash).""" + mom = trend.get("momentum_1m_pct") + ma_above = regime_signals.get("ma50_above_ma200") + if mom is None: + return "flat" + slow_dir = "up" if mom > 1.0 else "down" if mom < -1.0 else "flat" + if wavelet_slopes: + up_count = sum(1 for s in wavelet_slopes if s > 0) + down_count = sum(1 for s in wavelet_slopes if s < 0) + fast_dir = "up" if up_count > down_count else "down" if down_count > up_count else "flat" + if slow_dir != "flat" and fast_dir != "flat" and slow_dir != fast_dir: + return "mixed" + if slow_dir == "flat": + return "flat" + if ma_above is not None: + # MA50/MA200 disagreeing with recent momentum direction = the trend is stalling/ + # reversing under the surface, not a clean trend — treat as mixed too. + ma_dir = "up" if ma_above else "down" + if ma_dir != slow_dir: + return "mixed" + return slow_dir + + +def _axis_dynamique(trend: Dict, regime_signals: Dict, wavelet_slopes: List[float]) -> str: + """progressive (steady) / explosive (sharp acceleration) / chaotique (bands + disagreeing on direction) / accelere (momentum building) / variable (bands agree on + neither direction nor magnitude — dispersion-like).""" + vol_ratio = regime_signals.get("vol_ratio_pct") + mom_1m = trend.get("momentum_1m_pct") + mom_3m = trend.get("momentum_3m_pct") + + if wavelet_slopes and len(wavelet_slopes) >= 2: + signs = [1 if s > 0 else -1 if s < 0 else 0 for s in wavelet_slopes] + disagreement = len(set(signs)) > 1 and 0 not in signs # genuine split, not just noise near zero + else: + disagreement = False + + if vol_ratio is not None and vol_ratio > 180: + return "explosive" + if disagreement and vol_ratio is not None and vol_ratio > 110: + return "chaotique" + if disagreement: + return "variable" + if mom_1m is not None and mom_3m is not None and mom_3m != 0: + # This month's pace vs the trailing 3-month pace — accelerating if 1m alone is + # already running hotter than the average of the last 3. + monthly_avg_3m = mom_3m / 3 + if abs(mom_1m) > abs(monthly_avg_3m) * 1.8 and abs(mom_1m) > 3: + return "accelere" + return "progressive" + + +# ── Axis 3: volatilite level + trend, from ATR ratio + the realized-vol series ───────── + +def _axis_volatilite(trend: Dict, regime_signals: Dict, vol_series: List[Dict]) -> Dict[str, Optional[str]]: + """Returns {"level": ..., "trend": ...} — level is the primary bucket regimes are + matched against; trend (hausse/baisse_rapide/stable) only matters for the couple of + regimes defined by a MOVE in vol rather than its absolute level (Retour à la normale, + Transition de régime).""" + vol_ratio = regime_signals.get("vol_ratio_pct") # ATR14 / its own 3-month average, % + level = None + if vol_ratio is not None: + if vol_ratio > 200: + level = "explosive" + elif vol_ratio > 160: + level = "tres_elevee" + elif vol_ratio > 115: + level = "elevee" + elif vol_ratio >= 85: + level = "moyenne" + else: + level = "faible" + + trend_word = None + values = [p.get("value") for p in (vol_series or []) if p.get("value") is not None] + if len(values) >= 10: + recent = values[-1] + baseline = sum(values[-10:-3]) / len(values[-10:-3]) if len(values[-10:-3]) else None + if baseline and baseline > 0: + change_pct = (recent - baseline) / baseline * 100 + if change_pct <= -25: + trend_word = "baisse_rapide" + elif change_pct >= 30: + trend_word = "hausse" + else: + trend_word = "stable" + return {"level": level, "trend": trend_word} + + +# ── Axis 4+5: structure_iv / liquidite, from Saxo options data when linked ───────────── + +def _axis_options(watchlist_row: Optional[Dict]) -> Dict[str, Any]: + """None when this instrument has no saxo_option_symbol link — the caller excludes + structure_iv from scoring entirely in that case rather than defaulting to 'flat'.""" + if not watchlist_row or not watchlist_row.get("saxo_option_symbol"): + return {"structure_iv": None, "liquidite": None, "iv_snapshot": None} + try: + from services.saxo_iv_engine import get_saxo_iv_snapshot + snap = get_saxo_iv_snapshot(watchlist_row["saxo_option_symbol"]) + except Exception as e: + logger.warning(f"[curve_regime] IV snapshot failed for '{watchlist_row.get('saxo_option_symbol')}': {e}") + return {"structure_iv": None, "liquidite": None, "iv_snapshot": None} + + if snap.get("iv_current_pct") is None: + # No usable Saxo option data at all (empty/stale chain) — this itself is the + # "surface cassée" signal, not just an absent one. + return {"structure_iv": "degradee", "liquidite": "degradee", "iv_snapshot": snap} + + skew_pct = (snap.get("skew") or {}).get("skew_pct") + structure_iv = "flat" + if skew_pct is not None: + if skew_pct > 4: + structure_iv = "skew_put_fort" + elif skew_pct > 1.2: + structure_iv = "skew_put" + elif skew_pct < -1.2: + structure_iv = "smile" # calls bid up relative to puts — treated as the closest bucket we can score + + liquidite = "faible" if (snap.get("history_days") or 0) < 5 else "normal" + + return {"structure_iv": structure_iv, "liquidite": liquidite, "iv_snapshot": snap} + + +# ── Scoring ────────────────────────────────────────────────────────────────────────── + +def _score_regime(spec: Dict, profile: Dict) -> Dict[str, Any]: + axis_hits: Dict[str, bool] = {} + total_weight = 0.0 + earned = 0.0 + + for axis in ("direction", "dynamique"): + expected = spec.get(axis) + observed = profile.get(axis) + if expected is None or observed is None: + continue + w = _AXIS_WEIGHTS[axis] + total_weight += w + hit = observed == expected + if hit: + earned += w + axis_hits[axis] = hit + + # volatilite: match level OR trend, whichever the spec entry is actually about + expected_vol = spec.get("volatilite") + vol = profile.get("volatilite") or {} + if expected_vol is not None: + w = _AXIS_WEIGHTS["volatilite"] + total_weight += w + observed_vol = vol.get("trend") if expected_vol in ("hausse", "baisse_rapide") else vol.get("level") + hit = observed_vol == expected_vol + if hit: + earned += w + axis_hits["volatilite"] = hit + + expected_iv = spec.get("structure_iv") + observed_iv = profile.get("structure_iv") + if expected_iv is not None and observed_iv is not None: + w = _AXIS_WEIGHTS["structure_iv"] + total_weight += w + hit = observed_iv == expected_iv + if hit: + earned += w + axis_hits["structure_iv"] = hit + + score = (earned / total_weight) if total_weight > 0 else 0.0 + return {"key": spec["key"], "label": spec["label"], "score": round(score, 3), "axis_hits": axis_hits} + + +def classify_instrument_regime(instrument_id: str, snapshot: Dict) -> Dict[str, Any]: + """Main entry point — called by routers/instruments.py with the already-built snapshot + (indicators/regime/trend/macro_regime) so this doesn't refetch price history a second + time. Returns the top match, full ranked scores, the computed axis profile (for the UI + to show its work), and the matched regime's reference info (déclencheurs/stratégies).""" + from services.instrument_service import resolve_watchlist_ticker + from services.database import get_wavelet_decomposition_cache, get_instruments_watchlist + + watchlist_ticker = resolve_watchlist_ticker(instrument_id) + cached = get_wavelet_decomposition_cache(watchlist_ticker) + decomposition = cached.get("decomposition") if cached else None + wavelet_slopes = _wavelet_band_slopes(decomposition) + + watchlist_row = next( + (r for r in get_instruments_watchlist() if r["ticker"].upper() == watchlist_ticker.upper()), None + ) + + regime_signals = (snapshot.get("regime") or {}).get("signals") or {} + trend = snapshot.get("trend") or {} + vol_series = (snapshot.get("indicators") or {}).get("volatility") or [] + + direction = _axis_direction(trend, regime_signals, wavelet_slopes) + dynamique = _axis_dynamique(trend, regime_signals, wavelet_slopes) + volatilite = _axis_volatilite(trend, regime_signals, vol_series) + options_axes = _axis_options(watchlist_row) + + profile = { + "direction": direction, + "dynamique": dynamique, + "volatilite": volatilite, + "structure_iv": options_axes["structure_iv"], + "liquidite": options_axes["liquidite"], + "correlation": None, # no data source — always n/a, see module docstring + } + + scored = sorted( + (_score_regime(spec, profile) for spec in REGIME_SPECS), + key=lambda s: -s["score"], + ) + top = scored[0] if scored else None + top_spec = next((s for s in REGIME_SPECS if s["key"] == (top or {}).get("key")), None) + + macro_regime = snapshot.get("macro_regime") or {} + config_category = (snapshot.get("instrument") or {}).get("category") + facteur_dominant_bias = _macro_bias_for_category(macro_regime.get("asset_bias") or {}, config_category) + + return { + "instrument_id": instrument_id, + "watchlist_ticker": watchlist_ticker, + "profile": profile, + "top_match": { + "key": top_spec["key"], "label": top_spec["label"], "score": top["score"], + "axis_hits": top["axis_hits"], + "facteur_dominant_ref": top_spec["facteur_dominant"], + "declencheurs": top_spec["declencheurs"], + "strategies": top_spec["strategies"], + "exemple": top_spec["exemple"], + } if top_spec else None, + "ranked": scored, + "macro_regime_label": macro_regime.get("label"), + "facteur_dominant_bias": facteur_dominant_bias, + "iv_snapshot": options_axes.get("iv_snapshot"), + "has_options_data": options_axes["structure_iv"] is not None, + } + + +# instruments.json's own category vocabulary -> macro asset_bias's asset-class keys +_CATEGORY_TO_ASSET_BIAS_KEY = { + "fx": "forex", "energy": "energy", "metal": "metals", + "equity_index": "indices", "equity_intl": "indices", "stock": "equities", +} + + +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 diff --git a/frontend/src/pages/InstrumentDashboard.tsx b/frontend/src/pages/InstrumentDashboard.tsx index 47b6ff6..910da2f 100644 --- a/frontend/src/pages/InstrumentDashboard.tsx +++ b/frontend/src/pages/InstrumentDashboard.tsx @@ -1449,7 +1449,10 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i const [searchParams] = useSearchParams() // ?tab=wavelets lets other pages (e.g. Dashboard's Wavelets Signal card) deep-link // straight into the Wavelets panel instead of always landing on Counters. - const [tabUnder, setTabUnder] = useState<'counters' | 'analyse' | 'wavelets'>(() => (searchParams.get('tab') === 'wavelets' ? 'wavelets' : 'counters')) + const [tabUnder, setTabUnder] = useState<'counters' | 'analyse' | 'wavelets' | 'regime'>(() => { + const t = searchParams.get('tab') + return t === 'counters' || t === 'analyse' || t === 'regime' ? t : 'wavelets' + }) const [waveletLevels, setWaveletLevels] = useState(4) const [waveletFamily, setWaveletFamily] = useState<'gmw' | 'morlet' | 'bump'>('gmw') const [waveletMethod, setWaveletMethod] = useState<'cwt' | 'ssq'>('cwt') @@ -1466,6 +1469,8 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i const [waveletReliability, setWaveletReliability] = useState(null) const [loadingReliability, setLoadingReliability] = useState(false) const [hiddenBands, setHiddenBands] = useState>(new Set()) + const [curveRegime, setCurveRegime] = useState(null) + const [loadingCurveRegime, setLoadingCurveRegime] = useState(false) const [hoveredWaveletIdx, setHoveredWaveletIdx] = useState(null) const [templates, setTemplates] = useState([]) const [causalScores, setCausalScores] = useState>({}) // eventId → activation_score*100 @@ -1716,6 +1721,22 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i // eslint-disable-next-line react-hooks/exhaustive-deps }, [tabUnder, instrumentId, selected]) + // Curve Regime tab — auto-loads once per instrument, same pattern as the Wavelet tab + // above (reuses the period selector's current snapshot, no separate live trigger). + const curveRegimeAutoFetchKey = useRef(null) + useEffect(() => { + if (tabUnder !== 'regime' || !selected) return + if (curveRegimeAutoFetchKey.current === `${instrumentId}|${period}`) return + curveRegimeAutoFetchKey.current = `${instrumentId}|${period}` + setCurveRegime(null) + setLoadingCurveRegime(true) + api.get(`/instruments/${encodeURIComponent(instrumentId)}/curve-regime`, { params: { period } }) + .then(({ data }) => setCurveRegime(data)) + .catch((e) => console.error(`[CurveRegime] fetch failed for ${instrumentId}:`, e)) + .finally(() => setLoadingCurveRegime(false)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tabUnder, instrumentId, selected, period]) + const runWaveletReliability = async () => { if (!selected) return setLoadingReliability(true) @@ -2120,9 +2141,10 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i {/* ── Tabs sous la courbe ── */}
{([ + { key: 'wavelets', label: 'Wavelet' }, + { key: 'regime', label: 'Curve Regime' }, { key: 'counters', label: 'Compteurs' }, { key: 'analyse', label: 'Analyse de la courbe' }, - { key: 'wavelets', label: 'Wavelet' }, ] as const).map(t => (