diff --git a/backend/main.py b/backend/main.py
index 99365ef..411f21e 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -14,6 +14,7 @@ from routers import var as var_router
from routers import reports as reports_router
from routers import institutional as institutional_router
from routers import eco as eco_router
+from routers import simulator as simulator_router
from services.database import init_db, get_config, cleanup_stale_running_cycles
import os
import logging
@@ -196,6 +197,7 @@ app.include_router(cycle_actions_router.router)
app.include_router(market_events_router.router)
app.include_router(ai_desks_router.router)
app.include_router(eco_router.router)
+app.include_router(simulator_router.router)
@app.get("/")
diff --git a/backend/routers/simulator.py b/backend/routers/simulator.py
new file mode 100644
index 0000000..35cf163
--- /dev/null
+++ b/backend/routers/simulator.py
@@ -0,0 +1,176 @@
+"""
+Simulator baseline endpoint.
+Returns current market values to seed the EUR/USD causal simulator.
+Sources (priority): macro_gauge_snapshots → FRED economic_events → yfinance → hardcoded fallbacks.
+"""
+import json
+import logging
+from datetime import datetime
+from fastapi import APIRouter
+
+router = APIRouter()
+logger = logging.getLogger(__name__)
+
+_FALLBACK = {
+ "fed_rate": 4.25,
+ "ecb_rate": 3.65,
+ "us_2y": 4.50,
+ "eu_2y": 2.80,
+ "eurusd": 1.1450,
+ "vix": 18.0,
+ "oil": 80.0,
+ "real_yield_us": 2.10,
+ "pmi_us": 50.0,
+ "pmi_eu": 50.0,
+}
+
+
+def _gauge_val(gauges: dict, key: str):
+ g = gauges.get(key, {})
+ v = g.get("value")
+ return float(v) if v is not None else None
+
+
+def _yf_last(sym: str):
+ """Download last closing price for a single yfinance symbol."""
+ try:
+ import yfinance as yf
+ df = yf.download(sym, period="5d", interval="1d", progress=False, auto_adjust=True)
+ if df is None or len(df) == 0:
+ return None
+ if hasattr(df.columns, "levels"):
+ df.columns = df.columns.get_level_values(0)
+ close = df["Close"].dropna()
+ return float(close.iloc[-1]) if len(close) > 0 else None
+ except Exception as e:
+ logger.debug(f"[simulator/baseline] yf {sym}: {e}")
+ return None
+
+
+@router.get("/api/simulator/baseline")
+def simulator_baseline():
+ """
+ Aggregate current market values for the EUR/USD simulator.
+ Returns: fed_rate, ecb_rate, us_2y, eu_2y, eurusd, vix, oil, real_yield_us, sources{}, fetched_at.
+ """
+ result = dict(_FALLBACK)
+ sources: dict = {}
+
+ # ── 1. Macro gauge snapshots (fast, already in DB) ──────────────────────
+ try:
+ from services.database import get_conn
+ conn = get_conn()
+ row = conn.execute(
+ "SELECT gauges, snapshot_date FROM macro_gauge_snapshots ORDER BY snapshot_date DESC LIMIT 1"
+ ).fetchone()
+ conn.close()
+ if row:
+ raw = row["gauges"]
+ gauges = raw if isinstance(raw, dict) else json.loads(raw or "{}")
+ snap_date = (row["snapshot_date"] or "")[:10]
+
+ v = _gauge_val(gauges, "vix")
+ if v:
+ result["vix"] = round(v, 1)
+ sources["vix"] = f"snapshot {snap_date}"
+
+ v = _gauge_val(gauges, "brent")
+ if v:
+ result["oil"] = round(v, 1)
+ sources["oil"] = f"snapshot {snap_date}"
+
+ # us10y raw value is the yield in % → keep as reference
+ v = _gauge_val(gauges, "us10y")
+ if v:
+ result["_us10y_gauge"] = round(v, 2)
+
+ except Exception as e:
+ logger.debug(f"[simulator/baseline] gauge query: {e}")
+
+ # ── 2. FRED economic_events (policy rates, real yield) ──────────────────
+ try:
+ from services.database import get_conn
+ conn = get_conn()
+
+ for series_id, field in [
+ ("FEDFUNDS", "fed_rate"),
+ ("DFF", "fed_rate"), # daily effective fed funds (fallback)
+ ("ECBDFR", "ecb_rate"), # ECB deposit facility rate
+ ("DFII10", "real_yield_us"),# 10-year TIPS yield (real yield)
+ ]:
+ if field in sources:
+ continue
+ row = conn.execute(
+ "SELECT actual_value FROM economic_events WHERE series_id=? ORDER BY event_date DESC LIMIT 1",
+ (series_id,),
+ ).fetchone()
+ if row and row[0] is not None:
+ result[field] = round(float(row[0]), 2)
+ sources[field] = f"FRED {series_id}"
+
+ conn.close()
+ except Exception as e:
+ logger.debug(f"[simulator/baseline] FRED query: {e}")
+
+ # ── 3. yfinance live quotes ─────────────────────────────────────────────
+ try:
+ # EURUSD spot
+ v = _yf_last("EURUSD=X")
+ if v:
+ result["eurusd"] = round(v, 4)
+ sources["eurusd"] = "yfinance EURUSD=X"
+
+ # US Treasury yields (yfinance returns annualised %)
+ irx = _yf_last("^IRX") # 13-week T-bill (0.25Y)
+ fvx = _yf_last("^FVX") # 5-year T-note
+ tnx = _yf_last("^TNX") # 10-year T-note
+
+ if irx and fvx:
+ # Linear interpolation for 2Y on [0.25Y, 5Y] segment
+ # position of 2Y: (2 - 0.25) / (5 - 0.25) ≈ 0.368
+ us2y = irx + 0.368 * (fvx - irx)
+ result["us_2y"] = round(us2y, 2)
+ sources["us_2y"] = "yfinance ^IRX+^FVX interp"
+ elif tnx:
+ result["us_2y"] = round(tnx * 0.92, 2) # rough proxy
+ sources["us_2y"] = "yfinance ^TNX proxy"
+
+ # 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)
+ sources["real_yield_us"] = "yfinance ^TNX - breakeven approx"
+
+ # EU 2Y — German Schatz (not always available on yfinance)
+ eu2y = _yf_last("DE2YT=RR")
+ if eu2y:
+ result["eu_2y"] = round(eu2y, 2)
+ sources["eu_2y"] = "yfinance DE2YT=RR"
+ else:
+ # Approximation: ECB rate + 15 bps market premium
+ result["eu_2y"] = round(result["ecb_rate"] + 0.15, 2)
+ sources["eu_2y"] = "ECB rate +15bps approx"
+
+ # VIX — override gauge if not already set from DB
+ if "vix" not in sources:
+ v = _yf_last("^VIX")
+ if v:
+ result["vix"] = round(v, 1)
+ sources["vix"] = "yfinance ^VIX"
+
+ # Brent — override gauge if not already set
+ if "oil" not in sources:
+ v = _yf_last("BZ=F")
+ if v:
+ result["oil"] = round(v, 1)
+ sources["oil"] = "yfinance BZ=F"
+
+ except Exception as e:
+ 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 cc4bd34..5e3731f 100644
--- a/frontend/src/pages/EuroSimulator.tsx
+++ b/frontend/src/pages/EuroSimulator.tsx
@@ -1,41 +1,45 @@
-import { useState, useMemo } from 'react'
-import { RefreshCw, Sliders } from 'lucide-react'
+import { useState, useMemo, useEffect, useRef } from 'react'
+import { RefreshCw, Sliders, Wifi, WifiOff } from 'lucide-react'
import clsx from 'clsx'
-// ── Model types ────────────────────────────────────────────────────────────────
+// ── 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
+ fed_rate: number // absolute, step 0.25
+ fed_tone: number // –3 hawkish … +3 dovish
+ cpi_us_surprise: number // ±0.5 %
+ nfp_surprise: number // ±300 k
pmi_us: number // 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 %
+ ecb_rate: number // absolute, step 0.25
+ ecb_tone: number // –3 hawkish … +3 dovish
+ cpi_eu_surprise: number // ±0.5 %
pmi_eu: number // 40–65
// Markets
vix: number // 10–60
oil: number // 40–130
- real_yield_us: number // 0–4 %
+ 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
}
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
+ fed_pressure: number
+ ecb_pressure: number
+ us_2y_implied: number
+ eu_2y_implied: number
+ rate_diff: number
eurusd: number
delta_pips: number
contribs: { label: string; pips: number; color: 'red' | 'green' | 'slate' }[]
}
-// ── Baseline scenario (neutral / today's approximate) ─────────────────────────
+// ── Hardcoded fallback (used before live data arrives) ─────────────────────────
-const BASE: Params = {
+const FALLBACK: Params = {
fed_rate: 4.25, ecb_rate: 3.65,
fed_tone: 0, ecb_tone: 0,
cpi_us_surprise: 0, cpi_eu_surprise: 0,
@@ -43,303 +47,149 @@ const BASE: Params = {
pmi_us: 50, pmi_eu: 50,
vix: 18, oil: 80,
real_yield_us: 2.1,
+ us_2y: 4.50, eu_2y: 2.80,
+ eurusd: 1.1450,
}
-const BASE_US_2Y = 4.50
-const BASE_EU_2Y = 2.80
-const BASE_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).
-// ── Causal model (linearised, directionally correct coefficients) ──────────────
+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
-function compute(p: Params): ModelResult {
- // Fed pressure: positive = hawkish (higher rates, USD up, EUR/USD down)
+ // Fed pressure: positive = hawkish (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
+ (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
- // ECB pressure: positive = hawkish (higher rates, EUR up, EUR/USD up)
+ // ECB pressure: positive = hawkish (EUR up, EUR/USD up)
const ecb_pressure =
- (p.ecb_rate - BASE.ecb_rate) / 0.25 * 1.0
+ (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
+ // 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
- // 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)
+ // 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
- // PMI growth differential (positive = EU stronger = EUR/USD up)
+ // PMI growth differential (positive = EU stronger)
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
+ // 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
- // 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
+ // ── Pip contributions ─────────────────────────────────────────────────────
+ const c_rate = Math.round(-rate_diff_delta * 650) // ~650 pips per 1% spread
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 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)
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' },
+ 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' },
] 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,
+ us_2y_implied, eu_2y_implied, rate_diff,
eurusd: BASE_EURUSD + total_pips / 10000,
delta_pips: total_pips,
contribs,
}
}
-// ── Slider component ──────────────────────────────────────────────────────────
+// ── Slider ─────────────────────────────────────────────────────────────────────
function Slider({
label, value, min, max, step, format, onChange,
colorize = false, reverse = false, center,
}: {
label: string; value: number; min: number; max: number; step: number
- format: (v: number) => string
- onChange: (v: number) => void
- colorize?: boolean
- reverse?: boolean // true = value goes up → negative (red)
- center?: number // neutral pivot point (defaults to 0; use baseline value if 0 is out of range)
+ 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
- // Clamp mid to [0,100] so the track never overflows the container
- const mid = Math.max(0, Math.min(100, ((pivot - min) / (max - min)) * 100))
+ 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
- const atCenter = Math.abs(value - pivot) < step / 2
let trackColor = 'bg-blue-500'
if (colorize) {
- const isPositive = reverse ? value < pivot : value > pivot
- trackColor = atCenter ? 'bg-slate-600' : isPositive ? 'bg-emerald-500' : 'bg-rose-500'
+ const pos = reverse ? value < pivot : value > pivot
+ trackColor = atBase ? 'bg-slate-600' : pos ? 'bg-emerald-500' : 'bg-rose-500'
}
return (
{label}
- pivot) ? 'text-emerald-400' : 'text-rose-400'
: 'text-white',
)}>{format(value)}
- {/* Deviation bar (colored, from center to current value) */}
-
- {/* Position tick — always visible so cursor location is clear at baseline */}
+ : { left: 0, width: `${pct}%` }} />
{colorize && (
-
+
)}
-
onChange(parseFloat(e.target.value))}
- className="absolute inset-0 w-full opacity-0 cursor-pointer h-full"
- />
+ 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 (
-
- )
-}
-
-// ── 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 ─────────────────────────────────────────────────────────────
+const TONES = [
+ { v: -3, label: 'Très\nhawkish', color: 'border-rose-600/60 bg-rose-900/30 text-rose-300' },
+ { v: -1.5, label: 'Hawkish', color: 'border-rose-700/40 bg-rose-900/10 text-rose-400' },
+ { v: 0, label: 'Neutre', color: 'border-slate-600/40 bg-dark-800 text-slate-300' },
+ { v: 1.5, label: 'Dovish', color: 'border-emerald-700/40 bg-emerald-900/10 text-emerald-400' },
+ { v: 3, label: 'Très\ndovish', color: 'border-emerald-600/60 bg-emerald-900/30 text-emerald-300' },
+]
+
function ToneSelector({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) {
- 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 => (
-