feat: wavelets simulation

This commit is contained in:
OpenSquared
2026-07-20 21:43:35 +02:00
parent 28f7c77103
commit 52890f4d1d
4 changed files with 96 additions and 28 deletions

View File

@@ -2,10 +2,11 @@
Wavelet decomposition endpoints — adapted from InstrumentSimulator's /api/wavelet/analyze Wavelet decomposition endpoints — adapted from InstrumentSimulator's /api/wavelet/analyze
and /api/wavelet/rolling. That project fetches history from a Postgres-cached date-range and /api/wavelet/rolling. That project fetches history from a Postgres-cached date-range
store; we don't have that, so history comes from services.data_fetcher.get_historical() store; we don't have that, so history comes from services.data_fetcher.get_historical()
(yfinance period/interval strings) instead of explicit start/end dates. The wavelet math (yfinance period/interval strings by default, or explicit start/end dates when a custom
itself (services.wavelet_engine) is an unmodified port. range is requested). The wavelet math itself (services.wavelet_engine) is an unmodified
port.
""" """
from datetime import datetime, timedelta from datetime import date, datetime, timedelta
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
@@ -22,40 +23,71 @@ _PERIOD_TO_DAYS = {
} }
def _fetch_history(symbol: str, period: str, interval: str = "1d"): def _fetch_history(symbol: str, period: str, interval: str = "1d", start: Optional[str] = None, end: Optional[str] = None):
from services.data_fetcher import get_historical from services.data_fetcher import get_historical
hist = get_historical(symbol, period=period, interval=interval) hist = get_historical(symbol, period=period, interval=interval, start=start, end=end)
values = [h["close"] for h in hist] values = [h["close"] for h in hist]
dates = [h["date"] for h in hist] dates = [h["date"] for h in hist]
return values, dates return values, dates
def _fetch_padded_history(symbol: str, period: str, lookback: int): def _fetch_padded_history(
"""Fetch enough history to cover `period` worth of causal output plus a symbol: str, lookback: int, period: str = "1y",
`lookback`-sized warmup window before it (mirrors main.py's fetch_start padding).""" start_date: Optional[str] = None, end_date: Optional[str] = None,
out_days = _PERIOD_TO_DAYS.get(period, 365) future_padding_days: int = 0,
):
"""Fetch enough history to cover the requested causal-output range plus a
`lookback`-sized warmup window before it (mirrors main.py's fetch_start padding).
Returns (values, dates, cutoff) — `cutoff` is the first date that belongs in the
causal OUTPUT range; everything in `dates`/`values` before it is lookback padding.
Two modes:
- `period` (default): output range = the last `period` worth of days up to today,
cutoff computed backward from now. The fetch already reaches "today" regardless
of `future_padding_days` (yfinance can't return data past now), so that argument
is a no-op here — the reliability endpoint's per-turning-point "not enough real
future data yet" guard is what actually limits which recent points are testable.
- `start_date`/`end_date`: output range = that explicit window (e.g. a custom
backtest over 2013-2019) — fetches from `start_date` minus the lookback padding
through `end_date` (or through today if `end_date` is omitted). Unlike `period`
mode, `end_date` is an arbitrary cutoff with real data available past it, so
`future_padding_days` explicitly extends the fetch past it — needed for the
reliability endpoint to have real hindsight data for turning points near the end
of a custom range.
"""
pad_days = int(lookback * 1.6) + 15 pad_days = int(lookback * 1.6) + 15
if start_date:
fetch_start = (date.fromisoformat(start_date) - timedelta(days=pad_days)).isoformat()
fetch_end = (date.fromisoformat(end_date) + timedelta(days=future_padding_days)).isoformat() if end_date and future_padding_days else end_date
values, dates = _fetch_history(symbol, period, start=fetch_start, end=fetch_end)
return values, dates, start_date
out_days = _PERIOD_TO_DAYS.get(period, 365)
total_days = out_days + pad_days total_days = out_days + pad_days
fetch_period = next( fetch_period = next(
(p for p, d in sorted(_PERIOD_TO_DAYS.items(), key=lambda kv: kv[1]) if d >= total_days), (p for p, d in sorted(_PERIOD_TO_DAYS.items(), key=lambda kv: kv[1]) if d >= total_days),
"10y", "10y",
) )
values, dates = _fetch_history(symbol, fetch_period) values, dates = _fetch_history(symbol, fetch_period)
return values, dates, out_days cutoff = (datetime.utcnow() - timedelta(days=out_days)).date().isoformat()
return values, dates, cutoff
@router.get("/analyze") @router.get("/analyze")
def wavelet_analyze( def wavelet_analyze(
symbol: str = Query("SPY"), symbol: str = Query("SPY"),
period: str = Query("1y", description="yfinance period: 5d,1mo,3mo,6mo,1y,2y,5y,10y,max"), period: str = Query("1y", description="yfinance period: 5d,1mo,3mo,6mo,1y,2y,5y,10y,max — ignored if start_date is set"),
levels: int = Query(4, ge=2, le=6), levels: int = Query(4, ge=2, le=6),
wavelet: str = Query("gmw", description="gmw, morlet or bump"), wavelet: str = Query("gmw", description="gmw, morlet or bump"),
window_size: int = Query(0, description="0 = single window over the whole range"), window_size: int = Query(0, description="0 = single window over the whole range"),
method: str = Query("cwt", description="cwt (default) or ssq"), method: str = Query("cwt", description="cwt (default) or ssq"),
start_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD) — overrides `period` with an explicit custom range"),
end_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD), only used alongside start_date; omit for 'through today'"),
): ):
from services.wavelet_engine import windowed_band_decompose, band_decompose_ssq from services.wavelet_engine import windowed_band_decompose, band_decompose_ssq
values, dates = _fetch_history(symbol, period) values, dates = _fetch_history(symbol, period, start=start_date, end=end_date)
if len(values) < 32: if len(values) < 32:
raise HTTPException(400, "Historique insuffisant pour une analyse ondelette (32 points minimum).") raise HTTPException(400, "Historique insuffisant pour une analyse ondelette (32 points minimum).")
@@ -72,23 +104,24 @@ def wavelet_analyze(
@router.get("/rolling") @router.get("/rolling")
def wavelet_rolling( def wavelet_rolling(
symbol: str = Query("SPY"), symbol: str = Query("SPY"),
period: str = Query("1y", description="how much of the causal output range to return"), period: str = Query("1y", description="how much of the causal output range to return — ignored if start_date is set"),
lookback: int = Query(260, ge=32), lookback: int = Query(260, ge=32),
levels: int = Query(4, ge=2, le=6), levels: int = Query(4, ge=2, le=6),
wavelet: str = Query("gmw"), wavelet: str = Query("gmw"),
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"),
start_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD) — overrides `period`; the causal output starts here"),
end_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD), only used alongside start_date; omit for 'through today'"),
): ):
"""Walk-forward version of /analyze: band values are computed day by day from a """Walk-forward version of /analyze: band values are computed day by day from a
trailing `lookback`-point window only, so a trade simulation built on this never trailing `lookback`-point window only, so a trade simulation built on this never
sees data past its own decision date.""" sees data past its own decision date."""
from services.wavelet_engine import rolling_causal_bands, rolling_causal_bands_ssq from services.wavelet_engine import rolling_causal_bands, rolling_causal_bands_ssq
values, dates, out_days = _fetch_padded_history(symbol, period, lookback) values, dates, cutoff = _fetch_padded_history(symbol, lookback, period, start_date, end_date)
if len(values) < lookback + 32: if len(values) < lookback + 32:
raise HTTPException(400, "Historique insuffisant pour une analyse ondelette (32 points minimum).") raise HTTPException(400, "Historique insuffisant pour une analyse ondelette (32 points minimum).")
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)
if start_idx is None: if start_idx is None:
raise HTTPException(400, "Pas de donnees dans la plage de trading demandee.") raise HTTPException(400, "Pas de donnees dans la plage de trading demandee.")
@@ -113,7 +146,7 @@ def wavelet_rolling(
@router.get("/reliability") @router.get("/reliability")
def wavelet_reliability_endpoint( def wavelet_reliability_endpoint(
symbol: str = Query("SPY"), symbol: str = Query("SPY"),
period: str = Query("1y", description="how much of the causal output range to scan for turning points"), period: str = Query("1y", description="how much of the causal output range to scan for turning points — ignored if start_date is set"),
lookback: int = Query(260, ge=32), lookback: int = Query(260, ge=32),
levels: int = Query(4, ge=2, le=6), levels: int = Query(4, ge=2, le=6),
wavelet: str = Query("gmw"), wavelet: str = Query("gmw"),
@@ -123,6 +156,8 @@ def wavelet_reliability_endpoint(
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"), 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"), 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"), 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"),
start_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD) — overrides `period`; the causal output starts here"),
end_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD), only used alongside start_date; omit for 'through today'"),
): ):
"""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 later still shows the same reversal — a checks whether redoing the decomposition later still shows the same reversal — a
@@ -134,11 +169,10 @@ def wavelet_reliability_endpoint(
# The horizon is now computed per band inside wavelet_reliability (from each band's own # 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 # measured cycle length), so we don't know it in advance here — pad generously enough to
# cover even a slow band's cycle instead. # cover even a slow band's cycle instead.
values, dates, out_days = _fetch_padded_history(symbol, period, lookback + max_future_padding) values, dates, cutoff = _fetch_padded_history(symbol, lookback, period, start_date, end_date, future_padding_days=max_future_padding)
if len(values) < lookback + max_future_padding + 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 + marge).") 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) start_idx = next((i for i, d in enumerate(dates) if d[:10] >= cutoff), None)
if start_idx is None: if start_idx is None:
raise HTTPException(400, "Pas de donnees dans la plage de trading demandee.") raise HTTPException(400, "Pas de donnees dans la plage de trading demandee.")

View File

@@ -160,12 +160,17 @@ def get_all_quotes() -> Dict[str, List[Dict[str, Any]]]:
return result return result
def get_historical(symbol: str, period: str = "1y", interval: str = "1d") -> List[Dict[str, Any]]: def get_historical(
symbol: str, period: str = "1y", interval: str = "1d",
start: Optional[str] = None, end: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""`start`/`end` (ISO date strings) take priority over `period` when given — yfinance
only accepts one or the other, never both."""
try: try:
from urllib.parse import unquote from urllib.parse import unquote
symbol = unquote(symbol) symbol = unquote(symbol)
ticker = yf.Ticker(symbol) ticker = yf.Ticker(symbol)
hist = ticker.history(period=period, interval=interval) hist = ticker.history(start=start, end=end, interval=interval) if start else ticker.history(period=period, interval=interval)
if hist.empty: if hist.empty:
return [] return []
hist = hist.reset_index() hist = hist.reset_index()

