feat: wavelets
This commit is contained in:
@@ -110,6 +110,48 @@ def wavelet_rolling(
|
|||||||
return result
|
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 ────────────────────────────────────────
|
# ── Saved simulation/optimization runs ────────────────────────────────────────
|
||||||
|
|
||||||
class SimulationCreate(BaseModel):
|
class SimulationCreate(BaseModel):
|
||||||
|
|||||||
@@ -445,3 +445,100 @@ def rolling_causal_bands_ssq(
|
|||||||
"method": "ssq",
|
"method": "ssq",
|
||||||
"ridge_period_days": ridge_series,
|
"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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1433,6 +1433,8 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
const [waveletLookback, setWaveletLookback] = useState(130)
|
const [waveletLookback, setWaveletLookback] = useState(130)
|
||||||
const [waveletData, setWaveletData] = useState<any | null>(null)
|
const [waveletData, setWaveletData] = useState<any | null>(null)
|
||||||
const [loadingWavelet, setLoadingWavelet] = useState(false)
|
const [loadingWavelet, setLoadingWavelet] = useState(false)
|
||||||
|
const [waveletReliability, setWaveletReliability] = useState<any | null>(null)
|
||||||
|
const [loadingReliability, setLoadingReliability] = useState(false)
|
||||||
const [hiddenBands, setHiddenBands] = useState<Set<string>>(new Set())
|
const [hiddenBands, setHiddenBands] = useState<Set<string>>(new Set())
|
||||||
const [hoveredWaveletIdx, setHoveredWaveletIdx] = useState<number | null>(null)
|
const [hoveredWaveletIdx, setHoveredWaveletIdx] = useState<number | null>(null)
|
||||||
const [templates, setTemplates] = useState<CausalTemplate[]>([])
|
const [templates, setTemplates] = useState<CausalTemplate[]>([])
|
||||||
@@ -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(() => {
|
const waveletChartData = useMemo(() => {
|
||||||
if (!waveletData) return []
|
if (!waveletData) return []
|
||||||
const { dates, original, bands, mean } = waveletData
|
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">
|
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"}
|
{loadingWavelet ? '↻ Calcul…' : "▶ Lancer l'analyse"}
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={runWaveletReliability} disabled={loadingReliability}
|
||||||
|
title="Pour chaque retournement détecté en mode causal (walk-forward, sans anticipation), vérifie si un recalcul 10 jours plus tard confirme le même retournement — un indice de fiabilité par bande."
|
||||||
|
className="px-2.5 py-1 rounded text-xs font-medium border border-purple-600/40 bg-purple-700/30 text-purple-200 hover:bg-purple-700/50 transition-colors disabled:opacity-50">
|
||||||
|
{loadingReliability ? '↻ Calcul…' : '◈ Tester la fiabilité'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -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"}
|
{loadingWavelet ? 'Calcul en cours…' : "Cliquez sur \"Lancer l'analyse\" pour décomposer le prix en bandes de fréquence"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{waveletReliability && (
|
||||||
|
<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">
|
||||||
|
Fiabilité des retournements (walk-forward vs. recalcul +{waveletReliability.confirm_horizon}j)
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead>
|
||||||
|
<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 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">Indice de confiance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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 (
|
||||||
|
<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 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={clsx('py-1 pr-3 text-right font-mono font-semibold', color)}>
|
||||||
|
{pct != null ? `${pct.toFixed(0)}%` : 'n/a (pas assez de retournements testables)'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<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),
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user