feat: wavelets
This commit is contained in:
@@ -120,20 +120,23 @@ def wavelet_reliability_endpoint(
|
|||||||
step: int = Query(1, ge=1),
|
step: int = Query(1, ge=1),
|
||||||
method: str = Query("cwt", description="cwt (default) or ssq"),
|
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"),
|
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"),
|
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"),
|
||||||
confirm_horizon: int = Query(10, ge=1, le=30, description="how many extra days of real data the hindsight recomputation gets"),
|
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,
|
"""For every reversal a live (causal, walk-forward) decomposition would have flagged,
|
||||||
checks whether redoing the decomposition `confirm_horizon` days later still shows the
|
checks whether redoing the decomposition later still shows the same reversal — a
|
||||||
same reversal — a per-band reliability score for the wavelet's turning-point signals."""
|
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
|
from services.wavelet_engine import wavelet_reliability
|
||||||
|
|
||||||
# Needs confirm_horizon extra real days beyond the requested causal output range, on
|
# The horizon is now computed per band inside wavelet_reliability (from each band's own
|
||||||
# top of the usual lookback padding, so the most recent testable turning points aren't
|
# measured cycle length), so we don't know it in advance here — pad generously enough to
|
||||||
# silently dropped for lack of "future" data.
|
# cover even a slow band's cycle instead.
|
||||||
values, dates, out_days = _fetch_padded_history(symbol, period, lookback + confirm_horizon)
|
values, dates, out_days = _fetch_padded_history(symbol, period, lookback + max_future_padding)
|
||||||
if len(values) < lookback + confirm_horizon + 32:
|
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 + horizon).")
|
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()
|
cutoff = (datetime.utcnow() - timedelta(days=out_days)).date().isoformat()
|
||||||
start_idx = next((i for i, d in enumerate(dates) if d[:10] >= cutoff), None)
|
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,
|
values, dates,
|
||||||
start_idx=start_idx, lookback=lookback,
|
start_idx=start_idx, lookback=lookback,
|
||||||
num_levels=levels, wavelet=wavelet, method=method, step=step,
|
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:
|
except ValueError as exc:
|
||||||
raise HTTPException(400, str(exc)) from exc
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
|||||||
@@ -516,6 +516,19 @@ def _turning_points(series: list[float], smooth_days: int) -> list[tuple[int, in
|
|||||||
return turns
|
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(
|
def wavelet_reliability(
|
||||||
values: list[float],
|
values: list[float],
|
||||||
dates: list[str],
|
dates: list[str],
|
||||||
@@ -526,18 +539,27 @@ def wavelet_reliability(
|
|||||||
method: str = "cwt",
|
method: str = "cwt",
|
||||||
step: int = 1,
|
step: int = 1,
|
||||||
smooth_days: int = 3,
|
smooth_days: int = 3,
|
||||||
tolerance_days: int = 5,
|
tolerance_pct: float = 0.10,
|
||||||
confirm_horizon: int = 10,
|
min_confirm_horizon: int = 3,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
For every turning point a CAUSAL (walk-forward, no look-ahead) decomposition flags —
|
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
|
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
|
then — checks whether redoing the decomposition later still shows a same-direction
|
||||||
much more real data, so the date in question is no longer sitting at the unstable tip of
|
reversal near the original date. The fraction confirmed is a per-band confidence score:
|
||||||
the window) still shows a same-direction reversal within `tolerance_days` of the original
|
CWT/SSQ reconstructions are well known to be least reliable right at the edge of the
|
||||||
date. The fraction confirmed is a per-band confidence score: CWT/SSQ reconstructions are
|
available data — exactly where a live "the band just turned" reading gets made.
|
||||||
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
|
rolling_decomposer = rolling_causal_bands_ssq if method == "ssq" else rolling_causal_bands
|
||||||
batch_decompose = band_decompose_ssq if method == "ssq" else band_decompose
|
batch_decompose = band_decompose_ssq if method == "ssq" else band_decompose
|
||||||
@@ -552,6 +574,16 @@ def wavelet_reliability(
|
|||||||
bands_out = []
|
bands_out = []
|
||||||
for band in causal["bands"]:
|
for band in causal["bands"]:
|
||||||
turn_points = _turning_points(band["series"], smooth_days)
|
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 = []
|
tested = []
|
||||||
for t, direction in turn_points:
|
for t, direction in turn_points:
|
||||||
@@ -572,10 +604,13 @@ def wavelet_reliability(
|
|||||||
continue
|
continue
|
||||||
h_series = hindsight["bands"][band["index"]]["series"]
|
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
|
d_pos = g_idx - window_start
|
||||||
lo, hi = d_pos - tolerance_days, d_pos + tolerance_days
|
|
||||||
hindsight_turns = _turning_points(h_series, smooth_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})
|
tested.append({"date": d_date, "confirmed": confirmed})
|
||||||
|
|
||||||
n_tested = len(tested)
|
n_tested = len(tested)
|
||||||
@@ -585,13 +620,14 @@ def wavelet_reliability(
|
|||||||
"n_tested": n_tested,
|
"n_tested": n_tested,
|
||||||
"n_confirmed": n_confirmed,
|
"n_confirmed": n_confirmed,
|
||||||
"confidence_pct": round(n_confirmed / n_tested * 100, 1) if n_tested else None,
|
"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,
|
"turns": tested,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"bands": bands_out,
|
"bands": bands_out,
|
||||||
"smooth_days": smooth_days,
|
"smooth_days": smooth_days,
|
||||||
"tolerance_days": tolerance_days,
|
"tolerance_pct": tolerance_pct,
|
||||||
"confirm_horizon": confirm_horizon,
|
|
||||||
"method": method,
|
"method": method,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2379,12 +2379,14 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
{waveletReliability && (
|
{waveletReliability && (
|
||||||
<div className="mt-3 pt-3 border-t border-slate-700/30">
|
<div className="mt-3 pt-3 border-t border-slate-700/30">
|
||||||
<div className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-2">
|
<div className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-2">
|
||||||
Fiabilité des retournements (walk-forward vs. recalcul +{waveletReliability.confirm_horizon}j)
|
Fiabilité des retournements (horizon de recalcul = cycle propre à chaque bande)
|
||||||
</div>
|
</div>
|
||||||
<table className="w-full text-[11px]">
|
<table className="w-full text-[11px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-slate-500 text-left">
|
<tr className="text-slate-500 text-left">
|
||||||
<th className="py-1 pr-3 font-normal">Bande</th>
|
<th className="py-1 pr-3 font-normal">Bande</th>
|
||||||
|
<th className="py-1 pr-3 font-normal text-right" title="Écart moyen entre deux pics (ou deux creux) consécutifs, mesuré sur l'historique — pas la période théorique de la bande.">Cycle moyen</th>
|
||||||
|
<th className="py-1 pr-3 font-normal text-right" title="= cycle moyen × 1.10 — le retournement peut être confirmé n'importe où entre la date d'origine et date+horizon, pas juste pile à cette date">Horizon max</th>
|
||||||
<th className="py-1 pr-3 font-normal text-right">Retournements testés</th>
|
<th className="py-1 pr-3 font-normal text-right">Retournements testés</th>
|
||||||
<th className="py-1 pr-3 font-normal text-right">Confirmés</th>
|
<th className="py-1 pr-3 font-normal text-right">Confirmés</th>
|
||||||
<th className="py-1 pr-3 font-normal text-right">Indice de confiance</th>
|
<th className="py-1 pr-3 font-normal text-right">Indice de confiance</th>
|
||||||
@@ -2397,6 +2399,8 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
return (
|
return (
|
||||||
<tr key={b.label} className="border-t border-slate-800/60">
|
<tr key={b.label} className="border-t border-slate-800/60">
|
||||||
<td className="py-1 pr-3" style={{ color: WAVELET_BAND_COLORS[i % WAVELET_BAND_COLORS.length] }}>{b.label}</td>
|
<td className="py-1 pr-3" style={{ color: WAVELET_BAND_COLORS[i % WAVELET_BAND_COLORS.length] }}>{b.label}</td>
|
||||||
|
<td className="py-1 pr-3 text-right text-slate-400 font-mono">{b.avg_cycle_days != null ? `${b.avg_cycle_days}j` : '—'}</td>
|
||||||
|
<td className="py-1 pr-3 text-right text-slate-400 font-mono">{b.confirm_horizon != null ? `+${b.confirm_horizon}j` : '—'}</td>
|
||||||
<td className="py-1 pr-3 text-right text-slate-300 font-mono">{b.n_tested}</td>
|
<td className="py-1 pr-3 text-right text-slate-300 font-mono">{b.n_tested}</td>
|
||||||
<td className="py-1 pr-3 text-right text-slate-300 font-mono">{b.n_confirmed}</td>
|
<td className="py-1 pr-3 text-right text-slate-300 font-mono">{b.n_confirmed}</td>
|
||||||
<td className={clsx('py-1 pr-3 text-right font-mono font-semibold', color)}>
|
<td className={clsx('py-1 pr-3 text-right font-mono font-semibold', color)}>
|
||||||
@@ -2409,9 +2413,10 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
</table>
|
</table>
|
||||||
<p className="text-[10px] text-slate-600 mt-1">
|
<p className="text-[10px] text-slate-600 mt-1">
|
||||||
Pour chaque retournement détecté en mode causal (pente lissée sur {waveletReliability.smooth_days}j qui change de signe, sans regarder le futur),
|
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
|
l'horizon de recalcul est propre à chaque bande — cycle moyen historique (pic-à-pic) × 1.10, pas un nombre de jours fixe identique pour toutes
|
||||||
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
|
(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
|
||||||
décomposition (peu fiable pile au bord de la fenêtre disponible), pas à un vrai signal.
|
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user