diff --git a/backend/routers/simulator.py b/backend/routers/simulator.py index 35cf163..4eed2cd 100644 --- a/backend/routers/simulator.py +++ b/backend/routers/simulator.py @@ -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 diff --git a/frontend/src/pages/EuroSimulator.tsx b/frontend/src/pages/EuroSimulator.tsx index 5e3731f..0b63fbf 100644 --- a/frontend/src/pages/EuroSimulator.tsx +++ b/frontend/src/pages/EuroSimulator.tsx @@ -20,24 +20,31 @@ interface Params { vix: number // 10–60 oil: number // 40–130 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 (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' }, + { 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_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 (
Modèle causal — sans données historiques, purement simulé
+Modèle causal 2 canaux — taux directeurs → 2Y · ton/données → 10Y