feat: simulateur EUR/USD — canaux taux/ton séparés + saisie directe sliders

Modèle causal refactorisé en deux canaux distincts :
- Canal taux (solid) : variations taux directeurs → principalement 2Y (ancrage court terme)
- Canal ton/anticipations (tirets) : discours CB + surprises CPI/NFP → principalement 10Y (anticipations long terme)

Slider : clic sur la valeur affichée → input éditable (Enter/Blur pour valider, Escape pour annuler)

Backend : endpoint /api/simulator/baseline expose us_10y et eu_10y (yfinance ^TNX + GE10YT=RR)

SVG mis à jour : 4 nœuds de taux (2Y/10Y US+EU), 2 nœuds différentiels, flèches solides/tirets
Strip métrique : 6 cellules (US 2Y, US 10Y, Bund 2Y, Bund 10Y, Δ 2Y, Δ 10Y)
Décomposition : séparation "Δ 2Y — taux directeurs" / "Δ 10Y — anticipations/ton"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-26 23:08:08 +02:00
parent fe86cc1994
commit e27ff72c1c
2 changed files with 311 additions and 160 deletions

View File

@@ -15,7 +15,9 @@ _FALLBACK = {
"fed_rate": 4.25,
"ecb_rate": 3.65,
"us_2y": 4.50,
"us_10y": 4.30,
"eu_2y": 2.80,
"eu_10y": 2.60,
"eurusd": 1.1450,
"vix": 18.0,
"oil": 80.0,
@@ -79,10 +81,11 @@ def simulator_baseline():
result["oil"] = round(v, 1)
sources["oil"] = f"snapshot {snap_date}"
# us10y raw value is the yield in % → keep as reference
# US 10Y from gauge snapshot (yfinance will override if available)
v = _gauge_val(gauges, "us10y")
if v:
result["_us10y_gauge"] = round(v, 2)
result["us_10y"] = round(v, 2)
sources["us_10y"] = f"snapshot {snap_date}"
except Exception as e:
logger.debug(f"[simulator/baseline] gauge query: {e}")
@@ -135,8 +138,12 @@ def simulator_baseline():
result["us_2y"] = round(tnx * 0.92, 2) # rough proxy
sources["us_2y"] = "yfinance ^TNX proxy"
# US 10Y (^TNX)
if tnx:
result["us_10y"] = round(tnx, 2)
sources["us_10y"] = "yfinance ^TNX"
# 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)
@@ -152,6 +159,16 @@ def simulator_baseline():
result["eu_2y"] = round(result["ecb_rate"] + 0.15, 2)
sources["eu_2y"] = "ECB rate +15bps approx"
# EU 10Y — German Bund 10Y
eu10y = _yf_last("GE10YT=RR")
if eu10y:
result["eu_10y"] = round(eu10y, 2)
sources["eu_10y"] = "yfinance GE10YT=RR"
else:
# Approximation: ECB rate + term premium ~200bps
result["eu_10y"] = round(result["ecb_rate"] + 2.0, 2)
sources["eu_10y"] = "ECB rate +200bps approx"
# VIX — override gauge if not already set from DB
if "vix" not in sources:
v = _yf_last("^VIX")
@@ -170,7 +187,6 @@ def simulator_baseline():
logger.debug(f"[simulator/baseline] yfinance block: {e}")
# ── Cleanup & return ────────────────────────────────────────────────────
result.pop("_us10y_gauge", None)
result["sources"] = sources
result["fetched_at"] = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
return result

View File

@@ -20,24 +20,31 @@ interface Params {
vix: number // 1060
oil: number // 40130
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
// 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_pressure: number
ecb_pressure: number
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
rate_diff: 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 (used before live data arrives) ─────────────────────────
// ── Hardcoded fallback ─────────────────────────────────────────────────────────
const FALLBACK: Params = {
fed_rate: 4.25, ecb_rate: 3.65,
@@ -45,84 +52,101 @@ const FALLBACK: Params = {
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, eu_2y: 2.80,
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 ───────────────────────────────────────────────────────────────
// All deltas are computed vs the base anchors embedded in `base`.
// Surprises / tones / PMI are always centered at their neutral value (0 / 50).
//
// 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 {
const BASE_US_2Y = base.us_2y
const BASE_EU_2Y = base.eu_2y
const BASE_EURUSD = base.eurusd
// ── 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
// Fed pressure: positive = hawkish (USD up, EUR/USD down)
const fed_pressure =
(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
// ── 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
// ECB pressure: positive = hawkish (EUR up, EUR/USD up)
const ecb_pressure =
(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
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 2Y yields
const us_2y_implied = BASE_US_2Y + fed_pressure * 0.09
const eu_2y_implied = BASE_EU_2Y + ecb_pressure * 0.08
// ── 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
// 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
// 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
// PMI growth differential (positive = EU stronger)
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)
// 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
// ── Pip contributions ─────────────────────────────────────────────────────
const c_rate = Math.round(-rate_diff_delta * 650) // ~650 pips per 1% spread
const c_pmi = Math.round(pmi_diff * 8)
const c_vix = Math.round(-vix_dev * 4)
const c_ry = Math.round(-ry_dev * 120)
const c_oil = Math.round(oil_dev * 0.5)
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)
// ── 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_rate + c_pmi + c_vix + c_ry + c_oil + c_nfp + c_cpi_us + c_cpi_eu
const total_pips = c_2y + c_10y + c_pmi + c_vix + c_ry + c_oil
const contribs = ([
{ label: Taux (US 2Y Bund)', pips: c_rate, color: c_rate < 0 ? 'red' : c_rate > 0 ? 'green' : 'slate' },
{ label: 'CPI US (surprise)', pips: c_cpi_us, color: c_cpi_us < 0 ? 'red' : c_cpi_us > 0 ? 'green' : 'slate' },
{ label: 'NFP (surprise)', pips: c_nfp, color: c_nfp < 0 ? 'red' : c_nfp > 0 ? 'green' : 'slate' },
{ label: 'CPI EU (surprise)', pips: c_cpi_eu, color: c_cpi_eu > 0 ? 'green' : c_cpi_eu < 0 ? 'red' : 'slate' },
{ label: 'PMI diff (EUUS)', pips: c_pmi, color: c_pmi > 0 ? 'green' : c_pmi < 0 ? 'red' : 'slate' },
{ label: 'VIX / Risk off', pips: c_vix, color: c_vix < 0 ? 'red' : c_vix > 0 ? 'green' : 'slate' },
{ label: 'Taux réel US', pips: c_ry, color: c_ry < 0 ? 'red' : c_ry > 0 ? 'green' : 'slate' },
{ label: 'Pétrole', pips: c_oil, color: c_oil > 0 ? 'green' : c_oil < 0 ? 'red' : 'slate' },
{ 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_pressure, ecb_pressure,
us_2y_implied, eu_2y_implied, rate_diff,
eurusd: BASE_EURUSD + total_pips / 10000,
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 ─────────────────────────────────────────────────────────────────────
// ── Slider (with click-to-edit value) ─────────────────────────────────────────
function Slider({
label, value, min, max, step, format, onChange,
@@ -132,9 +156,12 @@ function Slider({
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
const mid = Math.max(0, Math.min(100, ((pivot - min) / (max - min)) * 100))
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'
@@ -143,15 +170,53 @@ function Slider({
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">
<span className="text-xs text-slate-400">{label}</span>
<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 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)}
@@ -199,80 +264,133 @@ function ToneSelector({ label, value, onChange }: { label: string; value: number
}
// ── 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 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 delta2yVal = r.rate_diff_2y - (base.us_2y - base.eu_2y)
const delta10yVal = r.rate_diff_10y - (base.us_10y - base.eu_10y)
const aw = (v: number) => Math.max(1, Math.min(3.5, Math.abs(v) * 0.7 + 1))
const col3 = (v: number, threshold = 0.03): string =>
Math.abs(v) < threshold ? '#94a3b8' : v > 0 ? '#f87171' : '#34d399'
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 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 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} />
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 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 }) => (
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 - 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>}
<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 }: { 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}
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>
<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)} />
{/* 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} />
{/* 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)} />
{/* 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} />
{/* Rate differential */}
<Node x={W / 2} y={yDiff} label="Δ Taux 2Y" sub={`${r.rate_diff.toFixed(2)}% (USEU)`} col={diffC} w={132} />
<Arr x1={W / 2} y1={yDiff + 18} x2={W / 2} y2={yFX - 22} col={diffC} w={aw(r.rate_diff - (base.us_2y - base.eu_2y))} />
{/* 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} />
{/* 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} />
{/* 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 />
{/* 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>
{/* 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>
)
}
@@ -282,7 +400,7 @@ function CausalChain({ r, base }: { r: ModelResult; base: Params }) {
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">
<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')}
@@ -306,15 +424,21 @@ function Section({ title, children, color }: { title: string; children: React.Re
// ── 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> }
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 [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 }))
@@ -325,11 +449,13 @@ export default function EuroSimulator() {
.then(r => r.json())
.then((data: Baseline) => {
const newBase: Params = {
...FALLBACK, // keep surprise/tone/PMI at neutral
...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,
@@ -346,8 +472,8 @@ export default function EuroSimulator() {
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'
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">
@@ -358,22 +484,19 @@ export default function EuroSimulator() {
<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 sans données historiques, purement simulé</p>
<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">
{/* 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 === '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) : ''}` :
liveStatus === 'ok' ? `Live — base ${fetchedAt ? fetchedAt.slice(0, 10) : ''}` :
'Fallback — données hardcodées'}
</div>
@@ -385,7 +508,7 @@ export default function EuroSimulator() {
</div>
</div>
<div className="grid grid-cols-[320px_1fr_270px] gap-4 items-start">
<div className="grid grid-cols-[320px_1fr_280px] gap-4 items-start">
{/* ── Controls ──────────────────────────────────────────────────────── */}
<div className="space-y-3">
@@ -444,14 +567,24 @@ export default function EuroSimulator() {
<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>
{/* Live metric strip */}
<div className="w-full grid grid-cols-4 gap-2 mb-4">
{[
{ 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 => (
{/* 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>
@@ -462,7 +595,7 @@ export default function EuroSimulator() {
<CausalChain r={r} base={base} />
<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)}
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>
@@ -480,18 +613,20 @@ export default function EuroSimulator() {
</div>
<div className="text-xs text-slate-600 mt-1">vs base {base.eurusd.toFixed(4)}</div>
{/* Pressure gauges */}
{/* 4 signal gauges: FED rate/tone + BCE rate/tone */}
<div className="grid grid-cols-2 gap-2 mt-4">
{[
{ 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' },
{ 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.posCol : g.v < -0.2 ? g.negCol : 'text-slate-400',
g.v > 0.2 ? g.posC : g.v < -0.2 ? g.negC : 'text-slate-400',
)}>
{g.v > 0.2 ? g.posLabel : g.v < -0.2 ? g.negLabel : 'Neutre'}
{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>
@@ -521,10 +656,10 @@ export default function EuroSimulator() {
))}
</div>
<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.
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 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>