feat: wavelets simulation
This commit is contained in:
@@ -2,10 +2,11 @@
|
||||
Wavelet decomposition endpoints — adapted from InstrumentSimulator's /api/wavelet/analyze
|
||||
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()
|
||||
(yfinance period/interval strings) instead of explicit start/end dates. The wavelet math
|
||||
itself (services.wavelet_engine) is an unmodified port.
|
||||
(yfinance period/interval strings by default, or explicit start/end dates when a custom
|
||||
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 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
|
||||
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]
|
||||
dates = [h["date"] for h in hist]
|
||||
return values, dates
|
||||
|
||||
|
||||
def _fetch_padded_history(symbol: str, period: str, lookback: int):
|
||||
"""Fetch enough history to cover `period` worth of causal output plus a
|
||||
`lookback`-sized warmup window before it (mirrors main.py's fetch_start padding)."""
|
||||
out_days = _PERIOD_TO_DAYS.get(period, 365)
|
||||
def _fetch_padded_history(
|
||||
symbol: str, lookback: int, period: str = "1y",
|
||||
start_date: Optional[str] = None, end_date: Optional[str] = None,
|
||||
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
|
||||
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
|
||||
fetch_period = next(
|
||||
(p for p, d in sorted(_PERIOD_TO_DAYS.items(), key=lambda kv: kv[1]) if d >= total_days),
|
||||
"10y",
|
||||
)
|
||||
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")
|
||||
def wavelet_analyze(
|
||||
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),
|
||||
wavelet: str = Query("gmw", description="gmw, morlet or bump"),
|
||||
window_size: int = Query(0, description="0 = single window over the whole range"),
|
||||
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
|
||||
|
||||
values, dates = _fetch_history(symbol, period)
|
||||
values, dates = _fetch_history(symbol, period, start=start_date, end=end_date)
|
||||
if len(values) < 32:
|
||||
raise HTTPException(400, "Historique insuffisant pour une analyse ondelette (32 points minimum).")
|
||||
|
||||
@@ -72,23 +104,24 @@ def wavelet_analyze(
|
||||
@router.get("/rolling")
|
||||
def wavelet_rolling(
|
||||
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),
|
||||
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"),
|
||||
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
|
||||
trailing `lookback`-point window only, so a trade simulation built on this never
|
||||
sees data past its own decision date."""
|
||||
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:
|
||||
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)
|
||||
if start_idx is None:
|
||||
raise HTTPException(400, "Pas de donnees dans la plage de trading demandee.")
|
||||
@@ -113,7 +146,7 @@ def wavelet_rolling(
|
||||
@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"),
|
||||
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),
|
||||
levels: int = Query(4, ge=2, le=6),
|
||||
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"),
|
||||
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"),
|
||||
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,
|
||||
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
|
||||
# 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)
|
||||
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:
|
||||
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)
|
||||
if start_idx is None:
|
||||
raise HTTPException(400, "Pas de donnees dans la plage de trading demandee.")
|
||||
|
||||
@@ -160,12 +160,17 @@ def get_all_quotes() -> Dict[str, List[Dict[str, Any]]]:
|
||||
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:
|
||||
from urllib.parse import unquote
|
||||
symbol = unquote(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:
|
||||
return []
|
||||
hist = hist.reset_index()
|
||||
|
||||
@@ -481,6 +481,8 @@ export function runOptimizationGrid(
|
||||
// lookback x wavelet combo) sequentially — deliberately not parallel, so the
|
||||
// backend isn't hammered with concurrent CPU-heavy CWT requests. The cheap part
|
||||
// (grid of trigger configs per fetched series) runs entirely in-browser.
|
||||
export type WaveletDateRange = { startDate: string; endDate?: string } | null
|
||||
|
||||
export async function runFullOptimization(
|
||||
instruments: string[], lookbacks: number[], wavelets: string[], period: string, levels: number,
|
||||
bandsToTest: number[], kindsToTest: WaveletTriggerKind[], modesToTest: WaveletPositionMode[],
|
||||
@@ -489,6 +491,7 @@ export async function runFullOptimization(
|
||||
onBatchResults: (results: WaveletOptimizationResult[]) => void,
|
||||
method: 'cwt' | 'ssq' = 'cwt',
|
||||
filterBandIndex: number | null = null,
|
||||
dateRange: WaveletDateRange = null,
|
||||
): Promise<void> {
|
||||
const combos: { symbol: string; lookback: number; wavelet: string }[] = []
|
||||
for (const symbol of instruments) {
|
||||
@@ -504,7 +507,10 @@ export async function runFullOptimization(
|
||||
onProgress(i, combos.length, `${symbol} @ ${lookback}j (${wavelet})`, failures)
|
||||
try {
|
||||
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))
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Waves, Play, Save, Trash2, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { api } from '../hooks/useApi'
|
||||
import {
|
||||
WaveletTriggerKind, WaveletPositionMode, WaveletExitVariant, WaveletOptimizationResult,
|
||||
WaveletTriggerKind, WaveletPositionMode, WaveletExitVariant, WaveletOptimizationResult, WaveletDateRange,
|
||||
WAVELET_TRIGGER_KINDS, runFullOptimization, formatBandIndex,
|
||||
} from '../lib/waveletTrade'
|
||||
|
||||
@@ -57,6 +57,9 @@ export default function WaveletsSimulation() {
|
||||
// ── Form state ──────────────────────────────────────────────────────────────
|
||||
const [selectedInstruments, setSelectedInstruments] = useState<Set<string>>(new Set())
|
||||
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 [lookbacks, setLookbacks] = useState<number[]>([90, 130])
|
||||
const [wavelets, setWavelets] = useState<Set<string>>(new Set(['gmw']))
|
||||
@@ -94,17 +97,21 @@ export default function WaveletsSimulation() {
|
||||
|
||||
const runOptimization = async () => {
|
||||
if (selectedInstruments.size === 0 || lookbacks.length === 0 || wavelets.size === 0 || kinds.size === 0) return
|
||||
if (useCustomRange && !customStart) return
|
||||
setRunning(true)
|
||||
setFailures([])
|
||||
setResults([])
|
||||
setExpandedRow(null)
|
||||
|
||||
const dateRange: WaveletDateRange = useCustomRange ? { startDate: customStart, endDate: customEnd || undefined } : null
|
||||
|
||||
// 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.
|
||||
const form = {
|
||||
instruments: [...selectedInstruments], period, levels, wavelets: [...wavelets],
|
||||
lookbacks, bands: [...bands], kinds: [...kinds], modes: [...modes],
|
||||
takeProfits, stopLosses, method, filterBand,
|
||||
customRange: dateRange,
|
||||
}
|
||||
const created = await api.post('/wavelet/simulations', { name: simName, form, results: [] }).then(r => r.data)
|
||||
setActiveSimId(created.id)
|
||||
@@ -126,6 +133,7 @@ export default function WaveletsSimulation() {
|
||||
},
|
||||
method,
|
||||
filterBand,
|
||||
dateRange,
|
||||
)
|
||||
setRunning(false)
|
||||
qc.invalidateQueries({ queryKey: ['wavelet-simulations'] })
|
||||
@@ -212,11 +220,26 @@ export default function WaveletsSimulation() {
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 text-xs">
|
||||
<div>
|
||||
<div className="text-slate-500 mb-1">Période</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 className="flex items-center justify-between mb-1">
|
||||
<span className="text-slate-500">Période</span>
|
||||
<label className="flex items-center gap-1 cursor-pointer text-slate-500">
|
||||
<input type="checkbox" checked={useCustomRange} onChange={e => setUseCustomRange(e.target.checked)} /> Personnalisée
|
||||
</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 className="text-slate-500 mb-1">Niveaux</div>
|
||||
@@ -316,7 +339,7 @@ export default function WaveletsSimulation() {
|
||||
</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">
|
||||
<Play className="w-4 h-4" /> {running ? 'Optimisation en cours…' : "Lancer l'optimisation"}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user