219 lines
9.3 KiB
Python
219 lines
9.3 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
|
|
|
|
|
|
@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"),
|
|
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"),
|
|
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_days: int = Query(5, ge=0, le=15, description="a hindsight reversal within this many days of the causal one still counts as confirming it"),
|
|
confirm_horizon: int = Query(10, ge=1, le=30, description="how many extra days of real data the hindsight recomputation gets"),
|
|
):
|
|
"""For every reversal a live (causal, walk-forward) decomposition would have flagged,
|
|
checks whether redoing the decomposition `confirm_horizon` days later still shows the
|
|
same reversal — a per-band reliability score for the wavelet's turning-point signals."""
|
|
from services.wavelet_engine import wavelet_reliability
|
|
|
|
# Needs confirm_horizon extra real days beyond the requested causal output range, on
|
|
# top of the usual lookback padding, so the most recent testable turning points aren't
|
|
# silently dropped for lack of "future" data.
|
|
values, dates, out_days = _fetch_padded_history(symbol, period, lookback + confirm_horizon)
|
|
if len(values) < lookback + confirm_horizon + 32:
|
|
raise HTTPException(400, "Historique insuffisant pour un test de fiabilité (32 points minimum au-delà de la fenêtre + horizon).")
|
|
|
|
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.")
|
|
|
|
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_days=tolerance_days, confirm_horizon=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()}
|