diff --git a/backend/routers/wavelet.py b/backend/routers/wavelet.py index 7b4e6f4..b60a250 100644 --- a/backend/routers/wavelet.py +++ b/backend/routers/wavelet.py @@ -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.") diff --git a/backend/services/data_fetcher.py b/backend/services/data_fetcher.py index 9bbb339..f64b782 100644 --- a/backend/services/data_fetcher.py +++ b/backend/services/data_fetcher.py @@ -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() diff --git a/frontend/src/lib/waveletTrade.ts b/frontend/src/lib/waveletTrade.ts index 4f462ad..9a96261 100644 --- a/frontend/src/lib/waveletTrade.ts +++ b/frontend/src/lib/waveletTrade.ts @@ -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 { 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) { diff --git a/frontend/src/pages/WaveletsSimulation.tsx b/frontend/src/pages/WaveletsSimulation.tsx index 04bbb37..1f9b0e7 100644 --- a/frontend/src/pages/WaveletsSimulation.tsx +++ b/frontend/src/pages/WaveletsSimulation.tsx @@ -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>(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([90, 130]) const [wavelets, setWavelets] = useState>(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() {
-
Période
- +
+ Période + +
+ {useCustomRange ? ( +
+ setCustomStart(e.target.value)} + className="bg-dark-900 border border-slate-700/40 rounded px-1.5 py-1 text-white w-full" /> + + setCustomEnd(e.target.value)} + className="bg-dark-900 border border-slate-700/40 rounded px-1.5 py-1 text-white w-full" /> +
+ ) : ( + + )}
Niveaux
@@ -316,7 +339,7 @@ export default function WaveletsSimulation() {
-