diff --git a/backend/services/market_event_detector.py b/backend/services/market_event_detector.py index c125c97..c8aa597 100644 --- a/backend/services/market_event_detector.py +++ b/backend/services/market_event_detector.py @@ -31,6 +31,37 @@ SUBTYPE_FROM_SERIES = { "BAMLH0A0HYM2": "Credit", "BAMLC0A0CM": "Credit", } +# Impact classification for FRED series +_SERIES_IMPACT = { + "UNRATE": "high", "PAYEMS": "high", + "CPIAUCSL": "high", "CPILFESL": "high", + "A191RL1Q225SBEA": "high", "GDP": "high", + "FEDFUNDS": "high", "DFF": "high", + "PCEPILFE": "high", "PCEPI": "high", + "BAMLH0A0HYM2": "medium", "BAMLC0A0CM": "medium", +} + +_IMPACT_RANKS = {"high": 3, "medium": 2, "low": 1} + + +def _parse_numeric(s: Optional[str]) -> Optional[float]: + """Parse numeric string with optional K/M/B/% suffix → float or None.""" + if not s: + return None + s = s.strip() + mult = 1.0 + if s.endswith(("B", "b")): + mult, s = 1e9, s[:-1] + elif s.endswith(("M", "m")): + mult, s = 1e6, s[:-1] + elif s.endswith(("K", "k")): + mult, s = 1e3, s[:-1] + s = s.rstrip("%").replace(",", "").strip() + try: + return float(s) * mult + except ValueError: + return None + # ── Helpers ─────────────────────────────────────────────────────────────────── @@ -293,52 +324,105 @@ FORMAT JSON STRICT: return created -# ── Source 2: Eco calendar — FRED surprises ─────────────────────────────────── +# ── Source 2: Eco calendar — FRED surprises + ff_calendar ──────────────────── -def _check_eco(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]: - from services.database import get_recent_economic_surprises +def _check_ff_calendar_surprises( + currencies: List[str], + min_impact: str, + days: int, + min_surprise_pct: float, + lookback_releases: int, + create_evt: bool, + existing: set, +) -> List[Dict]: + """ + Detect surprising releases in ff_calendar for the given currencies. + Used when currencies other than USD are configured (FRED only covers USD). + """ + from services.database import get_conn - z_threshold = float(desk_cfg.get("z_threshold", 1.5)) - days = int(desk_cfg.get("days", 7)) + impact_map = { + "high": ("high",), + "medium": ("high", "medium"), + "low": ("high", "medium", "low"), + } + allowed_impacts = impact_map.get(min_impact, ("high", "medium")) + cutoff = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d") try: - releases = get_recent_economic_surprises(days=days, min_zscore=z_threshold) + conn = get_conn() + ccy_ph = ",".join("?" * len(currencies)) + imp_ph = ",".join("?" * len(allowed_impacts)) + rows = conn.execute( + f"""SELECT event_date, currency, impact, event_name, + actual_value, forecast_value, previous_value + FROM ff_calendar + WHERE currency IN ({ccy_ph}) + AND impact IN ({imp_ph}) + AND event_date >= ? + AND actual_value IS NOT NULL + AND forecast_value IS NOT NULL + ORDER BY event_date DESC + LIMIT 200""", + (*currencies, *allowed_impacts, cutoff), + ).fetchall() + conn.close() except Exception as e: - logger.warning(f"[check_events/eco] query failed: {e}") + logger.warning(f"[check_events/eco/ff] query failed: {e}") return [] - existing = _existing_event_keys() - created: List[Dict] = [] + created = [] + for row in rows: + d = dict(row) + actual = _parse_numeric(d.get("actual_value")) + forecast = _parse_numeric(d.get("forecast_value")) + if actual is None or forecast is None or abs(forecast) < 1e-9: + continue + s_pct = (actual - forecast) / abs(forecast) * 100 + if abs(s_pct) < min_surprise_pct: + continue - for rel in releases: - z = abs(rel.get("surprise_zscore") or 0) - s_pct = rel.get("surprise_pct") or 0 - ev_date = (rel.get("event_date") or "")[:10] - s_id = rel.get("series_id", "") - ev_name_base = rel.get("event_name", s_id) - direction = rel.get("surprise_direction", "neutral") - - sign = "+" if s_pct >= 0 else "" - ev_name = f"{ev_name_base} — Surprise {sign}{s_pct:.1f}% ({ev_date[:7]})" + ev_date = (d.get("event_date") or "")[:10] + ev_name_base = d.get("event_name", "Unknown") + ccy = d.get("currency", "") + sign = "+" if s_pct >= 0 else "" + ev_name = f"{ccy} {ev_name_base} — Surprise {sign}{s_pct:.1f}% ({ev_date[:7]})" if _is_dup(ev_name, existing): continue - sub_type = SUBTYPE_FROM_SERIES.get(s_id, s_id[:10]) if s_id else ev_name_base[:10] - level = "long" if z >= 3 else ("medium" if z >= 2 else "short") - assets = rel.get("assets_impacted") or [] - if isinstance(assets, str): + # Historical context + context_str = "" + if lookback_releases > 0: try: - assets = json.loads(assets) + conn2 = get_conn() + hist = conn2.execute( + """SELECT event_date, actual_value, forecast_value + FROM ff_calendar + WHERE event_name = ? AND currency = ? AND event_date < ? + AND actual_value IS NOT NULL + ORDER BY event_date DESC LIMIT ?""", + (ev_name_base, ccy, ev_date, lookback_releases), + ).fetchall() + conn2.close() + if hist: + context_str = " Historique récent: " + ", ".join( + f"{r[0][:7]}: réel={r[1]} consensus={r[2]}" for r in hist + ) except Exception: - assets = [] + pass + + impact = d.get("impact", "low") + level = "medium" if impact == "high" else "short" + direction = "hausse" if s_pct > 0 else "baisse" + score = min(0.85, 0.30 + abs(s_pct) / 100) source_ref = { - "title": f"FRED release: {ev_name_base} ({ev_date})", - "source": "FRED", - "url": f"https://fred.stlouisfed.org/series/{s_id}" if s_id else "", + "title": f"Release: {ccy} {ev_name_base} ({ev_date})", + "source": "ff_calendar", + "url": "", "date": ev_date, - "original_score": round(min(0.95, 0.35 + z * 0.15), 3), + "original_score": round(score, 3), } ev = { @@ -346,26 +430,154 @@ def _check_eco(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]: "start_date": ev_date, "level": level, "category": "event_calendar", - "sub_type": sub_type, + "sub_type": ccy, "description": ( - f"Surprise {direction} {sign}{s_pct:.1f}% vs baseline " - f"(z-score: {z:.1f}σ). " - f"Réel: {rel.get('actual_value', '?')} {rel.get('actual_unit', '')} " - f"/ Prévision: {rel.get('forecast_value', '?')}." + f"Surprise en {direction} de {sign}{s_pct:.1f}% vs consensus. " + f"Réel: {d['actual_value']} / Consensus: {d['forecast_value']}." + + context_str ), "market_impact": "", - "affected_assets": assets, - "impact_score": min(0.95, 0.35 + z * 0.15), - "actual_value": str(rel.get("actual_value", "")), - "expected_value": str(rel.get("forecast_value", "")), + "affected_assets": [], + "impact_score": score, + "actual_value": str(d["actual_value"]), + "expected_value": str(d["forecast_value"]), "surprise_pct": float(s_pct), "source_refs": [source_ref], - "origin": "detector_eco", + "origin": "detector_eco_ff", } - result = _save_and_evaluate(ev, existing) - if result: - result["source"] = "eco" - created.append(result) + if create_evt: + result = _save_and_evaluate(ev, existing) + if result: + result["source"] = "eco" + created.append(result) + else: + logger.info(f"[check_events/eco] create_market_event=False — skipping: {ev_name}") + + return created + + +def _check_eco(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]: + from services.database import get_recent_economic_surprises, get_conn + + z_threshold = float(desk_cfg.get("z_threshold", 1.5)) + days = int(desk_cfg.get("days", 7)) + currencies = list(desk_cfg.get("currencies") or ["USD", "EUR", "GBP", "JPY"]) + min_impact = str(desk_cfg.get("min_impact", "medium")).lower() + create_evt = bool(desk_cfg.get("create_market_event", True)) + lookback_releases = int(desk_cfg.get("lookback_releases", 3)) + + min_rank = _IMPACT_RANKS.get(min_impact, 2) + # ff_calendar surprise threshold: z_threshold used as rough proxy (×10 → % equivalent) + ff_surprise_min = max(10.0, z_threshold * 10) + + existing = _existing_event_keys() + created: List[Dict] = [] + + # ── FRED path (USD only) ────────────────────────────────────────────────── + if "USD" in currencies: + try: + releases = get_recent_economic_surprises(days=days, min_zscore=z_threshold) + except Exception as e: + logger.warning(f"[check_events/eco] FRED query failed: {e}") + releases = [] + + for rel in releases: + s_id = rel.get("series_id", "") + impact = _SERIES_IMPACT.get(s_id, "low") + if _IMPACT_RANKS.get(impact, 1) < min_rank: + continue + + z = abs(rel.get("surprise_zscore") or 0) + s_pct = rel.get("surprise_pct") or 0 + ev_date = (rel.get("event_date") or "")[:10] + ev_name_base = rel.get("event_name", s_id) + direction = rel.get("surprise_direction", "neutral") + + sign = "+" if s_pct >= 0 else "" + ev_name = f"{ev_name_base} — Surprise {sign}{s_pct:.1f}% ({ev_date[:7]})" + + if _is_dup(ev_name, existing): + continue + + # Lookback context from FRED history + context_str = "" + if lookback_releases > 0 and s_id: + try: + conn = get_conn() + hist = conn.execute( + """SELECT event_date, actual_value, forecast_value + FROM economic_events + WHERE series_id = ? AND event_date < ? + ORDER BY event_date DESC LIMIT ?""", + (s_id, ev_date, lookback_releases), + ).fetchall() + conn.close() + if hist: + context_str = " Historique récent: " + ", ".join( + f"{r[0][:7]}: réel={r[1]} consensus={r[2]}" for r in hist + ) + except Exception: + pass + + sub_type = SUBTYPE_FROM_SERIES.get(s_id, s_id[:10]) if s_id else ev_name_base[:10] + level = "long" if z >= 3 else ("medium" if z >= 2 else "short") + assets = rel.get("assets_impacted") or [] + if isinstance(assets, str): + try: + assets = json.loads(assets) + except Exception: + assets = [] + + source_ref = { + "title": f"FRED release: {ev_name_base} ({ev_date})", + "source": "FRED", + "url": f"https://fred.stlouisfed.org/series/{s_id}" if s_id else "", + "date": ev_date, + "original_score": round(min(0.95, 0.35 + z * 0.15), 3), + } + + ev = { + "name": ev_name, + "start_date": ev_date, + "level": level, + "category": "event_calendar", + "sub_type": sub_type, + "description": ( + f"Surprise {direction} {sign}{s_pct:.1f}% vs baseline " + f"(z-score: {z:.1f}σ). " + f"Réel: {rel.get('actual_value', '?')} {rel.get('actual_unit', '')} " + f"/ Prévision: {rel.get('forecast_value', '?')}." + + context_str + ), + "market_impact": "", + "affected_assets": assets, + "impact_score": min(0.95, 0.35 + z * 0.15), + "actual_value": str(rel.get("actual_value", "")), + "expected_value": str(rel.get("forecast_value", "")), + "surprise_pct": float(s_pct), + "source_refs": [source_ref], + "origin": "detector_eco", + } + if create_evt: + result = _save_and_evaluate(ev, existing) + if result: + result["source"] = "eco" + created.append(result) + else: + logger.info(f"[check_events/eco] create_market_event=False — skipping: {ev_name}") + + # ── ff_calendar path (non-USD currencies) ──────────────────────────────── + non_usd = [c for c in currencies if c != "USD"] + if non_usd: + created += _check_ff_calendar_surprises( + currencies=non_usd, + min_impact=min_impact, + days=days, + min_surprise_pct=ff_surprise_min, + lookback_releases=lookback_releases, + create_evt=create_evt, + existing=existing, + ) return created diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ba57360..605fc85 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,6 +29,7 @@ import CycleActions from './pages/CycleActions' import MarketEvents from './pages/MarketEvents' import AIDesks from './pages/AIDesks' import MacroSeriesPage from './pages/MacroSeriesPage' +import EuroSimulator from './pages/EuroSimulator' import { Navigate } from 'react-router-dom' import { useCycleWatcher } from './hooks/useApi' @@ -78,6 +79,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 668ea53..7ed741e 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,7 +1,7 @@ import { NavLink } from 'react-router-dom' import { LayoutDashboard, Globe, BarChart2, FlaskConical, - History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, ScanEye, CandlestickChart, PlayCircle, Radio, Bot + History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, ScanEye, CandlestickChart, PlayCircle, Radio, Bot, Sliders } from 'lucide-react' import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi' import clsx from 'clsx' @@ -25,6 +25,7 @@ const nav = [ { to: '/position-history', icon: GitCompare, label: 'Position History' }, { to: '/backtest', icon: History, label: 'Backtest' }, { to: '/calendar', icon: Calendar, label: 'Calendar' }, + { to: '/simulator', icon: Sliders, label: 'EUR/USD Simulator' }, { to: '/macro-series', icon: TrendingUp, label: 'Macro Series' }, { to: '/institutional', icon: Building2, label: 'Inst. Reports' }, { to: '/specialist-desks', icon: Users, label: 'Specialist Desks' }, diff --git a/frontend/src/pages/AIDesks.tsx b/frontend/src/pages/AIDesks.tsx index 187850f..0cb0183 100644 --- a/frontend/src/pages/AIDesks.tsx +++ b/frontend/src/pages/AIDesks.tsx @@ -343,6 +343,9 @@ function NewsConfig({ } +const ECO_CURRENCIES = ['USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD', 'NZD', 'CHF', 'CNY'] +const ECO_DEFAULT_CURRENCIES = ['USD', 'EUR', 'GBP', 'JPY'] + function EcoConfig({ config, onChange, @@ -351,25 +354,99 @@ function EcoConfig({ onChange: (c: Record) => void }) { const set = (k: string, v: any) => onChange({ ...config, [k]: v }) + const currencies: string[] = config.currencies ?? ECO_DEFAULT_CURRENCIES + + const toggleCurrency = (ccy: string) => { + const next = currencies.includes(ccy) + ? currencies.filter(c => c !== ccy) + : [...currencies, ccy] + set('currencies', next) + } + return ( -
-
- - 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 */} +
+
+ + 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" + /> +
+
+ + 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" + /> +
+ + {/* Row 2: currencies */}
- - 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" - /> + +
+ {ECO_CURRENCIES.map(ccy => ( + + ))} +
+
+ + {/* Row 3: impact + lookback_releases */} +
+
+ + +
+
+ + 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 */} +
+ +
+ 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 ( +
+
{title}
+ {children} +
+ ) +} + +// ── 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 => ( + + ))} +
+
+ ) +} + +// ── 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é

+
+
+ +
+ +
+ + {/* ── 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. +
+
+
+
+ ) +}