import { useState, useMemo, useEffect, useRef } from 'react'
import { RefreshCw, Sliders, Wifi, WifiOff } from 'lucide-react'
import clsx from 'clsx'
// ── Types ──────────────────────────────────────────────────────────────────────
interface Params {
// FED / US
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 // 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 // –1 … 4 %
// Base anchors (set from live data, not displayed as sliders)
us_2y: number
us_10y: number
eu_2y: number
eu_10y: number
eurusd: number
}
interface ModelResult {
fed_rate_pressure: number // rate channel signal (→ 2Y primarily)
fed_fwd_signal: number // tone/data channel signal (→ 10Y primarily)
ecb_rate_pressure: number
ecb_fwd_signal: number
us_2y_implied: number
us_10y_implied: number
eu_2y_implied: number
eu_10y_implied: number
rate_diff_2y: number
rate_diff_10y: number
eurusd: number
delta_pips: number
contribs: { label: string; pips: number; color: 'red' | 'green' | 'slate' }[]
}
// ── Hardcoded fallback ─────────────────────────────────────────────────────────
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,
nfp_surprise: 0,
pmi_us: 50, pmi_eu: 50,
vix: 18, oil: 80, real_yield_us: 2.1,
us_2y: 4.50, us_10y: 4.30,
eu_2y: 2.80, eu_10y: 2.60,
eurusd: 1.1450,
}
// ── Causal model ───────────────────────────────────────────────────────────────
//
// Two distinct transmission channels:
// 1. Rate channel (fait accompli → anchors short end):
// fed_rate change → US 2Y (coefficient ~0.085)
// 2. Expectation/tone channel (forward guidance → long end):
// fed_tone + CPI/NFP surprises → US 10Y (coefficient ~0.070)
// with residual bleed into 2Y (~0.030) and 10Y from rate (~0.035)
//
// EUR/USD pip contributions:
// Δ 2Y differential → −500 pips / 1% spread widening
// Δ 10Y differential → −200 pips / 1% spread widening
// PMI diff, VIX, real yield, oil as secondary channels
function compute(p: Params, base: Params): ModelResult {
// ── Rate pressure (current policy rate, already priced → hits 2Y hard) ─────
const fed_rate_pressure = (p.fed_rate - base.fed_rate) / 0.25 // units of 25bps
const ecb_rate_pressure = (p.ecb_rate - base.ecb_rate) / 0.25
// ── Forward guidance / expectations (tone + data → shapes future path) ──────
// Positive = hawkish signal for USD (tone: +3 dovish → signal = −3*1.2 → negative → dovish)
const fed_fwd_signal =
-p.fed_tone * 1.2
+ p.cpi_us_surprise / 0.1 * 0.50 // hot CPI → more hikes expected
+ p.nfp_surprise / 100 * 0.35 // strong jobs → Fed stays restrictive
+ (p.pmi_us - 50) / 5 * 0.18 // strong activity → Fed stays restrictive longer
const ecb_fwd_signal =
-p.ecb_tone * 1.2
+ p.cpi_eu_surprise / 0.1 * 0.45
+ (p.pmi_eu - 50) / 5 * 0.22
// ── Implied yields ─────────────────────────────────────────────────────────
// 2Y: 90% anchored by current rate, 10% by near-term guidance
const us_2y_delta = fed_rate_pressure * 0.085 + fed_fwd_signal * 0.030
const eu_2y_delta = ecb_rate_pressure * 0.080 + ecb_fwd_signal * 0.025
// 10Y: 35% by current rate (level shift), 65% by long-term expectations
const us_10y_delta = fed_rate_pressure * 0.035 + fed_fwd_signal * 0.070 + (p.pmi_us - 50) / 50 * 0.012
const eu_10y_delta = ecb_rate_pressure * 0.030 + ecb_fwd_signal * 0.060 + (p.pmi_eu - 50) / 50 * 0.012
const us_2y_implied = base.us_2y + us_2y_delta
const us_10y_implied = base.us_10y + us_10y_delta
const eu_2y_implied = base.eu_2y + eu_2y_delta
const eu_10y_implied = base.eu_10y + eu_10y_delta
const rate_diff_2y = us_2y_implied - eu_2y_implied
const rate_diff_10y = us_10y_implied - eu_10y_implied
const delta_diff_2y = rate_diff_2y - (base.us_2y - base.eu_2y)
const delta_diff_10y = rate_diff_10y - (base.us_10y - base.eu_10y)
// ── Secondary channels ─────────────────────────────────────────────────────
const pmi_diff = (p.pmi_eu - 50) - (p.pmi_us - 50)
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
// ── Pip contributions ──────────────────────────────────────────────────────
const c_2y = Math.round(-delta_diff_2y * 500) // rate channel (dominant)
const c_10y = Math.round(-delta_diff_10y * 200) // tone/expectations channel
const c_pmi = Math.round(pmi_diff * 7)
const c_vix = Math.round(-vix_dev * 4)
const c_ry = Math.round(-ry_dev * 100)
const c_oil = Math.round(oil_dev * 0.5)
const total_pips = c_2y + c_10y + c_pmi + c_vix + c_ry + c_oil
const contribs = ([
{ label: 'Δ 2Y — taux directeurs', pips: c_2y, color: c_2y < 0 ? 'red' : c_2y > 0 ? 'green' : 'slate' },
{ label: 'Δ 10Y — anticipations/ton', pips: c_10y, color: c_10y < 0 ? 'red' : c_10y > 0 ? 'green' : '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_rate_pressure, fed_fwd_signal,
ecb_rate_pressure, ecb_fwd_signal,
us_2y_implied, us_10y_implied,
eu_2y_implied, eu_10y_implied,
rate_diff_2y, rate_diff_10y,
eurusd: base.eurusd + total_pips / 10000,
delta_pips: total_pips,
contribs,
}
}
// ── Slider (with click-to-edit value) ─────────────────────────────────────────
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; center?: number
}) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState('')
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
let trackColor = 'bg-blue-500'
if (colorize) {
const pos = reverse ? value < pivot : value > pivot
trackColor = atBase ? 'bg-slate-600' : pos ? 'bg-emerald-500' : 'bg-rose-500'
}
const startEdit = () => { setDraft(String(value)); setEditing(true) }
const commitEdit = () => {
const v = parseFloat(draft.replace(',', '.'))
if (!isNaN(v)) {
const clamped = Math.max(min, Math.min(max, v))
const snapped = Math.round((clamped - min) / step) * step + min
onChange(parseFloat(snapped.toFixed(10)))
}
setEditing(false)
}
const isColored = colorize && !atBase
const valueColor = isColored
? (reverse ? value < pivot : value > pivot) ? 'text-emerald-400' : 'text-rose-400'
: 'text-white'
return (
{label}
{editing ? (
setDraft(e.target.value)}
onBlur={commitEdit}
onKeyDown={e => {
if (e.key === 'Enter') commitEdit()
if (e.key === 'Escape') setEditing(false)
}}
className="w-20 bg-dark-900 border border-blue-500 rounded px-2 py-0.5 text-xs font-mono text-white text-right outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
) : (
)}
{colorize && (
)}
onChange(parseFloat(e.target.value))}
className="absolute inset-0 w-full opacity-0 cursor-pointer h-full" />
)
}
// ── 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 }) {
return (
{label}
{TONES.map(o => (
))}
)
}
// ── Causal chain SVG ──────────────────────────────────────────────────────────
// Two-channel layout:
// Solid arrows = rate channel (taux directeurs → 2Y)
// Dashed arrows = tone/expectations channel (ton + données → 10Y)
function CausalChain({ r, base, p }: { r: ModelResult; base: Params; p: Params }) {
const sign = (v: number) => v > 0 ? '+' : ''
const delta2yVal = r.rate_diff_2y - (base.us_2y - base.eu_2y)
const delta10yVal = r.rate_diff_10y - (base.us_10y - base.eu_10y)
const col3 = (v: number, threshold = 0.03): string =>
Math.abs(v) < threshold ? '#94a3b8' : v > 0 ? '#f87171' : '#34d399'
// ── Node colors ────────────────────────────────────────────────────────────
// CPI/NFP/PMI inputs → forward guidance signal (positive = hawkish Fed = red for EUR)
// Threshold 0.08 so PMI alone (~0.09-0.15 at typical deviations) triggers color
const fedFwdC = col3(r.fed_fwd_signal, 0.08)
const ecbFwdC = col3(-r.ecb_fwd_signal, 0.08) // ECB hawkish fwd signal → green for EUR
// CB nodes: combined rate + tone signal dominates
const fedC = col3(r.fed_rate_pressure + r.fed_fwd_signal * 0.4, 0.25)
const ecbC = col3(-(r.ecb_rate_pressure + r.ecb_fwd_signal * 0.4), 0.25)
// PMI US: positive deviation (> 50) = US strong = bearish EUR = red
const pmiUsC = col3(p.pmi_us - 50, 1.5)
// VIX: above baseline = risk-off = USD safe haven = bearish EUR = red
const vixC = col3(p.vix - base.vix, 2)
// Yield node colors
const us2C = col3(r.us_2y_implied - base.us_2y)
const us10C = col3(r.us_10y_implied - base.us_10y)
const eu2C = col3(-(r.eu_2y_implied - base.eu_2y))
const eu10C = col3(-(r.eu_10y_implied - base.eu_10y))
const diff2C = col3(delta2yVal, 0.02)
const diff10C = col3(delta10yVal, 0.02)
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) * 1.2 + 1))
const W = 400, H = 490
const fedX = 100, ecbX = 300
const us2X = 52, us10X = 155
const eu2X = 348, eu10X = 245
const diff2X = 140, diff10X = 260
const vixX = 28
const cX = 200
const yIn1 = 38, yIn2 = 74
const yCB = 132
const yYld = 220
const yDiff = 310
const yFX = 400
const COLORS = ['#f87171', '#34d399', '#94a3b8']
const mk = (id: string, c: string) => (
)
const Node = ({ x, y, label, sub, col, w = 88 }: { x: number; y: number; label: string; sub?: string; col: string; w?: number }) => (
{label}
{sub && {sub}}
)
const Arr = ({ x1, y1, x2, y2, col, w, dashed = false }: { x1: number; y1: number; x2: number; y2: number; col: string; w: number; dashed?: boolean }) => (
)
return (
)
}
// ── Sensitivity bar ────────────────────────────────────────────────────────────
function SensBar({ label, pips, maxAbs }: { label: string; pips: number; maxAbs: number }) {
const pct = maxAbs > 0 ? (Math.abs(pips) / maxAbs) * 100 : 0
return (
{label}
0 ? 'bg-emerald-500' : 'bg-rose-500')}
style={{ width: `${pct}%` }} />
0 ? 'text-emerald-400' : 'text-rose-400')}>
{pips > 0 ? '+' : ''}{pips}p
)
}
function Section({ title, children, color }: { title: string; children: React.ReactNode; color: string }) {
return (
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
interface Baseline {
fed_rate: number; ecb_rate: number
us_2y: number; us_10y: number
eu_2y: number; eu_10y: number
eurusd: number; vix: number; oil: number; real_yield_us: number
fetched_at?: string; sources?: Record
}
export default function EuroSimulator() {
const [base, setBase] = useState(FALLBACK)
const [p, setP] = useState(FALLBACK)
const [liveStatus, setLive] = useState<'loading' | 'ok' | 'fallback'>('loading')
const [fetchedAt, setAt] = useState(null)
const [sources, setSources] = useState>({})
const fetched = useRef(false)
const set = (k: keyof Params, v: number) => setP(prev => ({ ...prev, [k]: v }))
useEffect(() => {
if (fetched.current) return
fetched.current = true
fetch('/api/simulator/baseline')
.then(r => r.json())
.then((data: Baseline) => {
const newBase: Params = {
...FALLBACK,
fed_rate: data.fed_rate ?? FALLBACK.fed_rate,
ecb_rate: data.ecb_rate ?? FALLBACK.ecb_rate,
us_2y: data.us_2y ?? FALLBACK.us_2y,
us_10y: data.us_10y ?? FALLBACK.us_10y,
eu_2y: data.eu_2y ?? FALLBACK.eu_2y,
eu_10y: data.eu_10y ?? FALLBACK.eu_10y,
eurusd: data.eurusd ?? FALLBACK.eurusd,
vix: data.vix ?? FALLBACK.vix,
oil: data.oil ?? FALLBACK.oil,
real_yield_us: data.real_yield_us ?? FALLBACK.real_yield_us,
}
setBase(newBase)
setP(newBase)
setLive('ok')
setAt(data.fetched_at ?? null)
setSources(data.sources ?? {})
})
.catch(() => setLive('fallback'))
}, [])
const r = useMemo(() => compute(p, base), [p, base])
const maxAbs = useMemo(() => Math.max(1, ...r.contribs.map(c => Math.abs(c.pips))), [r.contribs])
const eurusdColor = r.delta_pips < -5 ? 'text-rose-400' : r.delta_pips > 5 ? 'text-emerald-400' : 'text-slate-300'
return (
{/* Header */}
Simulateur EUR/USD
Modèle causal 2 canaux — taux directeurs → 2Y · ton/données → 10Y
{liveStatus === 'ok' ? : }
{liveStatus === 'loading' ? 'Chargement…' :
liveStatus === 'ok' ? `Live — base ${fetchedAt ? fetchedAt.slice(0, 10) : ''}` :
'Fallback — données hardcodées'}
{/* ── Controls ──────────────────────────────────────────────────────── */}
`${v.toFixed(2)}%`} onChange={v => set('fed_rate', v)} />
set('fed_tone', v)} />
`${v > 0 ? '+' : ''}${v.toFixed(2)}%`}
onChange={v => set('cpi_us_surprise', v)} colorize reverse />
`${v > 0 ? '+' : ''}${v}k`}
onChange={v => set('nfp_surprise', v)} colorize reverse />
v.toFixed(1)} onChange={v => set('pmi_us', v)} />
`${v.toFixed(2)}%`} onChange={v => set('ecb_rate', v)} />
set('ecb_tone', v)} />
`${v > 0 ? '+' : ''}${v.toFixed(2)}%`}
onChange={v => set('cpi_eu_surprise', v)} colorize />
v.toFixed(1)} onChange={v => set('pmi_eu', v)} />
v.toFixed(1)} onChange={v => set('vix', v)}
colorize reverse center={base.vix} />
`$${v.toFixed(0)}`} onChange={v => set('oil', v)} />
`${v > 0 ? '+' : ''}${v.toFixed(2)}%`}
onChange={v => set('real_yield_us', v)} colorize reverse center={base.real_yield_us} />
{/* Sources panel */}
{liveStatus === 'ok' && Object.keys(sources).length > 0 && (
Sources baseline
{Object.entries(sources).map(([k, v]) => (
{k}
{v}
))}
)}
{/* ── Causal chain ──────────────────────────────────────────────────── */}
Chaîne de transmission causale
{/* Metric strip — 6 cells in 3×2 grid */}
{(() => {
const d2 = r.us_2y_implied - base.us_2y
const d10 = r.us_10y_implied - base.us_10y
const ed2 = r.eu_2y_implied - base.eu_2y
const ed10 = r.eu_10y_implied - base.eu_10y
const baseDiff2 = base.us_2y - base.eu_2y
const baseDiff10 = base.us_10y - base.eu_10y
return [
{ label: 'US 2Y', val: `${r.us_2y_implied.toFixed(2)}%`, col: Math.abs(d2) < 0.01 ? 'text-slate-400' : d2 > 0 ? 'text-rose-400' : 'text-emerald-400' },
{ label: 'US 10Y', val: `${r.us_10y_implied.toFixed(2)}%`, col: Math.abs(d10) < 0.01 ? 'text-slate-400' : d10 > 0 ? 'text-rose-400' : 'text-emerald-400' },
{ label: 'Bund 2Y', val: `${r.eu_2y_implied.toFixed(2)}%`, col: Math.abs(ed2) < 0.01 ? 'text-slate-400' : ed2 > 0 ? 'text-emerald-400' : 'text-rose-400' },
{ label: 'Bund 10Y', val: `${r.eu_10y_implied.toFixed(2)}%`, col: Math.abs(ed10) < 0.01 ? 'text-slate-400' : ed10 > 0 ? 'text-emerald-400' : 'text-rose-400' },
{ label: 'Δ 2Y', val: `${r.rate_diff_2y.toFixed(2)}%`, col: r.rate_diff_2y > baseDiff2 + 0.01 ? 'text-rose-400' : r.rate_diff_2y < baseDiff2 - 0.01 ? 'text-emerald-400' : 'text-slate-400' },
{ label: 'Δ 10Y', val: `${r.rate_diff_10y.toFixed(2)}%`, col: r.rate_diff_10y > baseDiff10 + 0.01 ? 'text-rose-400' : r.rate_diff_10y < baseDiff10 - 0.01 ? 'text-emerald-400' : 'text-slate-400' },
]
})().map(it => (
))}
Base: EURUSD {base.eurusd.toFixed(4)} · US 2Y {base.us_2y.toFixed(2)}% · US 10Y {base.us_10y.toFixed(2)}% · Bund 10Y {base.eu_10y.toFixed(2)}%
{/* ── Results ───────────────────────────────────────────────────────── */}
{/* EUR/USD display */}
EUR/USD simulé
{r.eurusd.toFixed(4)}
{r.delta_pips === 0 ? 'Neutre' : `${r.delta_pips > 0 ? '+' : ''}${r.delta_pips} pips`}
vs base {base.eurusd.toFixed(4)}
{/* 4 signal gauges: FED rate/tone + BCE rate/tone */}
{[
{ label: 'FED — taux', v: r.fed_rate_pressure, posL: 'Hawkish', negL: 'Dovish', posC: 'text-rose-400', negC: 'text-emerald-400' },
{ label: 'FED — ton', v: r.fed_fwd_signal, posL: 'Hawkish', negL: 'Dovish', posC: 'text-rose-400', negC: 'text-emerald-400' },
{ label: 'BCE — taux', v: r.ecb_rate_pressure, posL: 'Hawkish', negL: 'Dovish', posC: 'text-emerald-400', negC: 'text-rose-400' },
{ label: 'BCE — ton', v: r.ecb_fwd_signal, posL: 'Hawkish', negL: 'Dovish', posC: 'text-emerald-400', negC: 'text-rose-400' },
].map(g => (
{g.label}
0.2 ? g.posC : g.v < -0.2 ? g.negC : 'text-slate-400',
)}>
{g.v > 0.2 ? g.posL : g.v < -0.2 ? g.negL : 'Neutre'}
{g.v > 0 ? '+' : ''}{g.v.toFixed(1)}
))}
{/* Pip decomposition */}
Décomposition ({r.delta_pips > 0 ? '+' : ''}{r.delta_pips} pips)
{r.contribs.map(c =>
)}
{/* Relative influence */}
Influence relative
{r.contribs.slice(0, 5).map(c => (
0 ? 'bg-emerald-500' : 'bg-rose-500')} />
{c.label}
{maxAbs > 0 ? Math.round((Math.abs(c.pips) / maxAbs) * 100) : 0}%
))}
Canal taux (solid) : variations des taux directeurs → principalement US/Bund 2Y. Réponse rapide, déjà pricée.
Canal ton (tirets) : discours des CBs + surprises CPI/NFP → principalement 10Y. Anticipe la trajectoire future des taux.
Coefficients heuristiques — non calibrés sur données historiques.
)
}