diff --git a/backend/routers/wavelet.py b/backend/routers/wavelet.py index d32ad35..fa64947 100644 --- a/backend/routers/wavelet.py +++ b/backend/routers/wavelet.py @@ -110,6 +110,48 @@ def wavelet_rolling( return result +@router.get("/reliability") +def wavelet_reliability_endpoint( + symbol: str = Query("SPY"), + period: str = Query("1y", description="how much of the causal output range to scan for turning points"), + lookback: int = Query(130, ge=32), + levels: int = Query(4, ge=2, le=6), + wavelet: str = Query("gmw"), + step: int = Query(1, ge=1), + method: str = Query("cwt", description="cwt (default) or ssq"), + smooth_days: int = Query(3, ge=1, le=10, description="lag used to smooth the slope before flagging a sign-change as a turning point"), + tolerance_days: int = Query(5, ge=0, le=15, description="a hindsight reversal within this many days of the causal one still counts as confirming it"), + confirm_horizon: int = Query(10, ge=1, le=30, description="how many extra days of real data the hindsight recomputation gets"), +): + """For every reversal a live (causal, walk-forward) decomposition would have flagged, + checks whether redoing the decomposition `confirm_horizon` days later still shows the + same reversal — a per-band reliability score for the wavelet's turning-point signals.""" + from services.wavelet_engine import wavelet_reliability + + # Needs confirm_horizon extra real days beyond the requested causal output range, on + # top of the usual lookback padding, so the most recent testable turning points aren't + # silently dropped for lack of "future" data. + values, dates, out_days = _fetch_padded_history(symbol, period, lookback + confirm_horizon) + if len(values) < lookback + confirm_horizon + 32: + raise HTTPException(400, "Historique insuffisant pour un test de fiabilité (32 points minimum au-delà de la fenêtre + horizon).") + + cutoff = (datetime.utcnow() - timedelta(days=out_days)).date().isoformat() + start_idx = next((i for i, d in enumerate(dates) if d[:10] >= cutoff), None) + if start_idx is None: + raise HTTPException(400, "Pas de donnees dans la plage de trading demandee.") + + try: + result = wavelet_reliability( + values, dates, + start_idx=start_idx, lookback=lookback, + num_levels=levels, wavelet=wavelet, method=method, step=step, + smooth_days=smooth_days, tolerance_days=tolerance_days, confirm_horizon=confirm_horizon, + ) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + return result + + # ── Saved simulation/optimization runs ──────────────────────────────────────── class SimulationCreate(BaseModel): diff --git a/backend/services/wavelet_engine.py b/backend/services/wavelet_engine.py index 503375f..467a166 100644 --- a/backend/services/wavelet_engine.py +++ b/backend/services/wavelet_engine.py @@ -445,3 +445,100 @@ def rolling_causal_bands_ssq( "method": "ssq", "ridge_period_days": ridge_series, } + + +def _turning_points(series: list[float], smooth_days: int) -> list[tuple[int, int]]: + """Indices (and new direction, +1/-1) where the sign of the `smooth_days`-lag slope of + `series` flips. Using the raw day-to-day slope would flag noise as a "reversal"; lagging + over a few days smooths that out.""" + turns: list[tuple[int, int]] = [] + prev_sign = 0 + for t in range(smooth_days, len(series)): + slope = series[t] - series[t - smooth_days] + sign = 1 if slope > 0 else (-1 if slope < 0 else 0) + if sign != 0: + if prev_sign != 0 and sign != prev_sign: + turns.append((t, sign)) + prev_sign = sign + return turns + + +def wavelet_reliability( + values: list[float], + dates: list[str], + start_idx: int, + lookback: int, + num_levels: int = 4, + wavelet: str = "gmw", + method: str = "cwt", + step: int = 1, + smooth_days: int = 3, + tolerance_days: int = 5, + confirm_horizon: int = 10, +) -> dict: + """ + For every turning point a CAUSAL (walk-forward, no look-ahead) decomposition flags — + i.e. what a live signal would have shown on that date, using only data available up to + then — checks whether redoing the decomposition `confirm_horizon` days later (with that + much more real data, so the date in question is no longer sitting at the unstable tip of + the window) still shows a same-direction reversal within `tolerance_days` of the original + date. The fraction confirmed is a per-band confidence score: CWT/SSQ reconstructions are + well known to be least reliable right at the edge of the available data — exactly where a + live "the band just turned" reading gets made. + """ + rolling_decomposer = rolling_causal_bands_ssq if method == "ssq" else rolling_causal_bands + batch_decompose = band_decompose_ssq if method == "ssq" else band_decompose + + causal = rolling_decomposer( + values, dates, start_idx=start_idx, lookback=lookback, + num_levels=num_levels, wavelet=wavelet, step=step, + ) + causal_dates = causal["dates"] + date_to_global = {d: i for i, d in enumerate(dates)} + + bands_out = [] + for band in causal["bands"]: + turn_points = _turning_points(band["series"], smooth_days) + + tested = [] + for t, direction in turn_points: + d_date = causal_dates[t] + g_idx = date_to_global.get(d_date) + if g_idx is None or g_idx + confirm_horizon >= len(values): + continue # not enough real future data yet to judge this one + + end_idx = g_idx + confirm_horizon + window_start = end_idx - lookback + 1 + if window_start < 0: + continue + window_values = values[window_start:end_idx + 1] + window_dates = dates[window_start:end_idx + 1] + try: + hindsight = batch_decompose(window_values, window_dates, num_levels, wavelet) + except ValueError: + continue + h_series = hindsight["bands"][band["index"]]["series"] + + d_pos = g_idx - window_start + lo, hi = d_pos - tolerance_days, d_pos + tolerance_days + hindsight_turns = _turning_points(h_series, smooth_days) + confirmed = any(lo <= idx <= hi and new_dir == direction for idx, new_dir in hindsight_turns) + tested.append({"date": d_date, "confirmed": confirmed}) + + n_tested = len(tested) + n_confirmed = sum(1 for t in tested if t["confirmed"]) + bands_out.append({ + "label": band["label"], + "n_tested": n_tested, + "n_confirmed": n_confirmed, + "confidence_pct": round(n_confirmed / n_tested * 100, 1) if n_tested else None, + "turns": tested, + }) + + return { + "bands": bands_out, + "smooth_days": smooth_days, + "tolerance_days": tolerance_days, + "confirm_horizon": confirm_horizon, + "method": method, + } diff --git a/frontend/src/pages/InstrumentDashboard.tsx b/frontend/src/pages/InstrumentDashboard.tsx index b679a33..0691aa5 100644 --- a/frontend/src/pages/InstrumentDashboard.tsx +++ b/frontend/src/pages/InstrumentDashboard.tsx @@ -1433,6 +1433,8 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i const [waveletLookback, setWaveletLookback] = useState(130) const [waveletData, setWaveletData] = useState(null) const [loadingWavelet, setLoadingWavelet] = useState(false) + const [waveletReliability, setWaveletReliability] = useState(null) + const [loadingReliability, setLoadingReliability] = useState(false) const [hiddenBands, setHiddenBands] = useState>(new Set()) const [hoveredWaveletIdx, setHoveredWaveletIdx] = useState(null) const [templates, setTemplates] = useState([]) @@ -1647,6 +1649,25 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i } } + const runWaveletReliability = async () => { + if (!selected) return + setLoadingReliability(true) + try { + const { data } = await api.get('/wavelet/reliability', { + params: { + symbol: selected.yf_ticker, period, levels: waveletLevels, + wavelet: waveletFamily, method: waveletMethod, lookback: waveletLookback, + }, + }) + setWaveletReliability(data) + } catch (e) { + console.error('Wavelet reliability failed', e) + setWaveletReliability(null) + } finally { + setLoadingReliability(false) + } + } + const waveletChartData = useMemo(() => { if (!waveletData) return [] const { dates, original, bands, mean } = waveletData @@ -2142,6 +2163,11 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i className="px-2.5 py-1 rounded text-xs font-medium border border-blue-600/40 bg-blue-700/30 text-blue-200 hover:bg-blue-700/50 transition-colors disabled:opacity-50"> {loadingWavelet ? '↻ Calcul…' : "▶ Lancer l'analyse"} + @@ -2343,6 +2369,46 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i {loadingWavelet ? 'Calcul en cours…' : "Cliquez sur \"Lancer l'analyse\" pour décomposer le prix en bandes de fréquence"} )} + + {waveletReliability && ( +
+
+ Fiabilité des retournements (walk-forward vs. recalcul +{waveletReliability.confirm_horizon}j) +
+ + + + + + + + + + + {waveletReliability.bands.map((b: any, i: number) => { + const pct = b.confidence_pct + const color = pct == null ? 'text-slate-500' : pct >= 70 ? 'text-emerald-400' : pct >= 40 ? 'text-amber-400' : 'text-red-400' + return ( + + + + + + + ) + })} + +
BandeRetournements testésConfirmésIndice de confiance
{b.label}{b.n_tested}{b.n_confirmed} + {pct != null ? `${pct.toFixed(0)}%` : 'n/a (pas assez de retournements testables)'} +
+

+ Pour chaque retournement détecté en mode causal (pente lissée sur {waveletReliability.smooth_days}j qui change de signe, sans regarder le futur), + on recalcule la décomposition avec {waveletReliability.confirm_horizon} jours de données réelles en plus et on vérifie qu'un retournement dans le même sens + réapparaît à ±{waveletReliability.tolerance_days}j de la date d'origine. Un indice bas signale des retournements souvent dus à un effet de bord de la + décomposition (peu fiable pile au bord de la fenêtre disponible), pas à un vrai signal. +

+
+ )} )}