From b693aca2dce84bb53e85a3641c32b6323d7e1117 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Tue, 14 Jul 2026 16:23:18 +0200 Subject: [PATCH] feat: wavelets --- backend/main.py | 2 + backend/requirements.txt | 1 + backend/routers/wavelet.py | 176 ++++++++ backend/services/auto_cycle.py | 16 + backend/services/database.py | 159 ++++++- backend/services/geo_analyzer.py | 15 +- backend/services/wavelet_engine.py | 447 +++++++++++++++++++ backend/services/wavelet_signals.py | 137 ++++++ frontend/src/App.tsx | 2 + frontend/src/components/TradeRankList.tsx | 9 +- frontend/src/components/layout/Sidebar.tsx | 3 +- frontend/src/config/keepAliveMeta.ts | 3 +- frontend/src/hooks/useApi.ts | 8 + frontend/src/lib/waveletTrade.ts | 464 +++++++++++++++++++ frontend/src/pages/Dashboard.tsx | 66 ++- frontend/src/pages/InstrumentDashboard.tsx | 152 ++++++- frontend/src/pages/WaveletsSimulation.tsx | 494 +++++++++++++++++++++ 17 files changed, 2144 insertions(+), 10 deletions(-) create mode 100644 backend/routers/wavelet.py create mode 100644 backend/services/wavelet_engine.py create mode 100644 backend/services/wavelet_signals.py create mode 100644 frontend/src/lib/waveletTrade.ts create mode 100644 frontend/src/pages/WaveletsSimulation.tsx diff --git a/backend/main.py b/backend/main.py index fc56637..794e45a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -18,6 +18,7 @@ from routers import simulator as simulator_router from routers import causal_lab as causal_lab_router from routers import instrument_models as instrument_models_router from routers import instruments_watchlist as instruments_watchlist_router +from routers import wavelet as wavelet_router from services.database import init_db, get_config, cleanup_stale_running_cycles import os import logging @@ -235,6 +236,7 @@ app.include_router(simulator_router.router) app.include_router(causal_lab_router.router) app.include_router(instrument_models_router.router) app.include_router(instruments_watchlist_router.router) +app.include_router(wavelet_router.router) @app.get("/") diff --git a/backend/requirements.txt b/backend/requirements.txt index ff46e02..daf2245 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -19,3 +19,4 @@ apscheduler==3.10.4 pytz==2024.2 ta==0.11.0 lxml>=5.0.0 +ssqueezepy==0.6.6 diff --git a/backend/routers/wavelet.py b/backend/routers/wavelet.py new file mode 100644 index 0000000..d32ad35 --- /dev/null +++ b/backend/routers/wavelet.py @@ -0,0 +1,176 @@ +""" +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()} diff --git a/backend/services/auto_cycle.py b/backend/services/auto_cycle.py index d6ee89e..2ad250d 100644 --- a/backend/services/auto_cycle.py +++ b/backend/services/auto_cycle.py @@ -1016,6 +1016,17 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: except Exception as _ce: logger.warning(f"[Cycle] Régime clustering failed (non-blocking): {_ce}") + # ── Step 5.7: Wavelet signal scan (watchlist instruments) ──────────── + _wavelet_results: list = [] + try: + from services.wavelet_signals import compute_and_save_wavelet_signals + _wavelet_results = compute_and_save_wavelet_signals(run_id) + summary["wavelet_signals_count"] = len(_wavelet_results) + logger.info(f"[Cycle {run_id[:16]}] Wavelet signals: {len(_wavelet_results)} detected") + except Exception as _we: + logger.warning(f"[Cycle] Wavelet signal scan failed (non-blocking): {_we}") + summary["wavelet_signals_count"] = 0 + # ── Step 6: GPT-4o cycle commentary ────────────────────────────────── logger.info(f"[Cycle {run_id[:16]}] Step 6: generating commentary") commentary = _generate_cycle_commentary( @@ -1046,6 +1057,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: dominant_regime=dominant, commentary=commentary, status="completed", + wavelet_signals_count=summary.get("wavelet_signals_count", 0), ) _current_status["last_run_at"] = datetime.utcnow().isoformat() logger.info(f"[Cycle {run_id[:16]}] Completed — {added_count} new patterns, {len(scored)} scored") @@ -1116,6 +1128,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: portfolio_monitor=summary.get("portfolio_monitor"), commentary=commentary, options_assessment=_options_assessment, + wavelet_signals=_wavelet_results, ) if _cycle_report: from services.database import save_cycle_report @@ -1352,6 +1365,7 @@ def _generate_cycle_report( portfolio_monitor: Optional[Dict], commentary: Optional[str], options_assessment: Optional[Dict] = None, + wavelet_signals: Optional[List[Dict]] = None, ) -> Optional[Dict]: """ Build the full cycle report dict: @@ -1599,6 +1613,8 @@ Réponds en JSON avec ce schéma EXACT: # Risk "portfolio_monitor": portfolio_monitor, "risk_alerts": portfolio_monitor.get("alerts") if portfolio_monitor else None, + # Wavelet signals detected this cycle (watchlist scan, see services.wavelet_signals) + "wavelet_signals": wavelet_signals or [], # Top scored patterns "top_scored": [ {"name": s.get("geo_trigger"), "score": s.get("score"), diff --git a/backend/services/database.py b/backend/services/database.py index 5fe8b1b..b38e442 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -110,12 +110,42 @@ def init_db(): sort_order INTEGER DEFAULT 0, added_at TEXT DEFAULT (datetime('now')) )""", + # Wavelets — saved optimization/simulation runs (ported from InstrumentSimulator's + # WaveletOptimizationRun: form/results are free-form JSON blobs, not modeled relationally) + """CREATE TABLE IF NOT EXISTS wavelet_simulations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + form_json TEXT DEFAULT '{}', + results_json TEXT DEFAULT '[]', + excluded_instruments_json TEXT DEFAULT '[]' + )""", + # Wavelets — latest per-ticker signal detected during the auto-cycle watchlist scan + """CREATE TABLE IF NOT EXISTS wavelet_watchlist_signals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT, + ticker TEXT NOT NULL, + computed_at TEXT DEFAULT (datetime('now')), + band_label TEXT, + period_low_days REAL, + period_high_days REAL, + signal_kind TEXT, + direction TEXT, + price_at_signal REAL + )""", + "ALTER TABLE cycle_runs ADD COLUMN wavelet_signals_count INTEGER DEFAULT 0", ]: try: c.execute(_sql) except Exception: pass + try: + c.execute("CREATE INDEX IF NOT EXISTS idx_wws_ticker_date ON wavelet_watchlist_signals(ticker, computed_at DESC)") + except Exception: + pass + # Specialist Reports — surprise index + text sentiment columns try: c.execute("ALTER TABLE specialist_reports ADD COLUMN consensus_estimate REAL") @@ -2209,7 +2239,7 @@ def update_cycle_run(run_id: str, **fields) -> None: if not fields: return allowed = {"completed_at", "patterns_suggested", "patterns_added", "patterns_scored", - "geo_score", "dominant_regime", "commentary", "status"} + "geo_score", "dominant_regime", "commentary", "status", "wavelet_signals_count"} sets = ", ".join(f"{k}=?" for k in fields if k in allowed) vals = [v for k, v in fields.items() if k in allowed] if not sets: @@ -3056,6 +3086,133 @@ def reorder_instruments_watchlist(tickers: List[str]) -> None: conn.close() +# ── Wavelets — saved simulation/optimization runs ───────────────────────────── + +def save_wavelet_simulation(name: str, form: Dict, results: Optional[List[Dict]] = None, + excluded_instruments: Optional[List[str]] = None) -> Dict: + import uuid + sim_id = uuid.uuid4().hex + now = datetime.utcnow().isoformat() + conn = get_conn() + conn.execute( + "INSERT INTO wavelet_simulations (id, name, created_at, updated_at, form_json, results_json, excluded_instruments_json) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (sim_id, name, now, now, json.dumps(form or {}), json.dumps(results or []), json.dumps(excluded_instruments or [])), + ) + conn.commit() + conn.close() + return get_wavelet_simulation(sim_id) + + +def get_wavelet_simulations() -> List[Dict]: + conn = get_conn() + rows = conn.execute( + "SELECT id, name, created_at, updated_at, results_json FROM wavelet_simulations ORDER BY updated_at DESC" + ).fetchall() + conn.close() + out = [] + for r in rows: + d = dict(r) + try: + result_count = len(json.loads(d.pop("results_json")) or []) + except Exception: + result_count = 0 + d["result_count"] = result_count + out.append(d) + return out + + +def get_wavelet_simulation(sim_id: str) -> Optional[Dict]: + conn = get_conn() + row = conn.execute("SELECT * FROM wavelet_simulations WHERE id=?", (sim_id,)).fetchone() + conn.close() + if not row: + return None + d = dict(row) + d["form"] = json.loads(d.pop("form_json") or "{}") + d["results"] = json.loads(d.pop("results_json") or "[]") + d["excluded_instruments"] = json.loads(d.pop("excluded_instruments_json") or "[]") + return d + + +def update_wavelet_simulation(sim_id: str, name: Optional[str] = None, form: Optional[Dict] = None, + results: Optional[List[Dict]] = None, append_results: Optional[List[Dict]] = None, + excluded_instruments: Optional[List[str]] = None) -> Optional[Dict]: + current = get_wavelet_simulation(sim_id) + if not current: + return None + new_name = name if name is not None else current["name"] + new_form = form if form is not None else current["form"] + if results is not None: + new_results = results + elif append_results: + new_results = [*current["results"], *append_results] + else: + new_results = current["results"] + new_excluded = excluded_instruments if excluded_instruments is not None else current["excluded_instruments"] + + conn = get_conn() + conn.execute( + "UPDATE wavelet_simulations SET name=?, form_json=?, results_json=?, excluded_instruments_json=?, updated_at=? WHERE id=?", + (new_name, json.dumps(new_form), json.dumps(new_results), json.dumps(new_excluded), datetime.utcnow().isoformat(), sim_id), + ) + conn.commit() + conn.close() + return get_wavelet_simulation(sim_id) + + +def delete_wavelet_simulation(sim_id: str) -> bool: + conn = get_conn() + conn.execute("DELETE FROM wavelet_simulations WHERE id=?", (sim_id,)) + changed = conn.total_changes > 0 + conn.commit() + conn.close() + return changed + + +# ── Wavelets — automated watchlist signal scan (per cycle) ──────────────────── + +def save_wavelet_signals(run_id: str, signals: List[Dict]) -> None: + if not signals: + return + conn = get_conn() + for s in signals: + conn.execute( + "INSERT INTO wavelet_watchlist_signals " + "(run_id, ticker, band_label, period_low_days, period_high_days, signal_kind, direction, price_at_signal) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (run_id, s.get("ticker"), s.get("band_label"), s.get("period_low_days"), s.get("period_high_days"), + s.get("signal_kind"), s.get("direction"), s.get("price_at_signal")), + ) + conn.commit() + conn.close() + + +def get_latest_wavelet_signals() -> List[Dict]: + """Most recent signal per ticker (one row per ticker, its latest computed_at).""" + conn = get_conn() + rows = conn.execute( + """SELECT w.* FROM wavelet_watchlist_signals w + INNER JOIN ( + SELECT ticker, MAX(computed_at) AS max_computed_at + FROM wavelet_watchlist_signals GROUP BY ticker + ) latest ON w.ticker = latest.ticker AND w.computed_at = latest.max_computed_at + ORDER BY w.computed_at DESC""" + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_wavelet_signals_history(ticker: str, days: int = 30) -> List[Dict]: + conn = get_conn() + rows = conn.execute( + "SELECT * FROM wavelet_watchlist_signals WHERE ticker=? AND computed_at >= datetime('now', ?) ORDER BY computed_at DESC", + (ticker.upper(), f"-{days} days"), + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + # ── System Logs ─────────────────────────────────────────────────────────────── def log_system_event( diff --git a/backend/services/geo_analyzer.py b/backend/services/geo_analyzer.py index c2c0162..04e6047 100644 --- a/backend/services/geo_analyzer.py +++ b/backend/services/geo_analyzer.py @@ -640,6 +640,13 @@ GEOPOLITICAL_RISK_WEIGHTS = { } +# A single extreme-severity article (e.g. imminent war, market-moving Fed shock) must not get diluted +# away just because other categories are quiet that day. This floor is driven by the single worst +# category score and stays inert below ~0.7, then ramps up steeply — solved so a lone 0.90 floors the +# final score at 50 (see MAX_CATEGORY_FLOOR_EXPONENT below). +MAX_CATEGORY_FLOOR_EXPONENT = 6.58 + + def compute_geo_risk_score(events: List[Dict[str, Any]]) -> Dict[str, Any]: """Compute a global geopolitical risk score 0-100 from recent events.""" if not events: @@ -658,7 +665,13 @@ def compute_geo_risk_score(events: List[Dict[str, Any]]) -> Dict[str, Any]: category_scores.get(cat, 0) * weight for cat, weight in GEOPOLITICAL_RISK_WEIGHTS.items() ) - score = min(100, round(weighted * 100, 1)) + + # Non-linear floor: the single most severe category score alone can force the score up, + # even if it's the only hot category — breaks the "many quiet categories dilute one severe one" effect. + max_category_impact = max(category_scores.values(), default=0.0) + floor = max_category_impact ** MAX_CATEGORY_FLOOR_EXPONENT + + score = min(100, round(max(weighted, floor) * 100, 1)) if score < 25: level = "low" diff --git a/backend/services/wavelet_engine.py b/backend/services/wavelet_engine.py new file mode 100644 index 0000000..503375f --- /dev/null +++ b/backend/services/wavelet_engine.py @@ -0,0 +1,447 @@ +""" +Wavelet band decomposition (CWT + synchrosqueezed variant), ported near-verbatim from +c:\\DataS\\InstrumentSimulator\\backend\\app\\wavelet.py (project "Macro Causal Lab"). + +No project-specific dependencies — pure numpy/ssqueezepy, safe to call from any service. +""" +import warnings + +import numpy as np +from ssqueezepy import cwt, icwt +from ssqueezepy.utils.cwt_utils import center_frequency + +MIN_SCALES_PER_BAND = 4 + +# Fixed period (days) lower-bounds for bands 0..5, independent of the analysis +# window's length. Without this, dividing [scales.min(), scales.max()] into +# num_levels *equal* log-spaced buckets means "band i" covers a totally +# different real-world period range depending on how much history is fed in +# (a longer window resolves longer scales, which shifts every intermediate +# boundary upward) - so the same band index is not comparable across window +# lengths. Anchoring to absolute day-periods keeps "band i" meaning the same +# oscillation whether the window is 3 months or 3 years; only the last band's +# upper bound stays open, since a longer window can genuinely resolve slower +# cycles a short one cannot. +CANONICAL_PERIOD_EDGES_DAYS = [0.3, 1.5, 4.0, 12.0, 35.0, 90.0] + + +def _period_to_log_scale_fn(wavelet, scales, n, num_calib_points=16): + """Build an interpolation from log(period_days) to log(scale) by sampling + center_frequency at a handful of scales (not every scale - it's not + vectorized and can be slow over hundreds/thousands of scales). + """ + log_scales_full = np.log(scales) + calib_log_scales = np.linspace(log_scales_full.min(), log_scales_full.max(), min(num_calib_points, len(scales))) + calib_periods = np.array([1 / center_frequency(wavelet, scale=float(np.exp(s)), N=n) for s in calib_log_scales]) + calib_log_periods = np.log(calib_periods) + + def period_to_log_scale(period_days): + return float(np.interp(np.log(period_days), calib_log_periods, calib_log_scales)) + + return period_to_log_scale + + +def band_decompose( + values: list[float], + dates: list[str], + num_levels: int = 4, + wavelet: str = "gmw", +) -> dict: + x = np.asarray(values, dtype=float) + n = len(x) + if n < 32: + raise ValueError("Serie trop courte pour une analyse ondelette (32 points minimum).") + + x_mean = float(x.mean()) + xc = x - x_mean + + Wx, scales = cwt(xc, wavelet=wavelet) + log_scales = np.log(scales) + + period_to_log_scale = _period_to_log_scale_fn(wavelet, scales, n) + period_edges = list(CANONICAL_PERIOD_EDGES_DAYS[:num_levels]) + while len(period_edges) < num_levels: + period_edges.append(period_edges[-1] * 2.5) + + # scales[0] = smallest scale = highest frequency = shortest period (impulses/noise); + # scales[-1] = largest scale = lowest frequency = longest period (slow trend). + bands = [] + for i in range(num_levels): + lo = period_to_log_scale(period_edges[i]) + hi = period_to_log_scale(period_edges[i + 1]) if i + 1 < len(period_edges) else log_scales.max() + mask = (log_scales >= lo) & (log_scales <= hi if i == num_levels - 1 else log_scales < hi) + scales_band = scales[mask] + + if len(scales_band) >= MIN_SCALES_PER_BAND: + try: + recon = icwt(Wx[mask, :], wavelet=wavelet, scales=scales_band, x_len=n) + except Exception: + # ssqueezepy's internal scale-type inference (log vs linear spacing + # detection) can raise on some narrow slices depending on how many + # scales land in this bucket for this specific series length. + # Treat as a negligible band instead of failing the whole request. + recon = np.zeros(n) + scale_lo, scale_hi = float(scales_band.min()), float(scales_band.max()) + else: + # Too few scales in this bucket for a stable reconstruction; report a + # flat zero band (its content ends up folded into the residual) so the + # band list always stays num_levels long. + recon = np.zeros(n) + scale_lo, scale_hi = float(np.exp(lo)), float(np.exp(hi)) + + freq_at_scale_hi = center_frequency(wavelet, scale=scale_hi, N=n) + freq_at_scale_lo = center_frequency(wavelet, scale=scale_lo, N=n) + period_low_days = round(1 / freq_at_scale_lo, 1) if freq_at_scale_lo else None + period_high_days = round(1 / freq_at_scale_hi, 1) if freq_at_scale_hi else None + label = ( + f"{period_low_days}-{period_high_days}j" + if period_low_days is not None and period_high_days is not None + else f"bande {i + 1}" + ) + bands.append( + { + "index": i, + "label": label, + "period_low_days": period_low_days, + "period_high_days": period_high_days, + "series": [round(float(v), 6) for v in recon], + } + ) + + reconstructed_total = x_mean + sum(np.array(band["series"]) for band in bands) + residual = x - reconstructed_total + + return { + "dates": dates, + "original": [round(float(v), 6) for v in x], + "mean": round(x_mean, 6), + "bands": bands, + "residual": [round(float(v), 6) for v in residual], + "wavelet": wavelet, + } + + +def windowed_band_decompose( + values: list[float], + dates: list[str], + window_size: int, + num_levels: int = 4, + wavelet: str = "gmw", +) -> dict: + """Decompose a long series by running band_decompose independently on + consecutive slices of `window_size` points, then concatenating the + results. Each slice is analyzed on its own terms (own local mean, own + CWT scale range), so precision near the reconstructed curve stays high + even over a long history; the trade-off is a visible discontinuity at + each slice boundary, which is acceptable for this diagnostic view. + """ + n = len(values) + window_size = max(32, min(window_size, n)) if window_size else n + if n <= window_size: + result = band_decompose(values, dates, num_levels, wavelet) + result["window_size"] = window_size + result["chunks"] = 1 + return result + + all_dates: list[str] = [] + all_original: list[float] = [] + all_residual: list[float] = [] + band_series: list[list[float]] = [[] for _ in range(num_levels)] + band_meta: list[dict | None] = [None] * num_levels + + chunk_count = 0 + start = 0 + while start < n: + end = min(start + window_size, n) + if 0 < n - end < 32: + # Don't leave a too-short trailing slice: fold the remainder in. + end = n + chunk = band_decompose(values[start:end], dates[start:end], num_levels, wavelet) + chunk_count += 1 + all_dates.extend(chunk["dates"]) + all_original.extend(chunk["original"]) + all_residual.extend(chunk["residual"]) + for band in chunk["bands"]: + band_series[band["index"]].extend(band["series"]) + band_meta[band["index"]] = band + start = end + + bands = [] + for i in range(num_levels): + meta = band_meta[i] or {"label": f"bande {i + 1}", "period_low_days": None, "period_high_days": None} + bands.append( + { + "index": i, + "label": meta["label"], + "period_low_days": meta["period_low_days"], + "period_high_days": meta["period_high_days"], + "series": band_series[i], + } + ) + + return { + "dates": all_dates, + "original": all_original, + "mean": round(float(np.mean(all_original)), 6) if all_original else 0.0, + "bands": bands, + "residual": all_residual, + "wavelet": wavelet, + "window_size": window_size, + "chunks": chunk_count, + } + + +def rolling_causal_bands( + values: list[float], + dates: list[str], + start_idx: int, + lookback: int, + num_levels: int = 4, + wavelet: str = "gmw", + step: int = 1, +) -> dict: + """Walk-forward band decomposition with no look-ahead: the band value + reported for day `t` is computed from a CWT run only on the trailing + `lookback` points ending at `t` (never anything after `t`). This is + deliberately much slower than `band_decompose` (one CWT per day instead + of one for the whole range) - that cost is the point: a single + whole-range CWT lets every point "see" the entire future through the + transform's global/symmetric support, which makes any backtest built on + it meaningless (the peaks/troughs it finds were computed with hindsight). + `step` > 1 re-runs the CWT every `step` days and holds the last computed + value in between, trading fidelity for speed on long ranges. + """ + n = len(values) + out_dates: list[str] = [] + out_original: list[float] = [] + band_series: list[list[float]] = [[] for _ in range(num_levels)] + band_labels = [f"bande {i + 1}" for i in range(num_levels)] + last_tip: list[float] | None = None + recomputations = 0 + + for t in range(start_idx, n): + window_start = t - lookback + 1 + if window_start < 0: + continue # not enough trailing history yet for a full lookback window + if last_tip is None or (t - start_idx) % step == 0: + window_values = values[window_start:t + 1] + window_dates = dates[window_start:t + 1] + result = band_decompose(window_values, window_dates, num_levels, wavelet) + last_tip = [band["series"][-1] for band in result["bands"]] + band_labels = [band["label"] for band in result["bands"]] + recomputations += 1 + out_dates.append(dates[t]) + out_original.append(values[t]) + for i in range(num_levels): + band_series[i].append(last_tip[i]) + + bands = [ + { + "index": i, + "label": band_labels[i], + "period_low_days": None, + "period_high_days": None, + "series": band_series[i], + } + for i in range(num_levels) + ] + + return { + "dates": out_dates, + "original": out_original, + "bands": bands, + "wavelet": wavelet, + "lookback": lookback, + "step": step, + "recomputations": recomputations, + } + + +# --------------------------------------------------------------------------- +# Synchrosqueezed variant (opt-in, method="ssq"). Everything above this line +# is completely untouched by what follows: band_decompose, windowed_band_decompose +# and rolling_causal_bands are the exact same functions used when method="cwt" +# (the default), so any existing simulation re-run with its original config +# hits the same code path and produces byte-identical results as before. +# +# Rationale: plain cwt/icwt smears a single true frequency's energy across +# several neighboring scales (that's *why* band_decompose needs canonical period +# edges and a minimum-scales guard - the boundaries are fuzzy). Synchrosqueezing +# reassigns that smeared energy onto its estimated true instantaneous frequency +# before splitting into bands, which should reduce cross-band leakage. It also +# exposes two things plain cwt does not: per-band energy |Tx|^2 (how dominant +# that cycle is *right now*, independent of direction) and a dominant-frequency +# ridge (which cycle length is currently winning, tracked over time). +# +# Separate period edges from the cwt path's CANONICAL_PERIOD_EDGES_DAYS: with +# fs=1.0 (one sample = one day, matching our daily data), ssq_cwt has a hard +# Nyquist floor at exactly 2.0 days (max resolvable frequency = fs/2). The cwt +# path's edges start at 0.3 days - below that floor, so reusing them here would +# leave the fastest band permanently empty. This does mean "band 0" covers a +# different absolute range under ssq than under cwt; that's an inherent +# consequence of ssq's stricter frequency floor, not a rounding choice. +# --------------------------------------------------------------------------- + +CANONICAL_PERIOD_EDGES_DAYS_SSQ = [2.0, 4.0, 10.0, 25.0, 60.0, 120.0] + + +def band_decompose_ssq( + values: list[float], + dates: list[str], + num_levels: int = 4, + wavelet: str = "gmw", +) -> dict: + from ssqueezepy import issq_cwt, ssq_cwt + from ssqueezepy.ridge_extraction import extract_ridges + + x = np.asarray(values, dtype=float) + n = len(x) + if n < 32: + raise ValueError("Serie trop courte pour une analyse ondelette (32 points minimum).") + + x_mean = float(x.mean()) + xc = x - x_mean + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + # fs=1.0: one sample = one day, so ssq_freqs comes out directly in + # cycles/day and 1/ssq_freqs is a period in days with no extra + # calibration step (unlike band_decompose's scale->period conversion, + # which needs _period_to_log_scale_fn precisely because plain cwt's + # scale axis has no absolute unit by itself). + Tx, _Wx, ssq_freqs, _scales = ssq_cwt(xc, wavelet=wavelet, fs=1.0) + + ssq_freqs = np.asarray(ssq_freqs) + periods = 1.0 / ssq_freqs + + period_edges = list(CANONICAL_PERIOD_EDGES_DAYS_SSQ[:num_levels]) + while len(period_edges) < num_levels: + period_edges.append(period_edges[-1] * 2.5) + + bands = [] + for i in range(num_levels): + lo_period = period_edges[i] + hi_period = period_edges[i + 1] if i + 1 < len(period_edges) else float(periods.max()) + mask = (periods >= lo_period) & (periods <= hi_period if i == num_levels - 1 else periods < hi_period) + + if mask.any(): + try: + Tx_band = np.zeros_like(Tx) + Tx_band[mask, :] = Tx[mask, :] + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + recon = np.real(issq_cwt(Tx_band, wavelet=wavelet)) + except Exception: + # Defensive, mirroring band_decompose: never let one band's edge + # case fail the whole request. + recon = np.zeros(n) + energy = np.sum(np.abs(Tx[mask, :]) ** 2, axis=0) + else: + recon = np.zeros(n) + energy = np.zeros(n) + + period_low_days = round(float(lo_period), 1) + period_high_days = round(float(hi_period), 1) + bands.append( + { + "index": i, + "label": f"{period_low_days}-{period_high_days}j", + "period_low_days": period_low_days, + "period_high_days": period_high_days, + "series": [round(float(v), 6) for v in recon], + "energy": [round(float(v), 8) for v in energy], + } + ) + + reconstructed_total = x_mean + sum(np.array(band["series"]) for band in bands) + residual = x - reconstructed_total + + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + ridge_idxs = extract_ridges(Tx, ssq_freqs, n_ridges=1, bw=4) + ridge_periods = 1.0 / ssq_freqs[ridge_idxs.ravel()] + ridge_period_days = [round(float(v), 3) for v in ridge_periods] + except Exception: + ridge_period_days = [None] * n + + return { + "dates": dates, + "original": [round(float(v), 6) for v in x], + "mean": round(x_mean, 6), + "bands": bands, + "residual": [round(float(v), 6) for v in residual], + "wavelet": wavelet, + "ridge_period_days": ridge_period_days, + } + + +def rolling_causal_bands_ssq( + values: list[float], + dates: list[str], + start_idx: int, + lookback: int, + num_levels: int = 4, + wavelet: str = "gmw", + step: int = 1, +) -> dict: + """Synchrosqueezed counterpart to rolling_causal_bands - identical + walk-forward, no-look-ahead structure (same day-by-day trailing window, + same step logic), just calling band_decompose_ssq instead of + band_decompose. rolling_causal_bands itself is untouched. + """ + n = len(values) + out_dates: list[str] = [] + out_original: list[float] = [] + band_series: list[list[float]] = [[] for _ in range(num_levels)] + energy_series: list[list[float]] = [[] for _ in range(num_levels)] + ridge_series: list[float | None] = [] + band_labels = [f"bande {i + 1}" for i in range(num_levels)] + last_tip: list[float] | None = None + last_energy_tip: list[float] | None = None + last_ridge_tip: float | None = None + recomputations = 0 + + for t in range(start_idx, n): + window_start = t - lookback + 1 + if window_start < 0: + continue + if last_tip is None or (t - start_idx) % step == 0: + window_values = values[window_start:t + 1] + window_dates = dates[window_start:t + 1] + result = band_decompose_ssq(window_values, window_dates, num_levels, wavelet) + last_tip = [band["series"][-1] for band in result["bands"]] + last_energy_tip = [band["energy"][-1] for band in result["bands"]] + last_ridge_tip = result["ridge_period_days"][-1] + band_labels = [band["label"] for band in result["bands"]] + recomputations += 1 + out_dates.append(dates[t]) + out_original.append(values[t]) + for i in range(num_levels): + band_series[i].append(last_tip[i]) + energy_series[i].append(last_energy_tip[i]) + ridge_series.append(last_ridge_tip) + + bands = [ + { + "index": i, + "label": band_labels[i], + "period_low_days": None, + "period_high_days": None, + "series": band_series[i], + "energy": energy_series[i], + } + for i in range(num_levels) + ] + + return { + "dates": out_dates, + "original": out_original, + "bands": bands, + "wavelet": wavelet, + "lookback": lookback, + "step": step, + "recomputations": recomputations, + "method": "ssq", + "ridge_period_days": ridge_series, + } diff --git a/backend/services/wavelet_signals.py b/backend/services/wavelet_signals.py new file mode 100644 index 0000000..666b4b1 --- /dev/null +++ b/backend/services/wavelet_signals.py @@ -0,0 +1,137 @@ +""" +Automated wavelet signal detection for the watchlist — run once per cycle. + +Ported (Python subset) from the trigger-signal detectors in +c:\\DataS\\InstrumentSimulator\\frontend\\src\\main.tsx (lines 180-280, TypeScript). +Only `extremum` and `level_threshold` are ported here: they're self-contained +(single curve, no secondary curve/config needed) and robust enough for an +unattended scan. The richer configurable trigger set (trend_flatten, +acceleration, band_cross, ridge_shift, energy_threshold) stays exclusive to the +interactive Wavelets Simulation page (frontend/src/lib/waveletTrade.ts), where a +user picks and tunes them explicitly. +""" +from typing import Dict, List, Optional + + +def _build_extremum_signal(series: List[float], direction: str) -> List[bool]: + n = len(series) + raw = [False] * n + for i in range(1, n - 1): + prev_slope = series[i] - series[i - 1] + next_slope = series[i + 1] - series[i] + if direction == "up" and prev_slope > 0 and next_slope <= 0: + raw[i] = True # peak + if direction == "down" and prev_slope < 0 and next_slope >= 0: + raw[i] = True # trough + # raw[j] needs series[j+1] to confirm — shift by one day so the signal never + # requires tomorrow's data. + shifted = [False] * n + for i in range(1, n): + shifted[i] = raw[i - 1] + return shifted + + +def _build_level_threshold_signal(series: List[float], direction: str, threshold_k: float) -> List[bool]: + n = len(series) + signal = [False] * n + s = 0.0 + sq = 0.0 + for t in range(n): + s += series[t] + sq += series[t] * series[t] + count = t + 1 + if count < 20: + continue # not enough history yet for a stable mean/std + mean = s / count + variance = max(0.0, sq / count - mean * mean) + std = variance ** 0.5 + if direction == "up" and series[t] > mean + threshold_k * std: + signal[t] = True + if direction == "down" and series[t] < mean - threshold_k * std: + signal[t] = True + return signal + + +def detect_extremum_signal(series: List[float]) -> Optional[str]: + """Returns 'up' (confirmed peak) or 'down' (confirmed trough) if the most + recent point is a signal, else None.""" + if len(series) < 3: + return None + if _build_extremum_signal(series, "up")[-1]: + return "up" + if _build_extremum_signal(series, "down")[-1]: + return "down" + return None + + +def detect_level_threshold_signal(series: List[float], threshold_k: float = 2.0) -> Optional[str]: + """Returns 'up' (overbought) or 'down' (oversold) if the most recent point + breaches a causal z-score threshold, else None.""" + if len(series) < 20: + return None + if _build_level_threshold_signal(series, "up", threshold_k)[-1]: + return "up" + if _build_level_threshold_signal(series, "down", threshold_k)[-1]: + return "down" + return None + + +def scan_watchlist_wavelet_signals(num_levels: int = 4, wavelet: str = "gmw", lookback: int = 120, method: str = "cwt") -> List[Dict]: + """Compute a causal (no-look-ahead) band decomposition for each watchlist + instrument and flag any band whose most recent point is a signal. Only the + trailing ~60 output points are computed (not the whole history) — this scan + only needs to know about *today*, unlike the interactive Simulation page's + full-range backtest.""" + from services.database import get_instruments_watchlist + from services.data_fetcher import get_historical + from services.wavelet_engine import rolling_causal_bands, rolling_causal_bands_ssq + + results: List[Dict] = [] + decomposer = rolling_causal_bands_ssq if method == "ssq" else rolling_causal_bands + + for item in get_instruments_watchlist(): + ticker = item["ticker"] + try: + hist = get_historical(ticker, period="1y", interval="1d") + if len(hist) < lookback + 32: + continue + values = [h["close"] for h in hist] + dates = [h["date"] for h in hist] + start_idx = max(lookback, len(values) - 60) + decomposed = decomposer( + values, dates, + start_idx=start_idx, lookback=lookback, + num_levels=num_levels, wavelet=wavelet, step=1, + ) + if not decomposed["dates"]: + continue + price_at_signal = decomposed["original"][-1] + + for band in decomposed["bands"]: + series = band["series"] + direction = detect_extremum_signal(series) + kind = "extremum" if direction else None + if not direction: + direction = detect_level_threshold_signal(series) + kind = "level_threshold" if direction else None + if kind and direction: + results.append({ + "ticker": ticker, + "band_label": band["label"], + "period_low_days": band.get("period_low_days"), + "period_high_days": band.get("period_high_days"), + "signal_kind": kind, + "direction": direction, + "price_at_signal": price_at_signal, + }) + except Exception: + continue # one bad ticker must not abort the whole scan + + return results + + +def compute_and_save_wavelet_signals(run_id: str) -> List[Dict]: + from services.database import save_wavelet_signals + results = scan_watchlist_wavelet_signals() + save_wavelet_signals(run_id, results) + return results diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ed21708..1368c99 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import GeoRadar from './pages/GeoRadar' import Markets from './pages/Markets' import MacroRegime from './pages/MacroRegime' import OptionsLab from './pages/OptionsLab' +import WaveletsSimulation from './pages/WaveletsSimulation' import Backtest from './pages/Backtest' import CalendarPage from './pages/CalendarPage' import Portfolio from './pages/Portfolio' @@ -48,6 +49,7 @@ const KEEP_ALIVE_DEFS: { path: string; component: ComponentType }[] = [ { path: '/markets', component: Markets }, { path: '/macro', component: MacroRegime }, { path: '/options', component: OptionsLab }, + { path: '/wavelets-simulation', component: WaveletsSimulation }, { path: '/patterns', component: PatternExplorer }, { path: '/pattern-lab', component: PatternLab }, { path: '/portfolio', component: Portfolio }, diff --git a/frontend/src/components/TradeRankList.tsx b/frontend/src/components/TradeRankList.tsx index be54e55..f461cae 100644 --- a/frontend/src/components/TradeRankList.tsx +++ b/frontend/src/components/TradeRankList.tsx @@ -1,12 +1,14 @@ +import type { ReactNode } from 'react' import { Link } from 'react-router-dom' import { ArrowUpRight } from 'lucide-react' import clsx from 'clsx' -export default function TradeRankList({ trades, mode, title, linkTo }: { +export default function TradeRankList({ trades, mode, title, linkTo, toggle }: { trades: any[] mode: 'top' | 'worst' title: string linkTo: string + toggle?: ReactNode }) { const sorted = [...trades] .sort((a, b) => mode === 'top' @@ -18,7 +20,10 @@ export default function TradeRankList({ trades, mode, title, linkTo }: {
{title} - +
+ {toggle} + +
{sorted.length > 0 ? (
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index c816ffc..0777907 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,7 +1,7 @@ import { NavLink } from 'react-router-dom' import { LayoutDashboard, Globe, BarChart2, FlaskConical, - History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, ScanEye, CandlestickChart, PlayCircle, Radio, Bot, Sliders + History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, ScanEye, CandlestickChart, PlayCircle, Radio, Bot, Sliders, Waves } from 'lucide-react' import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi' import clsx from 'clsx' @@ -12,6 +12,7 @@ const nav = [ { to: '/markets', icon: BarChart2, label: 'Markets & Prices' }, { to: '/macro', icon: Activity, label: 'Macro Regime' }, { to: '/options', icon: TrendingUp, label: 'Options Lab' }, + { to: '/wavelets-simulation', icon: Waves, label: 'Wavelets Simulation' }, { to: '/patterns', icon: Zap, label: 'Patterns' }, { to: '/pattern-lab', icon: FlaskConical, label: 'Pattern Lab' }, { to: '/portfolio', icon: DollarSign, label: 'Portfolio' }, diff --git a/frontend/src/config/keepAliveMeta.ts b/frontend/src/config/keepAliveMeta.ts index 3ced8e1..a803d10 100644 --- a/frontend/src/config/keepAliveMeta.ts +++ b/frontend/src/config/keepAliveMeta.ts @@ -2,7 +2,7 @@ import { LayoutDashboard, Globe, BarChart2, Activity, TrendingUp, Zap, FlaskConical, DollarSign, BookOpen, FileBarChart, Brain, Microscope, ShieldAlert, Gauge, GitCompare, History, Calendar, Sliders, TrendingUp as MacroSeriesIcon, - Building2, Users, ScanEye, Radio, Bot, PlayCircle, ScrollText, Settings, + Building2, Users, ScanEye, Radio, Bot, PlayCircle, ScrollText, Settings, Waves, } from 'lucide-react' import type { LucideIcon } from 'lucide-react' @@ -18,6 +18,7 @@ export const KEEP_ALIVE_META: KeepAliveMeta[] = [ { path: '/markets', label: 'Markets', icon: BarChart2 }, { path: '/macro', label: 'Macro Regime', icon: Activity }, { path: '/options', label: 'Options Lab', icon: TrendingUp }, + { path: '/wavelets-simulation', label: 'Wavelets Sim.', icon: Waves }, { path: '/patterns', label: 'Patterns', icon: Zap }, { path: '/pattern-lab', label: 'Pattern Lab', icon: FlaskConical }, { path: '/portfolio', label: 'Portfolio', icon: DollarSign }, diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index f703ac9..58b708e 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1027,6 +1027,14 @@ export const useLogSources = () => staleTime: 60_000, }) +// ── Wavelets — latest per-ticker signal from the watchlist scan (auto-cycle) ── +export const useWaveletWatchlistSignals = () => + useQuery({ + queryKey: ['wavelet-watchlist-signals'], + queryFn: () => api.get('/wavelet/watchlist-signals').then(r => r.data as { signals: any[] }), + staleTime: 5 * 60_000, + }) + export const useLogCycles = () => useQuery({ queryKey: ['log-cycles'], diff --git a/frontend/src/lib/waveletTrade.ts b/frontend/src/lib/waveletTrade.ts new file mode 100644 index 0000000..c2d926a --- /dev/null +++ b/frontend/src/lib/waveletTrade.ts @@ -0,0 +1,464 @@ +// Wavelet trigger-signal detection + trade simulation engine. +// Ported near-verbatim from c:\DataS\InstrumentSimulator\frontend\src\main.tsx +// (lines 62-654, project "Macro Causal Lab") — pure functions, no React/component +// dependencies, safe to unit-reason-about independently of the page that uses them. +import { api } from '../hooks/useApi' + +export type WaveletCurve = { id: string; label: string; series: number[] } +// `kind` is optional and defaults to "slope" (the original, only behaviour) +// wherever it's read - every filter created before this field existed has +// kind===undefined, which must keep behaving exactly like "slope". +export type WaveletSlopeFilter = { curveId: string; sign: 'positive' | 'negative'; kind?: 'slope' | 'energy' } +export type WaveletTriggerKind = 'extremum' | 'trend_flatten' | 'acceleration' | 'level_threshold' | 'band_cross' | 'ridge_shift' | 'energy_threshold' +export type WaveletTrigger = { + kind: WaveletTriggerKind + curveId: string + secondaryCurveId: string + trendDays: number + flattenDays: number + trendThresholdK: number + flattenThresholdK: number + accelDays: number + accelThresholdK: number + levelThresholdK: number +} +export type WaveletPositionMode = 'long_only' | 'short_only' | 'both' +// "signal": close only on the mirror trigger (current behaviour). +// "pct": close only on a take-profit/stop-loss percentage target, ignore the exit trigger. +// "signal_or_pct": close on whichever happens first. +// P&L is tracked as a percentage return, not FX "pips" — that convention is wrong for +// anything not quoted like a 4-decimal forex pair, and results get aggregated across +// heterogeneous instruments (metals, energy, indices, forex, ...) so the P&L unit has +// to mean the same thing for every one of them. +export type WaveletExitStyle = 'signal' | 'pct' | 'signal_or_pct' +export type WaveletTradeConfig = { + mode: WaveletPositionMode + buyTrigger: WaveletTrigger + sellTrigger: WaveletTrigger + buyFilters: WaveletSlopeFilter[] + sellFilters: WaveletSlopeFilter[] + shortTrigger: WaveletTrigger + coverTrigger: WaveletTrigger + shortFilters: WaveletSlopeFilter[] + coverFilters: WaveletSlopeFilter[] + exitStyle: WaveletExitStyle + takeProfitPct: number | null + stopLossPct: number | null +} +export type WaveletTrade = { + direction: 'long' | 'short' + entryDate: string + entryPrice: number + exitDate: string + exitPrice: number + returnPct: number +} +export type WaveletTradeMarker = { date: string; type: 'buy' | 'sell'; price: number } + +export const WAVELET_TRIGGER_KINDS: { value: WaveletTriggerKind; label: string }[] = [ + { value: 'extremum', label: 'Extremum simple (pic/creux)' }, + { value: 'trend_flatten', label: 'Tendance puis tassement' }, + { value: 'acceleration', label: 'Deceleration/acceleration' }, + { value: 'level_threshold', label: 'Seuil de niveau (sur/sous-achete)' }, + { value: 'band_cross', label: 'Croisement de bandes' }, + { value: 'ridge_shift', label: 'Changement de regime (ridge, ssq uniquement)' }, + { value: 'energy_threshold', label: "Seuil d'energie (bande, ssq uniquement)" }, +] + +export const WAVELET_BAND_COLORS = ['#f59e0b', '#14b8a6', '#8b5cf6', '#ef4444', '#0ea5e9', '#84cc16'] + +export function formatBandIndex(bandIndex: number): string { + return bandIndex === -1 ? 'ridge' : `bande ${bandIndex}` +} + +export function defaultWaveletTrigger(curveId: string): WaveletTrigger { + return { + kind: 'extremum', + curveId, + secondaryCurveId: curveId, + trendDays: 2, + flattenDays: 4, + trendThresholdK: 0, + flattenThresholdK: 0.5, + accelDays: 1, + accelThresholdK: 0, + levelThresholdK: 1, + } +} + +function computeSlope(series: number[]): number[] { + const slope = new Array(series.length).fill(0) + for (let i = 1; i < series.length; i++) slope[i] = series[i] - series[i - 1] + if (series.length > 1) slope[0] = slope[1] + return slope +} + +function computeAcceleration(slope: number[]): number[] { + const accel = new Array(slope.length).fill(0) + for (let i = 1; i < slope.length; i++) accel[i] = slope[i] - slope[i - 1] + if (slope.length > 1) accel[0] = accel[1] + return accel +} + +// Average of `slope` over the interval (from, to], i.e. the average daily change +// between day `from` and day `to`. Used for both the "trend" window and the +// "flatten" window of the trend_flatten trigger. +function avgSlopeRange(slope: number[], from: number, to: number): number | null { + if (from < 0 || to > slope.length - 1 || to <= from) return null + let sum = 0 + for (let i = from + 1; i <= to; i++) sum += slope[i] + return sum / (to - from) +} + +// Every build*Signal function returns a boolean array where signal[t] is already +// causal-safe to act on exactly at day t (built only from data through day t) — +// callers never need to know which trigger needs a lag. +export function buildExtremumSignal(series: number[], direction: 'up' | 'down'): boolean[] { + const n = series.length + const raw = new Array(n).fill(false) + for (let i = 1; i < n - 1; i++) { + const prevSlope = series[i] - series[i - 1] + const nextSlope = series[i + 1] - series[i] + if (direction === 'up' && prevSlope > 0 && nextSlope <= 0) raw[i] = true // peak + if (direction === 'down' && prevSlope < 0 && nextSlope >= 0) raw[i] = true // trough + } + // raw[j] needs series[j+1] to confirm, i.e. it's only knowable at day j+1 — + // shift by one day so the signal itself never requires tomorrow's data. + const shifted = new Array(n).fill(false) + for (let i = 1; i < n; i++) shifted[i] = raw[i - 1] + return shifted +} + +export function buildTrendFlattenSignal( + series: number[], direction: 'up' | 'down', + trendDays: number, flattenDays: number, trendThresholdK: number, flattenThresholdK: number, +): boolean[] { + const n = series.length + const slope = computeSlope(series) + const signal = new Array(n).fill(false) + // Expanding (causal) std of the slope, accumulated only from data up to and + // including day t — NOT a single std computed once over the whole series. + let sum = 0, sumSq = 0 + for (let t = 1; t < n; t++) { + sum += slope[t]; sumSq += slope[t] * slope[t] + const count = t + if (t < trendDays + flattenDays || count < 20) continue + const mean = sum / count + const variance = Math.max(0, sumSq / count - mean * mean) + const std = Math.sqrt(variance) + const trendThresh = trendThresholdK * std + const flattenThresh = flattenThresholdK * std + const trend = avgSlopeRange(slope, t - flattenDays - trendDays, t - flattenDays) + const flat = avgSlopeRange(slope, t - flattenDays, t) + if (trend === null || flat === null) continue + if (direction === 'up' && trend > trendThresh && Math.abs(flat) <= flattenThresh) signal[t] = true + if (direction === 'down' && trend < -trendThresh && Math.abs(flat) <= flattenThresh) signal[t] = true + } + return signal +} + +export function buildAccelerationSignal(series: number[], direction: 'up' | 'down', days: number, thresholdK: number): boolean[] { + const n = series.length + const slope = computeSlope(series) + const accel = computeAcceleration(slope) + const signal = new Array(n).fill(false) + let sum = 0, sumSq = 0 + for (let t = 2; t < n; t++) { + sum += accel[t]; sumSq += accel[t] * accel[t] + const count = t - 1 + if (t < days || count < 20) continue + const mean = sum / count + const variance = Math.max(0, sumSq / count - mean * mean) + const std = Math.sqrt(variance) + const thresh = thresholdK * std + if (direction === 'up') { + if (slope[t] <= 0) continue + let ok = true + for (let d = 0; d < days; d++) if (!(accel[t - d] < -thresh)) { ok = false; break } + signal[t] = ok + } else { + if (slope[t] >= 0) continue + let ok = true + for (let d = 0; d < days; d++) if (!(accel[t - d] > thresh)) { ok = false; break } + signal[t] = ok + } + } + return signal +} + +export function buildLevelThresholdSignal(series: number[], direction: 'up' | 'down', thresholdK: number): boolean[] { + const n = series.length + const signal = new Array(n).fill(false) + let sum = 0, sumSq = 0 + for (let t = 0; t < n; t++) { + sum += series[t]; sumSq += series[t] * series[t] + const count = t + 1 + if (count < 20) continue // not enough history yet for a stable mean/std + const mean = sum / count + const variance = Math.max(0, sumSq / count - mean * mean) + const std = Math.sqrt(variance) + if (direction === 'up' && series[t] > mean + thresholdK * std) signal[t] = true + if (direction === 'down' && series[t] < mean - thresholdK * std) signal[t] = true + } + return signal +} + +export function buildBandCrossSignal(primary: number[], secondary: number[], direction: 'up' | 'down'): boolean[] { + const n = Math.min(primary.length, secondary.length) + const signal = new Array(n).fill(false) + for (let t = 1; t < n; t++) { + const prevDiff = primary[t - 1] - secondary[t - 1] + const currDiff = primary[t] - secondary[t] + if (direction === 'down' && prevDiff >= 0 && currDiff < 0) signal[t] = true // crosses below (sell) + if (direction === 'up' && prevDiff <= 0 && currDiff > 0) signal[t] = true // crosses above (buy) + } + return signal +} + +// Causal (expanding-window) mean of a series — only ever uses data up to and +// including day t. +function computeExpandingMean(series: number[]): number[] { + const n = series.length + const mean = new Array(n).fill(0) + let sum = 0 + for (let t = 0; t < n; t++) { sum += series[t]; mean[t] = sum / (t + 1) } + return mean +} + +// Only meaningful on a "ridge" curve (the synchrosqueezed dominant-cycle-period +// track, method="ssq" only): fires when the dominant period crosses more than +// thresholdK causal standard deviations away from its own expanding average. +export function buildRidgeShiftSignal(periodSeries: number[], direction: 'up' | 'down', thresholdK: number): boolean[] { + return buildLevelThresholdSignal(periodSeries, direction, thresholdK) +} + +export function buildTriggerSignal(trigger: WaveletTrigger, curves: WaveletCurve[], direction: 'up' | 'down', length: number): boolean[] { + const curveById = new Map(curves.map((curve) => [curve.id, curve.series])) + const series = curveById.get(trigger.curveId) + if (!series) return new Array(length).fill(false) + switch (trigger.kind) { + case 'extremum': return buildExtremumSignal(series, direction) + case 'trend_flatten': return buildTrendFlattenSignal(series, direction, trigger.trendDays, trigger.flattenDays, trigger.trendThresholdK, trigger.flattenThresholdK) + case 'acceleration': return buildAccelerationSignal(series, direction, trigger.accelDays, trigger.accelThresholdK) + case 'level_threshold': return buildLevelThresholdSignal(series, direction, trigger.levelThresholdK) + case 'band_cross': return buildBandCrossSignal(series, curveById.get(trigger.secondaryCurveId) ?? series, direction) + case 'ridge_shift': return buildRidgeShiftSignal(series, direction, trigger.levelThresholdK) + case 'energy_threshold': return buildLevelThresholdSignal(series, direction, trigger.levelThresholdK) + default: return new Array(length).fill(false) + } +} + +export function runWaveletTradeSimulation( + dates: string[], original: number[], curves: WaveletCurve[], config: WaveletTradeConfig, +): { + trades: WaveletTrade[] + markers: WaveletTradeMarker[] + totalReturnPct: number + winRate: number + openPosition: { direction: 'long' | 'short'; entryDate: string; entryPrice: number } | null +} { + const n = dates.length + const slopeById = new Map(curves.map((curve) => [curve.id, computeSlope(curve.series)])) + const aboveOwnMeanById = new Map( + curves.map((curve) => { + const mean = computeExpandingMean(curve.series) + return [curve.id, curve.series.map((v, i) => v > mean[i])] + }), + ) + + const allowLong = config.mode !== 'short_only' + const allowShort = config.mode !== 'long_only' + const buySignal = allowLong ? buildTriggerSignal(config.buyTrigger, curves, 'down', n) : null + const sellSignal = allowLong ? buildTriggerSignal(config.sellTrigger, curves, 'up', n) : null + const shortSignal = allowShort ? buildTriggerSignal(config.shortTrigger, curves, 'up', n) : null + const coverSignal = allowShort ? buildTriggerSignal(config.coverTrigger, curves, 'down', n) : null + + const trades: WaveletTrade[] = [] + const markers: WaveletTradeMarker[] = [] + let position: 'flat' | 'long' | 'short' = 'flat' + let entryDate = '' + let entryPrice = 0 + + const filtersPass = (filters: WaveletSlopeFilter[], i: number) => + filters.every((filter) => { + if (filter.kind === 'energy') { + const above = aboveOwnMeanById.get(filter.curveId)?.[i] ?? false + return filter.sign === 'positive' ? above : !above + } + const slope = slopeById.get(filter.curveId)?.[i] ?? 0 + return filter.sign === 'positive' ? slope > 0 : slope < 0 + }) + + const returnPct = (direction: 'long' | 'short', price: number) => + direction === 'long' ? ((price - entryPrice) / entryPrice) * 100 : ((entryPrice - price) / entryPrice) * 100 + + const pctExitHit = (direction: 'long' | 'short', price: number) => { + const unrealized = returnPct(direction, price) + if (config.takeProfitPct !== null && unrealized >= config.takeProfitPct) return true + if (config.stopLossPct !== null && unrealized <= -config.stopLossPct) return true + return false + } + + for (let i = 0; i < n; i++) { + // Check for closing the current position first, then check for opening a new + // one — so a same-day "close short, open long" reversal isn't missed. + const longSignalExit = position === 'long' && !!sellSignal?.[i] && filtersPass(config.sellFilters, i) + const longPctExit = position === 'long' && config.exitStyle !== 'signal' && pctExitHit('long', original[i]) + const shortSignalExit = position === 'short' && !!coverSignal?.[i] && filtersPass(config.coverFilters, i) + const shortPctExit = position === 'short' && config.exitStyle !== 'signal' && pctExitHit('short', original[i]) + + const longExits = config.exitStyle === 'pct' ? longPctExit : config.exitStyle === 'signal_or_pct' ? (longSignalExit || longPctExit) : longSignalExit + const shortExits = config.exitStyle === 'pct' ? shortPctExit : config.exitStyle === 'signal_or_pct' ? (shortSignalExit || shortPctExit) : shortSignalExit + + if (longExits) { + const exitPrice = original[i] + trades.push({ direction: 'long', entryDate, entryPrice, exitDate: dates[i], exitPrice, returnPct: returnPct('long', exitPrice) }) + markers.push({ date: dates[i], type: 'sell', price: exitPrice }) + position = 'flat' + } else if (shortExits) { + const exitPrice = original[i] + trades.push({ direction: 'short', entryDate, entryPrice, exitDate: dates[i], exitPrice, returnPct: returnPct('short', exitPrice) }) + markers.push({ date: dates[i], type: 'buy', price: exitPrice }) + position = 'flat' + } + + if (position === 'flat') { + // If both long and short entries qualify on the same day (only possible in + // "both" mode), the long entry takes priority — arbitrary but deterministic. + if (buySignal?.[i] && filtersPass(config.buyFilters, i)) { + position = 'long'; entryDate = dates[i]; entryPrice = original[i] + markers.push({ date: dates[i], type: 'buy', price: original[i] }) + } else if (shortSignal?.[i] && filtersPass(config.shortFilters, i)) { + position = 'short'; entryDate = dates[i]; entryPrice = original[i] + markers.push({ date: dates[i], type: 'sell', price: original[i] }) + } + } + } + + const totalReturnPct = trades.reduce((sum, trade) => sum + trade.returnPct, 0) + const wins = trades.filter((trade) => trade.returnPct > 0).length + return { + trades, markers, totalReturnPct, + winRate: trades.length ? wins / trades.length : 0, + openPosition: position !== 'flat' ? { direction: position, entryDate, entryPrice } : null, + } +} + +// ── Optimization grid (multi-instrument batch search) ───────────────────────── + +export type WaveletExitVariant = { exitStyle: WaveletExitStyle; takeProfitPct: number | null; stopLossPct: number | null } + +export type WaveletOptimizationResult = { + symbol: string + lookback: number + wavelet: string + mode: WaveletPositionMode + triggerKind: WaveletTriggerKind + bandIndex: number + exitStyle: WaveletExitStyle + takeProfitPct: number | null + stopLossPct: number | null + totalReturnPct: number + winRate: number + tradeCount: number + analysisMethod?: 'cwt' | 'ssq' +} + +// All grid combos use the SAME trigger kind/band symmetrically for both sides of a +// position with each kind's validated default parameters — testing every +// combination of *different* kinds for entry vs exit would multiply the grid by +// another 5x for little practical benefit. +export function buildSymmetricTradeConfig( + curveId: string, triggerKind: WaveletTriggerKind, mode: WaveletPositionMode, variant: WaveletExitVariant, +): WaveletTradeConfig { + const effectiveCurveId = + triggerKind === 'ridge_shift' ? 'ridge' : + triggerKind === 'energy_threshold' ? curveId.replace(/^band_/, 'energy_band_') : + curveId + const trigger = { ...defaultWaveletTrigger(effectiveCurveId), kind: triggerKind } + // ridge_shift's period series is coarser than a price band, so it rarely crosses + // a full standard deviation — the shared default K=1 starves it of trades. + if (triggerKind === 'ridge_shift') trigger.levelThresholdK = 0.5 + return { + mode, + buyTrigger: trigger, sellTrigger: trigger, buyFilters: [], sellFilters: [], + shortTrigger: trigger, coverTrigger: trigger, shortFilters: [], coverFilters: [], + exitStyle: variant.exitStyle, takeProfitPct: variant.takeProfitPct, stopLossPct: variant.stopLossPct, + } +} + +export function runOptimizationGrid( + symbol: string, lookback: number, wavelet: string, rolling: any, + bandsToTest: number[], kindsToTest: WaveletTriggerKind[], modesToTest: WaveletPositionMode[], + exitVariants: WaveletExitVariant[], +): WaveletOptimizationResult[] { + const curves: WaveletCurve[] = rolling.bands.map((band: any) => ({ id: `band_${band.index}`, label: band.label, series: band.series })) + if (rolling.ridge_period_days) { + curves.push({ id: 'ridge', label: 'Ridge (periode dominante, j)', series: rolling.ridge_period_days.map((v: number | null) => v ?? 0) }) + } + rolling.bands.forEach((band: any) => { + if (band.energy) curves.push({ id: `energy_band_${band.index}`, label: `Energie ${band.label}`, series: band.energy }) + }) + const availableBandIndices = new Set(rolling.bands.map((band: any) => band.index)) + const results: WaveletOptimizationResult[] = [] + let ridgeShiftComputed = false + for (const bandIndex of bandsToTest) { + if (!availableBandIndices.has(bandIndex)) continue + const curveId = `band_${bandIndex}` + for (const kind of kindsToTest) { + if (kind === 'ridge_shift' && ridgeShiftComputed) continue + for (const mode of modesToTest) { + for (const variant of exitVariants) { + const config = buildSymmetricTradeConfig(curveId, kind, mode, variant) + const sim = runWaveletTradeSimulation(rolling.dates, rolling.original, curves, config) + results.push({ + symbol, lookback, wavelet, mode, + triggerKind: kind, + bandIndex: kind === 'ridge_shift' ? -1 : bandIndex, + exitStyle: variant.exitStyle, takeProfitPct: variant.takeProfitPct, stopLossPct: variant.stopLossPct, + totalReturnPct: sim.totalReturnPct, winRate: sim.winRate, tradeCount: sim.trades.length, + analysisMethod: rolling.method ?? 'cwt', + }) + } + } + if (kind === 'ridge_shift') ridgeShiftComputed = true + } + } + return results +} + +// Orchestrates the expensive part (one backend rolling-CWT fetch per instrument x +// 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 async function runFullOptimization( + instruments: string[], lookbacks: number[], wavelets: string[], period: string, levels: number, + bandsToTest: number[], kindsToTest: WaveletTriggerKind[], modesToTest: WaveletPositionMode[], + exitVariants: WaveletExitVariant[], + onProgress: (done: number, total: number, label: string, failures: string[]) => void, + onBatchResults: (results: WaveletOptimizationResult[]) => void, + method: 'cwt' | 'ssq' = 'cwt', +): Promise { + const combos: { symbol: string; lookback: number; wavelet: string }[] = [] + for (const symbol of instruments) { + for (const lookback of lookbacks) { + for (const wavelet of wavelets) { + combos.push({ symbol, lookback, wavelet }) + } + } + } + const failures: string[] = [] + for (let i = 0; i < combos.length; i++) { + const { symbol, lookback, wavelet } = combos[i] + 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 }, + }) + onBatchResults(runOptimizationGrid(symbol, lookback, wavelet, rolling, bandsToTest, kindsToTest, modesToTest, exitVariants)) + } catch (error) { + failures.push(`${symbol} @ ${lookback}j (${wavelet}): ${error instanceof Error ? error.message : String(error)}`) + } + } + onProgress(combos.length, combos.length, 'Termine', failures) +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 0ffa0d2..e236fc1 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -6,8 +6,9 @@ import { useTradeMtm, useRiskDashboard, useGeoNews, useSimPortfolioRisk, useCycleStatus, useClosedTrades, useInstrumentsWatchlist, useInstrumentsWatchlistQuotes, useIvBatch, useLatestCycleReport, + useWaveletWatchlistSignals, } from '../hooks/useApi' -import { Clock, Globe, ShieldAlert, ArrowUpRight, Newspaper } from 'lucide-react' +import { Clock, Globe, ShieldAlert, ArrowUpRight, Newspaper, Waves } from 'lucide-react' import { Link } from 'react-router-dom' import clsx from 'clsx' import type { Quote } from '../types' @@ -80,6 +81,26 @@ function ViewToggle({ value, onChange }: { value: 'simulated' | 'portfolio'; onC ) } +function TradesViewToggle({ value, onChange }: { value: 'top' | 'worst'; onChange: (v: 'top' | 'worst') => void }) { + const click = (v: 'top' | 'worst') => (e: React.MouseEvent) => { + e.preventDefault(); e.stopPropagation(); onChange(v) + } + return ( +
+ + +
+ ) +} + function QuoteRow({ q }: { q: Quote }) { if (!q.price) return null const pos = q.change_pct >= 0 @@ -113,6 +134,7 @@ export default function Dashboard() { const { data: watchlistItems } = useInstrumentsWatchlist() const { data: watchlistQuotesData } = useInstrumentsWatchlistQuotes() const { data: latestCycleReportData } = useLatestCycleReport() + const { data: waveletSignalsData } = useWaveletWatchlistSignals() const watchlistTickers: string[] = (watchlistItems ?? []).map((w: any) => w.ticker) const { data: ivBatchData } = useIvBatch(watchlistTickers.join(',')) @@ -144,6 +166,13 @@ export default function Dashboard() { setRiskView(v); localStorage.setItem('dash_risk_view', v) } + const [tradesView, setTradesView] = useState<'top' | 'worst'>(() => + (localStorage.getItem('dash_trades_view') as any) ?? 'top' + ) + const setTradesViewPersist = (v: 'top' | 'worst') => { + setTradesView(v); localStorage.setItem('dash_trades_view', v) + } + // Row height is dictated by the config-driven watchlist cards (Watchlist Radar for row 1, // Options Lab for row 2) — the other cards in each row are capped to match, with internal scroll. const watchlistCardRef = useRef(null) @@ -925,8 +954,39 @@ export default function Dashboard() { {/* ── Command Center: Intelligence & Contexte ── */}
- - + } /> + + {/* Wavelets Signal — latest per-ticker signal from the watchlist scan (computed each cycle) */} + {(() => { + const signals: any[] = waveletSignalsData?.signals ?? [] + return ( + +
+ + Wavelets Signal + + +
+ {signals.length > 0 ? ( +
+ {signals.slice(0, 5).map((s: any, i: number) => ( +
+ + {s.direction === 'up' ? '↑' : '↓'} + + {s.ticker} + {s.band_label} · {s.signal_kind} + {s.computed_at && {s.computed_at.slice(0, 10)}} +
+ ))} +
+ ) : ( +
No wavelet signal detected — le prochain cycle en calculera
+ )} + + ) + })()} {/* Recommandations du jour — patterns from the most recent cycle only */} diff --git a/frontend/src/pages/InstrumentDashboard.tsx b/frontend/src/pages/InstrumentDashboard.tsx index f3dadc8..60b3219 100644 --- a/frontend/src/pages/InstrumentDashboard.tsx +++ b/frontend/src/pages/InstrumentDashboard.tsx @@ -6,10 +6,13 @@ import { } from 'lucide-react' import axios from 'axios' import clsx from 'clsx' +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' import InstrumentChart, { TheoPoint } from '../components/InstrumentChart' const api = axios.create({ baseURL: '/api' }) +const WAVELET_BAND_COLORS = ['#3b82f6', '#f59e0b', '#a855f7', '#ef4444', '#14b8a6', '#84cc16'] + // Stable empty array — prevents InstrumentChart useEffect from re-running on every render const NO_CHART_EVENTS: never[] = [] @@ -1337,7 +1340,15 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i const [loadingNarr, setLoadingNarr] = useState(false) const [selectorOpen, setSelectorOpen] = useState(false) const [selectedDate, setSelectedDate] = useState(null) - const [tabUnder, setTabUnder] = useState<'counters' | 'analyse'>('counters') + const [tabUnder, setTabUnder] = useState<'counters' | 'analyse' | 'wavelets'>('counters') + const [waveletLevels, setWaveletLevels] = useState(4) + const [waveletFamily, setWaveletFamily] = useState<'gmw' | 'morlet' | 'bump'>('gmw') + const [waveletMethod, setWaveletMethod] = useState<'cwt' | 'ssq'>('cwt') + const [waveletCausal, setWaveletCausal] = useState(false) + const [waveletLookback, setWaveletLookback] = useState(130) + const [waveletData, setWaveletData] = useState(null) + const [loadingWavelet, setLoadingWavelet] = useState(false) + const [hiddenBands, setHiddenBands] = useState>(new Set()) const [templates, setTemplates] = useState([]) const [causalScores, setCausalScores] = useState>({}) // eventId → activation_score*100 const [macroAtDate, setMacroAtDate] = useState(null) @@ -1532,6 +1543,54 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i const dateLabel = effectiveDate ? fmtDateFR(effectiveDate) : '—' const displayPrice = dateTrend?.current_price ?? snapshot?.current_price + const runWaveletAnalysis = async () => { + if (!selected) return + setLoadingWavelet(true) + try { + const path = waveletCausal ? '/wavelet/rolling' : '/wavelet/analyze' + const params: any = { symbol: selected.yf_ticker, period, levels: waveletLevels, wavelet: waveletFamily, method: waveletMethod } + if (waveletCausal) params.lookback = waveletLookback + const { data } = await api.get(path, { params }) + setWaveletData(data) + setHiddenBands(new Set()) + } catch (e) { + console.error('Wavelet analysis failed', e) + setWaveletData(null) + } finally { + setLoadingWavelet(false) + } + } + + const waveletChartData = useMemo(() => { + if (!waveletData) return [] + const { dates, original, bands, mean } = waveletData + return dates.map((d: string, i: number) => { + const row: any = { date: d.slice(0, 10), original: original[i] } + let recon = mean ?? 0 + for (const b of bands) { row[b.label] = b.series[i]; recon += b.series[i] } + row.reconstruction = recon + return row + }) + }, [waveletData]) + + const waveletStats = useMemo(() => { + if (!waveletData || waveletChartData.length < 2) return null + const orig: number[] = waveletData.original + const recon = waveletChartData.map((r: any) => r.reconstruction) + const n = orig.length + const meanO = orig.reduce((a: number, b: number) => a + b, 0) / n + const meanR = recon.reduce((a: number, b: number) => a + b, 0) / n + let cov = 0, varO = 0, varR = 0, sumAbs = 0, maxAbs = 0 + for (let i = 0; i < n; i++) { + const dO = orig[i] - meanO, dR = recon[i] - meanR + cov += dO * dR; varO += dO * dO; varR += dR * dR + const err = Math.abs(orig[i] - recon[i]) + sumAbs += err; maxAbs = Math.max(maxAbs, err) + } + const correlation = varO > 0 && varR > 0 ? cov / Math.sqrt(varO * varR) : null + return { correlation, meanAbsError: sumAbs / n, maxAbsError: maxAbs } + }, [waveletData, waveletChartData]) + return (
@@ -1666,6 +1725,7 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i {([ { key: 'counters', label: 'Compteurs' }, { key: 'analyse', label: 'Analyse de la courbe' }, + { key: 'wavelets', label: 'Ondelettes' }, ] as const).map(t => ( +
+
+ + {waveletData ? ( + <> +
+ {waveletData.bands.map((b: any, i: number) => ( + + ))} +
+ + + + + + + + + {waveletData.bands.map((b: any, i: number) => !hiddenBands.has(b.label) && ( + + ))} + + + {waveletStats && ( +
+ Corrélation reconstruction: {waveletStats.correlation != null ? waveletStats.correlation.toFixed(3) : '—'} + Erreur moy.: {waveletStats.meanAbsError.toFixed(3)} + Erreur max: {waveletStats.maxAbsError.toFixed(3)} + {waveletCausal && Mode causal (walk-forward) — {waveletData.recomputations} recalculs} +
+ )} + + ) : ( +
+ {loadingWavelet ? 'Calcul en cours…' : "Cliquez sur \"Lancer l'analyse\" pour décomposer le prix en bandes de fréquence"} +
+ )} +
+ )} + = { + energy: 'Énergie', metals: 'Métaux', agriculture: 'Agriculture', + indices: 'Indices', etfs: 'ETFs', equities: 'Actions', forex: 'Forex', +} +const WAVELET_FAMILIES = ['gmw', 'morlet', 'bump'] as const +const ALL_MODES: WaveletPositionMode[] = ['long_only', 'short_only', 'both'] +const MODE_LABELS: Record = { long_only: 'Long', short_only: 'Short', both: 'Les deux' } +const ALL_BANDS = [0, 1, 2, 3, 4, 5] + +function ChipInput({ values, onChange, placeholder, min = 0 }: { values: number[]; onChange: (v: number[]) => void; placeholder: string; min?: number }) { + const [input, setInput] = useState('') + const add = () => { + const n = Number(input) + if (!isNaN(n) && n >= min && !values.includes(n)) onChange([...values, n].sort((a, b) => a - b)) + setInput('') + } + return ( +
+ {values.map(v => ( + + {v} + + + ))} + setInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && add()} + placeholder={placeholder} + className="w-16 bg-dark-900 border border-slate-700/40 rounded px-1.5 py-0.5 text-[10px] text-white" + /> +
+ ) +} + +export default function WaveletsSimulation() { + const qc = useQueryClient() + const [subTab, setSubTab] = useState<'optimisation' | 'summary'>('optimisation') + + const { data: watchlistUniverse } = useQuery({ + queryKey: ['wavelet-sim-instruments'], + queryFn: () => api.get('/market/watchlist').then(r => r.data as Record), + staleTime: 60 * 60_000, + }) + + // ── Form state ────────────────────────────────────────────────────────────── + const [selectedInstruments, setSelectedInstruments] = useState>(new Set()) + const [period, setPeriod] = useState('2y') + const [levels, setLevels] = useState(4) + const [lookbacks, setLookbacks] = useState([90, 130]) + const [wavelets, setWavelets] = useState>(new Set(['gmw'])) + const [method, setMethod] = useState<'cwt' | 'ssq'>('cwt') + const [bands, setBands] = useState>(new Set([0, 1, 2, 3])) + const [kinds, setKinds] = useState>(new Set(['extremum', 'level_threshold'])) + const [modes, setModes] = useState>(new Set(['both'])) + const [takeProfits, setTakeProfits] = useState([0]) + const [stopLosses, setStopLosses] = useState([0]) + const [simName, setSimName] = useState(() => `Run ${new Date().toISOString().slice(0, 16).replace('T', ' ')}`) + + // ── Run state ──────────────────────────────────────────────────────────────── + const [running, setRunning] = useState(false) + const [progress, setProgress] = useState<{ done: number; total: number; label: string } | null>(null) + const [failures, setFailures] = useState([]) + const [results, setResults] = useState([]) + const [activeSimId, setActiveSimId] = useState(null) + + // ── Results table filters ─────────────────────────────────────────────────── + const [minTrades, setMinTrades] = useState(1) + const [sortKey, setSortKey] = useState<'totalReturnPct' | 'winRate' | 'tradeCount'>('totalReturnPct') + const [expandedRow, setExpandedRow] = useState(null) + + const { data: savedSims } = useQuery({ + queryKey: ['wavelet-simulations'], + queryFn: () => api.get('/wavelet/simulations').then(r => r.data as any[]), + }) + + const toggleSetValue = (set: Set, v: T, setter: (s: Set) => void) => { + const next = new Set(set) + if (next.has(v)) next.delete(v); else next.add(v) + setter(next) + } + + const runOptimization = async () => { + if (selectedInstruments.size === 0 || lookbacks.length === 0 || wavelets.size === 0 || kinds.size === 0) return + setRunning(true) + setFailures([]) + setResults([]) + setExpandedRow(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, + } + const created = await api.post('/wavelet/simulations', { name: simName, form, results: [] }).then(r => r.data) + setActiveSimId(created.id) + + const exitVariants: WaveletExitVariant[] = [] + for (const tp of takeProfits) for (const sl of stopLosses) { + exitVariants.push({ exitStyle: tp === 0 && sl === 0 ? 'signal' : 'signal_or_pct', takeProfitPct: tp || null, stopLossPct: sl || null }) + } + + const allResults: WaveletOptimizationResult[] = [] + await runFullOptimization( + [...selectedInstruments], lookbacks, [...wavelets], period, levels, + [...bands], [...kinds], [...modes], exitVariants, + (done, total, label, fails) => { setProgress({ done, total, label }); setFailures([...fails]) }, + async (batch) => { + allResults.push(...batch) + setResults([...allResults]) + try { await api.patch(`/wavelet/simulations/${created.id}`, { append_results: batch }) } catch { /* non-blocking */ } + }, + method, + ) + setRunning(false) + qc.invalidateQueries({ queryKey: ['wavelet-simulations'] }) + } + + const loadSimulation = async (id: string) => { + const sim = await api.get(`/wavelet/simulations/${id}`).then(r => r.data) + setResults(sim.results ?? []) + setActiveSimId(id) + setSimName(sim.name) + } + + const deleteSimulation = async (id: string) => { + await api.delete(`/wavelet/simulations/${id}`) + if (activeSimId === id) { setActiveSimId(null); setResults([]) } + qc.invalidateQueries({ queryKey: ['wavelet-simulations'] }) + } + + const filteredResults = useMemo(() => { + return [...results] + .filter(r => r.tradeCount >= minTrades) + .sort((a, b) => (b[sortKey] as number) - (a[sortKey] as number)) + }, [results, minTrades, sortKey]) + + const perSymbolBreakdown = (r: WaveletOptimizationResult) => + results.filter(x => x.symbol === r.symbol && x.lookback === r.lookback && x.wavelet === r.wavelet + && x.triggerKind === r.triggerKind && x.bandIndex === r.bandIndex && x.mode === r.mode + && x.exitStyle === r.exitStyle && x.takeProfitPct === r.takeProfitPct && x.stopLossPct === r.stopLossPct) + + const configSummary = useMemo(() => { + const symbols = new Set(results.map(r => r.symbol)) + const totalReturn = results.reduce((s, r) => s + r.totalReturnPct, 0) + const avgWinRate = results.length ? results.reduce((s, r) => s + r.winRate, 0) / results.length : 0 + return { configs: results.length, symbols: symbols.size, totalReturn, avgWinRate } + }, [results]) + + return ( +
+
+
+

+ Wavelets Simulation +

+

+ Décomposition en bandes de fréquence (CWT/SSQ) + backtest multi-instruments — portage de InstrumentSimulator +

+
+
+ +
+ {(['optimisation', 'summary'] as const).map(k => ( + + ))} +
+ + {subTab === 'optimisation' && ( +
+ {/* ── Config form ── */} +
+
+
Instruments
+
+ {Object.entries(watchlistUniverse ?? {}).map(([cls, items]) => ( +
+
{ASSET_CLASS_LABELS[cls] ?? cls}
+
+ {items.map(it => ( + + ))} +
+
+ ))} +
+
{selectedInstruments.size} instrument(s) sélectionné(s)
+
+ +
+
+
Période
+ +
+
+
Niveaux
+ setLevels(Math.max(2, Math.min(6, Number(e.target.value) || 4)))} + className="bg-dark-900 border border-slate-700/40 rounded px-2 py-1 text-white w-full" /> +
+
+
Méthode
+
+ {(['cwt', 'ssq'] as const).map(m => ( + + ))} +
+
+
+
Nom de la simulation
+ setSimName(e.target.value)} + className="bg-dark-900 border border-slate-700/40 rounded px-2 py-1 text-white w-full" /> +
+
+ +
+
+
Fenêtres glissantes à tester (jours, ≥32)
+ +
+
+
Ondelettes
+
+ {WAVELET_FAMILIES.map(w => ( + + ))} +
+
+
+ +
+
+
Bandes à tester
+
+ {ALL_BANDS.slice(0, levels).map(b => ( + + ))} +
+
+
+
Modes à tester
+
+ {ALL_MODES.map(m => ( + + ))} +
+
+
+
Take-profit / Stop-loss % (0 = aucun)
+
+ + +
+
+
+ +
+
Types de déclencheur
+
+ {WAVELET_TRIGGER_KINDS.filter(k => method === 'ssq' || (k.value !== 'ridge_shift' && k.value !== 'energy_threshold')).map(k => ( + + ))} +
+
+ + + + {progress && ( +
+
+ {progress.label} + {progress.done}/{progress.total} +
+
+
+
+ {failures.length > 0 && ( +
{failures.length} échec(s) : {failures.slice(0, 3).join(' · ')}
+ )} +
+ )} +
+ + {/* ── Saved simulations ── */} +
+
Simulations sauvegardées
+ {(savedSims ?? []).length === 0 ? ( +
Aucune simulation sauvegardée.
+ ) : ( +
+ {savedSims!.map(s => ( +
+
+ {s.name} + {s.result_count} résultats · {s.updated_at?.slice(0, 16)} +
+
+ + +
+
+ ))} +
+ )} +
+ + {/* ── Results ── */} + {results.length > 0 && ( +
+
+ {configSummary.configs} configs testées + {configSummary.symbols} instrument(s) + Retour cumulé: = 0 ? 'text-emerald-400' : 'text-red-400'}>{configSummary.totalReturn.toFixed(1)}% + Win rate moy.: {(configSummary.avgWinRate * 100).toFixed(0)}% +
+ + +
+
+ +
+ + + + + + + + + + + + + + + + {filteredResults.slice(0, 100).map((r, i) => ( + + setExpandedRow(expandedRow === i ? null : i)} + className="border-b border-slate-800/40 hover:bg-dark-800/40 cursor-pointer"> + + + + + + + + + + + {expandedRow === i && ( + + + + )} + + ))} + +
SymboleLookbackOndeletteBandeTriggerModeRetourWin rateTrades
{r.symbol}{r.lookback}j{r.wavelet}{formatBandIndex(r.bandIndex)}{r.triggerKind}{MODE_LABELS[r.mode]}= 0 ? 'text-emerald-400' : 'text-red-400')}> + {r.totalReturnPct >= 0 ? '+' : ''}{r.totalReturnPct.toFixed(1)}% + {(r.winRate * 100).toFixed(0)}%{r.tradeCount}
+
Détail par instrument (même config)
+
+ {perSymbolBreakdown(r).map((d, j) => ( +
+ {d.symbol} + = 0 ? 'text-emerald-400' : 'text-red-400')}> + {d.totalReturnPct >= 0 ? '+' : ''}{d.totalReturnPct.toFixed(1)}% + + {d.tradeCount}tr +
+ ))} +
+
+
+
+ )} +
+ )} + + {subTab === 'summary' && } +
+ ) +} + +function WaveletSummaryTab({ savedSims }: { savedSims: any[] }) { + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [aggregate, setAggregate] = useState<{ configs: number; avgReturn: number; avgWinRate: number; avgTrades: number } | null>(null) + const [loading, setLoading] = useState(false) + + const toggle = (id: string) => { + const next = new Set(selectedIds) + if (next.has(id)) next.delete(id); else next.add(id) + setSelectedIds(next) + } + + const computeAggregate = async () => { + if (selectedIds.size === 0) return + setLoading(true) + try { + const all: WaveletOptimizationResult[] = [] + for (const id of selectedIds) { + const sim = await api.get(`/wavelet/simulations/${id}`).then(r => r.data) + all.push(...(sim.results ?? [])) + } + if (all.length === 0) { setAggregate(null); return } + setAggregate({ + configs: all.length, + avgReturn: all.reduce((s, r) => s + r.totalReturnPct, 0) / all.length, + avgWinRate: all.reduce((s, r) => s + r.winRate, 0) / all.length, + avgTrades: all.reduce((s, r) => s + r.tradeCount, 0) / all.length, + }) + } finally { + setLoading(false) + } + } + + return ( +
+
Sélectionner des runs à agréger
+ {savedSims.length === 0 ? ( +
Aucune simulation sauvegardée.
+ ) : ( +
+ {savedSims.map(s => ( + + ))} +
+ )} + + {aggregate && ( +
+
Configs
{aggregate.configs}
+
Retour moy.
= 0 ? 'text-emerald-400' : 'text-red-400')}>{aggregate.avgReturn.toFixed(2)}%
+
Win rate moy.
{(aggregate.avgWinRate * 100).toFixed(1)}%
+
Trades moy.
{aggregate.avgTrades.toFixed(1)}
+
+ )} +
+ ) +}