From fe86cc19948d05979adcda0948e37c3213445ffb Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Fri, 26 Jun 2026 22:42:18 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20EUR/USD=20simulator=20=E2=80=94=20live?= =?UTF-8?q?=20baseline=20from=20market=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (routers/simulator.py): - GET /api/simulator/baseline aggregates current market values: 1. macro_gauge_snapshots → VIX, Brent oil 2. FRED economic_events → FEDFUNDS, ECBDFR, DFII10 (real yield) 3. yfinance live → EURUSD=X, ^IRX+^FVX interpolated US 2Y, DE2YT=RR EU 2Y (or ECB rate +15bps fallback), ^VIX, BZ=F - Returns sources dict so frontend can show data provenance Frontend (EuroSimulator.tsx): - Fetch /api/simulator/baseline on mount; use as base anchors - compute(p, base) now takes dynamic base instead of hardcoded constants - Reset button returns to today's live baseline, not hardcoded fallback - Live/Fallback status badge with fetch date - Sources panel showing data origin per field - Chart reference line updates to show live base values Co-Authored-By: Claude Sonnet 4.6 --- backend/main.py | 2 + backend/routers/simulator.py | 176 ++++++++ frontend/src/pages/EuroSimulator.tsx | 645 +++++++++++++-------------- 3 files changed, 500 insertions(+), 323 deletions(-) create mode 100644 backend/routers/simulator.py diff --git a/backend/main.py b/backend/main.py index 99365ef..411f21e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,6 +14,7 @@ from routers import var as var_router from routers import reports as reports_router from routers import institutional as institutional_router from routers import eco as eco_router +from routers import simulator as simulator_router from services.database import init_db, get_config, cleanup_stale_running_cycles import os import logging @@ -196,6 +197,7 @@ app.include_router(cycle_actions_router.router) app.include_router(market_events_router.router) app.include_router(ai_desks_router.router) app.include_router(eco_router.router) +app.include_router(simulator_router.router) @app.get("/") diff --git a/backend/routers/simulator.py b/backend/routers/simulator.py new file mode 100644 index 0000000..35cf163 --- /dev/null +++ b/backend/routers/simulator.py @@ -0,0 +1,176 @@ +""" +Simulator baseline endpoint. +Returns current market values to seed the EUR/USD causal simulator. +Sources (priority): macro_gauge_snapshots → FRED economic_events → yfinance → hardcoded fallbacks. +""" +import json +import logging +from datetime import datetime +from fastapi import APIRouter + +router = APIRouter() +logger = logging.getLogger(__name__) + +_FALLBACK = { + "fed_rate": 4.25, + "ecb_rate": 3.65, + "us_2y": 4.50, + "eu_2y": 2.80, + "eurusd": 1.1450, + "vix": 18.0, + "oil": 80.0, + "real_yield_us": 2.10, + "pmi_us": 50.0, + "pmi_eu": 50.0, +} + + +def _gauge_val(gauges: dict, key: str): + g = gauges.get(key, {}) + v = g.get("value") + return float(v) if v is not None else None + + +def _yf_last(sym: str): + """Download last closing price for a single yfinance symbol.""" + try: + import yfinance as yf + df = yf.download(sym, period="5d", interval="1d", progress=False, auto_adjust=True) + if df is None or len(df) == 0: + return None + if hasattr(df.columns, "levels"): + df.columns = df.columns.get_level_values(0) + close = df["Close"].dropna() + return float(close.iloc[-1]) if len(close) > 0 else None + except Exception as e: + logger.debug(f"[simulator/baseline] yf {sym}: {e}") + return None + + +@router.get("/api/simulator/baseline") +def simulator_baseline(): + """ + Aggregate current market values for the EUR/USD simulator. + Returns: fed_rate, ecb_rate, us_2y, eu_2y, eurusd, vix, oil, real_yield_us, sources{}, fetched_at. + """ + result = dict(_FALLBACK) + sources: dict = {} + + # ── 1. Macro gauge snapshots (fast, already in DB) ────────────────────── + try: + from services.database import get_conn + conn = get_conn() + row = conn.execute( + "SELECT gauges, snapshot_date FROM macro_gauge_snapshots ORDER BY snapshot_date DESC LIMIT 1" + ).fetchone() + conn.close() + if row: + raw = row["gauges"] + gauges = raw if isinstance(raw, dict) else json.loads(raw or "{}") + snap_date = (row["snapshot_date"] or "")[:10] + + v = _gauge_val(gauges, "vix") + if v: + result["vix"] = round(v, 1) + sources["vix"] = f"snapshot {snap_date}" + + v = _gauge_val(gauges, "brent") + if v: + result["oil"] = round(v, 1) + sources["oil"] = f"snapshot {snap_date}" + + # us10y raw value is the yield in % → keep as reference + v = _gauge_val(gauges, "us10y") + if v: + result["_us10y_gauge"] = round(v, 2) + + except Exception as e: + logger.debug(f"[simulator/baseline] gauge query: {e}") + + # ── 2. FRED economic_events (policy rates, real yield) ────────────────── + try: + from services.database import get_conn + conn = get_conn() + + for series_id, field in [ + ("FEDFUNDS", "fed_rate"), + ("DFF", "fed_rate"), # daily effective fed funds (fallback) + ("ECBDFR", "ecb_rate"), # ECB deposit facility rate + ("DFII10", "real_yield_us"),# 10-year TIPS yield (real yield) + ]: + if field in sources: + continue + row = conn.execute( + "SELECT actual_value FROM economic_events WHERE series_id=? ORDER BY event_date DESC LIMIT 1", + (series_id,), + ).fetchone() + if row and row[0] is not None: + result[field] = round(float(row[0]), 2) + sources[field] = f"FRED {series_id}" + + conn.close() + except Exception as e: + logger.debug(f"[simulator/baseline] FRED query: {e}") + + # ── 3. yfinance live quotes ───────────────────────────────────────────── + try: + # EURUSD spot + v = _yf_last("EURUSD=X") + if v: + result["eurusd"] = round(v, 4) + sources["eurusd"] = "yfinance EURUSD=X" + + # US Treasury yields (yfinance returns annualised %) + irx = _yf_last("^IRX") # 13-week T-bill (0.25Y) + fvx = _yf_last("^FVX") # 5-year T-note + tnx = _yf_last("^TNX") # 10-year T-note + + if irx and fvx: + # Linear interpolation for 2Y on [0.25Y, 5Y] segment + # position of 2Y: (2 - 0.25) / (5 - 0.25) ≈ 0.368 + us2y = irx + 0.368 * (fvx - irx) + result["us_2y"] = round(us2y, 2) + sources["us_2y"] = "yfinance ^IRX+^FVX interp" + elif tnx: + result["us_2y"] = round(tnx * 0.92, 2) # rough proxy + sources["us_2y"] = "yfinance ^TNX proxy" + + # Real yield: 10Y nominal minus 10Y breakeven inflation via TIPS + # ^TNX = 10Y nominal; ^FVX gives us a rough real yield proxy + if tnx and "real_yield_us" not in sources: + # TIPS yield approximation: nominal - 2.3% (rough breakeven) + result["real_yield_us"] = round(tnx - 2.3, 2) + sources["real_yield_us"] = "yfinance ^TNX - breakeven approx" + + # EU 2Y — German Schatz (not always available on yfinance) + eu2y = _yf_last("DE2YT=RR") + if eu2y: + result["eu_2y"] = round(eu2y, 2) + sources["eu_2y"] = "yfinance DE2YT=RR" + else: + # Approximation: ECB rate + 15 bps market premium + result["eu_2y"] = round(result["ecb_rate"] + 0.15, 2) + sources["eu_2y"] = "ECB rate +15bps approx" + + # VIX — override gauge if not already set from DB + if "vix" not in sources: + v = _yf_last("^VIX") + if v: + result["vix"] = round(v, 1) + sources["vix"] = "yfinance ^VIX" + + # Brent — override gauge if not already set + if "oil" not in sources: + v = _yf_last("BZ=F") + if v: + result["oil"] = round(v, 1) + sources["oil"] = "yfinance BZ=F" + + except Exception as e: + logger.debug(f"[simulator/baseline] yfinance block: {e}") + + # ── Cleanup & return ──────────────────────────────────────────────────── + result.pop("_us10y_gauge", None) + result["sources"] = sources + result["fetched_at"] = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + return result diff --git a/frontend/src/pages/EuroSimulator.tsx b/frontend/src/pages/EuroSimulator.tsx index cc4bd34..5e3731f 100644 --- a/frontend/src/pages/EuroSimulator.tsx +++ b/frontend/src/pages/EuroSimulator.tsx @@ -1,41 +1,45 @@ -import { useState, useMemo } from 'react' -import { RefreshCw, Sliders } from 'lucide-react' +import { useState, useMemo, useEffect, useRef } from 'react' +import { RefreshCw, Sliders, Wifi, WifiOff } from 'lucide-react' import clsx from 'clsx' -// ── Model types ──────────────────────────────────────────────────────────────── +// ── Types ────────────────────────────────────────────────────────────────────── interface Params { // FED / US - fed_rate: number // 0–6, step 0.25 - fed_tone: number // –3 hawkish → +3 dovish (flipped: positive = more cuts) - cpi_us_surprise: number // –0.5 → +0.5 % - nfp_surprise: number // –300 → +300 k + fed_rate: number // absolute, step 0.25 + fed_tone: number // –3 hawkish … +3 dovish + cpi_us_surprise: number // ±0.5 % + nfp_surprise: number // ±300 k pmi_us: number // 40–65 // ECB / EU - ecb_rate: number // 0–5, step 0.25 - ecb_tone: number // –3 hawkish → +3 dovish - cpi_eu_surprise: number // –0.5 → +0.5 % + ecb_rate: number // absolute, step 0.25 + ecb_tone: number // –3 hawkish … +3 dovish + cpi_eu_surprise: number // ±0.5 % pmi_eu: number // 40–65 // Markets vix: number // 10–60 oil: number // 40–130 - real_yield_us: number // 0–4 % + real_yield_us: number // –1 … 4 % + // Base anchors (not displayed as sliders — set from live data) + us_2y: number // US 2Y yield reference + eu_2y: number // EU 2Y yield reference + eurusd: number // EURUSD base price } interface ModelResult { - fed_pressure: number // positive = hawkish (USD up) - ecb_pressure: number // positive = hawkish (EUR up) - us_2y: number - eu_2y: number - rate_diff: number // US 2Y – EU 2Y + fed_pressure: number + ecb_pressure: number + us_2y_implied: number + eu_2y_implied: number + rate_diff: number eurusd: number delta_pips: number contribs: { label: string; pips: number; color: 'red' | 'green' | 'slate' }[] } -// ── Baseline scenario (neutral / today's approximate) ───────────────────────── +// ── Hardcoded fallback (used before live data arrives) ───────────────────────── -const BASE: Params = { +const FALLBACK: Params = { fed_rate: 4.25, ecb_rate: 3.65, fed_tone: 0, ecb_tone: 0, cpi_us_surprise: 0, cpi_eu_surprise: 0, @@ -43,303 +47,149 @@ const BASE: Params = { pmi_us: 50, pmi_eu: 50, vix: 18, oil: 80, real_yield_us: 2.1, + us_2y: 4.50, eu_2y: 2.80, + eurusd: 1.1450, } -const BASE_US_2Y = 4.50 -const BASE_EU_2Y = 2.80 -const BASE_EURUSD = 1.1450 +// ── Causal model ─────────────────────────────────────────────────────────────── +// All deltas are computed vs the base anchors embedded in `base`. +// Surprises / tones / PMI are always centered at their neutral value (0 / 50). -// ── Causal model (linearised, directionally correct coefficients) ────────────── +function compute(p: Params, base: Params): ModelResult { + const BASE_US_2Y = base.us_2y + const BASE_EU_2Y = base.eu_2y + const BASE_EURUSD = base.eurusd -function compute(p: Params): ModelResult { - // Fed pressure: positive = hawkish (higher rates, USD up, EUR/USD down) + // Fed pressure: positive = hawkish (USD up, EUR/USD down) const fed_pressure = - (p.fed_rate - BASE.fed_rate) / 0.25 * 1.0 // each 25 bps counts 1 unit - - p.fed_tone * 1.5 // tone (positive fed_tone = dovish) - + p.cpi_us_surprise / 0.1 * 0.7 // each 0.1% CPI surprise - + p.nfp_surprise / 100 * 0.45 // each 100k NFP + (p.fed_rate - base.fed_rate) / 0.25 * 1.0 + - p.fed_tone * 1.5 + + p.cpi_us_surprise / 0.1 * 0.7 + + p.nfp_surprise / 100 * 0.45 - // ECB pressure: positive = hawkish (higher rates, EUR up, EUR/USD up) + // ECB pressure: positive = hawkish (EUR up, EUR/USD up) const ecb_pressure = - (p.ecb_rate - BASE.ecb_rate) / 0.25 * 1.0 + (p.ecb_rate - base.ecb_rate) / 0.25 * 1.0 - p.ecb_tone * 1.5 + p.cpi_eu_surprise / 0.1 * 0.55 + (p.pmi_eu - 50) / 5 * 0.25 - // Implied yields (each "pressure unit" moves 2Y by ~8–9 bps) - const us_2y = BASE_US_2Y + fed_pressure * 0.09 - const eu_2y = BASE_EU_2Y + ecb_pressure * 0.08 + // Implied 2Y yields + const us_2y_implied = BASE_US_2Y + fed_pressure * 0.09 + const eu_2y_implied = BASE_EU_2Y + ecb_pressure * 0.08 - // Rate differential (positive = USD premium = EUR/USD down) - const rate_diff = us_2y - eu_2y - const rate_diff_delta = rate_diff - (BASE_US_2Y - BASE_EU_2Y) + // Rate differential delta vs baseline + const rate_diff_delta = (us_2y_implied - eu_2y_implied) - (BASE_US_2Y - BASE_EU_2Y) + const rate_diff = us_2y_implied - eu_2y_implied - // PMI growth differential (positive = EU stronger = EUR/USD up) + // PMI growth differential (positive = EU stronger) const pmi_diff = (p.pmi_eu - 50) - (p.pmi_us - 50) - // VIX: risk-off (VIX up) → USD safe haven → EUR/USD down - const vix_dev = p.vix - BASE.vix + // VIX / risk-off (positive vix_dev = risk-off = USD up = EUR/USD down) + const vix_dev = p.vix - base.vix + const ry_dev = p.real_yield_us - base.real_yield_us + const oil_dev = p.oil - base.oil - // Real yield US: higher = USD stronger - const ry_dev = p.real_yield_us - BASE.real_yield_us - - // Oil: mild EUR-positive (Europe imports oil priced in USD; USD appreciation hurts) - const oil_dev = p.oil - BASE.oil - - // ── Contributions to EUR/USD in pips ───────────────────────────────────── - // Rate differential is the dominant channel (~60–70% of FX moves at medium term) - const c_rate = Math.round(-rate_diff_delta * 650) // 1% spread = ~650 pips - // PMI/growth differential — smaller effect + // ── Pip contributions ───────────────────────────────────────────────────── + const c_rate = Math.round(-rate_diff_delta * 650) // ~650 pips per 1% spread const c_pmi = Math.round(pmi_diff * 8) - // VIX risk-off channel const c_vix = Math.round(-vix_dev * 4) - // Real yield (additional beyond what's in rate diff) const c_ry = Math.round(-ry_dev * 120) - // Oil const c_oil = Math.round(oil_dev * 0.5) - // NFP direct effect (beyond FED channel) - const c_nfp = Math.round(-p.nfp_surprise / 100 * 30) - // CPI US direct effect (beyond FED channel) - const c_cpi_us = Math.round(-p.cpi_us_surprise / 0.1 * 20) - // CPI EU direct effect (beyond ECB channel) - const c_cpi_eu = Math.round(p.cpi_eu_surprise / 0.1 * 15) + const c_nfp = Math.round(-(p.nfp_surprise / 100) * 30) + const c_cpi_us = Math.round(-(p.cpi_us_surprise / 0.1) * 20) + const c_cpi_eu = Math.round((p.cpi_eu_surprise / 0.1) * 15) const total_pips = c_rate + c_pmi + c_vix + c_ry + c_oil + c_nfp + c_cpi_us + c_cpi_eu - const contribs: ModelResult['contribs'] = ([ - { label: 'Δ Taux (US 2Y – Bund)', pips: c_rate, color: c_rate < 0 ? 'red' : 'green' }, - { label: 'CPI US (surprise)', pips: c_cpi_us, color: c_cpi_us < 0 ? 'red' : 'green' }, - { label: 'NFP (surprise)', pips: c_nfp, color: c_nfp < 0 ? 'red' : 'green' }, - { label: 'CPI EU (surprise)', pips: c_cpi_eu, color: c_cpi_eu > 0 ? 'green' : 'red' }, - { label: 'PMI diff (EU−US)', pips: c_pmi, color: c_pmi > 0 ? 'green' : c_pmi < 0 ? 'red' : 'slate' }, - { label: 'VIX / Risk off', pips: c_vix, color: c_vix < 0 ? 'red' : 'green' }, - { label: 'Taux réel US', pips: c_ry, color: c_ry < 0 ? 'red' : 'green' }, - { label: 'Pétrole', pips: c_oil, color: c_oil > 0 ? 'green' : 'slate' }, + const contribs = ([ + { label: 'Δ Taux (US 2Y – Bund)', pips: c_rate, color: c_rate < 0 ? 'red' : c_rate > 0 ? 'green' : 'slate' }, + { label: 'CPI US (surprise)', pips: c_cpi_us, color: c_cpi_us < 0 ? 'red' : c_cpi_us > 0 ? 'green' : 'slate' }, + { label: 'NFP (surprise)', pips: c_nfp, color: c_nfp < 0 ? 'red' : c_nfp > 0 ? 'green' : 'slate' }, + { label: 'CPI EU (surprise)', pips: c_cpi_eu, color: c_cpi_eu > 0 ? 'green' : c_cpi_eu < 0 ? 'red' : 'slate' }, + { label: 'PMI diff (EU−US)', pips: c_pmi, color: c_pmi > 0 ? 'green' : c_pmi < 0 ? 'red' : 'slate' }, + { label: 'VIX / Risk off', pips: c_vix, color: c_vix < 0 ? 'red' : c_vix > 0 ? 'green' : 'slate' }, + { label: 'Taux réel US', pips: c_ry, color: c_ry < 0 ? 'red' : c_ry > 0 ? 'green' : 'slate' }, + { label: 'Pétrole', pips: c_oil, color: c_oil > 0 ? 'green' : c_oil < 0 ? 'red' : 'slate' }, ] as ModelResult['contribs']).sort((a, b) => Math.abs(b.pips) - Math.abs(a.pips)) return { fed_pressure, ecb_pressure, - us_2y, eu_2y, rate_diff, + us_2y_implied, eu_2y_implied, rate_diff, eurusd: BASE_EURUSD + total_pips / 10000, delta_pips: total_pips, contribs, } } -// ── Slider component ────────────────────────────────────────────────────────── +// ── Slider ───────────────────────────────────────────────────────────────────── function Slider({ label, value, min, max, step, format, onChange, colorize = false, reverse = false, center, }: { label: string; value: number; min: number; max: number; step: number - format: (v: number) => string - onChange: (v: number) => void - colorize?: boolean - reverse?: boolean // true = value goes up → negative (red) - center?: number // neutral pivot point (defaults to 0; use baseline value if 0 is out of range) + format: (v: number) => string; onChange: (v: number) => void + colorize?: boolean; reverse?: boolean; center?: number }) { - const pct = ((value - min) / (max - min)) * 100 - const pivot = center ?? 0 - // Clamp mid to [0,100] so the track never overflows the container - const mid = Math.max(0, Math.min(100, ((pivot - min) / (max - min)) * 100)) + const pct = ((value - min) / (max - min)) * 100 + const pivot = center ?? 0 + const mid = Math.max(0, Math.min(100, ((pivot - min) / (max - min)) * 100)) + const atBase = Math.abs(value - pivot) < step / 2 - const atCenter = Math.abs(value - pivot) < step / 2 let trackColor = 'bg-blue-500' if (colorize) { - const isPositive = reverse ? value < pivot : value > pivot - trackColor = atCenter ? 'bg-slate-600' : isPositive ? 'bg-emerald-500' : 'bg-rose-500' + const pos = reverse ? value < pivot : value > pivot + trackColor = atBase ? 'bg-slate-600' : pos ? 'bg-emerald-500' : 'bg-rose-500' } return (
{label} - pivot) ? 'text-emerald-400' : 'text-rose-400' : 'text-white', )}>{format(value)}
- {/* Deviation bar (colored, from center to current value) */} -
- {/* Position tick — always visible so cursor location is clear at baseline */} + : { left: 0, width: `${pct}%` }} /> {colorize && ( -
+
)} - onChange(parseFloat(e.target.value))} - className="absolute inset-0 w-full opacity-0 cursor-pointer h-full" - /> + className="absolute inset-0 w-full opacity-0 cursor-pointer h-full" />
) } -// ── Causal chain SVG ────────────────────────────────────────────────────────── - -function CausalChain({ r }: { r: ModelResult }) { - // Pressure → color helpers - const fedColor = r.fed_pressure > 0.5 ? '#f87171' : r.fed_pressure < -0.5 ? '#34d399' : '#94a3b8' - const ecbColor = r.ecb_pressure > 0.5 ? '#34d399' : r.ecb_pressure < -0.5 ? '#f87171' : '#94a3b8' - const us2yColor = r.us_2y > BASE_US_2Y + 0.05 ? '#f87171' : r.us_2y < BASE_US_2Y - 0.05 ? '#34d399' : '#94a3b8' - const eu2yColor = r.eu_2y > BASE_EU_2Y + 0.05 ? '#34d399' : r.eu_2y < BASE_EU_2Y - 0.05 ? '#f87171' : '#94a3b8' - const diffColor = r.rate_diff > (BASE_US_2Y - BASE_EU_2Y) + 0.05 ? '#f87171' : '#34d399' - const eurusdColor = r.delta_pips < -5 ? '#f87171' : r.delta_pips > 5 ? '#34d399' : '#94a3b8' - - // Arrow stroke width based on magnitude - const arrowW = (v: number) => Math.max(1, Math.min(3.5, Math.abs(v) * 0.8 + 1)) - - const W = 360, H = 420 - // Node positions - const usX = 80, euX = 280 - const yIn1 = 42, yIn2 = 82, yIn3 = 118 - const yCB = 175, yYield = 255, yDiff = 335, yFX = 400 - const riskX = 36 - - // SVG Node box - const Node = ({ - x, y, label, sub, color, w = 100, - }: { x: number; y: number; label: string; sub?: string; color: string; w?: number }) => ( - - - {label} - {sub && {sub}} - - ) - - const Arrow = ({ x1, y1, x2, y2, color, w }: { x1: number; y1: number; x2: number; y2: number; color: string; w: number }) => ( - - ) - - const markers = [ - { id: `arr-f87171`, color: '#f87171' }, - { id: `arr-34d399`, color: '#34d399' }, - { id: `arr-94a3b8`, color: '#94a3b8' }, - ] - - return ( - - - {markers.map(m => ( - - - - ))} - - - {/* ── US chain ── */} - 0 ? '+' : ''}${(r.fed_pressure * 0.3).toFixed(1)}`} color={r.fed_pressure > 0.3 ? '#f87171' : '#94a3b8'} w={88} /> - 0.3 ? '#f87171' : '#94a3b8'} w={88} /> - - - - - 0 ? '+' : ''}${r.fed_pressure.toFixed(1)}`} color={fedColor} w={108} /> - - - - - {/* ── EU chain ── */} - 0.3 ? '#34d399' : '#94a3b8'} w={88} /> - 0 ? 'fort' : 'faible'}`} color={r.ecb_pressure > 0.3 ? '#34d399' : '#94a3b8'} w={88} /> - - - - 0 ? '+' : ''}${r.ecb_pressure.toFixed(1)}`} color={ecbColor} w={108} /> - - - - - {/* ── Rate differential ── */} - - - - {/* ── VIX / Risk ── */} - - - - {/* ── EURUSD ── */} - - EURUSD - - {r.eurusd.toFixed(4)} - - - ) -} - -// ── Sensitivity bar ────────────────────────────────────────────────────────── - -function SensBar({ label, pips, maxAbs }: { label: string; pips: number; maxAbs: number }) { - const pct = maxAbs > 0 ? (Math.abs(pips) / maxAbs) * 100 : 0 - const pos = pips > 0 - return ( -
- {label} -
-
-
- - {pips > 0 ? '+' : ''}{pips}p - -
- ) -} - -// ── Section header ──────────────────────────────────────────────────────────── - -function Section({ title, color, children }: { title: string; color: string; children: React.ReactNode }) { - return ( -
-
{title}
- {children} -
- ) -} - // ── Tone selector ───────────────────────────────────────────────────────────── +const TONES = [ + { v: -3, label: 'Très\nhawkish', color: 'border-rose-600/60 bg-rose-900/30 text-rose-300' }, + { v: -1.5, label: 'Hawkish', color: 'border-rose-700/40 bg-rose-900/10 text-rose-400' }, + { v: 0, label: 'Neutre', color: 'border-slate-600/40 bg-dark-800 text-slate-300' }, + { v: 1.5, label: 'Dovish', color: 'border-emerald-700/40 bg-emerald-900/10 text-emerald-400' }, + { v: 3, label: 'Très\ndovish', color: 'border-emerald-600/60 bg-emerald-900/30 text-emerald-300' }, +] + function ToneSelector({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) { - const opts = [ - { v: -3, label: 'Très hawkish', color: 'border-rose-600/60 bg-rose-900/30 text-rose-300' }, - { v: -1.5, label: 'Hawkish', color: 'border-rose-700/40 bg-rose-900/10 text-rose-400' }, - { v: 0, label: 'Neutre', color: 'border-slate-600/40 bg-dark-800 text-slate-300' }, - { v: 1.5, label: 'Dovish', color: 'border-emerald-700/40 bg-emerald-900/10 text-emerald-400' }, - { v: 3, label: 'Très dovish', color: 'border-emerald-600/60 bg-emerald-900/30 text-emerald-300' }, - ] return (
{label}
- {opts.map(o => ( - ))} @@ -348,23 +198,160 @@ function ToneSelector({ label, value, onChange }: { label: string; value: number ) } -// ── Main page ───────────────────────────────────────────────────────────────── +// ── Causal chain SVG ────────────────────────────────────────────────────────── -export default function EuroSimulator() { - const [p, setP] = useState(BASE) - const set = (k: keyof Params, v: number) => setP(prev => ({ ...prev, [k]: v })) - const r = useMemo(() => compute(p), [p]) +function CausalChain({ r, base }: { r: ModelResult; base: Params }) { + const sign = (v: number) => v > 0 ? '+' : '' - const maxAbs = useMemo( - () => Math.max(1, ...r.contribs.map(c => Math.abs(c.pips))), - [r.contribs], + const fedC = r.fed_pressure > 0.4 ? '#f87171' : r.fed_pressure < -0.4 ? '#34d399' : '#94a3b8' + const ecbC = r.ecb_pressure > 0.4 ? '#34d399' : r.ecb_pressure < -0.4 ? '#f87171' : '#94a3b8' + const us2C = r.us_2y_implied > base.us_2y + 0.04 ? '#f87171' : r.us_2y_implied < base.us_2y - 0.04 ? '#34d399' : '#94a3b8' + const eu2C = r.eu_2y_implied > base.eu_2y + 0.04 ? '#34d399' : r.eu_2y_implied < base.eu_2y - 0.04 ? '#f87171' : '#94a3b8' + const diffC = r.rate_diff > (base.us_2y - base.eu_2y) + 0.04 ? '#f87171' : '#34d399' + const fxC = r.delta_pips < -5 ? '#f87171' : r.delta_pips > 5 ? '#34d399' : '#94a3b8' + + const aw = (v: number) => Math.max(1, Math.min(3.5, Math.abs(v) * 0.7 + 1)) + + const W = 360, H = 420 + const usX = 80, euX = 280 + const y1 = 42, y2 = 82, y3 = 118 + const yCB = 178, yYld = 258, yDiff = 338, yFX = 403 + const riskX = 38 + + const mk = (id: string, col: string) => ( + + + + ) + const colors = ['#f87171','#34d399','#94a3b8'] + + const Node = ({ x, y, label, sub, col, w = 100 }: { x: number; y: number; label: string; sub?: string; col: string; w?: number }) => ( + + + {label} + {sub && {sub}} + + ) + const Arr = ({ x1, y1, x2, y2, col, w }: { x1: number; y1: number; x2: number; y2: number; col: string; w: number }) => ( + ) - const eurusdChange = r.delta_pips - const eurusdColor = eurusdChange < -5 ? 'text-rose-400' : eurusdChange > 5 ? 'text-emerald-400' : 'text-slate-300' + return ( + + {colors.map(c => mk(`a${c.replace('#', '')}`, c))} + + {/* US chain */} + 0.3 ? '#f87171' : '#94a3b8'} w={88} /> + 0.3 ? '#f87171' : '#94a3b8'} w={88} /> + + + + + + + + {/* EU chain */} + 0.3 ? '#34d399' : '#94a3b8'} w={88} /> + 0.3 ? 'fort' : 'faible'} col={r.ecb_pressure > 0.3 ? '#34d399' : '#94a3b8'} w={88} /> + + + + + + + {/* Rate differential */} + + + + {/* VIX */} + + + + {/* EURUSD */} + + EURUSD + {r.eurusd.toFixed(4)} + + ) +} + +// ── Sensitivity bar ──────────────────────────────────────────────────────────── + +function SensBar({ label, pips, maxAbs }: { label: string; pips: number; maxAbs: number }) { + const pct = maxAbs > 0 ? (Math.abs(pips) / maxAbs) * 100 : 0 + return ( +
+ {label} +
+
0 ? 'bg-emerald-500' : 'bg-rose-500')} + style={{ width: `${pct}%` }} /> +
+ 0 ? 'text-emerald-400' : 'text-rose-400')}> + {pips > 0 ? '+' : ''}{pips}p + +
+ ) +} + +function Section({ title, children, color }: { title: string; children: React.ReactNode; color: string }) { + return ( +
+
{title}
+ {children} +
+ ) +} + +// ── Main page ───────────────────────────────────────────────────────────────── + +interface Baseline { fed_rate: number; ecb_rate: number; us_2y: number; eu_2y: number; eurusd: number; vix: number; oil: number; real_yield_us: number; fetched_at?: string; sources?: Record } + +export default function EuroSimulator() { + const [base, setBase] = useState(FALLBACK) + const [p, setP] = useState(FALLBACK) + const [liveStatus, setLive] = useState<'loading' | 'ok' | 'fallback'>('loading') + const [fetchedAt, setAt] = useState(null) + const [sources, setSources] = useState>({}) + const fetched = useRef(false) + + const set = (k: keyof Params, v: number) => setP(prev => ({ ...prev, [k]: v })) + + useEffect(() => { + if (fetched.current) return + fetched.current = true + fetch('/api/simulator/baseline') + .then(r => r.json()) + .then((data: Baseline) => { + const newBase: Params = { + ...FALLBACK, // keep surprise/tone/PMI at neutral + fed_rate: data.fed_rate ?? FALLBACK.fed_rate, + ecb_rate: data.ecb_rate ?? FALLBACK.ecb_rate, + us_2y: data.us_2y ?? FALLBACK.us_2y, + eu_2y: data.eu_2y ?? FALLBACK.eu_2y, + eurusd: data.eurusd ?? FALLBACK.eurusd, + vix: data.vix ?? FALLBACK.vix, + oil: data.oil ?? FALLBACK.oil, + real_yield_us: data.real_yield_us ?? FALLBACK.real_yield_us, + } + setBase(newBase) + setP(newBase) + setLive('ok') + setAt(data.fetched_at ?? null) + setSources(data.sources ?? {}) + }) + .catch(() => setLive('fallback')) + }, []) + + const r = useMemo(() => compute(p, base), [p, base]) + + const maxAbs = useMemo(() => Math.max(1, ...r.contribs.map(c => Math.abs(c.pips))), [r.contribs]) + const eurusdColor = r.delta_pips < -5 ? 'text-rose-400' : r.delta_pips > 5 ? 'text-emerald-400' : 'text-slate-300' return (
+ {/* Header */}
@@ -374,21 +361,35 @@ export default function EuroSimulator() {

Modèle causal — sans données historiques, purement simulé

- + +
+ {/* Live status badge */} +
+ {liveStatus === 'ok' + ? + : } + {liveStatus === 'loading' ? 'Chargement…' : + liveStatus === 'ok' ? `Live — base ${fetchedAt ? fetchedAt.slice(0,10) : ''}` : + 'Fallback — données hardcodées'} +
+ + +
- {/* ── Left: Controls ──────────────────────────────────────────────── */} + {/* ── Controls ──────────────────────────────────────────────────────── */}
- {/* FED / US */}
`${v.toFixed(2)}%`} onChange={v => set('fed_rate', v)} /> @@ -403,7 +404,6 @@ export default function EuroSimulator() { format={v => v.toFixed(1)} onChange={v => set('pmi_us', v)} />
- {/* ECB / EU */}
`${v.toFixed(2)}%`} onChange={v => set('ecb_rate', v)} /> @@ -415,47 +415,58 @@ export default function EuroSimulator() { format={v => v.toFixed(1)} onChange={v => set('pmi_eu', v)} />
- {/* Markets */}
v.toFixed(1)} onChange={v => set('vix', v)} - colorize reverse center={BASE.vix} /> + colorize reverse center={base.vix} /> `$${v.toFixed(0)}`} onChange={v => set('oil', v)} /> `${v > 0 ? '+' : ''}${v.toFixed(2)}%`} - onChange={v => set('real_yield_us', v)} colorize reverse center={BASE.real_yield_us} /> + onChange={v => set('real_yield_us', v)} colorize reverse center={base.real_yield_us} />
+ + {/* Sources panel */} + {liveStatus === 'ok' && Object.keys(sources).length > 0 && ( +
+
Sources baseline
+ {Object.entries(sources).map(([k, v]) => ( +
+ {k} + {v} +
+ ))} +
+ )}
- {/* ── Center: Causal chain ────────────────────────────────────────── */} + {/* ── Causal chain ──────────────────────────────────────────────────── */}
Chaîne de transmission causale
- {/* Yield display strip */} + {/* Live metric strip */}
{[ - { label: 'US 2Y', value: r.us_2y.toFixed(2) + '%', color: r.us_2y > BASE_US_2Y + 0.01 ? 'text-rose-400' : r.us_2y < BASE_US_2Y - 0.01 ? 'text-emerald-400' : 'text-slate-400' }, - { label: 'Bund 2Y', value: r.eu_2y.toFixed(2) + '%', color: r.eu_2y > BASE_EU_2Y + 0.01 ? 'text-emerald-400' : r.eu_2y < BASE_EU_2Y - 0.01 ? 'text-rose-400' : 'text-slate-400' }, - { label: 'Δ Taux', value: r.rate_diff.toFixed(2) + '%', color: r.rate_diff > (BASE_US_2Y - BASE_EU_2Y) + 0.01 ? 'text-rose-400' : 'text-emerald-400' }, - { label: 'VIX', value: p.vix.toFixed(0), color: p.vix > 25 ? 'text-rose-400' : p.vix < 15 ? 'text-emerald-400' : 'text-slate-400' }, + { label: 'US 2Y', val: `${r.us_2y_implied.toFixed(2)}%`, col: r.us_2y_implied > base.us_2y + 0.01 ? 'text-rose-400' : r.us_2y_implied < base.us_2y - 0.01 ? 'text-emerald-400' : 'text-slate-400' }, + { label: 'Bund 2Y', val: `${r.eu_2y_implied.toFixed(2)}%`, col: r.eu_2y_implied > base.eu_2y + 0.01 ? 'text-emerald-400' : r.eu_2y_implied < base.eu_2y - 0.01 ? 'text-rose-400' : 'text-slate-400' }, + { label: 'Δ Taux', val: `${r.rate_diff.toFixed(2)}%`, col: r.rate_diff > (base.us_2y - base.eu_2y) + 0.01 ? 'text-rose-400' : 'text-emerald-400' }, + { label: 'VIX', val: p.vix.toFixed(0), col: p.vix > base.vix + 2 ? 'text-rose-400' : p.vix < base.vix - 2 ? 'text-emerald-400' : 'text-slate-400' }, ].map(it => (
{it.label}
-
{it.value}
+
{it.val}
))}
- + - {/* Baseline note */} -
- Référence: EUR/USD {BASE_EURUSD.toFixed(4)} · US 2Y {BASE_US_2Y}% · Bund 2Y {BASE_EU_2Y}% · VIX {BASE.vix} +
+ Base live: EUR/USD {base.eurusd.toFixed(4)} · US 2Y {base.us_2y.toFixed(2)}% · Bund 2Y {base.eu_2y.toFixed(2)}% · VIX {base.vix.toFixed(1)}
- {/* ── Right: Results ──────────────────────────────────────────────── */} + {/* ── Results ───────────────────────────────────────────────────────── */}
{/* EUR/USD display */} @@ -465,67 +476,55 @@ export default function EuroSimulator() { {r.eurusd.toFixed(4)}
- {eurusdChange === 0 ? 'Neutre' : `${eurusdChange > 0 ? '+' : ''}${eurusdChange} pips`} -
-
- vs base {BASE_EURUSD.toFixed(4)} + {r.delta_pips === 0 ? 'Neutre' : `${r.delta_pips > 0 ? '+' : ''}${r.delta_pips} pips`}
+
vs base {base.eurusd.toFixed(4)}
{/* Pressure gauges */}
{[ - { label: 'Pression FED', v: r.fed_pressure, pos_label: 'Hawkish', neg_label: 'Dovish', pos_color: 'text-rose-400', neg_color: 'text-emerald-400' }, - { label: 'Pression BCE', v: r.ecb_pressure, pos_label: 'Hawkish', neg_label: 'Dovish', pos_color: 'text-emerald-400', neg_color: 'text-rose-400' }, - ].map(g => { - const isPos = g.v > 0.2 - const isNeg = g.v < -0.2 - return ( -
-
{g.label}
-
- {isPos ? g.pos_label : isNeg ? g.neg_label : 'Neutre'} -
-
- {g.v > 0 ? '+' : ''}{g.v.toFixed(1)} -
+ { label: 'Pression FED', v: r.fed_pressure, posLabel: 'Hawkish', negLabel: 'Dovish', posCol: 'text-rose-400', negCol: 'text-emerald-400' }, + { label: 'Pression BCE', v: r.ecb_pressure, posLabel: 'Hawkish', negLabel: 'Dovish', posCol: 'text-emerald-400', negCol: 'text-rose-400' }, + ].map(g => ( +
+
{g.label}
+
0.2 ? g.posCol : g.v < -0.2 ? g.negCol : 'text-slate-400', + )}> + {g.v > 0.2 ? g.posLabel : g.v < -0.2 ? g.negLabel : 'Neutre'}
- ) - })} +
{g.v > 0 ? '+' : ''}{g.v.toFixed(1)}
+
+ ))}
{/* Pip decomposition */}
-
Décomposition ({eurusdChange > 0 ? '+' : ''}{eurusdChange} pips)
- {r.contribs.map(c => ( - +
+ Décomposition ({r.delta_pips > 0 ? '+' : ''}{r.delta_pips} pips) +
+ {r.contribs.map(c => )} +
+ + {/* Relative influence */} +
+
Influence relative
+ {r.contribs.slice(0, 5).map(c => ( +
+
0 ? 'bg-emerald-500' : 'bg-rose-500')} /> + {c.label} + + {maxAbs > 0 ? Math.round((Math.abs(c.pips) / maxAbs) * 100) : 0}% + +
))}
- {/* Dominance legend */} -
-
Influence relative
- {r.contribs.slice(0, 5).map(c => { - const pct = maxAbs > 0 ? Math.round((Math.abs(c.pips) / maxAbs) * 100) : 0 - return ( -
-
0 ? 'bg-emerald-500' : 'bg-rose-500', - )} /> - {c.label} - {pct}% -
- ) - })} -
- - {/* Interpretation note */}
Note: Modèle linéarisé heuristique. - Le différentiel de taux 2Y explique ~60-70% des mouvements FX à moyen terme. - Coefficients estimés — non calibrés sur données historiques. + La base est chargée en live (taux, VIX, Brent, EURUSD spot) au démarrage. + Les coefficients de transmission restent heuristiques — non calibrés sur données historiques.