feat: EUR/USD simulator — live baseline from market data

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 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-26 22:42:18 +02:00
parent e00b78f2f6
commit fe86cc1994
3 changed files with 500 additions and 323 deletions

View File

@@ -14,6 +14,7 @@ from routers import var as var_router
from routers import reports as reports_router from routers import reports as reports_router
from routers import institutional as institutional_router from routers import institutional as institutional_router
from routers import eco as eco_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 from services.database import init_db, get_config, cleanup_stale_running_cycles
import os import os
import logging import logging
@@ -196,6 +197,7 @@ app.include_router(cycle_actions_router.router)
app.include_router(market_events_router.router) app.include_router(market_events_router.router)
app.include_router(ai_desks_router.router) app.include_router(ai_desks_router.router)
app.include_router(eco_router.router) app.include_router(eco_router.router)
app.include_router(simulator_router.router)
@app.get("/") @app.get("/")

View File

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

View File

@@ -1,41 +1,45 @@
import { useState, useMemo } from 'react' import { useState, useMemo, useEffect, useRef } from 'react'
import { RefreshCw, Sliders } from 'lucide-react' import { RefreshCw, Sliders, Wifi, WifiOff } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
// ── Model types ──────────────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────────────────────
interface Params { interface Params {
// FED / US // FED / US
fed_rate: number // 06, step 0.25 fed_rate: number // absolute, step 0.25
fed_tone: number // 3 hawkish +3 dovish (flipped: positive = more cuts) fed_tone: number // 3 hawkish +3 dovish
cpi_us_surprise: number // 0.5 → +0.5 % cpi_us_surprise: number // ±0.5 %
nfp_surprise: number // 300 → +300 k nfp_surprise: number // ±300 k
pmi_us: number // 4065 pmi_us: number // 4065
// ECB / EU // ECB / EU
ecb_rate: number // 05, step 0.25 ecb_rate: number // absolute, step 0.25
ecb_tone: number // 3 hawkish +3 dovish ecb_tone: number // 3 hawkish +3 dovish
cpi_eu_surprise: number // 0.5 → +0.5 % cpi_eu_surprise: number // ±0.5 %
pmi_eu: number // 4065 pmi_eu: number // 4065
// Markets // Markets
vix: number // 1060 vix: number // 1060
oil: number // 40130 oil: number // 40130
real_yield_us: number // 04 % 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 { interface ModelResult {
fed_pressure: number // positive = hawkish (USD up) fed_pressure: number
ecb_pressure: number // positive = hawkish (EUR up) ecb_pressure: number
us_2y: number us_2y_implied: number
eu_2y: number eu_2y_implied: number
rate_diff: number // US 2Y EU 2Y rate_diff: number
eurusd: number eurusd: number
delta_pips: number delta_pips: number
contribs: { label: string; pips: number; color: 'red' | 'green' | 'slate' }[] 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_rate: 4.25, ecb_rate: 3.65,
fed_tone: 0, ecb_tone: 0, fed_tone: 0, ecb_tone: 0,
cpi_us_surprise: 0, cpi_eu_surprise: 0, cpi_us_surprise: 0, cpi_eu_surprise: 0,
@@ -43,303 +47,149 @@ const BASE: Params = {
pmi_us: 50, pmi_eu: 50, pmi_us: 50, pmi_eu: 50,
vix: 18, oil: 80, vix: 18, oil: 80,
real_yield_us: 2.1, real_yield_us: 2.1,
us_2y: 4.50, eu_2y: 2.80,
eurusd: 1.1450,
} }
const BASE_US_2Y = 4.50 // ── Causal model ───────────────────────────────────────────────────────────────
const BASE_EU_2Y = 2.80 // All deltas are computed vs the base anchors embedded in `base`.
const BASE_EURUSD = 1.1450 // 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 (USD up, EUR/USD down)
// Fed pressure: positive = hawkish (higher rates, USD up, EUR/USD down)
const fed_pressure = const fed_pressure =
(p.fed_rate - BASE.fed_rate) / 0.25 * 1.0 // each 25 bps counts 1 unit (p.fed_rate - base.fed_rate) / 0.25 * 1.0
- p.fed_tone * 1.5 // tone (positive fed_tone = dovish) - p.fed_tone * 1.5
+ p.cpi_us_surprise / 0.1 * 0.7 // each 0.1% CPI surprise + p.cpi_us_surprise / 0.1 * 0.7
+ p.nfp_surprise / 100 * 0.45 // each 100k NFP + 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 = 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.ecb_tone * 1.5
+ p.cpi_eu_surprise / 0.1 * 0.55 + p.cpi_eu_surprise / 0.1 * 0.55
+ (p.pmi_eu - 50) / 5 * 0.25 + (p.pmi_eu - 50) / 5 * 0.25
// Implied yields (each "pressure unit" moves 2Y by ~89 bps) // Implied 2Y yields
const us_2y = BASE_US_2Y + fed_pressure * 0.09 const us_2y_implied = BASE_US_2Y + fed_pressure * 0.09
const eu_2y = BASE_EU_2Y + ecb_pressure * 0.08 const eu_2y_implied = BASE_EU_2Y + ecb_pressure * 0.08
// Rate differential (positive = USD premium = EUR/USD down) // Rate differential delta vs baseline
const rate_diff = us_2y - eu_2y const rate_diff_delta = (us_2y_implied - eu_2y_implied) - (BASE_US_2Y - BASE_EU_2Y)
const rate_diff_delta = rate_diff - (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) const pmi_diff = (p.pmi_eu - 50) - (p.pmi_us - 50)
// VIX: risk-off (VIX up) → USD safe haven → EUR/USD down // VIX / risk-off (positive vix_dev = risk-off = USD up = EUR/USD down)
const vix_dev = p.vix - BASE.vix 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 // ── Pip contributions ─────────────────────────────────────────────────────
const ry_dev = p.real_yield_us - BASE.real_yield_us const c_rate = Math.round(-rate_diff_delta * 650) // ~650 pips per 1% spread
// 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 (~6070% of FX moves at medium term)
const c_rate = Math.round(-rate_diff_delta * 650) // 1% spread = ~650 pips
// PMI/growth differential — smaller effect
const c_pmi = Math.round(pmi_diff * 8) const c_pmi = Math.round(pmi_diff * 8)
// VIX risk-off channel
const c_vix = Math.round(-vix_dev * 4) 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) const c_ry = Math.round(-ry_dev * 120)
// Oil
const c_oil = Math.round(oil_dev * 0.5) 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)
const c_nfp = Math.round(-p.nfp_surprise / 100 * 30) const c_cpi_us = Math.round(-(p.cpi_us_surprise / 0.1) * 20)
// CPI US direct effect (beyond FED channel) const c_cpi_eu = Math.round((p.cpi_eu_surprise / 0.1) * 15)
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 total_pips = c_rate + c_pmi + c_vix + c_ry + c_oil + c_nfp + c_cpi_us + c_cpi_eu 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'] = ([ const contribs = ([
{ label: 'Δ Taux (US 2Y Bund)', pips: c_rate, color: c_rate < 0 ? 'red' : 'green' }, { 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' : 'green' }, { 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' : 'green' }, { 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' : 'red' }, { label: 'CPI EU (surprise)', pips: c_cpi_eu, color: c_cpi_eu > 0 ? 'green' : c_cpi_eu < 0 ? 'red' : 'slate' },
{ label: 'PMI diff (EUUS)', pips: c_pmi, color: c_pmi > 0 ? 'green' : c_pmi < 0 ? 'red' : 'slate' }, { label: 'PMI diff (EUUS)', 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: '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' : 'green' }, { 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' : '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)) ] as ModelResult['contribs']).sort((a, b) => Math.abs(b.pips) - Math.abs(a.pips))
return { return {
fed_pressure, ecb_pressure, fed_pressure, ecb_pressure,
us_2y, eu_2y, rate_diff, us_2y_implied, eu_2y_implied, rate_diff,
eurusd: BASE_EURUSD + total_pips / 10000, eurusd: BASE_EURUSD + total_pips / 10000,
delta_pips: total_pips, delta_pips: total_pips,
contribs, contribs,
} }
} }
// ── Slider component ────────────────────────────────────────────────────────── // ── Slider ─────────────────────────────────────────────────────────────────────
function Slider({ function Slider({
label, value, min, max, step, format, onChange, label, value, min, max, step, format, onChange,
colorize = false, reverse = false, center, colorize = false, reverse = false, center,
}: { }: {
label: string; value: number; min: number; max: number; step: number label: string; value: number; min: number; max: number; step: number
format: (v: number) => string format: (v: number) => string; onChange: (v: number) => void
onChange: (v: number) => void colorize?: boolean; reverse?: boolean; center?: number
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)
}) { }) {
const pct = ((value - min) / (max - min)) * 100 const pct = ((value - min) / (max - min)) * 100
const pivot = center ?? 0 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 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' let trackColor = 'bg-blue-500'
if (colorize) { if (colorize) {
const isPositive = reverse ? value < pivot : value > pivot const pos = reverse ? value < pivot : value > pivot
trackColor = atCenter ? 'bg-slate-600' : isPositive ? 'bg-emerald-500' : 'bg-rose-500' trackColor = atBase ? 'bg-slate-600' : pos ? 'bg-emerald-500' : 'bg-rose-500'
} }
return ( return (
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-slate-400">{label}</span> <span className="text-xs text-slate-400">{label}</span>
<span className={clsx( <span className={clsx('text-xs font-mono font-semibold',
'text-xs font-mono font-semibold', colorize && !atBase
colorize && !atCenter
? (reverse ? value < pivot : value > pivot) ? 'text-emerald-400' : 'text-rose-400' ? (reverse ? value < pivot : value > pivot) ? 'text-emerald-400' : 'text-rose-400'
: 'text-white', : 'text-white',
)}>{format(value)}</span> )}>{format(value)}</span>
</div> </div>
<div className="relative h-1.5 bg-dark-900 rounded overflow-hidden"> <div className="relative h-1.5 bg-dark-900 rounded overflow-hidden">
{/* Deviation bar (colored, from center to current value) */} <div className={clsx('absolute h-full rounded transition-all', trackColor)}
<div
className={clsx('absolute h-full rounded transition-all', trackColor)}
style={colorize style={colorize
? { left: `${Math.min(pct, mid)}%`, width: `${Math.abs(pct - mid)}%` } ? { left: `${Math.min(pct, mid)}%`, width: `${Math.abs(pct - mid)}%` }
: { left: 0, width: `${pct}%` } : { left: 0, width: `${pct}%` }} />
}
/>
{/* Position tick — always visible so cursor location is clear at baseline */}
{colorize && ( {colorize && (
<div <div className="absolute top-0 bottom-0 w-0.5 bg-slate-400 transition-all"
className="absolute top-0 bottom-0 w-0.5 bg-slate-400 transition-all" style={{ left: `${Math.min(98.5, pct)}%` }} />
style={{ left: `${Math.min(98.5, pct)}%` }}
/>
)} )}
<input <input type="range" min={min} max={max} step={step} value={value}
type="range" min={min} max={max} step={step} value={value}
onChange={e => onChange(parseFloat(e.target.value))} onChange={e => 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" />
/>
</div> </div>
</div> </div>
) )
} }
// ── 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 }) => (
<g>
<rect x={x - w / 2} y={y - 18} width={w} height={36} rx={6}
fill="#1e293b" stroke={color} strokeWidth={1.5} />
<text x={x} y={y - 4} textAnchor="middle" fill={color} fontSize={10} fontWeight={600}>{label}</text>
{sub && <text x={x} y={y + 10} textAnchor="middle" fill={color} fontSize={9} opacity={0.75}>{sub}</text>}
</g>
)
const Arrow = ({ x1, y1, x2, y2, color, w }: { x1: number; y1: number; x2: number; y2: number; color: string; w: number }) => (
<line x1={x1} y1={y1} x2={x2} y2={y2} stroke={color} strokeWidth={w} opacity={0.6}
markerEnd={`url(#arr-${color.replace('#', '')})`} />
)
const markers = [
{ id: `arr-f87171`, color: '#f87171' },
{ id: `arr-34d399`, color: '#34d399' },
{ id: `arr-94a3b8`, color: '#94a3b8' },
]
return (
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="w-full">
<defs>
{markers.map(m => (
<marker key={m.id} id={m.id} viewBox="0 0 6 6" refX={5} refY={3}
markerWidth={4} markerHeight={4} orient="auto">
<path d="M0,0 L6,3 L0,6 Z" fill={m.color} />
</marker>
))}
</defs>
{/* ── US chain ── */}
<Node x={usX} y={yIn1} label="CPI US" sub={`srp ${r.fed_pressure > 0 ? '+' : ''}${(r.fed_pressure * 0.3).toFixed(1)}`} color={r.fed_pressure > 0.3 ? '#f87171' : '#94a3b8'} w={88} />
<Node x={usX} y={yIn2} label="NFP" sub={`srp`} color={r.fed_pressure > 0.3 ? '#f87171' : '#94a3b8'} w={88} />
<Node x={usX} y={yIn3} label="PMI US" color={'#94a3b8'} w={88} />
<Arrow x1={usX} y1={yIn1 + 18} x2={usX} y2={yCB - 20} color={fedColor} w={arrowW(r.fed_pressure)} />
<Node x={usX} y={yCB} label="FED" sub={`pression ${r.fed_pressure > 0 ? '+' : ''}${r.fed_pressure.toFixed(1)}`} color={fedColor} w={108} />
<Arrow x1={usX} y1={yCB + 18} x2={usX} y2={yYield - 18} color={us2yColor} w={arrowW(r.us_2y - BASE_US_2Y)} />
<Node x={usX} y={yYield} label="US 2Y" sub={`${r.us_2y.toFixed(2)}%`} color={us2yColor} w={100} />
<Arrow x1={usX + 50} y1={yYield + 10} x2={W / 2 - 40} y2={yDiff - 12} color={us2yColor} w={arrowW(r.us_2y - BASE_US_2Y)} />
{/* ── EU chain ── */}
<Node x={euX} y={yIn1} label="CPI EU" sub={`srp`} color={r.ecb_pressure > 0.3 ? '#34d399' : '#94a3b8'} w={88} />
<Node x={euX} y={yIn2} label="PMI EU" sub={`${r.ecb_pressure > 0 ? 'fort' : 'faible'}`} color={r.ecb_pressure > 0.3 ? '#34d399' : '#94a3b8'} w={88} />
<Arrow x1={euX} y1={yIn1 + 18} x2={euX} y2={yCB - 20} color={ecbColor} w={arrowW(r.ecb_pressure)} />
<Node x={euX} y={yCB} label="BCE" sub={`pression ${r.ecb_pressure > 0 ? '+' : ''}${r.ecb_pressure.toFixed(1)}`} color={ecbColor} w={108} />
<Arrow x1={euX} y1={yCB + 18} x2={euX} y2={yYield - 18} color={eu2yColor} w={arrowW(r.eu_2y - BASE_EU_2Y)} />
<Node x={euX} y={yYield} label="Bund 2Y" sub={`${r.eu_2y.toFixed(2)}%`} color={eu2yColor} w={100} />
<Arrow x1={euX - 50} y1={yYield + 10} x2={W / 2 + 40} y2={yDiff - 12} color={eu2yColor} w={arrowW(r.eu_2y - BASE_EU_2Y)} />
{/* ── Rate differential ── */}
<Node x={W / 2} y={yDiff} label="Δ Taux 2Y" sub={`${r.rate_diff.toFixed(2)}% (USEU)`} color={diffColor} w={130} />
<Arrow x1={W / 2} y1={yDiff + 18} x2={W / 2} y2={yFX - 20} color={diffColor} w={arrowW(r.rate_diff - (BASE_US_2Y - BASE_EU_2Y))} />
{/* ── VIX / Risk ── */}
<Node x={riskX} y={yDiff} label="VIX" sub={`risk-off`} color="#94a3b8" w={58} />
<Arrow x1={riskX + 29} y1={yDiff + 5} x2={W / 2 - 68} y2={yFX - 6} color="#94a3b8" w={1.2} />
{/* ── EURUSD ── */}
<rect x={W / 2 - 75} y={yFX - 22} width={150} height={44} rx={8}
fill="#1e3a5f" stroke={eurusdColor} strokeWidth={2} />
<text x={W / 2} y={yFX - 4} textAnchor="middle" fill={eurusdColor} fontSize={11} fontWeight={700}>EURUSD</text>
<text x={W / 2} y={yFX + 12} textAnchor="middle" fill={eurusdColor} fontSize={13} fontWeight={800}>
{r.eurusd.toFixed(4)}
</text>
</svg>
)
}
// ── 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 (
<div className="grid grid-cols-[130px_1fr_48px] items-center gap-2">
<span className="text-xs text-slate-400 truncate">{label}</span>
<div className="h-2 bg-dark-900 rounded overflow-hidden">
<div
className={clsx('h-full rounded transition-all', pos ? 'bg-emerald-500' : 'bg-rose-500')}
style={{ width: `${pct}%` }}
/>
</div>
<span className={clsx('text-xs font-mono text-right', pos ? 'text-emerald-400' : 'text-rose-400')}>
{pips > 0 ? '+' : ''}{pips}p
</span>
</div>
)
}
// ── Section header ────────────────────────────────────────────────────────────
function Section({ title, color, children }: { title: string; color: string; children: React.ReactNode }) {
return (
<div className={clsx('rounded-lg border p-3 space-y-3', color)}>
<div className="text-xs font-semibold uppercase tracking-wider text-slate-300">{title}</div>
{children}
</div>
)
}
// ── Tone selector ───────────────────────────────────────────────────────────── // ── 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 }) { 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 ( return (
<div className="space-y-1"> <div className="space-y-1">
<span className="text-xs text-slate-400">{label}</span> <span className="text-xs text-slate-400">{label}</span>
<div className="flex gap-1"> <div className="flex gap-1">
{opts.map(o => ( {TONES.map(o => (
<button <button key={o.v} onClick={() => onChange(o.v)}
key={o.v} className={clsx('flex-1 px-1 py-1 rounded border text-[10px] leading-tight text-center transition-colors whitespace-pre-line',
onClick={() => onChange(o.v)}
className={clsx(
'flex-1 px-1 py-1 rounded border text-[10px] leading-tight text-center transition-colors',
value === o.v ? o.color : 'border-slate-700/30 text-slate-600 bg-dark-900 hover:border-slate-600', value === o.v ? o.color : 'border-slate-700/30 text-slate-600 bg-dark-900 hover:border-slate-600',
)} )}>
>
{o.label} {o.label}
</button> </button>
))} ))}
@@ -348,23 +198,160 @@ function ToneSelector({ label, value, onChange }: { label: string; value: number
) )
} }
// ── Main page ───────────────────────────────────────────────────────────────── // ── Causal chain SVG ──────────────────────────────────────────────────────────
export default function EuroSimulator() { function CausalChain({ r, base }: { r: ModelResult; base: Params }) {
const [p, setP] = useState<Params>(BASE) const sign = (v: number) => v > 0 ? '+' : ''
const set = (k: keyof Params, v: number) => setP(prev => ({ ...prev, [k]: v }))
const r = useMemo(() => compute(p), [p])
const maxAbs = useMemo( const fedC = r.fed_pressure > 0.4 ? '#f87171' : r.fed_pressure < -0.4 ? '#34d399' : '#94a3b8'
() => Math.max(1, ...r.contribs.map(c => Math.abs(c.pips))), const ecbC = r.ecb_pressure > 0.4 ? '#34d399' : r.ecb_pressure < -0.4 ? '#f87171' : '#94a3b8'
[r.contribs], 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) => (
<marker key={id} id={id} viewBox="0 0 6 6" refX={5} refY={3}
markerWidth={4} markerHeight={4} orient="auto">
<path d="M0,0 L6,3 L0,6 Z" fill={col} />
</marker>
)
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 }) => (
<g>
<rect x={x - w / 2} y={y - 18} width={w} height={36} rx={6} fill="#1e293b" stroke={col} strokeWidth={1.5} />
<text x={x} y={y - 4} textAnchor="middle" fill={col} fontSize={10} fontWeight={600}>{label}</text>
{sub && <text x={x} y={y + 10} textAnchor="middle" fill={col} fontSize={9} opacity={0.7}>{sub}</text>}
</g>
)
const Arr = ({ x1, y1, x2, y2, col, w }: { x1: number; y1: number; x2: number; y2: number; col: string; w: number }) => (
<line x1={x1} y1={y1} x2={x2} y2={y2} stroke={col} strokeWidth={w} opacity={0.6}
markerEnd={`url(#a${col.replace('#', '')})`} />
) )
const eurusdChange = r.delta_pips return (
const eurusdColor = eurusdChange < -5 ? 'text-rose-400' : eurusdChange > 5 ? 'text-emerald-400' : 'text-slate-300' <svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="w-full">
<defs>{colors.map(c => mk(`a${c.replace('#', '')}`, c))}</defs>
{/* US chain */}
<Node x={usX} y={y1} label="CPI US" sub={`srp ${sign(r.fed_pressure)}${(r.fed_pressure * 0.3).toFixed(1)}`} col={r.fed_pressure > 0.3 ? '#f87171' : '#94a3b8'} w={88} />
<Node x={usX} y={y2} label="NFP" sub="srp" col={r.fed_pressure > 0.3 ? '#f87171' : '#94a3b8'} w={88} />
<Node x={usX} y={y3} label="PMI US" col="#94a3b8" w={88} />
<Arr x1={usX} y1={y1 + 18} x2={usX} y2={yCB - 20} col={fedC} w={aw(r.fed_pressure)} />
<Node x={usX} y={yCB} label="FED" sub={`pression ${sign(r.fed_pressure)}${r.fed_pressure.toFixed(1)}`} col={fedC} w={110} />
<Arr x1={usX} y1={yCB + 18} x2={usX} y2={yYld - 18} col={us2C} w={aw(r.us_2y_implied - base.us_2y)} />
<Node x={usX} y={yYld} label="US 2Y" sub={`${r.us_2y_implied.toFixed(2)}%`} col={us2C} w={100} />
<Arr x1={usX + 50} y1={yYld + 10} x2={W / 2 - 42} y2={yDiff - 12} col={us2C} w={aw(r.us_2y_implied - base.us_2y)} />
{/* EU chain */}
<Node x={euX} y={y1} label="CPI EU" sub="srp" col={r.ecb_pressure > 0.3 ? '#34d399' : '#94a3b8'} w={88} />
<Node x={euX} y={y2} label="PMI EU" sub={r.ecb_pressure > 0.3 ? 'fort' : 'faible'} col={r.ecb_pressure > 0.3 ? '#34d399' : '#94a3b8'} w={88} />
<Arr x1={euX} y1={y1 + 18} x2={euX} y2={yCB - 20} col={ecbC} w={aw(r.ecb_pressure)} />
<Node x={euX} y={yCB} label="BCE" sub={`pression ${sign(r.ecb_pressure)}${r.ecb_pressure.toFixed(1)}`} col={ecbC} w={110} />
<Arr x1={euX} y1={yCB + 18} x2={euX} y2={yYld - 18} col={eu2C} w={aw(r.eu_2y_implied - base.eu_2y)} />
<Node x={euX} y={yYld} label="Bund 2Y" sub={`${r.eu_2y_implied.toFixed(2)}%`} col={eu2C} w={100} />
<Arr x1={euX - 50} y1={yYld + 10} x2={W / 2 + 42} y2={yDiff - 12} col={eu2C} w={aw(r.eu_2y_implied - base.eu_2y)} />
{/* Rate differential */}
<Node x={W / 2} y={yDiff} label="Δ Taux 2Y" sub={`${r.rate_diff.toFixed(2)}% (USEU)`} col={diffC} w={132} />
<Arr x1={W / 2} y1={yDiff + 18} x2={W / 2} y2={yFX - 22} col={diffC} w={aw(r.rate_diff - (base.us_2y - base.eu_2y))} />
{/* VIX */}
<Node x={riskX} y={yDiff} label="VIX" sub="risk-off" col="#94a3b8" w={60} />
<Arr x1={riskX + 30} y1={yDiff + 5} x2={W / 2 - 70} y2={yFX - 8} col="#94a3b8" w={1.2} />
{/* EURUSD */}
<rect x={W / 2 - 78} y={yFX - 22} width={156} height={44} rx={8} fill="#1e3a5f" stroke={fxC} strokeWidth={2} />
<text x={W / 2} y={yFX - 5} textAnchor="middle" fill={fxC} fontSize={11} fontWeight={700}>EURUSD</text>
<text x={W / 2} y={yFX + 13} textAnchor="middle" fill={fxC} fontSize={13} fontWeight={800}>{r.eurusd.toFixed(4)}</text>
</svg>
)
}
// ── Sensitivity bar ────────────────────────────────────────────────────────────
function SensBar({ label, pips, maxAbs }: { label: string; pips: number; maxAbs: number }) {
const pct = maxAbs > 0 ? (Math.abs(pips) / maxAbs) * 100 : 0
return (
<div className="grid grid-cols-[130px_1fr_48px] items-center gap-2">
<span className="text-xs text-slate-400 truncate">{label}</span>
<div className="h-2 bg-dark-900 rounded overflow-hidden">
<div className={clsx('h-full rounded transition-all', pips > 0 ? 'bg-emerald-500' : 'bg-rose-500')}
style={{ width: `${pct}%` }} />
</div>
<span className={clsx('text-xs font-mono text-right', pips > 0 ? 'text-emerald-400' : 'text-rose-400')}>
{pips > 0 ? '+' : ''}{pips}p
</span>
</div>
)
}
function Section({ title, children, color }: { title: string; children: React.ReactNode; color: string }) {
return (
<div className={clsx('rounded-lg border p-3 space-y-3', color)}>
<div className="text-xs font-semibold uppercase tracking-wider text-slate-300">{title}</div>
{children}
</div>
)
}
// ── 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<string, string> }
export default function EuroSimulator() {
const [base, setBase] = useState<Params>(FALLBACK)
const [p, setP] = useState<Params>(FALLBACK)
const [liveStatus, setLive] = useState<'loading' | 'ok' | 'fallback'>('loading')
const [fetchedAt, setAt] = useState<string | null>(null)
const [sources, setSources] = useState<Record<string, string>>({})
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 ( return (
<div className="p-4 max-w-[1400px] mx-auto space-y-4"> <div className="p-4 max-w-[1400px] mx-auto space-y-4">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -374,21 +361,35 @@ export default function EuroSimulator() {
<p className="text-xs text-slate-500">Modèle causal sans données historiques, purement simulé</p> <p className="text-xs text-slate-500">Modèle causal sans données historiques, purement simulé</p>
</div> </div>
</div> </div>
<button
onClick={() => setP(BASE)} <div className="flex items-center gap-3">
className="flex items-center gap-1.5 px-3 py-1.5 rounded border border-slate-700/40 text-xs text-slate-400 hover:text-white hover:border-slate-500 transition-colors" {/* Live status badge */}
> <div className={clsx('flex items-center gap-1.5 px-2.5 py-1 rounded border text-xs',
<RefreshCw className="w-3.5 h-3.5" /> liveStatus === 'loading' ? 'border-slate-700/40 text-slate-500' :
Reset liveStatus === 'ok' ? 'border-emerald-700/40 bg-emerald-900/20 text-emerald-400' :
</button> 'border-amber-700/40 bg-amber-900/20 text-amber-400',
)}>
{liveStatus === 'ok'
? <Wifi className="w-3 h-3" />
: <WifiOff className="w-3 h-3" />}
{liveStatus === 'loading' ? 'Chargement…' :
liveStatus === 'ok' ? `Live — base ${fetchedAt ? fetchedAt.slice(0,10) : ''}` :
'Fallback — données hardcodées'}
</div>
<button onClick={() => setP(base)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded border border-slate-700/40 text-xs text-slate-400 hover:text-white hover:border-slate-500 transition-colors">
<RefreshCw className="w-3.5 h-3.5" />
Reset
</button>
</div>
</div> </div>
<div className="grid grid-cols-[320px_1fr_270px] gap-4 items-start"> <div className="grid grid-cols-[320px_1fr_270px] gap-4 items-start">
{/* ── Left: Controls ──────────────────────────────────────────────── */} {/* ── Controls ──────────────────────────────────────────────────────── */}
<div className="space-y-3"> <div className="space-y-3">
{/* FED / US */}
<Section title="🇺🇸 Politique monétaire US (FED)" color="border-rose-900/30 bg-rose-950/10"> <Section title="🇺🇸 Politique monétaire US (FED)" color="border-rose-900/30 bg-rose-950/10">
<Slider label="Taux directeur FED" value={p.fed_rate} min={0} max={6} step={0.25} <Slider label="Taux directeur FED" value={p.fed_rate} min={0} max={6} step={0.25}
format={v => `${v.toFixed(2)}%`} onChange={v => set('fed_rate', v)} /> format={v => `${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)} /> format={v => v.toFixed(1)} onChange={v => set('pmi_us', v)} />
</Section> </Section>
{/* ECB / EU */}
<Section title="🇪🇺 Politique monétaire EU (BCE)" color="border-emerald-900/30 bg-emerald-950/10"> <Section title="🇪🇺 Politique monétaire EU (BCE)" color="border-emerald-900/30 bg-emerald-950/10">
<Slider label="Taux directeur BCE" value={p.ecb_rate} min={0} max={5} step={0.25} <Slider label="Taux directeur BCE" value={p.ecb_rate} min={0} max={5} step={0.25}
format={v => `${v.toFixed(2)}%`} onChange={v => set('ecb_rate', v)} /> format={v => `${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)} /> format={v => v.toFixed(1)} onChange={v => set('pmi_eu', v)} />
</Section> </Section>
{/* Markets */}
<Section title="📊 Marchés & sentiment" color="border-slate-700/30 bg-dark-800/60"> <Section title="📊 Marchés & sentiment" color="border-slate-700/30 bg-dark-800/60">
<Slider label="VIX" value={p.vix} min={10} max={60} step={0.5} <Slider label="VIX" value={p.vix} min={10} max={60} step={0.5}
format={v => v.toFixed(1)} onChange={v => set('vix', v)} format={v => v.toFixed(1)} onChange={v => set('vix', v)}
colorize reverse center={BASE.vix} /> colorize reverse center={base.vix} />
<Slider label="Pétrole (USD/bbl)" value={p.oil} min={40} max={130} step={1} <Slider label="Pétrole (USD/bbl)" value={p.oil} min={40} max={130} step={1}
format={v => `$${v.toFixed(0)}`} onChange={v => set('oil', v)} /> format={v => `$${v.toFixed(0)}`} onChange={v => set('oil', v)} />
<Slider label="Taux réel US 10Y" value={p.real_yield_us} min={-1} max={4} step={0.05} <Slider label="Taux réel US 10Y" value={p.real_yield_us} min={-1} max={4} step={0.05}
format={v => `${v > 0 ? '+' : ''}${v.toFixed(2)}%`} format={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} />
</Section> </Section>
{/* Sources panel */}
{liveStatus === 'ok' && Object.keys(sources).length > 0 && (
<div className="rounded-lg border border-slate-800/60 bg-dark-900/60 p-3 space-y-1">
<div className="text-[10px] text-slate-600 uppercase tracking-wider mb-1">Sources baseline</div>
{Object.entries(sources).map(([k, v]) => (
<div key={k} className="flex justify-between text-[10px]">
<span className="text-slate-600">{k}</span>
<span className="text-slate-500 font-mono">{v}</span>
</div>
))}
</div>
)}
</div> </div>
{/* ── Center: Causal chain ────────────────────────────────────────── */} {/* ── Causal chain ──────────────────────────────────────────────────── */}
<div className="bg-dark-800/60 rounded-xl border border-slate-700/30 p-4 flex flex-col items-center"> <div className="bg-dark-800/60 rounded-xl border border-slate-700/30 p-4 flex flex-col items-center">
<div className="text-xs text-slate-500 mb-3 uppercase tracking-wider">Chaîne de transmission causale</div> <div className="text-xs text-slate-500 mb-3 uppercase tracking-wider">Chaîne de transmission causale</div>
{/* Yield display strip */} {/* Live metric strip */}
<div className="w-full grid grid-cols-4 gap-2 mb-4"> <div className="w-full grid grid-cols-4 gap-2 mb-4">
{[ {[
{ 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: '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', 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: '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', 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: 'Δ 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', value: p.vix.toFixed(0), color: p.vix > 25 ? 'text-rose-400' : p.vix < 15 ? 'text-emerald-400' : 'text-slate-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 => ( ].map(it => (
<div key={it.label} className="bg-dark-900 rounded-lg p-2 text-center"> <div key={it.label} className="bg-dark-900 rounded-lg p-2 text-center">
<div className="text-[10px] text-slate-600 uppercase tracking-wider">{it.label}</div> <div className="text-[10px] text-slate-600 uppercase tracking-wider">{it.label}</div>
<div className={clsx('text-sm font-mono font-semibold mt-0.5', it.color)}>{it.value}</div> <div className={clsx('text-sm font-mono font-semibold mt-0.5', it.col)}>{it.val}</div>
</div> </div>
))} ))}
</div> </div>
<CausalChain r={r} /> <CausalChain r={r} base={base} />
{/* Baseline note */} <div className="mt-2 text-[10px] text-slate-600 text-center">
<div className="mt-3 text-[10px] text-slate-600 text-center"> 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)}
Référence: EUR/USD {BASE_EURUSD.toFixed(4)} · US 2Y {BASE_US_2Y}% · Bund 2Y {BASE_EU_2Y}% · VIX {BASE.vix}
</div> </div>
</div> </div>
{/* ── Right: Results ──────────────────────────────────────────────── */} {/* ── Results ───────────────────────────────────────────────────────── */}
<div className="space-y-3"> <div className="space-y-3">
{/* EUR/USD display */} {/* EUR/USD display */}
@@ -465,67 +476,55 @@ export default function EuroSimulator() {
{r.eurusd.toFixed(4)} {r.eurusd.toFixed(4)}
</div> </div>
<div className={clsx('text-base font-mono mt-1', eurusdColor)}> <div className={clsx('text-base font-mono mt-1', eurusdColor)}>
{eurusdChange === 0 ? 'Neutre' : `${eurusdChange > 0 ? '+' : ''}${eurusdChange} pips`} {r.delta_pips === 0 ? 'Neutre' : `${r.delta_pips > 0 ? '+' : ''}${r.delta_pips} pips`}
</div>
<div className="text-xs text-slate-600 mt-1">
vs base {BASE_EURUSD.toFixed(4)}
</div> </div>
<div className="text-xs text-slate-600 mt-1">vs base {base.eurusd.toFixed(4)}</div>
{/* Pressure gauges */} {/* Pressure gauges */}
<div className="grid grid-cols-2 gap-2 mt-4"> <div className="grid grid-cols-2 gap-2 mt-4">
{[ {[
{ 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 FED', v: r.fed_pressure, posLabel: 'Hawkish', negLabel: 'Dovish', posCol: 'text-rose-400', negCol: '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' }, { label: 'Pression BCE', v: r.ecb_pressure, posLabel: 'Hawkish', negLabel: 'Dovish', posCol: 'text-emerald-400', negCol: 'text-rose-400' },
].map(g => { ].map(g => (
const isPos = g.v > 0.2 <div key={g.label} className="bg-dark-900 rounded p-2">
const isNeg = g.v < -0.2 <div className="text-[10px] text-slate-600">{g.label}</div>
return ( <div className={clsx('text-xs font-semibold',
<div key={g.label} className="bg-dark-900 rounded p-2"> g.v > 0.2 ? g.posCol : g.v < -0.2 ? g.negCol : 'text-slate-400',
<div className="text-[10px] text-slate-600">{g.label}</div> )}>
<div className={clsx('text-xs font-semibold', {g.v > 0.2 ? g.posLabel : g.v < -0.2 ? g.negLabel : 'Neutre'}
isPos ? g.pos_color : isNeg ? g.neg_color : 'text-slate-400',
)}>
{isPos ? g.pos_label : isNeg ? g.neg_label : 'Neutre'}
</div>
<div className="text-[10px] font-mono text-slate-500">
{g.v > 0 ? '+' : ''}{g.v.toFixed(1)}
</div>
</div> </div>
) <div className="text-[10px] font-mono text-slate-500">{g.v > 0 ? '+' : ''}{g.v.toFixed(1)}</div>
})} </div>
))}
</div> </div>
</div> </div>
{/* Pip decomposition */} {/* Pip decomposition */}
<div className="bg-dark-800/60 rounded-xl border border-slate-700/30 p-4 space-y-2"> <div className="bg-dark-800/60 rounded-xl border border-slate-700/30 p-4 space-y-2">
<div className="text-xs text-slate-500 uppercase tracking-wider mb-2">Décomposition ({eurusdChange > 0 ? '+' : ''}{eurusdChange} pips)</div> <div className="text-xs text-slate-500 uppercase tracking-wider mb-2">
{r.contribs.map(c => ( Décomposition ({r.delta_pips > 0 ? '+' : ''}{r.delta_pips} pips)
<SensBar key={c.label} label={c.label} pips={c.pips} maxAbs={maxAbs} /> </div>
{r.contribs.map(c => <SensBar key={c.label} label={c.label} pips={c.pips} maxAbs={maxAbs} />)}
</div>
{/* Relative influence */}
<div className="bg-dark-800/60 rounded-xl border border-slate-700/30 p-3 space-y-1.5">
<div className="text-xs text-slate-500 uppercase tracking-wider mb-1">Influence relative</div>
{r.contribs.slice(0, 5).map(c => (
<div key={c.label} className="flex items-center gap-2 text-[11px]">
<div className={clsx('w-2 h-2 rounded-full shrink-0', c.pips > 0 ? 'bg-emerald-500' : 'bg-rose-500')} />
<span className="text-slate-400 flex-1 truncate">{c.label}</span>
<span className="text-slate-500 font-mono">
{maxAbs > 0 ? Math.round((Math.abs(c.pips) / maxAbs) * 100) : 0}%
</span>
</div>
))} ))}
</div> </div>
{/* Dominance legend */}
<div className="bg-dark-800/60 rounded-xl border border-slate-700/30 p-3 space-y-1.5">
<div className="text-xs text-slate-500 uppercase tracking-wider mb-1">Influence relative</div>
{r.contribs.slice(0, 5).map(c => {
const pct = maxAbs > 0 ? Math.round((Math.abs(c.pips) / maxAbs) * 100) : 0
return (
<div key={c.label} className="flex items-center gap-2 text-[11px]">
<div className={clsx('w-2 h-2 rounded-full shrink-0',
c.pips > 0 ? 'bg-emerald-500' : 'bg-rose-500',
)} />
<span className="text-slate-400 flex-1 truncate">{c.label}</span>
<span className="text-slate-500 font-mono">{pct}%</span>
</div>
)
})}
</div>
{/* Interpretation note */}
<div className="bg-dark-900/80 rounded-lg border border-slate-800/60 p-3 text-[10px] text-slate-600 leading-relaxed"> <div className="bg-dark-900/80 rounded-lg border border-slate-800/60 p-3 text-[10px] text-slate-600 leading-relaxed">
<span className="text-slate-500 font-medium">Note:</span> Modèle linéarisé heuristique. <span className="text-slate-500 font-medium">Note:</span> Modèle linéarisé heuristique.
Le différentiel de taux 2Y explique ~60-70% des mouvements FX à moyen terme. La base est chargée en live (taux, VIX, Brent, EURUSD spot) au démarrage.
Coefficients estimés non calibrés sur données historiques. Les coefficients de transmission restent heuristiques non calibrés sur données historiques.
</div> </div>
</div> </div>
</div> </div>