feat: wavelets
This commit is contained in:
@@ -18,6 +18,7 @@ from routers import simulator as simulator_router
|
|||||||
from routers import causal_lab as causal_lab_router
|
from routers import causal_lab as causal_lab_router
|
||||||
from routers import instrument_models as instrument_models_router
|
from routers import instrument_models as instrument_models_router
|
||||||
from routers import instruments_watchlist as instruments_watchlist_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
|
from services.database import init_db, get_config, cleanup_stale_running_cycles
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
@@ -235,6 +236,7 @@ app.include_router(simulator_router.router)
|
|||||||
app.include_router(causal_lab_router.router)
|
app.include_router(causal_lab_router.router)
|
||||||
app.include_router(instrument_models_router.router)
|
app.include_router(instrument_models_router.router)
|
||||||
app.include_router(instruments_watchlist_router.router)
|
app.include_router(instruments_watchlist_router.router)
|
||||||
|
app.include_router(wavelet_router.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@@ -19,3 +19,4 @@ apscheduler==3.10.4
|
|||||||
pytz==2024.2
|
pytz==2024.2
|
||||||
ta==0.11.0
|
ta==0.11.0
|
||||||
lxml>=5.0.0
|
lxml>=5.0.0
|
||||||
|
ssqueezepy==0.6.6
|
||||||
|
|||||||
176
backend/routers/wavelet.py
Normal file
176
backend/routers/wavelet.py
Normal file
@@ -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()}
|
||||||
@@ -1016,6 +1016,17 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
|
|||||||
except Exception as _ce:
|
except Exception as _ce:
|
||||||
logger.warning(f"[Cycle] Régime clustering failed (non-blocking): {_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 ──────────────────────────────────
|
# ── Step 6: GPT-4o cycle commentary ──────────────────────────────────
|
||||||
logger.info(f"[Cycle {run_id[:16]}] Step 6: generating commentary")
|
logger.info(f"[Cycle {run_id[:16]}] Step 6: generating commentary")
|
||||||
commentary = _generate_cycle_commentary(
|
commentary = _generate_cycle_commentary(
|
||||||
@@ -1046,6 +1057,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
|
|||||||
dominant_regime=dominant,
|
dominant_regime=dominant,
|
||||||
commentary=commentary,
|
commentary=commentary,
|
||||||
status="completed",
|
status="completed",
|
||||||
|
wavelet_signals_count=summary.get("wavelet_signals_count", 0),
|
||||||
)
|
)
|
||||||
_current_status["last_run_at"] = datetime.utcnow().isoformat()
|
_current_status["last_run_at"] = datetime.utcnow().isoformat()
|
||||||
logger.info(f"[Cycle {run_id[:16]}] Completed — {added_count} new patterns, {len(scored)} scored")
|
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"),
|
portfolio_monitor=summary.get("portfolio_monitor"),
|
||||||
commentary=commentary,
|
commentary=commentary,
|
||||||
options_assessment=_options_assessment,
|
options_assessment=_options_assessment,
|
||||||
|
wavelet_signals=_wavelet_results,
|
||||||
)
|
)
|
||||||
if _cycle_report:
|
if _cycle_report:
|
||||||
from services.database import save_cycle_report
|
from services.database import save_cycle_report
|
||||||
@@ -1352,6 +1365,7 @@ def _generate_cycle_report(
|
|||||||
portfolio_monitor: Optional[Dict],
|
portfolio_monitor: Optional[Dict],
|
||||||
commentary: Optional[str],
|
commentary: Optional[str],
|
||||||
options_assessment: Optional[Dict] = None,
|
options_assessment: Optional[Dict] = None,
|
||||||
|
wavelet_signals: Optional[List[Dict]] = None,
|
||||||
) -> Optional[Dict]:
|
) -> Optional[Dict]:
|
||||||
"""
|
"""
|
||||||
Build the full cycle report dict:
|
Build the full cycle report dict:
|
||||||
@@ -1599,6 +1613,8 @@ Réponds en JSON avec ce schéma EXACT:
|
|||||||
# Risk
|
# Risk
|
||||||
"portfolio_monitor": portfolio_monitor,
|
"portfolio_monitor": portfolio_monitor,
|
||||||
"risk_alerts": portfolio_monitor.get("alerts") if portfolio_monitor else None,
|
"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 patterns
|
||||||
"top_scored": [
|
"top_scored": [
|
||||||
{"name": s.get("geo_trigger"), "score": s.get("score"),
|
{"name": s.get("geo_trigger"), "score": s.get("score"),
|
||||||
|
|||||||
@@ -110,12 +110,42 @@ def init_db():
|
|||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
added_at TEXT DEFAULT (datetime('now'))
|
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:
|
try:
|
||||||
c.execute(_sql)
|
c.execute(_sql)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
# Specialist Reports — surprise index + text sentiment columns
|
||||||
try:
|
try:
|
||||||
c.execute("ALTER TABLE specialist_reports ADD COLUMN consensus_estimate REAL")
|
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:
|
if not fields:
|
||||||
return
|
return
|
||||||
allowed = {"completed_at", "patterns_suggested", "patterns_added", "patterns_scored",
|
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)
|
sets = ", ".join(f"{k}=?" for k in fields if k in allowed)
|
||||||
vals = [v for k, v in fields.items() if k in allowed]
|
vals = [v for k, v in fields.items() if k in allowed]
|
||||||
if not sets:
|
if not sets:
|
||||||
@@ -3056,6 +3086,133 @@ def reorder_instruments_watchlist(tickers: List[str]) -> None:
|
|||||||
conn.close()
|
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 ───────────────────────────────────────────────────────────────
|
# ── System Logs ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def log_system_event(
|
def log_system_event(
|
||||||
|
|||||||
@@ -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]:
|
def compute_geo_risk_score(events: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
"""Compute a global geopolitical risk score 0-100 from recent events."""
|
"""Compute a global geopolitical risk score 0-100 from recent events."""
|
||||||
if not 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
|
category_scores.get(cat, 0) * weight
|
||||||
for cat, weight in GEOPOLITICAL_RISK_WEIGHTS.items()
|
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:
|
if score < 25:
|
||||||
level = "low"
|
level = "low"
|
||||||
|
|||||||
447
backend/services/wavelet_engine.py
Normal file
447
backend/services/wavelet_engine.py
Normal file
@@ -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,
|
||||||
|
}
|
||||||
137
backend/services/wavelet_signals.py
Normal file
137
backend/services/wavelet_signals.py
Normal file
@@ -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
|
||||||
@@ -9,6 +9,7 @@ import GeoRadar from './pages/GeoRadar'
|
|||||||
import Markets from './pages/Markets'
|
import Markets from './pages/Markets'
|
||||||
import MacroRegime from './pages/MacroRegime'
|
import MacroRegime from './pages/MacroRegime'
|
||||||
import OptionsLab from './pages/OptionsLab'
|
import OptionsLab from './pages/OptionsLab'
|
||||||
|
import WaveletsSimulation from './pages/WaveletsSimulation'
|
||||||
import Backtest from './pages/Backtest'
|
import Backtest from './pages/Backtest'
|
||||||
import CalendarPage from './pages/CalendarPage'
|
import CalendarPage from './pages/CalendarPage'
|
||||||
import Portfolio from './pages/Portfolio'
|
import Portfolio from './pages/Portfolio'
|
||||||
@@ -48,6 +49,7 @@ const KEEP_ALIVE_DEFS: { path: string; component: ComponentType }[] = [
|
|||||||
{ path: '/markets', component: Markets },
|
{ path: '/markets', component: Markets },
|
||||||
{ path: '/macro', component: MacroRegime },
|
{ path: '/macro', component: MacroRegime },
|
||||||
{ path: '/options', component: OptionsLab },
|
{ path: '/options', component: OptionsLab },
|
||||||
|
{ path: '/wavelets-simulation', component: WaveletsSimulation },
|
||||||
{ path: '/patterns', component: PatternExplorer },
|
{ path: '/patterns', component: PatternExplorer },
|
||||||
{ path: '/pattern-lab', component: PatternLab },
|
{ path: '/pattern-lab', component: PatternLab },
|
||||||
{ path: '/portfolio', component: Portfolio },
|
{ path: '/portfolio', component: Portfolio },
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { ArrowUpRight } from 'lucide-react'
|
import { ArrowUpRight } from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
export default function TradeRankList({ trades, mode, title, linkTo }: {
|
export default function TradeRankList({ trades, mode, title, linkTo, toggle }: {
|
||||||
trades: any[]
|
trades: any[]
|
||||||
mode: 'top' | 'worst'
|
mode: 'top' | 'worst'
|
||||||
title: string
|
title: string
|
||||||
linkTo: string
|
linkTo: string
|
||||||
|
toggle?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const sorted = [...trades]
|
const sorted = [...trades]
|
||||||
.sort((a, b) => mode === 'top'
|
.sort((a, b) => mode === 'top'
|
||||||
@@ -18,7 +20,10 @@ export default function TradeRankList({ trades, mode, title, linkTo }: {
|
|||||||
<Link to={linkTo} className="card block hover:border-slate-600/60 transition-all cursor-pointer">
|
<Link to={linkTo} className="card block hover:border-slate-600/60 transition-all cursor-pointer">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="section-title mb-0 text-[10px]">{title}</span>
|
<span className="section-title mb-0 text-[10px]">{title}</span>
|
||||||
<ArrowUpRight className="w-3 h-3 text-slate-600" />
|
<div className="flex items-center gap-1.5">
|
||||||
|
{toggle}
|
||||||
|
<ArrowUpRight className="w-3 h-3 text-slate-600" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{sorted.length > 0 ? (
|
{sorted.length > 0 ? (
|
||||||
<div className="space-y-1.5 mt-1">
|
<div className="space-y-1.5 mt-1">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Globe, BarChart2, FlaskConical,
|
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'
|
} from 'lucide-react'
|
||||||
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
|
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
@@ -12,6 +12,7 @@ const nav = [
|
|||||||
{ to: '/markets', icon: BarChart2, label: 'Markets & Prices' },
|
{ to: '/markets', icon: BarChart2, label: 'Markets & Prices' },
|
||||||
{ to: '/macro', icon: Activity, label: 'Macro Regime' },
|
{ to: '/macro', icon: Activity, label: 'Macro Regime' },
|
||||||
{ to: '/options', icon: TrendingUp, label: 'Options Lab' },
|
{ to: '/options', icon: TrendingUp, label: 'Options Lab' },
|
||||||
|
{ to: '/wavelets-simulation', icon: Waves, label: 'Wavelets Simulation' },
|
||||||
{ to: '/patterns', icon: Zap, label: 'Patterns' },
|
{ to: '/patterns', icon: Zap, label: 'Patterns' },
|
||||||
{ to: '/pattern-lab', icon: FlaskConical, label: 'Pattern Lab' },
|
{ to: '/pattern-lab', icon: FlaskConical, label: 'Pattern Lab' },
|
||||||
{ to: '/portfolio', icon: DollarSign, label: 'Portfolio' },
|
{ to: '/portfolio', icon: DollarSign, label: 'Portfolio' },
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
LayoutDashboard, Globe, BarChart2, Activity, TrendingUp, Zap, FlaskConical,
|
LayoutDashboard, Globe, BarChart2, Activity, TrendingUp, Zap, FlaskConical,
|
||||||
DollarSign, BookOpen, FileBarChart, Brain, Microscope, ShieldAlert, Gauge,
|
DollarSign, BookOpen, FileBarChart, Brain, Microscope, ShieldAlert, Gauge,
|
||||||
GitCompare, History, Calendar, Sliders, TrendingUp as MacroSeriesIcon,
|
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'
|
} from 'lucide-react'
|
||||||
import type { LucideIcon } 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: '/markets', label: 'Markets', icon: BarChart2 },
|
||||||
{ path: '/macro', label: 'Macro Regime', icon: Activity },
|
{ path: '/macro', label: 'Macro Regime', icon: Activity },
|
||||||
{ path: '/options', label: 'Options Lab', icon: TrendingUp },
|
{ path: '/options', label: 'Options Lab', icon: TrendingUp },
|
||||||
|
{ path: '/wavelets-simulation', label: 'Wavelets Sim.', icon: Waves },
|
||||||
{ path: '/patterns', label: 'Patterns', icon: Zap },
|
{ path: '/patterns', label: 'Patterns', icon: Zap },
|
||||||
{ path: '/pattern-lab', label: 'Pattern Lab', icon: FlaskConical },
|
{ path: '/pattern-lab', label: 'Pattern Lab', icon: FlaskConical },
|
||||||
{ path: '/portfolio', label: 'Portfolio', icon: DollarSign },
|
{ path: '/portfolio', label: 'Portfolio', icon: DollarSign },
|
||||||
|
|||||||
@@ -1027,6 +1027,14 @@ export const useLogSources = () =>
|
|||||||
staleTime: 60_000,
|
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 = () =>
|
export const useLogCycles = () =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: ['log-cycles'],
|
queryKey: ['log-cycles'],
|
||||||
|
|||||||
464
frontend/src/lib/waveletTrade.ts
Normal file
464
frontend/src/lib/waveletTrade.ts
Normal file
@@ -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<void> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -6,8 +6,9 @@ import {
|
|||||||
useTradeMtm, useRiskDashboard, useGeoNews,
|
useTradeMtm, useRiskDashboard, useGeoNews,
|
||||||
useSimPortfolioRisk, useCycleStatus, useClosedTrades,
|
useSimPortfolioRisk, useCycleStatus, useClosedTrades,
|
||||||
useInstrumentsWatchlist, useInstrumentsWatchlistQuotes, useIvBatch, useLatestCycleReport,
|
useInstrumentsWatchlist, useInstrumentsWatchlistQuotes, useIvBatch, useLatestCycleReport,
|
||||||
|
useWaveletWatchlistSignals,
|
||||||
} from '../hooks/useApi'
|
} 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 { Link } from 'react-router-dom'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import type { Quote } from '../types'
|
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 (
|
||||||
|
<div className="flex gap-0.5 bg-dark-800 rounded p-0.5 text-[9px]">
|
||||||
|
<button onClick={click('top')}
|
||||||
|
className={clsx('px-1.5 py-0.5 rounded transition-colors',
|
||||||
|
value === 'top' ? 'bg-blue-600 text-white' : 'text-slate-600 hover:text-slate-400')}>
|
||||||
|
Best
|
||||||
|
</button>
|
||||||
|
<button onClick={click('worst')}
|
||||||
|
className={clsx('px-1.5 py-0.5 rounded transition-colors',
|
||||||
|
value === 'worst' ? 'bg-blue-600 text-white' : 'text-slate-600 hover:text-slate-400')}>
|
||||||
|
Worst
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function QuoteRow({ q }: { q: Quote }) {
|
function QuoteRow({ q }: { q: Quote }) {
|
||||||
if (!q.price) return null
|
if (!q.price) return null
|
||||||
const pos = q.change_pct >= 0
|
const pos = q.change_pct >= 0
|
||||||
@@ -113,6 +134,7 @@ export default function Dashboard() {
|
|||||||
const { data: watchlistItems } = useInstrumentsWatchlist()
|
const { data: watchlistItems } = useInstrumentsWatchlist()
|
||||||
const { data: watchlistQuotesData } = useInstrumentsWatchlistQuotes()
|
const { data: watchlistQuotesData } = useInstrumentsWatchlistQuotes()
|
||||||
const { data: latestCycleReportData } = useLatestCycleReport()
|
const { data: latestCycleReportData } = useLatestCycleReport()
|
||||||
|
const { data: waveletSignalsData } = useWaveletWatchlistSignals()
|
||||||
const watchlistTickers: string[] = (watchlistItems ?? []).map((w: any) => w.ticker)
|
const watchlistTickers: string[] = (watchlistItems ?? []).map((w: any) => w.ticker)
|
||||||
const { data: ivBatchData } = useIvBatch(watchlistTickers.join(','))
|
const { data: ivBatchData } = useIvBatch(watchlistTickers.join(','))
|
||||||
|
|
||||||
@@ -144,6 +166,13 @@ export default function Dashboard() {
|
|||||||
setRiskView(v); localStorage.setItem('dash_risk_view', v)
|
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,
|
// 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.
|
// Options Lab for row 2) — the other cards in each row are capped to match, with internal scroll.
|
||||||
const watchlistCardRef = useRef<HTMLDivElement>(null)
|
const watchlistCardRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -925,8 +954,39 @@ export default function Dashboard() {
|
|||||||
{/* ── Command Center: Intelligence & Contexte ── */}
|
{/* ── Command Center: Intelligence & Contexte ── */}
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
|
||||||
<TradeRankList trades={mtmTrades} mode="top" title="🏆 Top Trades" linkTo="/journal" />
|
<TradeRankList trades={mtmTrades} mode={tradesView} title="🏆 Trades Overview" linkTo="/journal"
|
||||||
<TradeRankList trades={mtmTrades} mode="worst" title="📉 Worst Trades" linkTo="/journal" />
|
toggle={<TradesViewToggle value={tradesView} onChange={setTradesViewPersist} />} />
|
||||||
|
|
||||||
|
{/* Wavelets Signal — latest per-ticker signal from the watchlist scan (computed each cycle) */}
|
||||||
|
{(() => {
|
||||||
|
const signals: any[] = waveletSignalsData?.signals ?? []
|
||||||
|
return (
|
||||||
|
<Link to="/wavelets-simulation" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="section-title mb-0 text-[10px] flex items-center gap-1">
|
||||||
|
<Waves className="w-2.5 h-2.5" /> Wavelets Signal
|
||||||
|
</span>
|
||||||
|
<ArrowUpRight className="w-3 h-3 text-slate-600" />
|
||||||
|
</div>
|
||||||
|
{signals.length > 0 ? (
|
||||||
|
<div className="space-y-1.5 mt-1">
|
||||||
|
{signals.slice(0, 5).map((s: any, i: number) => (
|
||||||
|
<div key={i} className="flex items-center gap-1.5 text-[10px]">
|
||||||
|
<span className={clsx('shrink-0', s.direction === 'up' ? 'text-emerald-400' : 'text-red-400')}>
|
||||||
|
{s.direction === 'up' ? '↑' : '↓'}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-slate-200 font-bold shrink-0">{s.ticker}</span>
|
||||||
|
<span className="text-slate-500 truncate flex-1 text-[9px]">{s.band_label} · {s.signal_kind}</span>
|
||||||
|
{s.computed_at && <span className="text-[8px] text-slate-700 shrink-0 font-mono">{s.computed_at.slice(0, 10)}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[10px] text-slate-600 mt-1">No wavelet signal detected — le prochain cycle en calculera</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Recommandations du jour — patterns from the most recent cycle only */}
|
{/* Recommandations du jour — patterns from the most recent cycle only */}
|
||||||
<Link to="/patterns" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
|
<Link to="/patterns" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||||
import InstrumentChart, { TheoPoint } from '../components/InstrumentChart'
|
import InstrumentChart, { TheoPoint } from '../components/InstrumentChart'
|
||||||
|
|
||||||
const api = axios.create({ baseURL: '/api' })
|
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
|
// Stable empty array — prevents InstrumentChart useEffect from re-running on every render
|
||||||
const NO_CHART_EVENTS: never[] = []
|
const NO_CHART_EVENTS: never[] = []
|
||||||
|
|
||||||
@@ -1337,7 +1340,15 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
const [loadingNarr, setLoadingNarr] = useState(false)
|
const [loadingNarr, setLoadingNarr] = useState(false)
|
||||||
const [selectorOpen, setSelectorOpen] = useState(false)
|
const [selectorOpen, setSelectorOpen] = useState(false)
|
||||||
const [selectedDate, setSelectedDate] = useState<string | null>(null)
|
const [selectedDate, setSelectedDate] = useState<string | null>(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<any | null>(null)
|
||||||
|
const [loadingWavelet, setLoadingWavelet] = useState(false)
|
||||||
|
const [hiddenBands, setHiddenBands] = useState<Set<string>>(new Set())
|
||||||
const [templates, setTemplates] = useState<CausalTemplate[]>([])
|
const [templates, setTemplates] = useState<CausalTemplate[]>([])
|
||||||
const [causalScores, setCausalScores] = useState<Record<number, number | null>>({}) // eventId → activation_score*100
|
const [causalScores, setCausalScores] = useState<Record<number, number | null>>({}) // eventId → activation_score*100
|
||||||
const [macroAtDate, setMacroAtDate] = useState<MacroGaugeSnap | null>(null)
|
const [macroAtDate, setMacroAtDate] = useState<MacroGaugeSnap | null>(null)
|
||||||
@@ -1532,6 +1543,54 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
const dateLabel = effectiveDate ? fmtDateFR(effectiveDate) : '—'
|
const dateLabel = effectiveDate ? fmtDateFR(effectiveDate) : '—'
|
||||||
const displayPrice = dateTrend?.current_price ?? snapshot?.current_price
|
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 (
|
return (
|
||||||
<div className="p-6 max-w-screen-xl mx-auto space-y-4">
|
<div className="p-6 max-w-screen-xl mx-auto space-y-4">
|
||||||
|
|
||||||
@@ -1666,6 +1725,7 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
{([
|
{([
|
||||||
{ key: 'counters', label: 'Compteurs' },
|
{ key: 'counters', label: 'Compteurs' },
|
||||||
{ key: 'analyse', label: 'Analyse de la courbe' },
|
{ key: 'analyse', label: 'Analyse de la courbe' },
|
||||||
|
{ key: 'wavelets', label: 'Ondelettes' },
|
||||||
] as const).map(t => (
|
] as const).map(t => (
|
||||||
<button
|
<button
|
||||||
key={t.key}
|
key={t.key}
|
||||||
@@ -1799,6 +1859,96 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{tabUnder === 'wavelets' && (
|
||||||
|
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||||
|
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Décomposition Ondelette</span>
|
||||||
|
<div className="ml-auto flex items-center gap-2 flex-wrap text-xs">
|
||||||
|
<label className="flex items-center gap-1 text-slate-400">
|
||||||
|
Niveaux
|
||||||
|
<input type="number" min={2} max={6} value={waveletLevels}
|
||||||
|
onChange={e => setWaveletLevels(Math.max(2, Math.min(6, Number(e.target.value) || 4)))}
|
||||||
|
className="w-12 bg-dark-900 border border-slate-700/40 rounded px-1.5 py-0.5 text-white" />
|
||||||
|
</label>
|
||||||
|
<select value={waveletFamily} onChange={e => setWaveletFamily(e.target.value as any)}
|
||||||
|
className="bg-dark-900 border border-slate-700/40 rounded px-1.5 py-0.5 text-slate-300">
|
||||||
|
<option value="gmw">gmw</option>
|
||||||
|
<option value="morlet">morlet</option>
|
||||||
|
<option value="bump">bump</option>
|
||||||
|
</select>
|
||||||
|
<select value={waveletMethod} onChange={e => setWaveletMethod(e.target.value as any)}
|
||||||
|
className="bg-dark-900 border border-slate-700/40 rounded px-1.5 py-0.5 text-slate-300">
|
||||||
|
<option value="cwt">CWT</option>
|
||||||
|
<option value="ssq">SSQ</option>
|
||||||
|
</select>
|
||||||
|
<label className="flex items-center gap-1 text-slate-400">
|
||||||
|
<input type="checkbox" checked={waveletCausal} onChange={e => setWaveletCausal(e.target.checked)} />
|
||||||
|
Mode causal
|
||||||
|
</label>
|
||||||
|
{waveletCausal && (
|
||||||
|
<label className="flex items-center gap-1 text-slate-400">
|
||||||
|
Lookback
|
||||||
|
<input type="number" min={32} value={waveletLookback}
|
||||||
|
onChange={e => setWaveletLookback(Math.max(32, Number(e.target.value) || 130))}
|
||||||
|
className="w-14 bg-dark-900 border border-slate-700/40 rounded px-1.5 py-0.5 text-white" />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<button onClick={runWaveletAnalysis} disabled={loadingWavelet}
|
||||||
|
className="px-2.5 py-1 rounded text-xs font-medium border border-blue-600/40 bg-blue-700/30 text-blue-200 hover:bg-blue-700/50 transition-colors disabled:opacity-50">
|
||||||
|
{loadingWavelet ? '↻ Calcul…' : "▶ Lancer l'analyse"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{waveletData ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-3 mb-2 text-[10px]">
|
||||||
|
{waveletData.bands.map((b: any, i: number) => (
|
||||||
|
<label key={b.label} className="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={!hiddenBands.has(b.label)}
|
||||||
|
onChange={() => setHiddenBands(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(b.label)) next.delete(b.label); else next.add(b.label)
|
||||||
|
return next
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<span style={{ color: WAVELET_BAND_COLORS[i % WAVELET_BAND_COLORS.length] }}>{b.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<LineChart data={waveletChartData} margin={{ top: 4, right: 8, left: -14, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 9, fill: '#64748b' }}
|
||||||
|
interval={Math.max(0, Math.floor(waveletChartData.length / 8))} />
|
||||||
|
<YAxis tick={{ fontSize: 9, fill: '#64748b' }} domain={['auto', 'auto']} />
|
||||||
|
<Tooltip contentStyle={{ background: '#0f172a', border: '1px solid #334155', borderRadius: 6, fontSize: 10 }} />
|
||||||
|
<Line type="monotone" dataKey="original" stroke="#94a3b8" strokeWidth={1.5} dot={false} name="Prix" isAnimationActive={false} />
|
||||||
|
<Line type="monotone" dataKey="reconstruction" stroke="#22c55e" strokeWidth={1} strokeDasharray="4 2" dot={false} name="Reconstruction" isAnimationActive={false} />
|
||||||
|
{waveletData.bands.map((b: any, i: number) => !hiddenBands.has(b.label) && (
|
||||||
|
<Line key={b.label} type="monotone" dataKey={b.label}
|
||||||
|
stroke={WAVELET_BAND_COLORS[i % WAVELET_BAND_COLORS.length]}
|
||||||
|
strokeWidth={1} dot={false} name={b.label} isAnimationActive={false} />
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
{waveletStats && (
|
||||||
|
<div className="mt-2 flex items-center gap-4 flex-wrap text-[10px] text-slate-500">
|
||||||
|
<span>Corrélation reconstruction: <span className="text-slate-300 font-mono">{waveletStats.correlation != null ? waveletStats.correlation.toFixed(3) : '—'}</span></span>
|
||||||
|
<span>Erreur moy.: <span className="text-slate-300 font-mono">{waveletStats.meanAbsError.toFixed(3)}</span></span>
|
||||||
|
<span>Erreur max: <span className="text-slate-300 font-mono">{waveletStats.maxAbsError.toFixed(3)}</span></span>
|
||||||
|
{waveletCausal && <span className="ml-auto text-blue-400/70">Mode causal (walk-forward) — {waveletData.recomputations} recalculs</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-slate-500 text-center py-10">
|
||||||
|
{loadingWavelet ? 'Calcul en cours…' : "Cliquez sur \"Lancer l'analyse\" pour décomposer le prix en bandes de fréquence"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<NarrativeCard
|
<NarrativeCard
|
||||||
narrative={narrative}
|
narrative={narrative}
|
||||||
loading={loadingNarr}
|
loading={loadingNarr}
|
||||||
|
|||||||
494
frontend/src/pages/WaveletsSimulation.tsx
Normal file
494
frontend/src/pages/WaveletsSimulation.tsx
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
import { Fragment, useMemo, useState } from 'react'
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Waves, Play, Save, Trash2, X } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { api } from '../hooks/useApi'
|
||||||
|
import {
|
||||||
|
WaveletTriggerKind, WaveletPositionMode, WaveletExitVariant, WaveletOptimizationResult,
|
||||||
|
WAVELET_TRIGGER_KINDS, runFullOptimization, formatBandIndex,
|
||||||
|
} from '../lib/waveletTrade'
|
||||||
|
|
||||||
|
const ASSET_CLASS_LABELS: Record<string, string> = {
|
||||||
|
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<WaveletPositionMode, string> = { 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 (
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{values.map(v => (
|
||||||
|
<span key={v} className="flex items-center gap-1 bg-dark-700 border border-slate-700/40 rounded px-1.5 py-0.5 text-[10px] text-slate-300">
|
||||||
|
{v}
|
||||||
|
<button onClick={() => onChange(values.filter(x => x !== v))} className="text-slate-600 hover:text-red-400">
|
||||||
|
<X className="w-2.5 h-2.5" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
value={input} onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, { symbol: string; name: string; currency: string }[]>),
|
||||||
|
staleTime: 60 * 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Form state ──────────────────────────────────────────────────────────────
|
||||||
|
const [selectedInstruments, setSelectedInstruments] = useState<Set<string>>(new Set())
|
||||||
|
const [period, setPeriod] = useState('2y')
|
||||||
|
const [levels, setLevels] = useState(4)
|
||||||
|
const [lookbacks, setLookbacks] = useState<number[]>([90, 130])
|
||||||
|
const [wavelets, setWavelets] = useState<Set<string>>(new Set(['gmw']))
|
||||||
|
const [method, setMethod] = useState<'cwt' | 'ssq'>('cwt')
|
||||||
|
const [bands, setBands] = useState<Set<number>>(new Set([0, 1, 2, 3]))
|
||||||
|
const [kinds, setKinds] = useState<Set<WaveletTriggerKind>>(new Set(['extremum', 'level_threshold']))
|
||||||
|
const [modes, setModes] = useState<Set<WaveletPositionMode>>(new Set(['both']))
|
||||||
|
const [takeProfits, setTakeProfits] = useState<number[]>([0])
|
||||||
|
const [stopLosses, setStopLosses] = useState<number[]>([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<string[]>([])
|
||||||
|
const [results, setResults] = useState<WaveletOptimizationResult[]>([])
|
||||||
|
const [activeSimId, setActiveSimId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// ── Results table filters ───────────────────────────────────────────────────
|
||||||
|
const [minTrades, setMinTrades] = useState(1)
|
||||||
|
const [sortKey, setSortKey] = useState<'totalReturnPct' | 'winRate' | 'tradeCount'>('totalReturnPct')
|
||||||
|
const [expandedRow, setExpandedRow] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const { data: savedSims } = useQuery({
|
||||||
|
queryKey: ['wavelet-simulations'],
|
||||||
|
queryFn: () => api.get('/wavelet/simulations').then(r => r.data as any[]),
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleSetValue = <T,>(set: Set<T>, v: T, setter: (s: Set<T>) => 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 (
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
<Waves className="w-5 h-5 text-blue-400" /> Wavelets Simulation
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">
|
||||||
|
Décomposition en bandes de fréquence (CWT/SSQ) + backtest multi-instruments — portage de InstrumentSimulator
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1 bg-dark-700 p-1 rounded w-fit">
|
||||||
|
{(['optimisation', 'summary'] as const).map(k => (
|
||||||
|
<button key={k} onClick={() => setSubTab(k)}
|
||||||
|
className={clsx('px-3 py-1.5 rounded text-sm transition-colors capitalize',
|
||||||
|
subTab === k ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-slate-200')}>
|
||||||
|
{k === 'optimisation' ? 'Optimisation' : 'Summary'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subTab === 'optimisation' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* ── Config form ── */}
|
||||||
|
<div className="card space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="section-title mb-2">Instruments</div>
|
||||||
|
<div className="grid grid-cols-4 gap-3 max-h-56 overflow-y-auto">
|
||||||
|
{Object.entries(watchlistUniverse ?? {}).map(([cls, items]) => (
|
||||||
|
<div key={cls}>
|
||||||
|
<div className="text-[10px] text-slate-500 uppercase mb-1">{ASSET_CLASS_LABELS[cls] ?? cls}</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{items.map(it => (
|
||||||
|
<label key={it.symbol} className="flex items-center gap-1.5 text-[10px] text-slate-300 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={selectedInstruments.has(it.symbol)}
|
||||||
|
onChange={() => toggleSetValue(selectedInstruments, it.symbol, setSelectedInstruments)} />
|
||||||
|
<span className="truncate">{it.symbol}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-slate-600 mt-1">{selectedInstruments.size} instrument(s) sélectionné(s)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-4 text-xs">
|
||||||
|
<div>
|
||||||
|
<div className="text-slate-500 mb-1">Période</div>
|
||||||
|
<select value={period} onChange={e => setPeriod(e.target.value)}
|
||||||
|
className="bg-dark-900 border border-slate-700/40 rounded px-2 py-1 text-white w-full">
|
||||||
|
{['6mo', '1y', '2y', '5y'].map(p => <option key={p} value={p}>{p}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-slate-500 mb-1">Niveaux</div>
|
||||||
|
<input type="number" min={2} max={6} value={levels}
|
||||||
|
onChange={e => 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" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-slate-500 mb-1">Méthode</div>
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
{(['cwt', 'ssq'] as const).map(m => (
|
||||||
|
<label key={m} className="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="radio" checked={method === m} onChange={() => setMethod(m)} /> {m.toUpperCase()}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-slate-500 mb-1">Nom de la simulation</div>
|
||||||
|
<input value={simName} onChange={e => setSimName(e.target.value)}
|
||||||
|
className="bg-dark-900 border border-slate-700/40 rounded px-2 py-1 text-white w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||||
|
<div>
|
||||||
|
<div className="text-slate-500 mb-1">Fenêtres glissantes à tester (jours, ≥32)</div>
|
||||||
|
<ChipInput values={lookbacks} onChange={setLookbacks} placeholder="130" min={32} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-slate-500 mb-1">Ondelettes</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{WAVELET_FAMILIES.map(w => (
|
||||||
|
<label key={w} className="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={wavelets.has(w)} onChange={() => toggleSetValue(wavelets, w, setWavelets)} /> {w}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-xs">
|
||||||
|
<div>
|
||||||
|
<div className="text-slate-500 mb-1">Bandes à tester</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{ALL_BANDS.slice(0, levels).map(b => (
|
||||||
|
<label key={b} className="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={bands.has(b)} onChange={() => toggleSetValue(bands, b, setBands)} /> {b}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-slate-500 mb-1">Modes à tester</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{ALL_MODES.map(m => (
|
||||||
|
<label key={m} className="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={modes.has(m)} onChange={() => toggleSetValue(modes, m, setModes)} /> {MODE_LABELS[m]}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-slate-500 mb-1">Take-profit / Stop-loss % (0 = aucun)</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<ChipInput values={takeProfits} onChange={setTakeProfits} placeholder="TP%" />
|
||||||
|
<ChipInput values={stopLosses} onChange={setStopLosses} placeholder="SL%" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-slate-500 text-xs mb-1">Types de déclencheur</div>
|
||||||
|
<div className="flex gap-3 flex-wrap text-xs">
|
||||||
|
{WAVELET_TRIGGER_KINDS.filter(k => method === 'ssq' || (k.value !== 'ridge_shift' && k.value !== 'energy_threshold')).map(k => (
|
||||||
|
<label key={k.value} className="flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={kinds.has(k.value)} onChange={() => toggleSetValue(kinds, k.value, setKinds)} />
|
||||||
|
{k.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={runOptimization} disabled={running || selectedInstruments.size === 0}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 rounded bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium disabled:opacity-40 transition-colors">
|
||||||
|
<Play className="w-4 h-4" /> {running ? 'Optimisation en cours…' : "Lancer l'optimisation"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{progress && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-[10px] text-slate-500">
|
||||||
|
<span>{progress.label}</span>
|
||||||
|
<span>{progress.done}/{progress.total}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-dark-700 rounded-full h-1.5">
|
||||||
|
<div className="bg-blue-500 h-1.5 rounded-full transition-all" style={{ width: `${progress.total ? (progress.done / progress.total) * 100 : 0}%` }} />
|
||||||
|
</div>
|
||||||
|
{failures.length > 0 && (
|
||||||
|
<div className="text-[10px] text-red-400">{failures.length} échec(s) : {failures.slice(0, 3).join(' · ')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Saved simulations ── */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="section-title mb-2">Simulations sauvegardées</div>
|
||||||
|
{(savedSims ?? []).length === 0 ? (
|
||||||
|
<div className="text-xs text-slate-600">Aucune simulation sauvegardée.</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{savedSims!.map(s => (
|
||||||
|
<div key={s.id} className={clsx('flex items-center justify-between px-2 py-1.5 rounded text-xs',
|
||||||
|
activeSimId === s.id ? 'bg-blue-900/20 border border-blue-700/30' : 'bg-dark-800/60')}>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-200 font-medium">{s.name}</span>
|
||||||
|
<span className="text-slate-600 ml-2">{s.result_count} résultats · {s.updated_at?.slice(0, 16)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => loadSimulation(s.id)} className="text-blue-400 hover:text-blue-300">Charger</button>
|
||||||
|
<button onClick={() => deleteSimulation(s.id)} className="text-slate-600 hover:text-red-400"><Trash2 className="w-3.5 h-3.5" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Results ── */}
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div className="card space-y-3">
|
||||||
|
<div className="flex items-center gap-4 text-xs text-slate-400 flex-wrap">
|
||||||
|
<span>{configSummary.configs} configs testées</span>
|
||||||
|
<span>{configSummary.symbols} instrument(s)</span>
|
||||||
|
<span>Retour cumulé: <span className={configSummary.totalReturn >= 0 ? 'text-emerald-400' : 'text-red-400'}>{configSummary.totalReturn.toFixed(1)}%</span></span>
|
||||||
|
<span>Win rate moy.: {(configSummary.avgWinRate * 100).toFixed(0)}%</span>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<label className="flex items-center gap-1">Min. trades
|
||||||
|
<input type="number" min={0} value={minTrades} onChange={e => setMinTrades(Number(e.target.value) || 0)}
|
||||||
|
className="w-12 bg-dark-900 border border-slate-700/40 rounded px-1 py-0.5 text-white" />
|
||||||
|
</label>
|
||||||
|
<select value={sortKey} onChange={e => setSortKey(e.target.value as any)}
|
||||||
|
className="bg-dark-900 border border-slate-700/40 rounded px-1.5 py-0.5 text-slate-300">
|
||||||
|
<option value="totalReturnPct">Trier: Retour</option>
|
||||||
|
<option value="winRate">Trier: Win rate</option>
|
||||||
|
<option value="tradeCount">Trier: Nb trades</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-slate-500 border-b border-slate-700/30 text-left">
|
||||||
|
<th className="py-1.5 pr-3">Symbole</th>
|
||||||
|
<th className="pr-3">Lookback</th>
|
||||||
|
<th className="pr-3">Ondelette</th>
|
||||||
|
<th className="pr-3">Bande</th>
|
||||||
|
<th className="pr-3">Trigger</th>
|
||||||
|
<th className="pr-3">Mode</th>
|
||||||
|
<th className="pr-3 text-right">Retour</th>
|
||||||
|
<th className="pr-3 text-right">Win rate</th>
|
||||||
|
<th className="pr-3 text-right">Trades</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredResults.slice(0, 100).map((r, i) => (
|
||||||
|
<Fragment key={i}>
|
||||||
|
<tr onClick={() => setExpandedRow(expandedRow === i ? null : i)}
|
||||||
|
className="border-b border-slate-800/40 hover:bg-dark-800/40 cursor-pointer">
|
||||||
|
<td className="py-1 pr-3 font-mono text-slate-200">{r.symbol}</td>
|
||||||
|
<td className="pr-3 text-slate-400">{r.lookback}j</td>
|
||||||
|
<td className="pr-3 text-slate-400">{r.wavelet}</td>
|
||||||
|
<td className="pr-3 text-slate-400">{formatBandIndex(r.bandIndex)}</td>
|
||||||
|
<td className="pr-3 text-slate-400">{r.triggerKind}</td>
|
||||||
|
<td className="pr-3 text-slate-400">{MODE_LABELS[r.mode]}</td>
|
||||||
|
<td className={clsx('pr-3 text-right font-mono font-bold', r.totalReturnPct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||||
|
{r.totalReturnPct >= 0 ? '+' : ''}{r.totalReturnPct.toFixed(1)}%
|
||||||
|
</td>
|
||||||
|
<td className="pr-3 text-right font-mono text-slate-300">{(r.winRate * 100).toFixed(0)}%</td>
|
||||||
|
<td className="pr-3 text-right font-mono text-slate-300">{r.tradeCount}</td>
|
||||||
|
</tr>
|
||||||
|
{expandedRow === i && (
|
||||||
|
<tr key={`${i}-detail`} className="bg-dark-900/40">
|
||||||
|
<td colSpan={9} className="p-2">
|
||||||
|
<div className="text-[10px] text-slate-500 mb-1">Détail par instrument (même config)</div>
|
||||||
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
|
{perSymbolBreakdown(r).map((d, j) => (
|
||||||
|
<div key={j} className="flex items-center justify-between bg-dark-800/60 rounded px-2 py-1 text-[10px]">
|
||||||
|
<span className="font-mono text-slate-300">{d.symbol}</span>
|
||||||
|
<span className={clsx('font-mono', d.totalReturnPct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||||
|
{d.totalReturnPct >= 0 ? '+' : ''}{d.totalReturnPct.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-600">{d.tradeCount}tr</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{subTab === 'summary' && <WaveletSummaryTab savedSims={savedSims ?? []} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WaveletSummaryTab({ savedSims }: { savedSims: any[] }) {
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(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 (
|
||||||
|
<div className="card space-y-3">
|
||||||
|
<div className="section-title">Sélectionner des runs à agréger</div>
|
||||||
|
{savedSims.length === 0 ? (
|
||||||
|
<div className="text-xs text-slate-600">Aucune simulation sauvegardée.</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{savedSims.map(s => (
|
||||||
|
<label key={s.id} className="flex items-center gap-2 text-xs text-slate-300 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={selectedIds.has(s.id)} onChange={() => toggle(s.id)} />
|
||||||
|
{s.name} <span className="text-slate-600">({s.result_count} résultats)</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button onClick={computeAggregate} disabled={loading || selectedIds.size === 0}
|
||||||
|
className="px-3 py-1.5 rounded bg-blue-600 hover:bg-blue-500 text-white text-xs font-medium disabled:opacity-40">
|
||||||
|
{loading ? 'Calcul…' : 'Agréger'}
|
||||||
|
</button>
|
||||||
|
{aggregate && (
|
||||||
|
<div className="grid grid-cols-4 gap-3 pt-2 border-t border-slate-700/30 text-xs">
|
||||||
|
<div><div className="text-slate-600">Configs</div><div className="text-slate-200 font-mono">{aggregate.configs}</div></div>
|
||||||
|
<div><div className="text-slate-600">Retour moy.</div><div className={clsx('font-mono', aggregate.avgReturn >= 0 ? 'text-emerald-400' : 'text-red-400')}>{aggregate.avgReturn.toFixed(2)}%</div></div>
|
||||||
|
<div><div className="text-slate-600">Win rate moy.</div><div className="text-slate-200 font-mono">{(aggregate.avgWinRate * 100).toFixed(1)}%</div></div>
|
||||||
|
<div><div className="text-slate-600">Trades moy.</div><div className="text-slate-200 font-mono">{aggregate.avgTrades.toFixed(1)}</div></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user