-
Seuil z-score
-
set('z_threshold', parseFloat(e.target.value))}
- className="w-full bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white"
- />
+
+ {/* Row 1: z-score + days */}
+
+
+ {/* Row 2: currencies */}
-
Jours lookback
-
set('days', parseInt(e.target.value))}
- className="w-full bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white"
- />
+
Devises surveillées
+
+ {ECO_CURRENCIES.map(ccy => (
+ toggleCurrency(ccy)}
+ className={clsx(
+ 'px-2.5 py-0.5 rounded text-xs border font-mono transition-colors',
+ currencies.includes(ccy)
+ ? 'bg-emerald-900/40 border-emerald-600/60 text-emerald-300'
+ : 'bg-dark-800 border-slate-700/40 text-slate-500 hover:border-slate-600',
+ )}
+ >
+ {ccy}
+
+ ))}
+
+
+
+ {/* Row 3: impact + lookback_releases */}
+
+
+ Impact minimum
+ set('min_impact', e.target.value)}
+ className="w-full bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white"
+ >
+ High seulement
+ High + Medium
+ Tous niveaux
+
+
+
+ Releases contexte
+ set('lookback_releases', parseInt(e.target.value))}
+ className="w-full bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white"
+ />
+
+
+
+ {/* Row 4: create_market_event toggle */}
+
+
set('create_market_event', !(config.create_market_event ?? true))}
+ className="shrink-0"
+ >
+ {(config.create_market_event ?? true)
+ ?
+ : }
+
+
+
Créer market_event automatiquement
+
Si désactivé, les surprises sont détectées mais pas enregistrées
+
)
diff --git a/frontend/src/pages/EuroSimulator.tsx b/frontend/src/pages/EuroSimulator.tsx
new file mode 100644
index 0000000..e21ef02
--- /dev/null
+++ b/frontend/src/pages/EuroSimulator.tsx
@@ -0,0 +1,522 @@
+import { useState, useMemo } from 'react'
+import { RefreshCw, Sliders } from 'lucide-react'
+import clsx from 'clsx'
+
+// ── Model 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
+ 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 %
+ pmi_eu: number // 40–65
+ // Markets
+ vix: number // 10–60
+ oil: number // 40–130
+ real_yield_us: number // 0–4 %
+}
+
+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
+ eurusd: number
+ delta_pips: number
+ contribs: { label: string; pips: number; color: 'red' | 'green' | 'slate' }[]
+}
+
+// ── Baseline scenario (neutral / today's approximate) ─────────────────────────
+
+const BASE: 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,
+}
+
+const BASE_US_2Y = 4.50
+const BASE_EU_2Y = 2.80
+const BASE_EURUSD = 1.1450
+
+// ── Causal model (linearised, directionally correct coefficients) ──────────────
+
+function compute(p: Params): ModelResult {
+ // Fed pressure: positive = hawkish (higher rates, 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
+
+ // ECB pressure: positive = hawkish (higher rates, 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
+
+ // 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
+
+ // 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)
+
+ // PMI growth differential (positive = EU stronger = EUR/USD up)
+ 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
+
+ // 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
+ 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 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' },
+ ] 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,
+ eurusd: BASE_EURUSD + total_pips / 10000,
+ delta_pips: total_pips,
+ contribs,
+ }
+}
+
+// ── Slider component ──────────────────────────────────────────────────────────
+
+function Slider({
+ label, value, min, max, step, format, onChange,
+ colorize = false, reverse = false,
+}: {
+ 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)
+}) {
+ const pct = ((value - min) / (max - min)) * 100
+ const mid = ((0 - min) / (max - min)) * 100
+
+ let trackColor = 'bg-blue-500'
+ if (colorize) {
+ const isPositive = reverse ? value < 0 : value > 0
+ trackColor = value === 0 ? 'bg-slate-600' : isPositive ? 'bg-emerald-500' : 'bg-rose-500'
+ }
+
+ return (
+
+
+ {label}
+ 0) ? 'text-emerald-400' : 'text-rose-400'
+ : 'text-white',
+ )}>{format(value)}
+
+
+
+
onChange(parseFloat(e.target.value))}
+ className="absolute inset-0 w-full opacity-0 cursor-pointer h-full"
+ />
+
+
+ )
+}
+
+// ── 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 }) => (
+
+
+ {label}
+ {sub && {sub} }
+
+ )
+
+ const Arrow = ({ x1, y1, x2, y2, color, w }: { x1: number; y1: number; x2: number; y2: number; color: string; w: number }) => (
+
+ )
+
+ const markers = [
+ { id: `arr-f87171`, color: '#f87171' },
+ { id: `arr-34d399`, color: '#34d399' },
+ { id: `arr-94a3b8`, color: '#94a3b8' },
+ ]
+
+ return (
+
+
+ {markers.map(m => (
+
+
+
+ ))}
+
+
+ {/* ── US chain ── */}
+ 0 ? '+' : ''}${(r.fed_pressure * 0.3).toFixed(1)}`} color={r.fed_pressure > 0.3 ? '#f87171' : '#94a3b8'} w={88} />
+ 0.3 ? '#f87171' : '#94a3b8'} w={88} />
+
+
+
+
+ 0 ? '+' : ''}${r.fed_pressure.toFixed(1)}`} color={fedColor} w={108} />
+
+
+
+
+ {/* ── EU chain ── */}
+ 0.3 ? '#34d399' : '#94a3b8'} w={88} />
+ 0 ? 'fort' : 'faible'}`} color={r.ecb_pressure > 0.3 ? '#34d399' : '#94a3b8'} w={88} />
+
+
+
+ 0 ? '+' : ''}${r.ecb_pressure.toFixed(1)}`} color={ecbColor} w={108} />
+
+
+
+
+ {/* ── Rate differential ── */}
+
+
+
+ {/* ── VIX / Risk ── */}
+
+
+
+ {/* ── EURUSD ── */}
+
+ EURUSD
+
+ {r.eurusd.toFixed(4)}
+
+
+ )
+}
+
+// ── 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 (
+
+
{label}
+
+
+ {pips > 0 ? '+' : ''}{pips}p
+
+
+ )
+}
+
+// ── Section header ────────────────────────────────────────────────────────────
+
+function Section({ title, color, children }: { title: string; color: string; children: React.ReactNode }) {
+ return (
+
+ )
+}
+
+// ── Tone selector ─────────────────────────────────────────────────────────────
+
+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 (
+
+
{label}
+
+ {opts.map(o => (
+ onChange(o.v)}
+ className={clsx(
+ 'flex-1 px-1 py-1 rounded border text-[10px] leading-tight text-center transition-colors',
+ value === o.v ? o.color : 'border-slate-700/30 text-slate-600 bg-dark-900 hover:border-slate-600',
+ )}
+ >
+ {o.label}
+
+ ))}
+
+
+ )
+}
+
+// ── Main page ─────────────────────────────────────────────────────────────────
+
+export default function EuroSimulator() {
+ const [p, setP] = useState
(BASE)
+ const set = (k: keyof Params, v: number) => setP(prev => ({ ...prev, [k]: v }))
+ const r = useMemo(() => compute(p), [p])
+
+ const maxAbs = useMemo(
+ () => Math.max(1, ...r.contribs.map(c => Math.abs(c.pips))),
+ [r.contribs],
+ )
+
+ const eurusdChange = r.delta_pips
+ const eurusdColor = eurusdChange < -5 ? 'text-rose-400' : eurusdChange > 5 ? 'text-emerald-400' : 'text-slate-300'
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
Simulateur EUR/USD
+
Modèle causal — sans données historiques, purement simulé
+
+
+
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"
+ >
+
+ Reset
+
+
+
+
+
+ {/* ── Left: Controls ──────────────────────────────────────────────── */}
+
+
+ {/* FED / US */}
+
+ `${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)} />
+
+
+ {/* ECB / EU */}
+
+ `${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)} />
+
+
+ {/* Markets */}
+
+ v.toFixed(1)} onChange={v => set('vix', v)}
+ colorize reverse />
+ `$${v.toFixed(0)}`} onChange={v => set('oil', v)} />
+ `${v > 0 ? '+' : ''}${v.toFixed(2)}%`}
+ onChange={v => set('real_yield_us', v)} colorize reverse />
+
+
+
+ {/* ── Center: Causal chain ────────────────────────────────────────── */}
+
+
Chaîne de transmission causale
+
+ {/* Yield display strip */}
+
+ {[
+ { 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' },
+ ].map(it => (
+
+
{it.label}
+
{it.value}
+
+ ))}
+
+
+
+
+ {/* Baseline note */}
+
+ Référence: EUR/USD {BASE_EURUSD.toFixed(4)} · US 2Y {BASE_US_2Y}% · Bund 2Y {BASE_EU_2Y}% · VIX {BASE.vix}
+
+
+
+ {/* ── Right: Results ──────────────────────────────────────────────── */}
+
+
+ {/* EUR/USD display */}
+
+
EUR/USD simulé
+
+ {r.eurusd.toFixed(4)}
+
+
+ {eurusdChange === 0 ? 'Neutre' : `${eurusdChange > 0 ? '+' : ''}${eurusdChange} pips`}
+
+
+ vs base {BASE_EURUSD.toFixed(4)}
+
+
+ {/* Pressure gauges */}
+
+ {[
+ { 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 (
+
+
{g.label}
+
+ {isPos ? g.pos_label : isNeg ? g.neg_label : 'Neutre'}
+
+
+ {g.v > 0 ? '+' : ''}{g.v.toFixed(1)}
+
+
+ )
+ })}
+
+
+
+ {/* Pip decomposition */}
+
+
Décomposition ({eurusdChange > 0 ? '+' : ''}{eurusdChange} pips)
+ {r.contribs.map(c => (
+
+ ))}
+
+
+ {/* Dominance legend */}
+
+
Influence relative
+ {r.contribs.slice(0, 5).map(c => {
+ const pct = maxAbs > 0 ? Math.round((Math.abs(c.pips) / maxAbs) * 100) : 0
+ return (
+
+
0 ? 'bg-emerald-500' : 'bg-rose-500',
+ )} />
+ {c.label}
+ {pct}%
+
+ )
+ })}
+
+
+ {/* Interpretation note */}
+
+ Note: 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.
+
+
+
+
+ )
+}