""" 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 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 date, datetime, timedelta from typing import Any, Dict, List, Optional from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel router = APIRouter(prefix="/api/wavelet", tags=["wavelet"]) # Approximate calendar-day span of each yfinance period string, used to pick a fetch # window large enough to cover the requested causal (rolling) output range plus the # lookback padding needed to fill the first walk-forward window. _PERIOD_TO_DAYS = { "5d": 5, "1mo": 30, "3mo": 90, "6mo": 182, "1y": 365, "2y": 730, "5y": 1825, "10y": 3650, "max": 3650, } 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, 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, 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) 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 — 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, start=start_date, end=end_date) if len(values) < 32: raise HTTPException(400, "Historique insuffisant pour une analyse ondelette (32 points minimum).") try: if method == "ssq": # No windowed/chunked variant for ssq in the source project — analyze the # whole fetched range in one pass (matches window_size=0 semantics). return band_decompose_ssq(values, dates, num_levels=levels, wavelet=wavelet) return windowed_band_decompose(values, dates, window_size=window_size, num_levels=levels, wavelet=wavelet) except ValueError as exc: raise HTTPException(400, str(exc)) from exc @router.get("/rolling") def wavelet_rolling( symbol: str = Query("SPY"), 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, 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).") 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.") decomposer = rolling_causal_bands_ssq if method == "ssq" else rolling_causal_bands try: result = decomposer( values, dates, start_idx=start_idx, lookback=lookback, num_levels=levels, wavelet=wavelet, step=step, ) except ValueError as exc: raise HTTPException(400, str(exc)) from exc if not result["dates"]: raise HTTPException( 400, f"Pas assez d'historique avant {cutoff} pour remplir une fenetre glissante de {lookback} points.", ) 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 — 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"), 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_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 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 # 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, 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).") 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_pct=tolerance_pct, min_confirm_horizon=min_confirm_horizon, ) except ValueError as exc: raise HTTPException(400, str(exc)) from exc return result # ── Saved simulation/optimization runs ──────────────────────────────────────── class SimulationCreate(BaseModel): name: str form: Dict[str, Any] = {} results: List[Dict[str, Any]] = [] excluded_instruments: List[str] = [] class SimulationUpdate(BaseModel): name: Optional[str] = None form: Optional[Dict[str, Any]] = None results: Optional[List[Dict[str, Any]]] = None append_results: Optional[List[Dict[str, Any]]] = None excluded_instruments: Optional[List[str]] = None @router.get("/simulations") def list_simulations(): from services.database import get_wavelet_simulations return get_wavelet_simulations() @router.post("/simulations") def create_simulation(payload: SimulationCreate): from services.database import save_wavelet_simulation return save_wavelet_simulation(payload.name, payload.form, payload.results, payload.excluded_instruments) @router.get("/simulations/{sim_id}") def get_simulation(sim_id: str): from services.database import get_wavelet_simulation sim = get_wavelet_simulation(sim_id) if not sim: raise HTTPException(404, "Simulation introuvable.") return sim @router.patch("/simulations/{sim_id}") def patch_simulation(sim_id: str, payload: SimulationUpdate): from services.database import update_wavelet_simulation sim = update_wavelet_simulation( sim_id, name=payload.name, form=payload.form, results=payload.results, append_results=payload.append_results, excluded_instruments=payload.excluded_instruments, ) if not sim: raise HTTPException(404, "Simulation introuvable.") return sim @router.delete("/simulations/{sim_id}") def remove_simulation(sim_id: str): from services.database import delete_wavelet_simulation if not delete_wavelet_simulation(sim_id): raise HTTPException(404, "Simulation introuvable.") return {"deleted": sim_id} # ── Watchlist signal scan (populated by the auto-cycle, see services.wavelet_signals) ── @router.get("/watchlist-signals") def watchlist_signals(): from services.database import get_latest_wavelet_signals return {"signals": get_latest_wavelet_signals()}