View File

@@ -481,6 +481,8 @@ export function runOptimizationGrid(
// lookback x wavelet combo) sequentially — deliberately not parallel, so the // lookback x wavelet combo) sequentially — deliberately not parallel, so the
// backend isn't hammered with concurrent CPU-heavy CWT requests. The cheap part // backend isn't hammered with concurrent CPU-heavy CWT requests. The cheap part
// (grid of trigger configs per fetched series) runs entirely in-browser. // (grid of trigger configs per fetched series) runs entirely in-browser.
export type WaveletDateRange = { startDate: string; endDate?: string } | null
export async function runFullOptimization( export async function runFullOptimization(
instruments: string[], lookbacks: number[], wavelets: string[], period: string, levels: number, instruments: string[], lookbacks: number[], wavelets: string[], period: string, levels: number,
bandsToTest: number[], kindsToTest: WaveletTriggerKind[], modesToTest: WaveletPositionMode[], bandsToTest: number[], kindsToTest: WaveletTriggerKind[], modesToTest: WaveletPositionMode[],
@@ -489,6 +491,7 @@ export async function runFullOptimization(
onBatchResults: (results: WaveletOptimizationResult[]) => void, onBatchResults: (results: WaveletOptimizationResult[]) => void,
method: 'cwt' | 'ssq' = 'cwt', method: 'cwt' | 'ssq' = 'cwt',
filterBandIndex: number | null = null, filterBandIndex: number | null = null,
dateRange: WaveletDateRange = null,
): Promise<void> { ): Promise<void> {
const combos: { symbol: string; lookback: number; wavelet: string }[] = [] const combos: { symbol: string; lookback: number; wavelet: string }[] = []
for (const symbol of instruments) { for (const symbol of instruments) {
@@ -504,7 +507,10 @@ export async function runFullOptimization(
onProgress(i, combos.length, `${symbol} @ ${lookback}j (${wavelet})`, failures) onProgress(i, combos.length, `${symbol} @ ${lookback}j (${wavelet})`, failures)
try { try {
const { data: rolling } = await api.get('/wavelet/rolling', { const { data: rolling } = await api.get('/wavelet/rolling', {
params: { symbol, period, levels, wavelet, lookback, step: 1, method }, params: {
symbol, levels, wavelet, lookback, step: 1, method,
...(dateRange ? { start_date: dateRange.startDate, end_date: dateRange.endDate } : { period }),
},
}) })
onBatchResults(runOptimizationGrid(symbol, lookback, wavelet, rolling, bandsToTest, kindsToTest, modesToTest, exitVariants, filterBandIndex)) onBatchResults(runOptimizationGrid(symbol, lookback, wavelet, rolling, bandsToTest, kindsToTest, modesToTest, exitVariants, filterBandIndex))
} catch (error) { } catch (error) {

View File

@@ -4,7 +4,7 @@ import { Waves, Play, Save, Trash2, X } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import { api } from '../hooks/useApi' import { api } from '../hooks/useApi'
import { import {
WaveletTriggerKind, WaveletPositionMode, WaveletExitVariant, WaveletOptimizationResult, WaveletTriggerKind, WaveletPositionMode, WaveletExitVariant, WaveletOptimizationResult, WaveletDateRange,
WAVELET_TRIGGER_KINDS, runFullOptimization, formatBandIndex, WAVELET_TRIGGER_KINDS, runFullOptimization, formatBandIndex,
} from '../lib/waveletTrade' } from '../lib/waveletTrade'
@@ -57,6 +57,9 @@ export default function WaveletsSimulation() {
// ── Form state ────────────────────────────────────────────────────────────── // ── Form state ──────────────────────────────────────────────────────────────
const [selectedInstruments, setSelectedInstruments] = useState<Set<string>>(new Set()) const [selectedInstruments, setSelectedInstruments] = useState<Set<string>>(new Set())
const [period, setPeriod] = useState('2y') const [period, setPeriod] = useState('2y')
const [useCustomRange, setUseCustomRange] = useState(false)
const [customStart, setCustomStart] = useState('2013-01-01')
const [customEnd, setCustomEnd] = useState('2019-12-31')
const [levels, setLevels] = useState(4) const [levels, setLevels] = useState(4)
const [lookbacks, setLookbacks] = useState<number[]>([90, 130]) const [lookbacks, setLookbacks] = useState<number[]>([90, 130])
const [wavelets, setWavelets] = useState<Set<string>>(new Set(['gmw'])) const [wavelets, setWavelets] = useState<Set<string>>(new Set(['gmw']))
@@ -94,17 +97,21 @@ export default function WaveletsSimulation() {
const runOptimization = async () => { const runOptimization = async () => {
if (selectedInstruments.size === 0 || lookbacks.length === 0 || wavelets.size === 0 || kinds.size === 0) return if (selectedInstruments.size === 0 || lookbacks.length === 0 || wavelets.size === 0 || kinds.size === 0) return
if (useCustomRange && !customStart) return
setRunning(true) setRunning(true)
setFailures([]) setFailures([])
setResults([]) setResults([])
setExpandedRow(null) setExpandedRow(null)
const dateRange: WaveletDateRange = useCustomRange ? { startDate: customStart, endDate: customEnd || undefined } : null
// Create the persisted run up-front so results can be appended incrementally — // Create the persisted run up-front so results can be appended incrementally —
// a run spanning many combos shouldn't lose progress to a refresh/interruption. // a run spanning many combos shouldn't lose progress to a refresh/interruption.
const form = { const form = {
instruments: [...selectedInstruments], period, levels, wavelets: [...wavelets], instruments: [...selectedInstruments], period, levels, wavelets: [...wavelets],
lookbacks, bands: [...bands], kinds: [...kinds], modes: [...modes], lookbacks, bands: [...bands], kinds: [...kinds], modes: [...modes],
takeProfits, stopLosses, method, filterBand, takeProfits, stopLosses, method, filterBand,
customRange: dateRange,
} }
const created = await api.post('/wavelet/simulations', { name: simName, form, results: [] }).then(r => r.data) const created = await api.post('/wavelet/simulations', { name: simName, form, results: [] }).then(r => r.data)
setActiveSimId(created.id) setActiveSimId(created.id)
@@ -126,6 +133,7 @@ export default function WaveletsSimulation() {
}, },
method, method,
filterBand, filterBand,
dateRange,
) )
setRunning(false) setRunning(false)
qc.invalidateQueries({ queryKey: ['wavelet-simulations'] }) qc.invalidateQueries({ queryKey: ['wavelet-simulations'] })
@@ -212,11 +220,26 @@ export default function WaveletsSimulation() {
<div className="grid grid-cols-4 gap-4 text-xs"> <div className="grid grid-cols-4 gap-4 text-xs">
<div> <div>
<div className="text-slate-500 mb-1">Période</div> <div className="flex items-center justify-between mb-1">
<select value={period} onChange={e => setPeriod(e.target.value)} <span className="text-slate-500">Période</span>
className="bg-dark-900 border border-slate-700/40 rounded px-2 py-1 text-white w-full"> <label className="flex items-center gap-1 cursor-pointer text-slate-500">
{['6mo', '1y', '2y', '5y'].map(p => <option key={p} value={p}>{p}</option>)} <input type="checkbox" checked={useCustomRange} onChange={e => setUseCustomRange(e.target.checked)} /> Personnalisée
</select> </label>
</div>
{useCustomRange ? (
<div className="flex items-center gap-1">
<input type="date" value={customStart} onChange={e => setCustomStart(e.target.value)}
className="bg-dark-900 border border-slate-700/40 rounded px-1.5 py-1 text-white w-full" />
<span className="text-slate-600"></span>
<input type="date" value={customEnd} onChange={e => setCustomEnd(e.target.value)}
className="bg-dark-900 border border-slate-700/40 rounded px-1.5 py-1 text-white w-full" />
</div>
) : (
<select value={period} onChange={e => setPeriod(e.target.value)}
className="bg-dark-900 border border-slate-700/40 rounded px-2 py-1 text-white w-full">
{['6mo', '1y', '2y', '5y'].map(p => <option key={p} value={p}>{p}</option>)}
</select>
)}
</div> </div>
<div> <div>
<div className="text-slate-500 mb-1">Niveaux</div> <div className="text-slate-500 mb-1">Niveaux</div>
@@ -316,7 +339,7 @@ export default function WaveletsSimulation() {
</div> </div>
</div> </div>
<button onClick={runOptimization} disabled={running || selectedInstruments.size === 0} <button onClick={runOptimization} disabled={running || selectedInstruments.size === 0 || (useCustomRange && !customStart)}
className="flex items-center gap-1.5 px-4 py-2 rounded bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium disabled:opacity-40 transition-colors"> className="flex items-center gap-1.5 px-4 py-2 rounded bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium disabled:opacity-40 transition-colors">
<Play className="w-4 h-4" /> {running ? 'Optimisation en cours…' : "Lancer l'optimisation"} <Play className="w-4 h-4" /> {running ? 'Optimisation en cours…' : "Lancer l'optimisation"}
</button> </button>