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 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("/")

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 { 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 // 06, 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 // 4065
// ECB / EU
ecb_rate: number // 05, 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 // 4065
// Markets
vix: number // 1060
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 {
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 ~89 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 (~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
// ── 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 (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: '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 (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' : 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 (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-400">{label}</span>
<span className={clsx(
'text-xs font-mono font-semibold',
colorize && !atCenter
<span className={clsx('text-xs font-mono font-semibold',
colorize && !atBase
? (reverse ? value < pivot : value > pivot) ? 'text-emerald-400' : 'text-rose-400'
: 'text-white',
)}>{format(value)}</span>
</div>
<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
? { left: `${Math.min(pct, mid)}%`, width: `${Math.abs(pct - mid)}%` }
: { left: 0, width: `${pct}%` }
}
/>
{/* Position tick — always visible so cursor location is clear at baseline */}
: { left: 0, width: `${pct}%` }} />
{colorize && (
<div
className="absolute top-0 bottom-0 w-0.5 bg-slate-400 transition-all"
style={{ left: `${Math.min(98.5, pct)}%` }}
/>
<div className="absolute top-0 bottom-0 w-0.5 bg-slate-400 transition-all"
style={{ left: `${Math.min(98.5, pct)}%` }} />
)}
<input
type="range" min={min} max={max} step={step} value={value}
<input type="range" min={min} max={max} step={step} value={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>
)
}
// ── 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 ─────────────────────────────────────────────────────────────
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 (
<div className="space-y-1">
<span className="text-xs text-slate-400">{label}</span>
<div className="flex gap-1">
{opts.map(o => (
<button
key={o.v}
onClick={() => onChange(o.v)}
className={clsx(
'flex-1 px-1 py-1 rounded border text-[10px] leading-tight text-center transition-colors',
{TONES.map(o => (
<button key={o.v} onClick={() => onChange(o.v)}
className={clsx('flex-1 px-1 py-1 rounded border text-[10px] leading-tight text-center transition-colors whitespace-pre-line',
value === o.v ? o.color : 'border-slate-700/30 text-slate-600 bg-dark-900 hover:border-slate-600',
)}
>
)}>
{o.label}
</button>
))}
@@ -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<Params>(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) => (
<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
const eurusdColor = eurusdChange < -5 ? 'text-rose-400' : eurusdChange > 5 ? 'text-emerald-400' : 'text-slate-300'
return (
<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 (
<div className="p-4 max-w-[1400px] mx-auto space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<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>
</div>
</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 className="flex items-center gap-3">
{/* Live status badge */}
<div className={clsx('flex items-center gap-1.5 px-2.5 py-1 rounded border text-xs',
liveStatus === 'loading' ? 'border-slate-700/40 text-slate-500' :
liveStatus === 'ok' ? 'border-emerald-700/40 bg-emerald-900/20 text-emerald-400' :
'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 className="grid grid-cols-[320px_1fr_270px] gap-4 items-start">
{/* ── Left: Controls ──────────────────────────────────────────────── */}
{/* ── Controls ──────────────────────────────────────────────────────── */}
<div className="space-y-3">
{/* FED / US */}
<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}
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)} />
</Section>
{/* ECB / EU */}
<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}
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)} />
</Section>
{/* Markets */}
<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}
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}
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}
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>
{/* 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>
{/* ── 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="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">
{[
{ 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 => (
<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={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>
<CausalChain r={r} />
<CausalChain r={r} base={base} />
{/* Baseline note */}
<div className="mt-3 text-[10px] text-slate-600 text-center">
Référence: EUR/USD {BASE_EURUSD.toFixed(4)} · US 2Y {BASE_US_2Y}% · Bund 2Y {BASE_EU_2Y}% · VIX {BASE.vix}
<div className="mt-2 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)}
</div>
</div>
{/* ── Right: Results ──────────────────────────────────────────────── */}
{/* ── Results ───────────────────────────────────────────────────────── */}
<div className="space-y-3">
{/* EUR/USD display */}
@@ -465,67 +476,55 @@ export default function EuroSimulator() {
{r.eurusd.toFixed(4)}
</div>
<div className={clsx('text-base font-mono mt-1', eurusdColor)}>
{eurusdChange === 0 ? 'Neutre' : `${eurusdChange > 0 ? '+' : ''}${eurusdChange} pips`}
</div>
<div className="text-xs text-slate-600 mt-1">
vs base {BASE_EURUSD.toFixed(4)}
{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>
{/* Pressure gauges */}
<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 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 (
<div key={g.label} className="bg-dark-900 rounded p-2">
<div className="text-[10px] text-slate-600">{g.label}</div>
<div className={clsx('text-xs font-semibold',
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>
{ 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 => (
<div key={g.label} className="bg-dark-900 rounded p-2">
<div className="text-[10px] text-slate-600">{g.label}</div>
<div className={clsx('text-xs font-semibold',
g.v > 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'}
</div>
)
})}
<div className="text-[10px] font-mono text-slate-500">{g.v > 0 ? '+' : ''}{g.v.toFixed(1)}</div>
</div>
))}
</div>
</div>
{/* Pip decomposition */}
<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>
{r.contribs.map(c => (
<SensBar key={c.label} label={c.label} pips={c.pips} maxAbs={maxAbs} />
<div className="text-xs text-slate-500 uppercase tracking-wider mb-2">
Décomposition ({r.delta_pips > 0 ? '+' : ''}{r.delta_pips} pips)
</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>
{/* 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">
<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.
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.
</div>
</div>
</div>