feat: eco desk enhanced config + EUR/USD causal simulator
Eco desk (market_event_detector.py + AIDesks.tsx): - Add currencies filter (USD via FRED, EUR/GBP/JPY/etc via ff_calendar) - Add min_impact filter (high / high+medium / all levels) - Add create_market_event toggle — detect surprises without creating events - Add lookback_releases — inject last N historical releases into event description - New _check_ff_calendar_surprises() for non-USD surprising releases - Frontend EcoConfig: currency chips, impact dropdown, releases input, toggle EUR/USD Simulator (EuroSimulator.tsx): - Pure frontend causal model — no API calls, no historical data - 3-column layout: controls | causal chain SVG | results - FED/BCE rate sliders + hawkish/dovish tone selector - CPI/NFP/PMI surprise inputs - SVG causal chain: CPI→CB→2Y→ΔRate→EURUSD with dynamic colors - Real-time pip decomposition by factor, sensitivity bars - Route /simulator + sidebar entry Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,37 @@ SUBTYPE_FROM_SERIES = {
|
|||||||
"BAMLH0A0HYM2": "Credit", "BAMLC0A0CM": "Credit",
|
"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 ───────────────────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -293,52 +324,105 @@ FORMAT JSON STRICT:
|
|||||||
return created
|
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]]:
|
def _check_ff_calendar_surprises(
|
||||||
from services.database import get_recent_economic_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))
|
impact_map = {
|
||||||
days = int(desk_cfg.get("days", 7))
|
"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:
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"[check_events/eco] query failed: {e}")
|
logger.warning(f"[check_events/eco/ff] query failed: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
existing = _existing_event_keys()
|
created = []
|
||||||
created: List[Dict] = []
|
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:
|
ev_date = (d.get("event_date") or "")[:10]
|
||||||
z = abs(rel.get("surprise_zscore") or 0)
|
ev_name_base = d.get("event_name", "Unknown")
|
||||||
s_pct = rel.get("surprise_pct") or 0
|
ccy = d.get("currency", "")
|
||||||
ev_date = (rel.get("event_date") or "")[:10]
|
sign = "+" if s_pct >= 0 else ""
|
||||||
s_id = rel.get("series_id", "")
|
ev_name = f"{ccy} {ev_name_base} — Surprise {sign}{s_pct:.1f}% ({ev_date[:7]})"
|
||||||
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):
|
if _is_dup(ev_name, existing):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
sub_type = SUBTYPE_FROM_SERIES.get(s_id, s_id[:10]) if s_id else ev_name_base[:10]
|
# Historical context
|
||||||
level = "long" if z >= 3 else ("medium" if z >= 2 else "short")
|
context_str = ""
|
||||||
assets = rel.get("assets_impacted") or []
|
if lookback_releases > 0:
|
||||||
if isinstance(assets, str):
|
|
||||||
try:
|
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:
|
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 = {
|
source_ref = {
|
||||||
"title": f"FRED release: {ev_name_base} ({ev_date})",
|
"title": f"Release: {ccy} {ev_name_base} ({ev_date})",
|
||||||
"source": "FRED",
|
"source": "ff_calendar",
|
||||||
"url": f"https://fred.stlouisfed.org/series/{s_id}" if s_id else "",
|
"url": "",
|
||||||
"date": ev_date,
|
"date": ev_date,
|
||||||
"original_score": round(min(0.95, 0.35 + z * 0.15), 3),
|
"original_score": round(score, 3),
|
||||||
}
|
}
|
||||||
|
|
||||||
ev = {
|
ev = {
|
||||||
@@ -346,26 +430,154 @@ def _check_eco(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|||||||
"start_date": ev_date,
|
"start_date": ev_date,
|
||||||
"level": level,
|
"level": level,
|
||||||
"category": "event_calendar",
|
"category": "event_calendar",
|
||||||
"sub_type": sub_type,
|
"sub_type": ccy,
|
||||||
"description": (
|
"description": (
|
||||||
f"Surprise {direction} {sign}{s_pct:.1f}% vs baseline "
|
f"Surprise en {direction} de {sign}{s_pct:.1f}% vs consensus. "
|
||||||
f"(z-score: {z:.1f}σ). "
|
f"Réel: {d['actual_value']} / Consensus: {d['forecast_value']}."
|
||||||
f"Réel: {rel.get('actual_value', '?')} {rel.get('actual_unit', '')} "
|
+ context_str
|
||||||
f"/ Prévision: {rel.get('forecast_value', '?')}."
|
|
||||||
),
|
),
|
||||||
"market_impact": "",
|
"market_impact": "",
|
||||||
"affected_assets": assets,
|
"affected_assets": [],
|
||||||
"impact_score": min(0.95, 0.35 + z * 0.15),
|
"impact_score": score,
|
||||||
"actual_value": str(rel.get("actual_value", "")),
|
"actual_value": str(d["actual_value"]),
|
||||||
"expected_value": str(rel.get("forecast_value", "")),
|
"expected_value": str(d["forecast_value"]),
|
||||||
"surprise_pct": float(s_pct),
|
"surprise_pct": float(s_pct),
|
||||||
"source_refs": [source_ref],
|
"source_refs": [source_ref],
|
||||||
"origin": "detector_eco",
|
"origin": "detector_eco_ff",
|
||||||
}
|
}
|
||||||
result = _save_and_evaluate(ev, existing)
|
if create_evt:
|
||||||
if result:
|
result = _save_and_evaluate(ev, existing)
|
||||||
result["source"] = "eco"
|
if result:
|
||||||
created.append(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
|
return created
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import CycleActions from './pages/CycleActions'
|
|||||||
import MarketEvents from './pages/MarketEvents'
|
import MarketEvents from './pages/MarketEvents'
|
||||||
import AIDesks from './pages/AIDesks'
|
import AIDesks from './pages/AIDesks'
|
||||||
import MacroSeriesPage from './pages/MacroSeriesPage'
|
import MacroSeriesPage from './pages/MacroSeriesPage'
|
||||||
|
import EuroSimulator from './pages/EuroSimulator'
|
||||||
import { Navigate } from 'react-router-dom'
|
import { Navigate } from 'react-router-dom'
|
||||||
import { useCycleWatcher } from './hooks/useApi'
|
import { useCycleWatcher } from './hooks/useApi'
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ export default function App() {
|
|||||||
<Route path="/market-events" element={<MarketEvents />} />
|
<Route path="/market-events" element={<MarketEvents />} />
|
||||||
<Route path="/cycle-actions" element={<CycleActions />} />
|
<Route path="/cycle-actions" element={<CycleActions />} />
|
||||||
<Route path="/ai-desks" element={<AIDesks />} />
|
<Route path="/ai-desks" element={<AIDesks />} />
|
||||||
|
<Route path="/simulator" element={<EuroSimulator />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Globe, BarChart2, FlaskConical,
|
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'
|
} from 'lucide-react'
|
||||||
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
|
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
@@ -25,6 +25,7 @@ const nav = [
|
|||||||
{ to: '/position-history', icon: GitCompare, label: 'Position History' },
|
{ to: '/position-history', icon: GitCompare, label: 'Position History' },
|
||||||
{ to: '/backtest', icon: History, label: 'Backtest' },
|
{ to: '/backtest', icon: History, label: 'Backtest' },
|
||||||
{ to: '/calendar', icon: Calendar, label: 'Calendar' },
|
{ to: '/calendar', icon: Calendar, label: 'Calendar' },
|
||||||
|
{ to: '/simulator', icon: Sliders, label: 'EUR/USD Simulator' },
|
||||||
{ to: '/macro-series', icon: TrendingUp, label: 'Macro Series' },
|
{ to: '/macro-series', icon: TrendingUp, label: 'Macro Series' },
|
||||||
{ to: '/institutional', icon: Building2, label: 'Inst. Reports' },
|
{ to: '/institutional', icon: Building2, label: 'Inst. Reports' },
|
||||||
{ to: '/specialist-desks', icon: Users, label: 'Specialist Desks' },
|
{ to: '/specialist-desks', icon: Users, label: 'Specialist Desks' },
|
||||||
|
|||||||
@@ -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({
|
function EcoConfig({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -351,25 +354,99 @@ function EcoConfig({
|
|||||||
onChange: (c: Record<string, any>) => void
|
onChange: (c: Record<string, any>) => void
|
||||||
}) {
|
}) {
|
||||||
const set = (k: string, v: any) => onChange({ ...config, [k]: v })
|
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 (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="space-y-4">
|
||||||
<div>
|
{/* Row 1: z-score + days */}
|
||||||
<label className="text-xs text-slate-400 block mb-1">Seuil z-score</label>
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<input
|
<div>
|
||||||
type="number" min={0.5} max={5} step={0.1}
|
<label className="text-xs text-slate-400 block mb-1">Seuil z-score (FRED)</label>
|
||||||
value={config.z_threshold ?? 1.5}
|
<input
|
||||||
onChange={e => set('z_threshold', parseFloat(e.target.value))}
|
type="number" min={0.5} max={5} step={0.1}
|
||||||
className="w-full bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white"
|
value={config.z_threshold ?? 1.5}
|
||||||
/>
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-400 block mb-1">Jours lookback</label>
|
||||||
|
<input
|
||||||
|
type="number" min={1} max={30}
|
||||||
|
value={config.days ?? 7}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: currencies */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-slate-400 block mb-1">Jours lookback</label>
|
<label className="text-xs text-slate-400 block mb-1.5">Devises surveillées</label>
|
||||||
<input
|
<div className="flex flex-wrap gap-1.5">
|
||||||
type="number" min={1} max={30}
|
{ECO_CURRENCIES.map(ccy => (
|
||||||
value={config.days ?? 7}
|
<button
|
||||||
onChange={e => set('days', parseInt(e.target.value))}
|
key={ccy}
|
||||||
className="w-full bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white"
|
onClick={() => 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}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: impact + lookback_releases */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-400 block mb-1">Impact minimum</label>
|
||||||
|
<select
|
||||||
|
value={config.min_impact ?? 'medium'}
|
||||||
|
onChange={e => 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"
|
||||||
|
>
|
||||||
|
<option value="high">High seulement</option>
|
||||||
|
<option value="medium">High + Medium</option>
|
||||||
|
<option value="low">Tous niveaux</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-400 block mb-1">Releases contexte</label>
|
||||||
|
<input
|
||||||
|
type="number" min={0} max={10}
|
||||||
|
value={config.lookback_releases ?? 3}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 4: create_market_event toggle */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => set('create_market_event', !(config.create_market_event ?? true))}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{(config.create_market_event ?? true)
|
||||||
|
? <ToggleRight className="w-5 h-5 text-emerald-400" />
|
||||||
|
: <ToggleLeft className="w-5 h-5 text-slate-600" />}
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-slate-300">Créer market_event automatiquement</span>
|
||||||
|
<p className="text-xs text-slate-600">Si désactivé, les surprises sont détectées mais pas enregistrées</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
522
frontend/src/pages/EuroSimulator.tsx
Normal file
522
frontend/src/pages/EuroSimulator.tsx
Normal file
@@ -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 (
|
||||||
|
<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 && value !== 0
|
||||||
|
? (reverse ? value < 0 : value > 0) ? 'text-emerald-400' : 'text-rose-400'
|
||||||
|
: 'text-white',
|
||||||
|
)}>{format(value)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative h-1.5 bg-dark-900 rounded">
|
||||||
|
<div
|
||||||
|
className={clsx('absolute h-full rounded transition-all', trackColor)}
|
||||||
|
style={colorize
|
||||||
|
? { left: `${Math.min(pct, mid)}%`, width: `${Math.abs(pct - mid)}%` }
|
||||||
|
: { left: 0, width: `${pct}%` }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="range" min={min} max={max} step={step} value={value}
|
||||||
|
onChange={e => onChange(parseFloat(e.target.value))}
|
||||||
|
className="absolute inset-0 w-full opacity-0 cursor-pointer h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 }) => (
|
||||||
|
<g>
|
||||||
|
<rect x={x - w / 2} y={y - 18} width={w} height={36} rx={6}
|
||||||
|
fill="#1e293b" stroke={color} strokeWidth={1.5} />
|
||||||
|
<text x={x} y={y - 4} textAnchor="middle" fill={color} fontSize={10} fontWeight={600}>{label}</text>
|
||||||
|
{sub && <text x={x} y={y + 10} textAnchor="middle" fill={color} fontSize={9} opacity={0.75}>{sub}</text>}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
|
||||||
|
const Arrow = ({ x1, y1, x2, y2, color, w }: { x1: number; y1: number; x2: number; y2: number; color: string; w: number }) => (
|
||||||
|
<line x1={x1} y1={y1} x2={x2} y2={y2} stroke={color} strokeWidth={w} opacity={0.6}
|
||||||
|
markerEnd={`url(#arr-${color.replace('#', '')})`} />
|
||||||
|
)
|
||||||
|
|
||||||
|
const markers = [
|
||||||
|
{ id: `arr-f87171`, color: '#f87171' },
|
||||||
|
{ id: `arr-34d399`, color: '#34d399' },
|
||||||
|
{ id: `arr-94a3b8`, color: '#94a3b8' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="w-full">
|
||||||
|
<defs>
|
||||||
|
{markers.map(m => (
|
||||||
|
<marker key={m.id} id={m.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={m.color} />
|
||||||
|
</marker>
|
||||||
|
))}
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* ── US chain ── */}
|
||||||
|
<Node x={usX} y={yIn1} label="CPI US" sub={`srp ${r.fed_pressure > 0 ? '+' : ''}${(r.fed_pressure * 0.3).toFixed(1)}`} color={r.fed_pressure > 0.3 ? '#f87171' : '#94a3b8'} w={88} />
|
||||||
|
<Node x={usX} y={yIn2} label="NFP" sub={`srp`} color={r.fed_pressure > 0.3 ? '#f87171' : '#94a3b8'} w={88} />
|
||||||
|
<Node x={usX} y={yIn3} label="PMI US" color={'#94a3b8'} w={88} />
|
||||||
|
|
||||||
|
<Arrow x1={usX} y1={yIn1 + 18} x2={usX} y2={yCB - 20} color={fedColor} w={arrowW(r.fed_pressure)} />
|
||||||
|
|
||||||
|
<Node x={usX} y={yCB} label="FED" sub={`pression ${r.fed_pressure > 0 ? '+' : ''}${r.fed_pressure.toFixed(1)}`} color={fedColor} w={108} />
|
||||||
|
<Arrow x1={usX} y1={yCB + 18} x2={usX} y2={yYield - 18} color={us2yColor} w={arrowW(r.us_2y - BASE_US_2Y)} />
|
||||||
|
<Node x={usX} y={yYield} label="US 2Y" sub={`${r.us_2y.toFixed(2)}%`} color={us2yColor} w={100} />
|
||||||
|
<Arrow x1={usX + 50} y1={yYield + 10} x2={W / 2 - 40} y2={yDiff - 12} color={us2yColor} w={arrowW(r.us_2y - BASE_US_2Y)} />
|
||||||
|
|
||||||
|
{/* ── EU chain ── */}
|
||||||
|
<Node x={euX} y={yIn1} label="CPI EU" sub={`srp`} color={r.ecb_pressure > 0.3 ? '#34d399' : '#94a3b8'} w={88} />
|
||||||
|
<Node x={euX} y={yIn2} label="PMI EU" sub={`${r.ecb_pressure > 0 ? 'fort' : 'faible'}`} color={r.ecb_pressure > 0.3 ? '#34d399' : '#94a3b8'} w={88} />
|
||||||
|
|
||||||
|
<Arrow x1={euX} y1={yIn1 + 18} x2={euX} y2={yCB - 20} color={ecbColor} w={arrowW(r.ecb_pressure)} />
|
||||||
|
|
||||||
|
<Node x={euX} y={yCB} label="BCE" sub={`pression ${r.ecb_pressure > 0 ? '+' : ''}${r.ecb_pressure.toFixed(1)}`} color={ecbColor} w={108} />
|
||||||
|
<Arrow x1={euX} y1={yCB + 18} x2={euX} y2={yYield - 18} color={eu2yColor} w={arrowW(r.eu_2y - BASE_EU_2Y)} />
|
||||||
|
<Node x={euX} y={yYield} label="Bund 2Y" sub={`${r.eu_2y.toFixed(2)}%`} color={eu2yColor} w={100} />
|
||||||
|
<Arrow x1={euX - 50} y1={yYield + 10} x2={W / 2 + 40} y2={yDiff - 12} color={eu2yColor} w={arrowW(r.eu_2y - BASE_EU_2Y)} />
|
||||||
|
|
||||||
|
{/* ── Rate differential ── */}
|
||||||
|
<Node x={W / 2} y={yDiff} label="Δ Taux 2Y" sub={`${r.rate_diff.toFixed(2)}% (US−EU)`} color={diffColor} w={130} />
|
||||||
|
<Arrow x1={W / 2} y1={yDiff + 18} x2={W / 2} y2={yFX - 20} color={diffColor} w={arrowW(r.rate_diff - (BASE_US_2Y - BASE_EU_2Y))} />
|
||||||
|
|
||||||
|
{/* ── VIX / Risk ── */}
|
||||||
|
<Node x={riskX} y={yDiff} label="VIX" sub={`risk-off`} color="#94a3b8" w={58} />
|
||||||
|
<Arrow x1={riskX + 29} y1={yDiff + 5} x2={W / 2 - 68} y2={yFX - 6} color="#94a3b8" w={1.2} />
|
||||||
|
|
||||||
|
{/* ── EURUSD ── */}
|
||||||
|
<rect x={W / 2 - 75} y={yFX - 22} width={150} height={44} rx={8}
|
||||||
|
fill="#1e3a5f" stroke={eurusdColor} strokeWidth={2} />
|
||||||
|
<text x={W / 2} y={yFX - 4} textAnchor="middle" fill={eurusdColor} fontSize={11} fontWeight={700}>EURUSD</text>
|
||||||
|
<text x={W / 2} y={yFX + 12} textAnchor="middle" fill={eurusdColor} fontSize={13} fontWeight={800}>
|
||||||
|
{r.eurusd.toFixed(4)}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<div className="grid grid-cols-[130px_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', pos ? 'bg-emerald-500' : 'bg-rose-500')}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={clsx('text-xs font-mono text-right', pos ? 'text-emerald-400' : 'text-rose-400')}>
|
||||||
|
{pips > 0 ? '+' : ''}{pips}p
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section header ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Section({ title, color, children }: { title: string; color: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={clsx('rounded-lg border p-3 space-y-3', color)}>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wider text-slate-300">{title}</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs text-slate-400">{label}</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{opts.map(o => (
|
||||||
|
<button
|
||||||
|
key={o.v}
|
||||||
|
onClick={() => 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}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function EuroSimulator() {
|
||||||
|
const [p, setP] = useState<Params>(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 (
|
||||||
|
<div className="p-4 max-w-[1400px] mx-auto space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => 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"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[320px_1fr_270px] gap-4 items-start">
|
||||||
|
|
||||||
|
{/* ── Left: Controls ──────────────────────────────────────────────── */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
|
||||||
|
{/* FED / US */}
|
||||||
|
<Section title="🇺🇸 Politique monétaire US (FED)" color="border-rose-900/30 bg-rose-950/10">
|
||||||
|
<Slider label="Taux directeur FED" value={p.fed_rate} min={0} max={6} step={0.25}
|
||||||
|
format={v => `${v.toFixed(2)}%`} onChange={v => set('fed_rate', v)} />
|
||||||
|
<ToneSelector label="Ton FED" value={p.fed_tone} onChange={v => set('fed_tone', v)} />
|
||||||
|
<Slider label="Surprise CPI US" value={p.cpi_us_surprise} min={-0.5} max={0.5} step={0.05}
|
||||||
|
format={v => `${v > 0 ? '+' : ''}${v.toFixed(2)}%`}
|
||||||
|
onChange={v => set('cpi_us_surprise', v)} colorize reverse />
|
||||||
|
<Slider label="Surprise NFP" value={p.nfp_surprise} min={-300} max={300} step={10}
|
||||||
|
format={v => `${v > 0 ? '+' : ''}${v}k`}
|
||||||
|
onChange={v => set('nfp_surprise', v)} colorize reverse />
|
||||||
|
<Slider label="PMI manufacturier US" value={p.pmi_us} min={40} max={65} step={0.5}
|
||||||
|
format={v => v.toFixed(1)} onChange={v => set('pmi_us', v)} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ECB / EU */}
|
||||||
|
<Section title="🇪🇺 Politique monétaire EU (BCE)" color="border-emerald-900/30 bg-emerald-950/10">
|
||||||
|
<Slider label="Taux directeur BCE" value={p.ecb_rate} min={0} max={5} step={0.25}
|
||||||
|
format={v => `${v.toFixed(2)}%`} onChange={v => set('ecb_rate', v)} />
|
||||||
|
<ToneSelector label="Ton BCE" value={p.ecb_tone} onChange={v => set('ecb_tone', v)} />
|
||||||
|
<Slider label="Surprise CPI EU" value={p.cpi_eu_surprise} min={-0.5} max={0.5} step={0.05}
|
||||||
|
format={v => `${v > 0 ? '+' : ''}${v.toFixed(2)}%`}
|
||||||
|
onChange={v => set('cpi_eu_surprise', v)} colorize />
|
||||||
|
<Slider label="PMI manufacturier EU" value={p.pmi_eu} min={40} max={65} step={0.5}
|
||||||
|
format={v => v.toFixed(1)} onChange={v => set('pmi_eu', v)} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Markets */}
|
||||||
|
<Section title="📊 Marchés & sentiment" color="border-slate-700/30 bg-dark-800/60">
|
||||||
|
<Slider label="VIX" value={p.vix} min={10} max={60} step={0.5}
|
||||||
|
format={v => v.toFixed(1)} onChange={v => set('vix', v)}
|
||||||
|
colorize reverse />
|
||||||
|
<Slider label="Pétrole (USD/bbl)" value={p.oil} min={40} max={130} step={1}
|
||||||
|
format={v => `$${v.toFixed(0)}`} onChange={v => set('oil', v)} />
|
||||||
|
<Slider label="Taux réel US 10Y" value={p.real_yield_us} min={-1} max={4} step={0.05}
|
||||||
|
format={v => `${v > 0 ? '+' : ''}${v.toFixed(2)}%`}
|
||||||
|
onChange={v => set('real_yield_us', v)} colorize reverse />
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Center: Causal chain ────────────────────────────────────────── */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Yield display strip */}
|
||||||
|
<div className="w-full grid grid-cols-4 gap-2 mb-4">
|
||||||
|
{[
|
||||||
|
{ 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 => (
|
||||||
|
<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.color)}>{it.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CausalChain r={r} />
|
||||||
|
|
||||||
|
{/* Baseline note */}
|
||||||
|
<div className="mt-3 text-[10px] text-slate-600 text-center">
|
||||||
|
Référence: EUR/USD {BASE_EURUSD.toFixed(4)} · US 2Y {BASE_US_2Y}% · Bund 2Y {BASE_EU_2Y}% · VIX {BASE.vix}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Right: Results ──────────────────────────────────────────────── */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
|
||||||
|
{/* EUR/USD display */}
|
||||||
|
<div className="bg-dark-800/60 rounded-xl border border-slate-700/30 p-4 text-center">
|
||||||
|
<div className="text-xs text-slate-500 uppercase tracking-wider mb-1">EUR/USD simulé</div>
|
||||||
|
<div className={clsx('text-4xl font-mono font-bold tracking-tight', eurusdColor)}>
|
||||||
|
{r.eurusd.toFixed(4)}
|
||||||
|
</div>
|
||||||
|
<div className={clsx('text-base font-mono mt-1', eurusdColor)}>
|
||||||
|
{eurusdChange === 0 ? 'Neutre' : `${eurusdChange > 0 ? '+' : ''}${eurusdChange} pips`}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-600 mt-1">
|
||||||
|
vs base {BASE_EURUSD.toFixed(4)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pressure gauges */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-4">
|
||||||
|
{[
|
||||||
|
{ 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 (
|
||||||
|
<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',
|
||||||
|
isPos ? g.pos_color : isNeg ? g.neg_color : 'text-slate-400',
|
||||||
|
)}>
|
||||||
|
{isPos ? g.pos_label : isNeg ? g.neg_label : 'Neutre'}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-mono text-slate-500">
|
||||||
|
{g.v > 0 ? '+' : ''}{g.v.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pip decomposition */}
|
||||||
|
<div className="bg-dark-800/60 rounded-xl border border-slate-700/30 p-4 space-y-2">
|
||||||
|
<div className="text-xs text-slate-500 uppercase tracking-wider mb-2">Décomposition ({eurusdChange > 0 ? '+' : ''}{eurusdChange} pips)</div>
|
||||||
|
{r.contribs.map(c => (
|
||||||
|
<SensBar key={c.label} label={c.label} pips={c.pips} maxAbs={maxAbs} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dominance legend */}
|
||||||
|
<div className="bg-dark-800/60 rounded-xl border border-slate-700/30 p-3 space-y-1.5">
|
||||||
|
<div className="text-xs text-slate-500 uppercase tracking-wider mb-1">Influence relative</div>
|
||||||
|
{r.contribs.slice(0, 5).map(c => {
|
||||||
|
const pct = maxAbs > 0 ? Math.round((Math.abs(c.pips) / maxAbs) * 100) : 0
|
||||||
|
return (
|
||||||
|
<div key={c.label} className="flex items-center gap-2 text-[11px]">
|
||||||
|
<div className={clsx('w-2 h-2 rounded-full shrink-0',
|
||||||
|
c.pips > 0 ? 'bg-emerald-500' : 'bg-rose-500',
|
||||||
|
)} />
|
||||||
|
<span className="text-slate-400 flex-1 truncate">{c.label}</span>
|
||||||
|
<span className="text-slate-500 font-mono">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Interpretation note */}
|
||||||
|
<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.
|
||||||
|
Le différentiel de taux 2Y explique ~60-70% des mouvements FX à moyen terme.
|
||||||
|
Coefficients estimés — non calibrés sur données historiques.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user