feat: wavelets simulation

This commit is contained in:
OpenSquared
2026-07-20 21:43:35 +02:00
parent 28f7c77103
commit 52890f4d1d
4 changed files with 96 additions and 28 deletions

View File

@@ -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.")

View File

@@ -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()