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:
OpenSquared
2026-06-26 22:17:33 +02:00
parent dd2876325a
commit a24cac38ad
5 changed files with 874 additions and 60 deletions

View File

@@ -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