diff --git a/backend/routers/wavelet.py b/backend/routers/wavelet.py index fa64947..edd9fb8 100644 --- a/backend/routers/wavelet.py +++ b/backend/routers/wavelet.py @@ -120,20 +120,23 @@ def wavelet_reliability_endpoint( 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"), + tolerance_pct: float = Query(0.10, ge=0, le=0.5, description="date-matching tolerance as a fraction of each band's own average cycle length, applied both sides"), + min_confirm_horizon: int = Query(3, ge=1, le=30, description="floor on the hindsight horizon in days, in case a band's measured cycle length comes out very short"), + max_future_padding: int = Query(60, ge=10, le=180, description="extra real days fetched beyond the requested range, to cover the slowest band's own (data-driven) confirm horizon"), ): """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.""" + checks whether redoing the decomposition later still shows the same reversal — a + per-band reliability score. The hindsight horizon is dynamic per band (each band's own + historical average peak-to-peak cycle length, not one fixed day count for every band — + a fixed horizon is meaningless across bands with wildly different natural periods).""" 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).") + # The horizon is now computed per band inside wavelet_reliability (from each band's own + # measured cycle length), so we don't know it in advance here — pad generously enough to + # cover even a slow band's cycle instead. + values, dates, out_days = _fetch_padded_history(symbol, period, lookback + max_future_padding) + if len(values) < lookback + max_future_padding + 32: + raise HTTPException(400, "Historique insuffisant pour un test de fiabilité (32 points minimum au-delà de la fenêtre + marge).") cutoff = (datetime.utcnow() - timedelta(days=out_days)).date().isoformat() start_idx = next((i for i, d in enumerate(dates) if d[:10] >= cutoff), None) @@ -145,7 +148,7 @@ def wavelet_reliability_endpoint( 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, + smooth_days=smooth_days, tolerance_pct=tolerance_pct, min_confirm_horizon=min_confirm_horizon, ) except ValueError as exc: raise HTTPException(400, str(exc)) from exc diff --git a/backend/services/wavelet_engine.py b/backend/services/wavelet_engine.py index d115526..cb7d8b5 100644 --- a/backend/services/wavelet_engine.py +++ b/backend/services/wavelet_engine.py @@ -516,6 +516,19 @@ def _turning_points(series: list[float], smooth_days: int) -> list[tuple[int, in return turns +def _average_cycle_days(turn_points: list[tuple[int, int]]) -> float | None: + """Empirical average gap (in days) between consecutive SAME-direction turning points — + peak-to-peak or trough-to-trough, i.e. one full oscillation — measured from the actual + reconstructed series rather than assumed from the band's theoretical period_low/high + labels (which describe the wavelet scale bucket, not necessarily the real cycle length + this specific window's price action produced). None if there aren't at least 2 + same-direction turns to measure a gap from.""" + peaks = [t for t, d in turn_points if d == -1] # a downturn = just passed a peak + troughs = [t for t, d in turn_points if d == 1] # an upturn = just passed a trough + gaps = [seq[i + 1] - seq[i] for seq in (peaks, troughs) for i in range(len(seq) - 1)] + return sum(gaps) / len(gaps) if gaps else None + + def wavelet_reliability( values: list[float], dates: list[str], @@ -526,18 +539,27 @@ def wavelet_reliability( method: str = "cwt", step: int = 1, smooth_days: int = 3, - tolerance_days: int = 5, - confirm_horizon: int = 10, + tolerance_pct: float = 0.10, + min_confirm_horizon: int = 3, ) -> 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. + then — checks whether redoing the decomposition later still shows a same-direction + reversal near 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. + + A fixed confirm horizon (e.g. always +10 days) is meaningless across bands whose natural + oscillation period can range from ~1 day to several weeks: 10 days is many cycles for a + fast band (trivially "confirmed", inflating its score) but less than half a cycle for a + slow one (never has time to actually turn back, deflating its score) — the exact pattern + that shows up as fast bands scoring ~100% and the slow band scoring ~10% for no real + reliability reason. Instead, per band: measure its own historical average peak-to-peak + (or trough-to-trough) period from the causal series, and use `avg_cycle * (1 + + tolerance_pct)` as a MAXIMUM horizon — a same-direction reversal confirms the original one + if it shows up ANYWHERE in the forward window [original date, original date + that + horizon], not just near one specific point in it. """ rolling_decomposer = rolling_causal_bands_ssq if method == "ssq" else rolling_causal_bands batch_decompose = band_decompose_ssq if method == "ssq" else band_decompose @@ -552,6 +574,16 @@ def wavelet_reliability( bands_out = [] for band in causal["bands"]: turn_points = _turning_points(band["series"], smooth_days) + avg_cycle_days = _average_cycle_days(turn_points) + + if avg_cycle_days is None: + bands_out.append({ + "label": band["label"], "n_tested": 0, "n_confirmed": 0, "confidence_pct": None, + "avg_cycle_days": None, "confirm_horizon": None, "turns": [], + }) + continue + + confirm_horizon = max(min_confirm_horizon, round(avg_cycle_days * (1 + tolerance_pct))) tested = [] for t, direction in turn_points: @@ -572,10 +604,13 @@ def wavelet_reliability( continue h_series = hindsight["bands"][band["index"]]["series"] + # Forward-only window [original date, original date + horizon] — the reversal + # can be confirmed anywhere in it, not just near a specific point. window_values + # was built to end exactly at end_idx = g_idx + confirm_horizon, so h_series's + # last index already IS "original date + horizon"; no separate bound needed. 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) + confirmed = any(idx >= d_pos and new_dir == direction for idx, new_dir in hindsight_turns) tested.append({"date": d_date, "confirmed": confirmed}) n_tested = len(tested) @@ -585,13 +620,14 @@ def wavelet_reliability( "n_tested": n_tested, "n_confirmed": n_confirmed, "confidence_pct": round(n_confirmed / n_tested * 100, 1) if n_tested else None, + "avg_cycle_days": round(avg_cycle_days, 1), + "confirm_horizon": confirm_horizon, "turns": tested, }) return { "bands": bands_out, "smooth_days": smooth_days, - "tolerance_days": tolerance_days, - "confirm_horizon": confirm_horizon, + "tolerance_pct": tolerance_pct, "method": method, } diff --git a/frontend/src/pages/InstrumentDashboard.tsx b/frontend/src/pages/InstrumentDashboard.tsx index 405dfc3..2b7671b 100644 --- a/frontend/src/pages/InstrumentDashboard.tsx +++ b/frontend/src/pages/InstrumentDashboard.tsx @@ -2379,12 +2379,14 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i {waveletReliability && (
| Bande | +Cycle moyen | +Horizon max | Retournements testés | Confirmés | Indice de confiance | @@ -2397,6 +2399,8 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i return (
|---|---|---|---|---|---|
| {b.label} | +{b.avg_cycle_days != null ? `${b.avg_cycle_days}j` : '—'} | +{b.confirm_horizon != null ? `+${b.confirm_horizon}j` : '—'} | {b.n_tested} | {b.n_confirmed} | @@ -2409,9 +2413,10 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i |
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. + l'horizon de recalcul est propre à chaque bande — cycle moyen historique (pic-à-pic) × 1.10, pas un nombre de jours fixe identique pour toutes + (10 jours ne veut rien dire pour une bande à 2j de période comme pour une à 20j). Le retournement confirme l'original s'il réapparaît n'importe + où entre la date d'origine et date+horizon. 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.