Files
OpenFin/backend/routers/wavelet.py
2026-07-14 16:23:18 +02:00

177 lines
7.0 KiB
Python

"""
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.
"""
from datetime import 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"):
from services.data_fetcher import get_historical
hist = get_historical(symbol, period=period, interval=interval)
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)
pad_days = int(lookback * 1.6) + 15
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
@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"),
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"),
):
from services.wavelet_engine import windowed_band_decompose, band_decompose_ssq
values, dates = _fetch_history(symbol, period)
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"),
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"),
):
"""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)
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.")
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
# ── 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()}