Files
OpenFin/frontend/src/pages/EuroSimulator.tsx
OpenSquared 91e624aa56 Simu EUR/USD
2026-06-26 23:19:28 +02:00

670 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 // 4065
// 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 // 4065
// Markets
vix: number // 1060
oil: number // 40130
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
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 (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_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 (
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-slate-400 flex-1 min-w-0 truncate">{label}</span>
{editing ? (
<input
autoFocus
type="number"
min={min} max={max} step={step}
value={draft}
onChange={e => 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"
/>
) : (
<button
onClick={startEdit}
title="Cliquer pour saisir une valeur"
className={clsx(
'text-xs font-mono font-semibold shrink-0 px-1 py-0.5 rounded hover:bg-slate-700/40 cursor-text transition-colors',
valueColor,
)}
>
{format(value)}
</button>
)}
</div>
<div className="relative h-1.5 bg-dark-900 rounded overflow-hidden">
<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}%` }} />
{colorize && (
<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}
onChange={e => onChange(parseFloat(e.target.value))}
className="absolute inset-0 w-full opacity-0 cursor-pointer h-full" />
</div>
</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 }) {
return (
<div className="space-y-1">
<span className="text-xs text-slate-400">{label}</span>
<div className="flex gap-1">
{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>
))}
</div>
</div>
)
}
// ── 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 }: { r: ModelResult; base: 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'
const fedRateC = col3(r.fed_rate_pressure, 0.3)
const fedFwdC = col3(r.fed_fwd_signal, 0.5)
const ecbRateC = col3(-r.ecb_rate_pressure, 0.3) // flipped: ECB hawkish → EUR up → green
const ecbFwdC = col3(-r.ecb_fwd_signal, 0.5)
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)) // EU yield up → EUR/USD up → green
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) => (
<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={c} />
</marker>
)
const Node = ({ x, y, label, sub, col, w = 88 }: { x: number; y: number; label: string; sub?: string; col: string; w?: number }) => (
<g>
<rect x={x - w / 2} y={y - 16} width={w} height={sub ? 32 : 26} rx={5} fill="#1e293b" stroke={col} strokeWidth={1.5} />
<text x={x} y={y - 2} textAnchor="middle" fill={col} fontSize={9.5} fontWeight={600}>{label}</text>
{sub && <text x={x} y={y + 11} textAnchor="middle" fill={col} fontSize={8.5} opacity={0.75}>{sub}</text>}
</g>
)
const Arr = ({ x1, y1, x2, y2, col, w, dashed = false }: { x1: number; y1: number; x2: number; y2: number; col: string; w: number; dashed?: boolean }) => (
<line x1={x1} y1={y1} x2={x2} y2={y2} stroke={col} strokeWidth={w} opacity={0.65}
strokeDasharray={dashed ? '5 3' : undefined}
markerEnd={`url(#a${col.replace('#', '')})`} />
)
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 inputs */}
<Node x={fedX - 22} y={yIn1} label="CPI US / NFP" col={r.fed_fwd_signal > 0.4 ? '#f87171' : '#94a3b8'} w={98} />
<Node x={fedX + 30} y={yIn2} label="PMI US" col="#94a3b8" w={70} />
<Arr x1={fedX - 8} y1={yIn1 + 14} x2={fedX - 5} y2={yCB - 17} col={fedRateC} w={1.4} />
<Arr x1={fedX + 28} y1={yIn2 + 13} x2={fedX + 8} y2={yCB - 17} col="#94a3b8" w={1.1} dashed />
{/* EU inputs */}
<Node x={ecbX + 22} y={yIn1} label="CPI EU / PMI" col={r.ecb_fwd_signal > 0.4 ? '#34d399' : '#94a3b8'} w={98} />
<Arr x1={ecbX + 8} y1={yIn1 + 14} x2={ecbX + 5} y2={yCB - 17} col={ecbRateC} w={1.4} />
{/* CB nodes */}
<Node x={fedX} y={yCB} label="FED"
sub={`taux ${sign(r.fed_rate_pressure)}${r.fed_rate_pressure.toFixed(1)} | ton ${sign(r.fed_fwd_signal)}${r.fed_fwd_signal.toFixed(1)}`}
col={fedRateC} w={118} />
<Node x={ecbX} y={yCB} label="BCE"
sub={`taux ${sign(r.ecb_rate_pressure)}${r.ecb_rate_pressure.toFixed(1)} | ton ${sign(r.ecb_fwd_signal)}${r.ecb_fwd_signal.toFixed(1)}`}
col={ecbRateC} w={118} />
{/* FED → US 2Y (solid, rate) and FED → US 10Y (dashed, tone) */}
<Arr x1={fedX - 18} y1={yCB + 17} x2={us2X + 8} y2={yYld - 17} col={us2C} w={aw(Math.abs(r.fed_rate_pressure) * 0.085)} />
<Arr x1={fedX + 18} y1={yCB + 17} x2={us10X - 8} y2={yYld - 17} col={us10C} w={aw(Math.abs(r.fed_fwd_signal) * 0.07)} dashed />
{/* BCE → EU 2Y (solid) and BCE → EU 10Y (dashed) */}
<Arr x1={ecbX + 18} y1={yCB + 17} x2={eu2X - 8} y2={yYld - 17} col={eu2C} w={aw(Math.abs(r.ecb_rate_pressure) * 0.080)} />
<Arr x1={ecbX - 18} y1={yCB + 17} x2={eu10X + 8} y2={yYld - 17} col={eu10C} w={aw(Math.abs(r.ecb_fwd_signal) * 0.060)} dashed />
{/* Yield nodes */}
<Node x={us2X} y={yYld} label="US 2Y" sub={`${r.us_2y_implied.toFixed(2)}%`} col={us2C} w={76} />
<Node x={us10X} y={yYld} label="US 10Y" sub={`${r.us_10y_implied.toFixed(2)}%`} col={us10C} w={76} />
<Node x={eu2X} y={yYld} label="Bund 2Y" sub={`${r.eu_2y_implied.toFixed(2)}%`} col={eu2C} w={76} />
<Node x={eu10X} y={yYld} label="Bund 10Y" sub={`${r.eu_10y_implied.toFixed(2)}%`} col={eu10C} w={76} />
{/* Yields → differentials */}
<Arr x1={us2X + 20} y1={yYld + 16} x2={diff2X - 8} y2={yDiff - 17} col={us2C} w={aw(Math.abs(r.us_2y_implied - base.us_2y))} />
<Arr x1={eu2X - 20} y1={yYld + 16} x2={diff2X + 8} y2={yDiff - 17} col={eu2C} w={aw(Math.abs(r.eu_2y_implied - base.eu_2y))} />
<Arr x1={us10X + 14} y1={yYld + 16} x2={diff10X - 8} y2={yDiff - 17} col={us10C} w={aw(Math.abs(r.us_10y_implied - base.us_10y))} dashed />
<Arr x1={eu10X - 14} y1={yYld + 16} x2={diff10X + 8} y2={yDiff - 17} col={eu10C} w={aw(Math.abs(r.eu_10y_implied - base.eu_10y))} dashed />
{/* Differential nodes */}
<Node x={diff2X} y={yDiff} label="Δ 2Y (taux)" sub={`${r.rate_diff_2y.toFixed(2)}% USEU`} col={diff2C} w={108} />
<Node x={diff10X} y={yDiff} label="Δ 10Y (anticipations)" sub={`${r.rate_diff_10y.toFixed(2)}% USEU`} col={diff10C} w={114} />
{/* VIX (left) */}
<Node x={vixX} y={yDiff} label="VIX" sub="risk" col="#94a3b8" w={48} />
<Arr x1={vixX + 24} y1={yDiff + 8} x2={cX - 68} y2={yFX - 16} col="#94a3b8" w={1.2} />
{/* Differentials → EURUSD */}
<Arr x1={diff2X + 12} y1={yDiff + 17} x2={cX - 22} y2={yFX - 22} col={diff2C} w={aw(Math.abs(delta2yVal) * 5)} />
<Arr x1={diff10X - 12} y1={yDiff + 17} x2={cX + 22} y2={yFX - 22} col={diff10C} w={aw(Math.abs(delta10yVal) * 2)} dashed />
{/* EURUSD box */}
<rect x={cX - 78} y={yFX - 22} width={156} height={44} rx={8} fill="#1e3a5f" stroke={fxC} strokeWidth={2} />
<text x={cX} y={yFX - 5} textAnchor="middle" fill={fxC} fontSize={11} fontWeight={700}>EURUSD</text>
<text x={cX} y={yFX + 13} textAnchor="middle" fill={fxC} fontSize={13} fontWeight={800}>{r.eurusd.toFixed(4)}</text>
{/* Legend */}
<line x1={cX - 64} y1={H - 14} x2={cX - 46} y2={H - 14} stroke="#64748b" strokeWidth={1.5} opacity={0.7} />
<text x={cX - 41} y={H - 10} fill="#64748b" fontSize={8} opacity={0.7}>taux (2Y)</text>
<line x1={cX + 18} y1={H - 14} x2={cX + 36} y2={H - 14} stroke="#64748b" strokeWidth={1.5} strokeDasharray="5 3" opacity={0.7} />
<text x={cX + 40} y={H - 10} fill="#64748b" fontSize={8} opacity={0.7}>ton (10Y)</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-[140px_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; us_10y: number
eu_2y: number; eu_10y: 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,
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 (
<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">
<Sliders className="w-5 h-5 text-blue-400" />
<div>
<h1 className="text-lg font-bold text-white">Simulateur EUR/USD</h1>
<p className="text-xs text-slate-500">Modèle causal 2 canaux taux directeurs 2Y · ton/données 10Y</p>
</div>
</div>
<div className="flex items-center gap-3">
<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_280px] gap-4 items-start">
{/* ── Controls ──────────────────────────────────────────────────────── */}
<div className="space-y-3">
<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)} />
<ToneSelector label="Ton FED" value={p.fed_tone} onChange={v => set('fed_tone', v)} />
<Slider label="Surprise CPI US" value={p.cpi_us_surprise} min={-0.5} max={0.5} step={0.05}
format={v => `${v > 0 ? '+' : ''}${v.toFixed(2)}%`}
onChange={v => set('cpi_us_surprise', v)} colorize reverse />
<Slider label="Surprise NFP" value={p.nfp_surprise} min={-300} max={300} step={10}
format={v => `${v > 0 ? '+' : ''}${v}k`}
onChange={v => set('nfp_surprise', v)} colorize reverse />
<Slider label="PMI manufacturier US" value={p.pmi_us} min={40} max={65} step={0.5}
format={v => v.toFixed(1)} onChange={v => set('pmi_us', v)} />
</Section>
<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)} />
<ToneSelector label="Ton BCE" value={p.ecb_tone} onChange={v => set('ecb_tone', v)} />
<Slider label="Surprise CPI EU" value={p.cpi_eu_surprise} min={-0.5} max={0.5} step={0.05}
format={v => `${v > 0 ? '+' : ''}${v.toFixed(2)}%`}
onChange={v => set('cpi_eu_surprise', v)} colorize />
<Slider label="PMI manufacturier EU" value={p.pmi_eu} min={40} max={65} step={0.5}
format={v => v.toFixed(1)} onChange={v => set('pmi_eu', v)} />
</Section>
<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} />
<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} />
</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>
{/* ── 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>
{/* Metric strip — 6 cells in 3×2 grid */}
<div className="w-full grid grid-cols-3 gap-2 mb-4">
{(() => {
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 => (
<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.col)}>{it.val}</div>
</div>
))}
</div>
<CausalChain r={r} base={base} />
<div className="mt-2 text-[10px] text-slate-600 text-center">
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)}%
</div>
</div>
{/* ── Results ───────────────────────────────────────────────────────── */}
<div className="space-y-3">
{/* EUR/USD display */}
<div className="bg-dark-800/60 rounded-xl border border-slate-700/30 p-4 text-center">
<div className="text-xs text-slate-500 uppercase tracking-wider mb-1">EUR/USD simulé</div>
<div className={clsx('text-4xl font-mono font-bold tracking-tight', eurusdColor)}>
{r.eurusd.toFixed(4)}
</div>
<div className={clsx('text-base font-mono mt-1', eurusdColor)}>
{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>
{/* 4 signal gauges: FED rate/tone + BCE rate/tone */}
<div className="grid grid-cols-2 gap-2 mt-4">
{[
{ 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 => (
<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.posC : g.v < -0.2 ? g.negC : 'text-slate-400',
)}>
{g.v > 0.2 ? g.posL : g.v < -0.2 ? g.negL : '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 ({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>
<div className="bg-dark-900/80 rounded-lg border border-slate-800/60 p-3 text-[10px] text-slate-600 leading-relaxed space-y-1">
<div><span className="text-slate-500 font-medium">Canal taux (solid) :</span> variations des taux directeurs principalement US/Bund 2Y. Réponse rapide, déjà pricée.</div>
<div><span className="text-slate-500 font-medium">Canal ton (tirets) :</span> discours des CBs + surprises CPI/NFP principalement 10Y. Anticipe la trajectoire future des taux.</div>
<div className="text-slate-700">Coefficients heuristiques non calibrés sur données historiques.</div>
</div>
</div>
</div>
</div>
)
}