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:
@@ -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("/")
|
||||
|
||||
176
backend/routers/simulator.py
Normal file
176
backend/routers/simulator.py
Normal 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
|
||||
@@ -1,41 +1,45 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { RefreshCw, Sliders } from 'lucide-react'
|
||||
import { useState, useMemo, useEffect, useRef } from 'react'
|
||||
import { RefreshCw, Sliders, Wifi, WifiOff } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// ── Model types ────────────────────────────────────────────────────────────────
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Params {
|
||||
// FED / US
|
||||
fed_rate: number // 0–6, step 0.25
|
||||
fed_tone: number // –3 hawkish → +3 dovish (flipped: positive = more cuts)
|
||||
cpi_us_surprise: number // –0.5 → +0.5 %
|
||||
nfp_surprise: number // –300 → +300 k
|
||||
fed_rate: number // absolute, step 0.25
|
||||
fed_tone: number // –3 hawkish … +3 dovish
|
||||
cpi_us_surprise: number // ±0.5 %
|
||||
nfp_surprise: number // ±300 k
|
||||
pmi_us: number // 40–65
|
||||
// ECB / EU
|
||||
ecb_rate: number // 0–5, step 0.25
|
||||
ecb_tone: number // –3 hawkish → +3 dovish
|
||||
cpi_eu_surprise: number // –0.5 → +0.5 %
|
||||
ecb_rate: number // absolute, step 0.25
|
||||
ecb_tone: number // –3 hawkish … +3 dovish
|
||||
cpi_eu_surprise: number // ±0.5 %
|
||||
pmi_eu: number // 40–65
|
||||
// Markets
|
||||
vix: number // 10–60
|
||||
oil: number // 40–130
|
||||
real_yield_us: number // 0–4 %
|
||||
real_yield_us: number // –1 … 4 %
|
||||
// Base anchors (not displayed as sliders — set from live data)
|
||||
us_2y: number // US 2Y yield reference
|
||||
eu_2y: number // EU 2Y yield reference
|
||||
eurusd: number // EURUSD base price
|
||||
}
|
||||
|
||||
interface ModelResult {
|
||||
fed_pressure: number // positive = hawkish (USD up)
|
||||
ecb_pressure: number // positive = hawkish (EUR up)
|
||||
us_2y: number
|
||||
eu_2y: number
|
||||
rate_diff: number // US 2Y – EU 2Y
|
||||
fed_pressure: number
|
||||
ecb_pressure: number
|
||||
us_2y_implied: number
|
||||
eu_2y_implied: number
|
||||
rate_diff: number
|
||||
eurusd: number
|
||||
delta_pips: number
|
||||
contribs: { label: string; pips: number; color: 'red' | 'green' | 'slate' }[]
|
||||
}
|
||||
|
||||
// ── Baseline scenario (neutral / today's approximate) ─────────────────────────
|
||||
// ── Hardcoded fallback (used before live data arrives) ─────────────────────────
|
||||
|
||||
const BASE: Params = {
|
||||
const FALLBACK: Params = {
|
||||
fed_rate: 4.25, ecb_rate: 3.65,
|
||||
fed_tone: 0, ecb_tone: 0,
|
||||
cpi_us_surprise: 0, cpi_eu_surprise: 0,
|
||||
@@ -43,303 +47,149 @@ const BASE: Params = {
|
||||
pmi_us: 50, pmi_eu: 50,
|
||||
vix: 18, oil: 80,
|
||||
real_yield_us: 2.1,
|
||||
us_2y: 4.50, eu_2y: 2.80,
|
||||
eurusd: 1.1450,
|
||||
}
|
||||
|
||||
const BASE_US_2Y = 4.50
|
||||
const BASE_EU_2Y = 2.80
|
||||
const BASE_EURUSD = 1.1450
|
||||
// ── Causal model ───────────────────────────────────────────────────────────────
|
||||
// All deltas are computed vs the base anchors embedded in `base`.
|
||||
// Surprises / tones / PMI are always centered at their neutral value (0 / 50).
|
||||
|
||||
// ── Causal model (linearised, directionally correct coefficients) ──────────────
|
||||
function compute(p: Params, base: Params): ModelResult {
|
||||
const BASE_US_2Y = base.us_2y
|
||||
const BASE_EU_2Y = base.eu_2y
|
||||
const BASE_EURUSD = base.eurusd
|
||||
|
||||
function compute(p: Params): ModelResult {
|
||||
// Fed pressure: positive = hawkish (higher rates, USD up, EUR/USD down)
|
||||
// Fed pressure: positive = hawkish (USD up, EUR/USD down)
|
||||
const fed_pressure =
|
||||
(p.fed_rate - BASE.fed_rate) / 0.25 * 1.0 // each 25 bps counts 1 unit
|
||||
- p.fed_tone * 1.5 // tone (positive fed_tone = dovish)
|
||||
+ p.cpi_us_surprise / 0.1 * 0.7 // each 0.1% CPI surprise
|
||||
+ p.nfp_surprise / 100 * 0.45 // each 100k NFP
|
||||
(p.fed_rate - base.fed_rate) / 0.25 * 1.0
|
||||
- p.fed_tone * 1.5
|
||||
+ p.cpi_us_surprise / 0.1 * 0.7
|
||||
+ p.nfp_surprise / 100 * 0.45
|
||||
|
||||
// ECB pressure: positive = hawkish (higher rates, EUR up, EUR/USD up)
|
||||
// ECB pressure: positive = hawkish (EUR up, EUR/USD up)
|
||||
const ecb_pressure =
|
||||
(p.ecb_rate - BASE.ecb_rate) / 0.25 * 1.0
|
||||
(p.ecb_rate - base.ecb_rate) / 0.25 * 1.0
|
||||
- p.ecb_tone * 1.5
|
||||
+ p.cpi_eu_surprise / 0.1 * 0.55
|
||||
+ (p.pmi_eu - 50) / 5 * 0.25
|
||||
|
||||
// Implied yields (each "pressure unit" moves 2Y by ~8–9 bps)
|
||||
const us_2y = BASE_US_2Y + fed_pressure * 0.09
|
||||
const eu_2y = BASE_EU_2Y + ecb_pressure * 0.08
|
||||
// Implied 2Y yields
|
||||
const us_2y_implied = BASE_US_2Y + fed_pressure * 0.09
|
||||
const eu_2y_implied = BASE_EU_2Y + ecb_pressure * 0.08
|
||||
|
||||
// Rate differential (positive = USD premium = EUR/USD down)
|
||||
const rate_diff = us_2y - eu_2y
|
||||
const rate_diff_delta = rate_diff - (BASE_US_2Y - BASE_EU_2Y)
|
||||
// Rate differential delta vs baseline
|
||||
const rate_diff_delta = (us_2y_implied - eu_2y_implied) - (BASE_US_2Y - BASE_EU_2Y)
|
||||
const rate_diff = us_2y_implied - eu_2y_implied
|
||||
|
||||
// PMI growth differential (positive = EU stronger = EUR/USD up)
|
||||
// PMI growth differential (positive = EU stronger)
|
||||
const pmi_diff = (p.pmi_eu - 50) - (p.pmi_us - 50)
|
||||
|
||||
// VIX: risk-off (VIX up) → USD safe haven → EUR/USD down
|
||||
const vix_dev = p.vix - BASE.vix
|
||||
// VIX / risk-off (positive vix_dev = risk-off = USD up = EUR/USD down)
|
||||
const vix_dev = p.vix - base.vix
|
||||
const ry_dev = p.real_yield_us - base.real_yield_us
|
||||
const oil_dev = p.oil - base.oil
|
||||
|
||||
// Real yield US: higher = USD stronger
|
||||
const ry_dev = p.real_yield_us - BASE.real_yield_us
|
||||
|
||||
// Oil: mild EUR-positive (Europe imports oil priced in USD; USD appreciation hurts)
|
||||
const oil_dev = p.oil - BASE.oil
|
||||
|
||||
// ── Contributions to EUR/USD in pips ─────────────────────────────────────
|
||||
// Rate differential is the dominant channel (~60–70% of FX moves at medium term)
|
||||
const c_rate = Math.round(-rate_diff_delta * 650) // 1% spread = ~650 pips
|
||||
// PMI/growth differential — smaller effect
|
||||
// ── Pip contributions ─────────────────────────────────────────────────────
|
||||
const c_rate = Math.round(-rate_diff_delta * 650) // ~650 pips per 1% spread
|
||||
const c_pmi = Math.round(pmi_diff * 8)
|
||||
// VIX risk-off channel
|
||||
const c_vix = Math.round(-vix_dev * 4)
|
||||
// Real yield (additional beyond what's in rate diff)
|
||||
const c_ry = Math.round(-ry_dev * 120)
|
||||
// Oil
|
||||
const c_oil = Math.round(oil_dev * 0.5)
|
||||
// NFP direct effect (beyond FED channel)
|
||||
const c_nfp = Math.round(-p.nfp_surprise / 100 * 30)
|
||||
// CPI US direct effect (beyond FED channel)
|
||||
const c_cpi_us = Math.round(-p.cpi_us_surprise / 0.1 * 20)
|
||||
// CPI EU direct effect (beyond ECB channel)
|
||||
const c_cpi_eu = Math.round(p.cpi_eu_surprise / 0.1 * 15)
|
||||
const c_nfp = Math.round(-(p.nfp_surprise / 100) * 30)
|
||||
const c_cpi_us = Math.round(-(p.cpi_us_surprise / 0.1) * 20)
|
||||
const c_cpi_eu = Math.round((p.cpi_eu_surprise / 0.1) * 15)
|
||||
|
||||
const total_pips = c_rate + c_pmi + c_vix + c_ry + c_oil + c_nfp + c_cpi_us + c_cpi_eu
|
||||
|
||||
const contribs: ModelResult['contribs'] = ([
|
||||
{ label: 'Δ Taux (US 2Y – Bund)', pips: c_rate, color: c_rate < 0 ? 'red' : 'green' },
|
||||
{ label: 'CPI US (surprise)', pips: c_cpi_us, color: c_cpi_us < 0 ? 'red' : 'green' },
|
||||
{ label: 'NFP (surprise)', pips: c_nfp, color: c_nfp < 0 ? 'red' : 'green' },
|
||||
{ label: 'CPI EU (surprise)', pips: c_cpi_eu, color: c_cpi_eu > 0 ? 'green' : 'red' },
|
||||
{ label: 'PMI diff (EU−US)', pips: c_pmi, color: c_pmi > 0 ? 'green' : c_pmi < 0 ? 'red' : 'slate' },
|
||||
{ label: 'VIX / Risk off', pips: c_vix, color: c_vix < 0 ? 'red' : 'green' },
|
||||
{ label: 'Taux réel US', pips: c_ry, color: c_ry < 0 ? 'red' : 'green' },
|
||||
{ label: 'Pétrole', pips: c_oil, color: c_oil > 0 ? 'green' : 'slate' },
|
||||
const contribs = ([
|
||||
{ label: 'Δ Taux (US 2Y – Bund)', pips: c_rate, color: c_rate < 0 ? 'red' : c_rate > 0 ? 'green' : 'slate' },
|
||||
{ label: 'CPI US (surprise)', pips: c_cpi_us, color: c_cpi_us < 0 ? 'red' : c_cpi_us > 0 ? 'green' : 'slate' },
|
||||
{ label: 'NFP (surprise)', pips: c_nfp, color: c_nfp < 0 ? 'red' : c_nfp > 0 ? 'green' : 'slate' },
|
||||
{ label: 'CPI EU (surprise)', pips: c_cpi_eu, color: c_cpi_eu > 0 ? 'green' : c_cpi_eu < 0 ? 'red' : 'slate' },
|
||||
{ label: 'PMI diff (EU−US)', pips: c_pmi, color: c_pmi > 0 ? 'green' : c_pmi < 0 ? 'red' : 'slate' },
|
||||
{ label: 'VIX / Risk off', pips: c_vix, color: c_vix < 0 ? 'red' : c_vix > 0 ? 'green' : 'slate' },
|
||||
{ label: 'Taux réel US', pips: c_ry, color: c_ry < 0 ? 'red' : c_ry > 0 ? 'green' : 'slate' },
|
||||
{ label: 'Pétrole', pips: c_oil, color: c_oil > 0 ? 'green' : c_oil < 0 ? 'red' : 'slate' },
|
||||
] as ModelResult['contribs']).sort((a, b) => Math.abs(b.pips) - Math.abs(a.pips))
|
||||
|
||||
return {
|
||||
fed_pressure, ecb_pressure,
|
||||
us_2y, eu_2y, rate_diff,
|
||||
us_2y_implied, eu_2y_implied, rate_diff,
|
||||
eurusd: BASE_EURUSD + total_pips / 10000,
|
||||
delta_pips: total_pips,
|
||||
contribs,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Slider component ──────────────────────────────────────────────────────────
|
||||
// ── Slider ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Slider({
|
||||
label, value, min, max, step, format, onChange,
|
||||
colorize = false, reverse = false, center,
|
||||
}: {
|
||||
label: string; value: number; min: number; max: number; step: number
|
||||
format: (v: number) => string
|
||||
onChange: (v: number) => void
|
||||
colorize?: boolean
|
||||
reverse?: boolean // true = value goes up → negative (red)
|
||||
center?: number // neutral pivot point (defaults to 0; use baseline value if 0 is out of range)
|
||||
format: (v: number) => string; onChange: (v: number) => void
|
||||
colorize?: boolean; reverse?: boolean; center?: number
|
||||
}) {
|
||||
const pct = ((value - min) / (max - min)) * 100
|
||||
const pivot = center ?? 0
|
||||
// Clamp mid to [0,100] so the track never overflows the container
|
||||
const mid = Math.max(0, Math.min(100, ((pivot - min) / (max - min)) * 100))
|
||||
const pct = ((value - min) / (max - min)) * 100
|
||||
const pivot = center ?? 0
|
||||
const mid = Math.max(0, Math.min(100, ((pivot - min) / (max - min)) * 100))
|
||||
const atBase = Math.abs(value - pivot) < step / 2
|
||||
|
||||
const atCenter = Math.abs(value - pivot) < step / 2
|
||||
let trackColor = 'bg-blue-500'
|
||||
if (colorize) {
|
||||
const isPositive = reverse ? value < pivot : value > pivot
|
||||
trackColor = atCenter ? 'bg-slate-600' : isPositive ? 'bg-emerald-500' : 'bg-rose-500'
|
||||
const pos = reverse ? value < pivot : value > pivot
|
||||
trackColor = atBase ? 'bg-slate-600' : pos ? 'bg-emerald-500' : 'bg-rose-500'
|
||||
}
|
||||
|
||||
return (
|
||||
<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)}% (US−EU)`} 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)}% (US−EU)`} 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>
|
||||
|
||||
Reference in New Issue
Block a user