From d178615c745c2e211b3f0bf363d252e94ebdaabd Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Mon, 22 Jun 2026 14:00:35 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202=20=E2=80=94=20economic=20even?= =?UTF-8?q?t=20surprise=20tracker=20(FRED=20actuals=20+=20z-score)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - economic_events table in DB (series_id, actual, forecast_baseline, surprise_pct, surprise_zscore, direction) - DB helpers: save_economic_event(), get_recent_economic_surprises(), get_economic_events_for_calendar() - fred_fetcher.py: _compute_zscore_surprise() computes 12-period MA as implied consensus + z-score deviation; save_fred_releases_to_db() persists releases per cycle; build_economic_surprise_block() formats significant surprises for AI prompt - auto_cycle.py: saves FRED releases to economic_events each cycle, appends surprise block to fred_block for injection into both suggestion and scoring prompts - data_fetcher.py: get_economic_calendar() now merges static upcoming events with past FRED actuals from DB (Prev/Fcst/Actual/z-score fields populated) - CalendarPage.tsx: past events show colored z-score badge (⚡ for |z|≥1.5, bullish/bearish colors) - EconomicEvent type: added surprise_zscore, surprise_direction, source fields Activates automatically once fred_api_key is set in Configuration. Co-Authored-By: Claude Sonnet 4.6 --- backend/services/auto_cycle.py | 32 +++++++- backend/services/data_fetcher.py | 80 +++++++++++++++---- backend/services/database.py | 119 ++++++++++++++++++++++++++++ backend/services/fred_fetcher.py | 119 ++++++++++++++++++++++++++-- frontend/src/pages/CalendarPage.tsx | 22 ++++- frontend/src/types/index.ts | 3 + 6 files changed, 346 insertions(+), 29 deletions(-) diff --git a/backend/services/auto_cycle.py b/backend/services/auto_cycle.py index 36f49b7..568bb10 100644 --- a/backend/services/auto_cycle.py +++ b/backend/services/auto_cycle.py @@ -431,15 +431,41 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: except Exception as _abs_e: logger.warning(f"[Cycle] Price absorption failed (non-blocking): {_abs_e}") - # ── Phase 2: FRED recent macro releases ────────────────────────────── + # ── Phase 2: FRED recent macro releases + economic surprise tracking ──── _fred_releases = [] _fred_block = "" + _surprise_block = "" try: - from services.fred_fetcher import get_fred_recent_releases, build_fred_context_block + from services.fred_fetcher import ( + get_fred_recent_releases, build_fred_context_block, + save_fred_releases_to_db, build_economic_surprise_block, + ) + _fred_key = get_config("fred_api_key") or "" _fred_releases = get_fred_recent_releases() - _fred_block = build_fred_context_block(_fred_releases) if _fred_releases: logger.info(f"[Cycle {run_id[:16]}] FRED: {len(_fred_releases)} releases fetched") + # Persist to economic_events with z-score surprise scoring + try: + _saved = save_fred_releases_to_db(_fred_releases, api_key=_fred_key) + if _saved: + logger.info(f"[Cycle {run_id[:16]}] FRED: {_saved} releases saved to economic_events") + except Exception as _dbe: + logger.warning(f"[Cycle] FRED DB save failed (non-blocking): {_dbe}") + # Add surprise scores to release dicts for richer prompt block + if _fred_key: + try: + from services.database import get_recent_economic_surprises + _db_surprises = {ev["series_id"]: ev for ev in get_recent_economic_surprises(days=60)} + for rel in _fred_releases: + sid = rel.get("series_id", "") + if sid in _db_surprises: + rel["surprise_zscore"] = _db_surprises[sid].get("surprise_zscore") + except Exception: + pass + _fred_block = build_fred_context_block(_fred_releases) + _surprise_block = build_economic_surprise_block(days=14) + if _surprise_block: + _fred_block = _fred_block + "\n\n" + _surprise_block if _fred_block else _surprise_block except Exception as _fe: logger.warning(f"[Cycle] FRED fetch failed (non-blocking): {_fe}") diff --git a/backend/services/data_fetcher.py b/backend/services/data_fetcher.py index 66de61e..0ae488c 100644 --- a/backend/services/data_fetcher.py +++ b/backend/services/data_fetcher.py @@ -255,49 +255,99 @@ def extract_tags(text: str) -> List[str]: # ── Economic calendar (using free Trading Economics RSS or static) ──────────── +_FRED_SERIES_TO_CALENDAR = { + "PAYEMS": "US Non-Farm Payrolls", + "CPIAUCSL": "US CPI (Consumer Price Index)", + "UNRATE": "US Unemployment Rate", + "GDP": "US GDP (Preliminary)", + "ICSA": "US Initial Jobless Claims", + "FEDFUNDS": "Fed Funds Rate (FOMC)", + "T10Y2Y": "10Y-2Y Yield Spread", +} + + def get_economic_calendar() -> List[Dict[str, Any]]: - """Return next 30 days of major economic events (static + scraped).""" + """Return recent + upcoming major economic events, enriched with FRED actuals from DB.""" from datetime import date, timedelta today = date.today() - events = [ - {"title": "US Non-Farm Payrolls", "country": "US", "importance": "high", + + upcoming_static = [ + {"title": "US Non-Farm Payrolls", "country": "US", "importance": "high", "date": (today + timedelta(days=(4 - today.weekday()) % 7 + 7)).isoformat(), "asset_impact": ["indices", "forex", "rates"]}, - {"title": "US CPI (Consumer Price Index)", "country": "US", "importance": "high", + {"title": "US CPI (Consumer Price Index)", "country": "US", "importance": "high", "date": (today + timedelta(days=12)).isoformat(), "asset_impact": ["indices", "forex", "metals"]}, - {"title": "FOMC Meeting / Fed Rate Decision", "country": "US", "importance": "high", + {"title": "FOMC Meeting / Fed Rate Decision","country": "US", "importance": "high", "date": (today + timedelta(days=18)).isoformat(), "asset_impact": ["indices", "forex", "metals", "energy"]}, - {"title": "ECB Rate Decision", "country": "EU", "importance": "high", + {"title": "ECB Rate Decision", "country": "EU", "importance": "high", "date": (today + timedelta(days=20)).isoformat(), "asset_impact": ["forex", "indices"]}, - {"title": "US GDP (Preliminary)", "country": "US", "importance": "high", + {"title": "US GDP (Preliminary)", "country": "US", "importance": "high", "date": (today + timedelta(days=25)).isoformat(), "asset_impact": ["indices", "forex"]}, - {"title": "OPEC+ Meeting", "country": "Global", "importance": "high", + {"title": "OPEC+ Meeting", "country": "Global", "importance": "high", "date": (today + timedelta(days=14)).isoformat(), "asset_impact": ["energy"]}, - {"title": "US Crude Oil Inventories (EIA)", "country": "US", "importance": "medium", + {"title": "US Crude Oil Inventories (EIA)", "country": "US", "importance": "medium", "date": (today + timedelta(days=3)).isoformat(), "asset_impact": ["energy"]}, - {"title": "EU Inflation (CPI)", "country": "EU", "importance": "medium", + {"title": "EU Inflation (CPI)", "country": "EU", "importance": "medium", "date": (today + timedelta(days=8)).isoformat(), "asset_impact": ["forex", "indices"]}, - {"title": "China Trade Balance", "country": "CN", "importance": "medium", + {"title": "China Trade Balance", "country": "CN", "importance": "medium", "date": (today + timedelta(days=10)).isoformat(), "asset_impact": ["metals", "agriculture", "forex"]}, - {"title": "US Unemployment Claims", "country": "US", "importance": "medium", + {"title": "US Unemployment Claims", "country": "US", "importance": "medium", "date": (today + timedelta(days=2)).isoformat(), "asset_impact": ["forex", "indices"]}, - {"title": "USDA Crop Report", "country": "US", "importance": "medium", + {"title": "USDA Crop Report", "country": "US", "importance": "medium", "date": (today + timedelta(days=6)).isoformat(), "asset_impact": ["agriculture"]}, - {"title": "G7 Summit", "country": "Global", "importance": "high", + {"title": "G7 Summit", "country": "Global", "importance": "high", "date": (today + timedelta(days=30)).isoformat(), "asset_impact": ["forex", "indices", "metals"]}, ] - return sorted(events, key=lambda x: x["date"]) + + # Merge past FRED actuals from DB + try: + from services.database import get_economic_events_for_calendar + db_events = get_economic_events_for_calendar(days_back=45, days_forward=0) + past_events = [] + for ev in db_events: + actual = ev.get("actual_value") + forecast = ev.get("forecast_value") + previous = ev.get("previous_value") + unit = ev.get("actual_unit", "") + zscore = ev.get("surprise_zscore") + past_events.append({ + "title": ev.get("event_name", ev.get("series_id", "")), + "country": "US", + "importance": "high" if abs(zscore or 0) >= 1.5 else "medium", + "date": ev.get("event_date", ""), + "asset_impact": ev.get("assets_impacted", []), + "actual": f"{actual:.2f}{unit}" if actual is not None else None, + "forecast": f"{forecast:.2f}{unit}" if forecast is not None else None, + "previous": f"{previous:.2f}{unit}" if previous is not None else None, + "surprise_zscore": zscore, + "surprise_direction": ev.get("surprise_direction"), + "source": "FRED", + }) + except Exception: + past_events = [] + + all_events = past_events + upcoming_static + # Deduplicate by date+title (past events take precedence) + seen = set() + result = [] + for ev in sorted(all_events, key=lambda x: x["date"], reverse=True): + key = (ev["date"], ev["title"][:20]) + if key not in seen: + seen.add(key) + result.append(ev) + + return sorted(result, key=lambda x: x["date"]) # ── Macro Gauges & Scenario Scoring ────────────────────────────────────────── diff --git a/backend/services/database.py b/backend/services/database.py index 59de684..796a2d4 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -576,6 +576,29 @@ def init_db(): except Exception: pass + c.execute("""CREATE TABLE IF NOT EXISTS economic_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_name TEXT NOT NULL, + series_id TEXT NOT NULL, + event_date TEXT NOT NULL, + actual_value REAL, + actual_unit TEXT DEFAULT '', + forecast_value REAL, + previous_value REAL, + surprise_pct REAL, + surprise_zscore REAL, + surprise_direction TEXT DEFAULT 'neutral', + assets_impacted TEXT DEFAULT '[]', + source TEXT DEFAULT 'FRED', + fetched_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(series_id, event_date) + )""") + try: + c.execute("CREATE INDEX IF NOT EXISTS idx_econ_date ON economic_events(event_date DESC)") + c.execute("CREATE INDEX IF NOT EXISTS idx_econ_series ON economic_events(series_id, event_date DESC)") + except Exception: + pass + try: c.execute("CREATE INDEX IF NOT EXISTS idx_kb_category ON knowledge_base(category, status)") c.execute("CREATE INDEX IF NOT EXISTS idx_rs_version ON reasoning_state(version DESC)") @@ -3742,3 +3765,99 @@ def get_latest_trade_assessments(limit: int = 20) -> List[Dict[str, Any]]: d["issues"] = [] result.append(d) return result + + +# ── Economic events (Phase 2) ───────────────────────────────────────────────── + +def save_economic_event( + event_name: str, + series_id: str, + event_date: str, + actual_value: Optional[float], + actual_unit: str = "", + forecast_value: Optional[float] = None, + previous_value: Optional[float] = None, + surprise_pct: Optional[float] = None, + surprise_zscore: Optional[float] = None, + surprise_direction: str = "neutral", + assets_impacted: Optional[List] = None, + source: str = "FRED", +) -> Optional[int]: + conn = get_conn() + try: + conn.execute( + """INSERT INTO economic_events + (event_name, series_id, event_date, actual_value, actual_unit, + forecast_value, previous_value, surprise_pct, surprise_zscore, + surprise_direction, assets_impacted, source) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(series_id, event_date) DO UPDATE SET + actual_value=excluded.actual_value, + forecast_value=excluded.forecast_value, + previous_value=excluded.previous_value, + surprise_pct=excluded.surprise_pct, + surprise_zscore=excluded.surprise_zscore, + surprise_direction=excluded.surprise_direction, + fetched_at=datetime('now')""", + ( + event_name, series_id, event_date, actual_value, actual_unit, + forecast_value, previous_value, surprise_pct, surprise_zscore, + surprise_direction, + json.dumps(assets_impacted or []), + source, + ), + ) + conn.commit() + return conn.execute("SELECT last_insert_rowid()").fetchone()[0] + finally: + conn.close() + + +def get_recent_economic_surprises(days: int = 30, min_zscore: float = 0.0) -> List[Dict[str, Any]]: + conn = get_conn() + try: + cutoff = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d") + rows = conn.execute( + """SELECT * FROM economic_events + WHERE event_date >= ? + AND ABS(COALESCE(surprise_zscore, 0)) >= ? + ORDER BY event_date DESC, ABS(COALESCE(surprise_zscore, 0)) DESC + LIMIT 20""", + (cutoff, min_zscore), + ).fetchall() + result = [] + for r in rows: + d = dict(r) + try: + d["assets_impacted"] = json.loads(d.get("assets_impacted") or "[]") + except Exception: + d["assets_impacted"] = [] + result.append(d) + return result + finally: + conn.close() + + +def get_economic_events_for_calendar(days_back: int = 30, days_forward: int = 14) -> List[Dict[str, Any]]: + """Return stored economic events for calendar enrichment.""" + conn = get_conn() + try: + cutoff_past = (datetime.utcnow() - timedelta(days=days_back)).strftime("%Y-%m-%d") + cutoff_future = (datetime.utcnow() + timedelta(days=days_forward)).strftime("%Y-%m-%d") + rows = conn.execute( + """SELECT * FROM economic_events + WHERE event_date BETWEEN ? AND ? + ORDER BY event_date DESC""", + (cutoff_past, cutoff_future), + ).fetchall() + result = [] + for r in rows: + d = dict(r) + try: + d["assets_impacted"] = json.loads(d.get("assets_impacted") or "[]") + except Exception: + d["assets_impacted"] = [] + result.append(d) + return result + finally: + conn.close() diff --git a/backend/services/fred_fetcher.py b/backend/services/fred_fetcher.py index 6936e8f..b277ae9 100644 --- a/backend/services/fred_fetcher.py +++ b/backend/services/fred_fetcher.py @@ -157,20 +157,123 @@ def get_fred_recent_releases() -> List[Dict[str, Any]]: return results +def _compute_zscore_surprise(series_id: str, api_key: str, latest_value: float) -> tuple: + """Compute z-score of latest value vs 12-period MA. Returns (forecast, surprise_pct, zscore).""" + import math + obs = _fetch_series_observations(series_id, api_key, count=14) + values = [_parse_value(o["value"]) for o in obs if _parse_value(o["value"]) is not None] + if len(values) < 3: + return None, None, None + # Use obs[1:] as history (exclude latest), up to 12 periods + history = values[1:13] + if not history: + return None, None, None + mean = sum(history) / len(history) + variance = sum((x - mean) ** 2 for x in history) / len(history) + std = math.sqrt(variance) if variance > 0 else 0.0 + surprise_pct = round((latest_value - mean) / abs(mean) * 100, 2) if mean != 0 else 0.0 + zscore = round((latest_value - mean) / std, 2) if std > 0 else 0.0 + return round(mean, 4), surprise_pct, zscore + + +def save_fred_releases_to_db(releases: List[Dict], api_key: str = "") -> int: + """Persist FRED releases to economic_events table with surprise scores. Returns count saved.""" + from services.database import save_economic_event + import json as _json + + # Map series_id → assets_impacted from FRED_SERIES config + _assets_map = {sid: assets for sid, _, _, assets, _ in FRED_SERIES} + + saved = 0 + for r in releases: + sid = r.get("series_id", "") + if not sid or r.get("latest_value") is None: + continue + # Compute z-score surprise if api_key available + forecast_val, surprise_pct, zscore = (None, None, None) + if api_key: + try: + forecast_val, surprise_pct, zscore = _compute_zscore_surprise(sid, api_key, r["latest_value"]) + except Exception: + pass + + try: + save_economic_event( + event_name=r.get("label", sid), + series_id=sid, + event_date=r.get("latest_date", ""), + actual_value=r.get("latest_value"), + actual_unit=r.get("unit", ""), + forecast_value=forecast_val, + previous_value=r.get("previous_value"), + surprise_pct=surprise_pct, + surprise_zscore=zscore, + surprise_direction=r.get("direction", "neutral"), + assets_impacted=_assets_map.get(sid, []), + source="FRED", + ) + saved += 1 + except Exception as e: + logger.warning(f"[FRED] Failed to save {sid} to DB: {e}") + + return saved + + def build_fred_context_block(releases: List[Dict]) -> str: - """Format FRED releases as a prompt-ready string.""" + """Format FRED releases as a prompt-ready string with surprise scoring.""" if not releases: return "" - lines = ["## 📈 DONNÉES MACRO RÉCENTES (FRED — dernières releases)"] + lines = ["## RECENT MACRO DATA (FRED — latest releases)"] for r in releases: - val_str = f"{r['latest_value']:.2f}{r['unit']}" if isinstance(r.get("latest_value"), float) else str(r.get("latest_value", "N/A")) + val_str = f"{r['latest_value']:.2f} {r['unit']}" if isinstance(r.get("latest_value"), float) else str(r.get("latest_value", "N/A")) chg_str = "" if r.get("change") is not None: - arrow = "▲" if r["change"] > 0 else "▼" - chg_str = f" ({arrow}{abs(r['change']):.3f} vs release précédente)" - dir_emoji = {"bullish": "🟢", "bearish": "🔴", "neutral": "⚪"}.get(r.get("direction", "neutral"), "⚪") + arrow = "+" if r["change"] > 0 else "" + chg_str = f" ({arrow}{r['change']:.3f} vs prior)" + surprise_str = "" + if r.get("surprise_zscore") is not None: + z = r["surprise_zscore"] + if abs(z) >= 1.5: + surprise_str = f" ⚡ SURPRISE z={z:+.1f}" + elif abs(z) >= 0.8: + surprise_str = f" (notable z={z:+.1f})" + dir_tag = {"bullish": "[BULLISH]", "bearish": "[BEARISH]", "neutral": "[NEUTRAL]"}.get(r.get("direction", "neutral"), "") lines.append( - f" {dir_emoji} {r['label']} [{r['latest_date']}] : {val_str}{chg_str} → {r['direction'].upper()}" + f" {r['label']} [{r['latest_date']}]: {val_str}{chg_str}{surprise_str} → {dir_tag}" ) - lines.append("⚠️ Compare ces chiffres au consensus attendu pour évaluer si le marché a déjà intégré la surprise.") + lines.append("Use these figures to assess whether current market pricing already reflects macro reality.") return "\n".join(lines) + + +def build_economic_surprise_block(days: int = 14) -> str: + """Build a prompt block from stored economic_events table — recent surprises highlighted.""" + try: + from services.database import get_recent_economic_surprises + events = get_recent_economic_surprises(days=days, min_zscore=0.0) + if not events: + return "" + lines = [f"## ECONOMIC SURPRISE TRACKER (last {days} days — FRED actuals vs trend baseline)"] + for ev in events[:8]: + z = ev.get("surprise_zscore") or 0.0 + sp = ev.get("surprise_pct") or 0.0 + direction = ev.get("surprise_direction", "neutral") + actual = ev.get("actual_value") + unit = ev.get("actual_unit", "") + forecast = ev.get("forecast_value") + flag = "" + if abs(z) >= 1.5: + flag = " ⚡ SIGNIFICANT SURPRISE" + elif abs(z) >= 0.8: + flag = " (notable)" + actual_str = f"{actual:.2f}{unit}" if actual is not None else "N/A" + forecast_str = f"{forecast:.2f}{unit}" if forecast is not None else "N/A" + dir_tag = {"bullish": "BULLISH", "bearish": "BEARISH", "neutral": "NEUTRAL"}.get(direction, "") + lines.append( + f" [{ev['event_date']}] {ev['event_name']}: actual={actual_str} vs baseline={forecast_str}" + f" (z={z:+.2f}, {sp:+.1f}%) → {dir_tag}{flag}" + ) + lines.append("→ High z-scores = unexpected move vs recent trend → potential mispricing in related assets") + return "\n".join(lines) + except Exception as e: + logger.warning(f"[FRED] build_economic_surprise_block failed: {e}") + return "" diff --git a/frontend/src/pages/CalendarPage.tsx b/frontend/src/pages/CalendarPage.tsx index 4f89d8f..9832942 100644 --- a/frontend/src/pages/CalendarPage.tsx +++ b/frontend/src/pages/CalendarPage.tsx @@ -49,11 +49,27 @@ function EventCard({ ev }: { ev: EconomicEvent }) { {format(evDate, "EEEE, MMM d yyyy")} · {ev.country} -
+
{cfg.label}
- {ev.previous &&
Prev: {ev.previous}
} + {ev.previous &&
Prev: {ev.previous}
} {ev.forecast &&
Fcst: {ev.forecast}
} - {ev.actual &&
Actual: {ev.actual}
} + {ev.actual && ( +
Actual: {ev.actual}
+ )} + {ev.surprise_zscore !== undefined && Math.abs(ev.surprise_zscore) >= 0.8 && ( +
+ z={ev.surprise_zscore > 0 ? '+' : ''}{ev.surprise_zscore?.toFixed(1)} { + Math.abs(ev.surprise_zscore) >= 1.5 ? '⚡' : '' + } +
+ )}
{ev.asset_impact && ev.asset_impact.length > 0 && ( diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index cad906a..5c52e1d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -124,6 +124,9 @@ export interface EconomicEvent { previous?: string forecast?: string actual?: string + surprise_zscore?: number + surprise_direction?: 'bullish' | 'bearish' | 'neutral' + source?: string } export interface HistoricalCandle {