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",
|
||||
}
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user