diff --git a/backend/main.py b/backend/main.py index c447648..196f507 100644 --- a/backend/main.py +++ b/backend/main.py @@ -7,6 +7,7 @@ from routers import timeline as timeline_router from routers import instruments as instruments_router from routers import impact as impact_router from routers import cycle_actions as cycle_actions_router +from routers import market_events as market_events_router from routers import logs as logs_router from routers import var as var_router from routers import reports as reports_router @@ -141,6 +142,7 @@ app.include_router(timeline_router.router) app.include_router(instruments_router.router) app.include_router(impact_router.router) app.include_router(cycle_actions_router.router) +app.include_router(market_events_router.router) @app.get("/") diff --git a/backend/routers/market_events.py b/backend/routers/market_events.py new file mode 100644 index 0000000..ccffc66 --- /dev/null +++ b/backend/routers/market_events.py @@ -0,0 +1,269 @@ +""" +Market Events — unified admin API for market_events + instrument impacts. +Prefix: /api/market-events + +Merges what was split across /api/timeline and /api/impact. +""" +import json +import logging +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/market-events", tags=["Market Events"]) + + +# ── Schemas ─────────────────────────────────────────────────────────────────── + +class SourceRef(BaseModel): + title: str + source: str = "" + url: str = "" + date: str = "" + original_score: float = 0.0 + + +class EventCreate(BaseModel): + name: str + start_date: str + end_date: Optional[str] = None + level: str = "short" + category: str = "fundamental" + sub_type: str = "" + description: str = "" + market_impact: str = "" + affected_assets: List[str] = [] + impact_score: float = 0.5 + absorption_pct: Optional[float] = None + parent_event_id: Optional[int] = None + source_refs: List[SourceRef] = [] + + +class EventUpdate(EventCreate): + pass + + +class InstrumentImpactCreate(BaseModel): + instrument_id: str + impact_score: float = 0.5 + direction: str = "neutral" + rationale: str = "" + confidence: float = 0.7 + + +class InstrumentImpactAdjust(BaseModel): + adjusted_score: float + adjusted_direction: str + override_rationale: str = "" + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _parse_event(ev: Dict) -> Dict: + """Normalize JSON fields on a raw DB row.""" + for field in ("affected_assets", "source_refs", "relevant_indicators"): + val = ev.get(field) + if isinstance(val, str): + try: + ev[field] = json.loads(val) if val else [] + except Exception: + ev[field] = [] + elif val is None: + ev[field] = [] + return ev + + +# ── List / filter ───────────────────────────────────────────────────────────── + +@router.get("") +def list_events( + category: Optional[str] = Query(None), + level: Optional[str] = Query(None), + search: Optional[str] = Query(None), + date_from: Optional[str] = Query(None), + date_to: Optional[str] = Query(None), + min_score: float = Query(0.0, ge=0.0, le=1.0), + evaluated: Optional[bool] = Query(None, description="True=only evaluated, False=only not evaluated"), + limit: int = Query(200, ge=1, le=1000), + offset: int = Query(0, ge=0), +) -> Dict[str, Any]: + from services.database import get_all_market_events, get_conn + + events = get_all_market_events() + + # Collect evaluated event IDs + evaluated_ids: set = set() + if evaluated is not None: + conn = get_conn() + rows = conn.execute( + "SELECT DISTINCT source_id FROM instrument_impacts WHERE source_type='event'" + ).fetchall() + conn.close() + evaluated_ids = {r[0] for r in rows} + + filtered = [] + for ev in events: + if category and ev.get("category") != category: + continue + if level and ev.get("level") != level: + continue + if date_from and ev.get("start_date", "") < date_from: + continue + if date_to and ev.get("start_date", "") > date_to: + continue + if (ev.get("impact_score") or 0) < min_score: + continue + if search: + s = search.lower() + if s not in (ev.get("name") or "").lower() and \ + s not in (ev.get("description") or "").lower() and \ + s not in (ev.get("sub_type") or "").lower(): + continue + if evaluated is True and ev["id"] not in evaluated_ids: + continue + if evaluated is False and ev["id"] in evaluated_ids: + continue + filtered.append(_parse_event(ev)) + + total = len(filtered) + page = filtered[offset: offset + limit] + return {"total": total, "offset": offset, "limit": limit, "events": page} + + +# ── CRUD ────────────────────────────────────────────────────────────────────── + +@router.post("", status_code=201) +def create_event(body: EventCreate) -> Dict[str, Any]: + from services.database import save_market_event + data = body.dict() + data["source_refs"] = [r.dict() for r in body.source_refs] + new_id = save_market_event(data) + return {"id": new_id, "status": "created"} + + +@router.get("/{event_id}") +def get_event(event_id: int) -> Dict[str, Any]: + from services.database import get_all_market_events, get_impacts_for_source + events = get_all_market_events() + ev = next((e for e in events if e["id"] == event_id), None) + if not ev: + raise HTTPException(404, f"Event {event_id} not found") + ev = _parse_event(ev) + impacts = get_impacts_for_source("event", event_id) + ev["impacts"] = impacts + ev["evaluated"] = len(impacts) > 0 + return ev + + +@router.put("/{event_id}") +def update_event(event_id: int, body: EventUpdate) -> Dict[str, Any]: + from services.database import update_market_event + data = body.dict() + data["source_refs"] = [r.dict() for r in body.source_refs] + ok = update_market_event(event_id, data) + if not ok: + raise HTTPException(404, "Event not found") + return {"status": "updated"} + + +@router.delete("/{event_id}") +def delete_event(event_id: int) -> Dict[str, Any]: + from services.database import delete_market_event, delete_impacts_for_source + delete_impacts_for_source("event", event_id) + delete_market_event(event_id) + return {"status": "deleted"} + + +# ── AI evaluation ───────────────────────────────────────────────────────────── + +@router.post("/{event_id}/evaluate") +def evaluate_event( + event_id: int, + force: bool = Query(False), +) -> Dict[str, Any]: + """Trigger AI impact evaluation — evaluates instrument-level impacts.""" + from services.impact_service import evaluate_event_impacts + try: + result = evaluate_event_impacts(event_id, force=force) + if "error" in result: + raise HTTPException(400, result["error"]) + return result + except HTTPException: + raise + except Exception as e: + logger.error(f"[market_events] evaluate {event_id} failed: {e}") + raise HTTPException(500, str(e)) + + +@router.post("/bulk-evaluate") +def bulk_evaluate( + event_ids: List[int], + force: bool = Query(False), +) -> Dict[str, Any]: + from services.impact_service import evaluate_event_impacts + results, errors = [], [] + for eid in event_ids[:30]: + try: + r = evaluate_event_impacts(eid, force=force) + if "error" not in r: + results.append({"event_id": eid, "n_instruments": r.get("n_instruments", 0)}) + else: + errors.append({"event_id": eid, "error": r["error"]}) + except Exception as e: + errors.append({"event_id": eid, "error": str(e)}) + return {"evaluated": len(results), "errors": len(errors), "results": results, "error_details": errors} + + +# ── Instrument impacts ──────────────────────────────────────────────────────── + +@router.get("/{event_id}/impacts") +def get_impacts(event_id: int) -> List[Dict[str, Any]]: + from services.database import get_impacts_for_source + return get_impacts_for_source("event", event_id) + + +@router.post("/{event_id}/impacts", status_code=201) +def add_impact(event_id: int, body: InstrumentImpactCreate) -> Dict[str, Any]: + """Manually add an instrument impact (not AI-generated).""" + from services.database import get_all_market_events, save_instrument_impacts + events = get_all_market_events() + ev = next((e for e in events if e["id"] == event_id), None) + if not ev: + raise HTTPException(404, "Event not found") + save_instrument_impacts([{ + "source_type": "event", + "source_id": event_id, + "source_name": ev["name"], + "source_date": ev["start_date"], + "category_name": ev.get("sub_type") or ev.get("category", ""), + "instrument_id": body.instrument_id, + "impact_score": body.impact_score, + "direction": body.direction, + "rationale": body.rationale, + "confidence": body.confidence, + "ai_generated": 0, + }]) + return {"status": "added"} + + +@router.put("/{event_id}/impacts/{impact_id}") +def adjust_impact(event_id: int, impact_id: int, body: InstrumentImpactAdjust) -> Dict[str, Any]: + from services.database import update_impact_adjustment + ok = update_impact_adjustment(impact_id, body.adjusted_score, body.adjusted_direction, body.override_rationale) + if not ok: + raise HTTPException(404, f"Impact {impact_id} not found") + return {"id": impact_id, "status": "adjusted"} + + +@router.delete("/{event_id}/impacts/{impact_id}") +def delete_impact(event_id: int, impact_id: int) -> Dict[str, Any]: + from services.database import get_conn + conn = get_conn() + try: + conn.execute("DELETE FROM instrument_impacts WHERE id=? AND source_id=?", (impact_id, event_id)) + conn.commit() + finally: + conn.close() + return {"status": "deleted"} diff --git a/backend/services/database.py b/backend/services/database.py index f305211..d9321b4 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -942,6 +942,15 @@ def init_db(): except Exception: pass + # Idempotent column additions + for _col_sql in [ + "ALTER TABLE market_events ADD COLUMN source_refs TEXT DEFAULT '[]'", + ]: + try: + c.execute(_col_sql) + except Exception: + pass + conn.commit() conn.close() @@ -4545,18 +4554,22 @@ def save_market_event(ev: Dict[str, Any]) -> int: import json conn = get_conn() try: + src_refs = ev.get("source_refs", []) + if isinstance(src_refs, list): + src_refs = json.dumps(src_refs) cur = conn.execute("""INSERT INTO market_events (name, start_date, end_date, level, category, description, market_impact, affected_assets, impact_score, parent_event_id, - expected_value, actual_value, surprise_pct, unit, sub_type, absorption_pct) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + expected_value, actual_value, surprise_pct, unit, sub_type, absorption_pct, + source_refs) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (ev["name"], ev["start_date"], ev.get("end_date"), ev["level"], ev.get("category", "macro"), ev.get("description", ""), ev.get("market_impact", ""), json.dumps(ev.get("affected_assets", [])), ev.get("impact_score", 0.5), ev.get("parent_event_id"), ev.get("expected_value"), ev.get("actual_value"), ev.get("surprise_pct"), ev.get("unit"), ev.get("sub_type"), - ev.get("absorption_pct"))) + ev.get("absorption_pct"), src_refs)) conn.commit() return cur.lastrowid finally: @@ -4567,17 +4580,20 @@ def update_market_event(event_id: int, ev: Dict[str, Any]) -> bool: import json conn = get_conn() try: + src_refs = ev.get("source_refs", []) + if isinstance(src_refs, list): + src_refs = json.dumps(src_refs) conn.execute("""UPDATE market_events SET name=?, start_date=?, end_date=?, level=?, category=?, description=?, market_impact=?, affected_assets=?, impact_score=?, - absorption_pct=?, relevant_indicators=? + absorption_pct=?, relevant_indicators=?, source_refs=? WHERE id=?""", (ev["name"], ev["start_date"], ev.get("end_date"), ev["level"], ev.get("category", "macro"), ev.get("description", ""), ev.get("market_impact", ""), json.dumps(ev.get("affected_assets", [])), ev.get("impact_score", 0.5), ev.get("absorption_pct"), json.dumps(ev.get("relevant_indicators", [])), - event_id)) + src_refs, event_id)) conn.commit() return True finally: diff --git a/backend/services/market_event_detector.py b/backend/services/market_event_detector.py index 52e5087..5121840 100644 --- a/backend/services/market_event_detector.py +++ b/backend/services/market_event_detector.py @@ -6,6 +6,9 @@ Scans 4 sources and creates market_events for significant findings: - eco : FRED economic releases with high surprise z-score - technical: MA50/MA100/MA200 crossovers on key instruments - reports : institutional reports (COT, EIA) with high importance + +After each event is created, instrument impacts are evaluated immediately +via the AI (impact_service.evaluate_event_impacts). """ import json import logging @@ -14,7 +17,6 @@ from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) -# Instruments monitored for technical crossovers WATCH_INSTRUMENTS = [ "SPY", "QQQ", "IWM", "EEM", "EFA", "GLD", "SLV", "USO", "TLT", "HYG", @@ -43,7 +45,6 @@ def _get_api_key() -> str: def _existing_event_keys() -> set: - """Lowercase 50-char prefix of existing market_event names for fast dedup.""" from services.database import get_all_market_events return {ev["name"].lower()[:50] for ev in get_all_market_events()} @@ -53,15 +54,12 @@ def _is_dup(name: str, existing: set) -> bool: def _parse_date(raw: str) -> str: - """Return YYYY-MM-DD from any date string; fallback to today.""" if not raw: return datetime.utcnow().strftime("%Y-%m-%d") - # ISO-like try: return datetime.fromisoformat(raw[:19]).strftime("%Y-%m-%d") except Exception: pass - # RFC 2822 (RSS) try: from email.utils import parsedate_to_datetime return parsedate_to_datetime(raw).strftime("%Y-%m-%d") @@ -70,6 +68,27 @@ def _parse_date(raw: str) -> str: return raw[:10] if len(raw) >= 10 else datetime.utcnow().strftime("%Y-%m-%d") +def _save_and_evaluate(ev: Dict, existing: set) -> Optional[Dict]: + """Save a market_event and immediately evaluate instrument impacts. Returns created dict or None.""" + from services.database import save_market_event + try: + event_id = save_market_event(ev) + existing.add(ev["name"].lower()[:50]) + logger.info(f"[check_events] ✓ saved event #{event_id}: {ev['name']}") + except Exception as e: + logger.error(f"[check_events] save failed for '{ev['name']}': {e}") + return None + + # Evaluate instrument impacts immediately + try: + from services.impact_service import evaluate_event_impacts + evaluate_event_impacts(event_id, force=False) + except Exception as e: + logger.warning(f"[check_events] impact eval failed for #{event_id}: {e}") + + return {"name": ev["name"], "category": ev.get("category", ""), "date": ev.get("start_date", ""), "event_id": event_id} + + # ── Source 1: Geopolitical / macro news ────────────────────────────────────── def _check_news( @@ -77,12 +96,7 @@ def _check_news( lookback_hours: int = 48, max_to_evaluate: int = 15, ) -> List[Dict[str, Any]]: - """ - Fetch recent RSS news, keep those with impact_score >= threshold, - ask GPT-4o-mini which ones deserve a permanent market_event record. - """ from services.data_fetcher import fetch_geo_news - from services.database import save_market_event api_key = _get_api_key() if not api_key: @@ -170,6 +184,14 @@ FORMAT JSON STRICT: if _is_dup(ev_name, existing): continue + source_ref = { + "title": title, + "source": n.get("source", ""), + "url": n.get("url") or n.get("link", ""), + "date": _parse_date(n.get("date", "")), + "original_score": round(float(n.get("impact_score", 0)), 3), + } + ev = { "name": ev_name, "start_date": _parse_date(n.get("date", "")), @@ -180,14 +202,12 @@ FORMAT JSON STRICT: "market_impact": "", "affected_assets": parsed.get("affected_assets", []), "impact_score": float(parsed.get("impact_score", 0.6)), + "source_refs": [source_ref], } - try: - save_market_event(ev) - existing.add(ev_name.lower()[:50]) - created.append({"name": ev_name, "category": ev["category"], "date": ev["start_date"], "source": "news"}) - logger.info(f"[check_events/news] ✓ {ev_name}") - except Exception as e: - logger.error(f"[check_events/news] save failed: {e}") + result = _save_and_evaluate(ev, existing) + if result: + result["source"] = "news" + created.append(result) return created @@ -195,11 +215,7 @@ FORMAT JSON STRICT: # ── Source 2: Eco calendar — FRED surprises ─────────────────────────────────── def _check_eco(z_threshold: float = 1.5, days: int = 7) -> List[Dict[str, Any]]: - """ - Reads economic_events table (FRED releases already stored by fred_fetcher). - Creates market_events for releases with |z-score| >= threshold. - """ - from services.database import get_recent_economic_surprises, save_market_event + from services.database import get_recent_economic_surprises try: releases = get_recent_economic_surprises(days=days, min_zscore=z_threshold) @@ -233,6 +249,14 @@ def _check_eco(z_threshold: float = 1.5, days: int = 7) -> List[Dict[str, Any]]: 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, @@ -251,14 +275,12 @@ def _check_eco(z_threshold: float = 1.5, days: int = 7) -> List[Dict[str, Any]]: "actual_value": str(rel.get("actual_value", "")), "expected_value": str(rel.get("forecast_value", "")), "surprise_pct": float(s_pct), + "source_refs": [source_ref], } - try: - save_market_event(ev) - existing.add(ev_name.lower()[:50]) - created.append({"name": ev_name, "category": "event_calendar", "date": ev_date, "source": "eco"}) - logger.info(f"[check_events/eco] ✓ {ev_name}") - except Exception as e: - logger.error(f"[check_events/eco] save failed: {e}") + result = _save_and_evaluate(ev, existing) + if result: + result["source"] = "eco" + created.append(result) return created @@ -266,10 +288,6 @@ def _check_eco(z_threshold: float = 1.5, days: int = 7) -> List[Dict[str, Any]]: # ── Source 3: MA crossovers (technical) ────────────────────────────────────── def _check_technical(instruments: List[str] = None, lookback_days: int = 7) -> List[Dict[str, Any]]: - """ - Downloads recent OHLCV for each instrument, detects MA50/MA100/MA200 crossovers - in the last `lookback_days` days. Creates technical market_events. - """ try: import yfinance as yf import pandas as pd @@ -277,8 +295,6 @@ def _check_technical(instruments: List[str] = None, lookback_days: int = 7) -> L logger.warning("[check_events/technical] yfinance/pandas not available") return [] - from services.database import save_market_event - if instruments is None: instruments = WATCH_INSTRUMENTS @@ -299,7 +315,6 @@ def _check_technical(instruments: List[str] = None, lookback_days: int = 7) -> L recent = df.tail(lookback_days + 2) - # Check consecutive row pairs for crossovers for i in range(1, len(recent)): date_str = str(recent.index[i])[:10] if date_str < cutoff: @@ -308,12 +323,12 @@ def _check_technical(instruments: List[str] = None, lookback_days: int = 7) -> L prev = recent.iloc[i - 1] curr = recent.iloc[i] - def cross(fast_prev, fast_curr, slow_prev, slow_curr): - if any(pd.isna(v) for v in [fast_prev, fast_curr, slow_prev, slow_curr]): + def cross(fp, fc, sp, sc): + if any(pd.isna(v) for v in [fp, fc, sp, sc]): return None - if fast_prev < slow_prev and fast_curr >= slow_curr: + if fp < sp and fc >= sc: return "golden" - if fast_prev > slow_prev and fast_curr <= slow_curr: + if fp > sp and fc <= sc: return "death" return None @@ -336,6 +351,14 @@ def _check_technical(instruments: List[str] = None, lookback_days: int = 7) -> L direction = "bullish" if kind == "golden" else "bearish" level = "medium" if slow_lbl == "MA200" else "short" + source_ref = { + "title": f"Technical signal: {ev_name}", + "source": "yfinance/computed", + "url": f"https://finance.yahoo.com/quote/{ticker}", + "date": date_str, + "original_score": 0.65 if slow_lbl == "MA200" else 0.45, + } + ev = { "name": ev_name, "start_date": date_str, @@ -343,21 +366,21 @@ def _check_technical(instruments: List[str] = None, lookback_days: int = 7) -> L "category": "technical", "sub_type": f"{fast_lbl}/{slow_lbl} Cross", "description": ( - f"{cross_label} : {fast_lbl} passe {'au-dessus' if kind == 'golden' else 'en-dessous'} " + f"{cross_label} : {fast_lbl} passe " + f"{'au-dessus' if kind == 'golden' else 'en-dessous'} " f"de la {slow_lbl} sur {ticker}. " - f"Signal {direction} de tendance {'long terme' if slow_lbl == 'MA200' else 'moyen terme'}." + f"Signal {direction} de tendance " + f"{'long terme' if slow_lbl == 'MA200' else 'moyen terme'}." ), "market_impact": f"Signal {direction} sur {ticker}", "affected_assets": [ticker], "impact_score": 0.65 if slow_lbl == "MA200" else 0.45, + "source_refs": [source_ref], } - try: - save_market_event(ev) - existing.add(ev_name.lower()[:50]) - created.append({"name": ev_name, "category": "technical", "date": date_str, "source": "technical"}) - logger.info(f"[check_events/technical] ✓ {ev_name}") - except Exception as e: - logger.error(f"[check_events/technical] save failed: {e}") + result = _save_and_evaluate(ev, existing) + if result: + result["source"] = "technical" + created.append(result) except Exception as e: logger.debug(f"[check_events/technical] {ticker} failed: {e}") @@ -368,11 +391,7 @@ def _check_technical(instruments: List[str] = None, lookback_days: int = 7) -> L # ── Source 4: Institutional reports ────────────────────────────────────────── def _check_reports(days: int = 7, min_importance: int = 3) -> List[Dict[str, Any]]: - """ - Reads institutional_reports table for recent high-importance entries. - Creates report market_events. - """ - from services.database import get_conn, save_market_event + from services.database import get_conn try: cutoff = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d") @@ -410,7 +429,6 @@ def _check_reports(days: int = 7, min_importance: int = 3) -> List[Dict[str, Any except Exception: pass - # Derive affected assets from signal columns assets: List[str] = [] for sig_col, asset_list in [ ("signal_energy", ["USO", "XOM"]), @@ -421,6 +439,14 @@ def _check_reports(days: int = 7, min_importance: int = 3) -> List[Dict[str, Any if rpt.get(sig_col, "neutral") not in ("neutral", "", None): assets.extend(asset_list) + source_ref = { + "title": title, + "source": rpt.get("source", rpt_type), + "url": "", + "date": rpt_date, + "original_score": round(min(0.9, 0.3 + rpt.get("importance", 2) * 0.12), 3), + } + ev = { "name": ev_name, "start_date": rpt_date, @@ -431,14 +457,12 @@ def _check_reports(days: int = 7, min_importance: int = 3) -> List[Dict[str, Any "market_impact": rpt.get("trading_implications", ""), "affected_assets": list(set(assets)), "impact_score": min(0.9, 0.3 + rpt.get("importance", 2) * 0.12), + "source_refs": [source_ref], } - try: - save_market_event(ev) - existing.add(ev_name.lower()[:50]) - created.append({"name": ev_name, "category": "report", "date": rpt_date, "source": "reports"}) - logger.info(f"[check_events/reports] ✓ {ev_name}") - except Exception as e: - logger.error(f"[check_events/reports] save failed: {e}") + result = _save_and_evaluate(ev, existing) + if result: + result["source"] = "reports" + created.append(result) return created @@ -456,62 +480,43 @@ def check_new_market_events( report_min_importance: int = 3, ) -> Dict[str, Any]: """ - Isolated cycle action — scans all (or selected) sources and creates - market_events for significant findings. - - sources: subset of ['news', 'eco', 'technical', 'reports'] - default = all four + Isolated cycle action — scans all (or selected) sources, creates + market_events with source_refs, and immediately evaluates instrument impacts. """ if sources is None: sources = ["news", "eco", "technical", "reports"] results: Dict[str, Any] = { - "news": [], - "eco": [], - "technical": [], - "reports": [], + "news": [], "eco": [], "technical": [], "reports": [], "total_created": 0, "ran_at": datetime.utcnow().isoformat(), } if "news" in sources: try: - results["news"] = _check_news( - min_impact=news_impact_min, - lookback_hours=news_lookback_hours, - ) + results["news"] = _check_news(min_impact=news_impact_min, lookback_hours=news_lookback_hours) except Exception as e: logger.error(f"[check_events] news source error: {e}") if "eco" in sources: try: - results["eco"] = _check_eco( - z_threshold=eco_z_threshold, - days=eco_days, - ) + results["eco"] = _check_eco(z_threshold=eco_z_threshold, days=eco_days) except Exception as e: logger.error(f"[check_events] eco source error: {e}") if "technical" in sources: try: - results["technical"] = _check_technical( - lookback_days=technical_lookback_days, - ) + results["technical"] = _check_technical(lookback_days=technical_lookback_days) except Exception as e: logger.error(f"[check_events] technical source error: {e}") if "reports" in sources: try: - results["reports"] = _check_reports( - days=report_days, - min_importance=report_min_importance, - ) + results["reports"] = _check_reports(days=report_days, min_importance=report_min_importance) except Exception as e: logger.error(f"[check_events] reports source error: {e}") - results["total_created"] = sum( - len(results[s]) for s in ["news", "eco", "technical", "reports"] - ) + results["total_created"] = sum(len(results[s]) for s in ["news", "eco", "technical", "reports"]) logger.info( f"[check_events] Done — {results['total_created']} new events: " f"news={len(results['news'])} eco={len(results['eco'])} " diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 29cfc7b..0b41b1c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,6 +28,7 @@ import ExternalSnapshot from './pages/ExternalSnapshot' import InstrumentDashboard from './pages/InstrumentDashboard' import ImpactMonitor from './pages/ImpactMonitor' import CycleActions from './pages/CycleActions' +import MarketEvents from './pages/MarketEvents' import { Navigate } from 'react-router-dom' import { useCycleWatcher } from './hooks/useApi' @@ -72,6 +73,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index ed38461..8dd65ec 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,7 +1,7 @@ import { NavLink } from 'react-router-dom' import { LayoutDashboard, Globe, BarChart2, FlaskConical, - History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, Layers, ScanEye, CandlestickChart, Crosshair, PlayCircle + History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, Layers, ScanEye, CandlestickChart, Crosshair, PlayCircle, Radio } from 'lucide-react' import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi' import clsx from 'clsx' @@ -28,6 +28,7 @@ const nav = [ { to: '/institutional', icon: Building2, label: 'Inst. Reports' }, { to: '/specialist-desks', icon: Users, label: 'Specialist Desks' }, { to: '/instruments', icon: CandlestickChart, label: 'Instrument Snap.' }, + { to: '/market-events', icon: Radio, label: 'Market Events' }, { to: '/impact', icon: Crosshair, label: 'Impact Monitor' }, { to: '/cycle-actions', icon: PlayCircle, label: 'Cycle Actions' }, { to: '/snapshot', icon: ScanEye, label: 'Snapshot Externe' }, diff --git a/frontend/src/pages/MarketEvents.tsx b/frontend/src/pages/MarketEvents.tsx new file mode 100644 index 0000000..625c2ac --- /dev/null +++ b/frontend/src/pages/MarketEvents.tsx @@ -0,0 +1,893 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { + Search, RefreshCw, Plus, Trash2, Edit3, Sparkles, ExternalLink, + ChevronDown, ChevronUp, Loader2, CheckCircle, AlertCircle, X, + SlidersHorizontal, BarChart2, Star, Users +} from 'lucide-react' +import clsx from 'clsx' + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface SourceRef { + title: string + source: string + url: string + date: string + original_score: number +} + +interface InstrumentImpact { + id: number + instrument_id: string + impact_score: number + direction: 'bullish' | 'bearish' | 'neutral' | 'depends_on_outcome' + rationale: string + confidence: number + ai_generated: number + manually_adjusted: number + adjusted_score: number | null + adjusted_direction: string | null + override_rationale: string +} + +interface MarketEvent { + id: number + name: string + start_date: string + end_date: string | null + level: 'long' | 'medium' | 'short' + category: string + sub_type: string + description: string + market_impact: string + affected_assets: string[] + impact_score: number + absorption_pct: number | null + source_refs: SourceRef[] + impacts?: InstrumentImpact[] + evaluated?: boolean +} + +// ── Constants ───────────────────────────────────────────────────────────────── + +const CAT_COLORS: Record = { + event_calendar: 'bg-amber-500/20 text-amber-300 border-amber-500/30', + geopolitical: 'bg-red-500/20 text-red-300 border-red-500/30', + fundamental: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30', + report: 'bg-blue-500/20 text-blue-300 border-blue-500/30', + sentiment: 'bg-purple-500/20 text-purple-300 border-purple-500/30', + technical: 'bg-cyan-500/20 text-cyan-300 border-cyan-500/30', +} +const CAT_DOT: Record = { + event_calendar: 'bg-amber-400', + geopolitical: 'bg-red-400', + fundamental: 'bg-emerald-400', + report: 'bg-blue-400', + sentiment: 'bg-purple-400', + technical: 'bg-cyan-400', +} +const LEVEL_COLORS: Record = { + long: 'text-purple-400', + medium: 'text-amber-400', + short: 'text-emerald-400', +} +const DIR_COLORS: Record = { + bullish: 'text-emerald-400', + bearish: 'text-red-400', + neutral: 'text-slate-400', + depends_on_outcome: 'text-amber-400', +} +const DIR_ICONS: Record = { + bullish: '▲', bearish: '▼', neutral: '●', depends_on_outcome: '◆', +} + +const CATEGORIES = ['event_calendar', 'geopolitical', 'fundamental', 'report', 'sentiment', 'technical'] +const LEVELS = ['long', 'medium', 'short'] +const ALL_INSTRUMENTS = [ + 'SPY','QQQ','IWM','EEM','EFA','GLD','SLV','USO','UNG','TLT', + 'HYG','EURUSD=X','USDJPY=X','GBPUSD=X','VXX','AAPL','NVDA','GS','XOM','BTC-USD', +] + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function ScoreBar({ score, max = 1 }: { score: number; max?: number }) { + const pct = Math.round((score / max) * 100) + const color = score >= 0.7 ? 'bg-red-500' : score >= 0.5 ? 'bg-amber-500' : 'bg-emerald-500' + return ( +
+
+
+
+ {score.toFixed(2)} +
+ ) +} + +function CatBadge({ cat }: { cat: string }) { + return ( + + + {cat.replace('_', ' ')} + + ) +} + +// ── Instrument impact row (inline editable) ─────────────────────────────────── + +function ImpactRow({ + imp, eventId, onUpdated, onDeleted, +}: { + imp: InstrumentImpact + eventId: number + onUpdated: () => void + onDeleted: () => void +}) { + const [editing, setEditing] = useState(false) + const [score, setScore] = useState(String(imp.adjusted_score ?? imp.impact_score)) + const [dir, setDir] = useState(imp.adjusted_direction || imp.direction) + const [note, setNote] = useState(imp.override_rationale || '') + const [saving, setSaving] = useState(false) + + const effectiveScore = imp.adjusted_score ?? imp.impact_score + const effectiveDir = imp.adjusted_direction || imp.direction + + const save = async () => { + setSaving(true) + await fetch(`/api/market-events/${eventId}/impacts/${imp.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ adjusted_score: +score, adjusted_direction: dir, override_rationale: note }), + }) + setSaving(false) + setEditing(false) + onUpdated() + } + + const del = async () => { + if (!confirm(`Supprimer l'impact sur ${imp.instrument_id} ?`)) return + await fetch(`/api/market-events/${eventId}/impacts/${imp.id}`, { method: 'DELETE' }) + onDeleted() + } + + return ( +
+
+ {/* Ticker */} + {imp.instrument_id} + {/* Direction */} + + {DIR_ICONS[effectiveDir] ?? '●'} + + {/* Score bar */} +
+ +
+ {/* Confidence */} + {Math.round(imp.confidence * 100)}% + {/* Rationale */} + {imp.rationale} + {/* AI badge */} + {imp.ai_generated ? ( + AI + ) : ( + manual + )} + {imp.manually_adjusted ? adj. : null} + {/* Actions */} + + +
+ {editing && ( +
+ + + +
+ + +
+
+ )} +
+ ) +} + +// ── Add instrument panel ────────────────────────────────────────────────────── + +function AddInstrumentPanel({ eventId, onAdded }: { eventId: number; onAdded: () => void }) { + const [open, setOpen] = useState(false) + const [ticker, setTicker] = useState('') + const [score, setScore] = useState(0.5) + const [dir, setDir] = useState('neutral') + const [rationale, setRat] = useState('') + const [saving, setSaving] = useState(false) + + const submit = async () => { + if (!ticker) return + setSaving(true) + await fetch(`/api/market-events/${eventId}/impacts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ instrument_id: ticker, impact_score: score, direction: dir, rationale, confidence: 0.8 }), + }) + setSaving(false) + setOpen(false) + setTicker('') + setRat('') + onAdded() + } + + if (!open) { + return ( + + ) + } + + return ( +
+ + +
+ setScore(+e.target.value)} className="accent-emerald-400" /> + {score.toFixed(2)} +
+ setRat(e.target.value)} placeholder="Rationale..." + className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm" /> +
+ + +
+
+ ) +} + +// ── Event detail panel (right side) ────────────────────────────────────────── + +function EventDetail({ + event, onClose, onUpdated, onDeleted, +}: { + event: MarketEvent + onClose: () => void + onUpdated: (ev: MarketEvent) => void + onDeleted: (id: number) => void +}) { + const [detail, setDetail] = useState(event) + const [evaluating, setEval] = useState(false) + const [evalMsg, setEvalMsg] = useState(null) + const [editing, setEditing] = useState(false) + const [editData, setEditData] = useState>({}) + const [saving, setSaving] = useState(false) + + const loadDetail = useCallback(async () => { + const r = await fetch(`/api/market-events/${event.id}`) + if (r.ok) { + const d = await r.json() + setDetail(d) + } + }, [event.id]) + + useEffect(() => { loadDetail() }, [loadDetail]) + + const evaluate = async (force = false) => { + setEval(true) + setEvalMsg(null) + try { + const r = await fetch(`/api/market-events/${event.id}/evaluate?force=${force}`, { method: 'POST' }) + const d = await r.json() + if (!r.ok) throw new Error(d.detail || r.statusText) + setEvalMsg(`✓ ${d.n_instruments} instruments évalués`) + await loadDetail() + } catch (e: any) { + setEvalMsg(`✗ ${e.message}`) + } finally { + setEval(false) + } + } + + const saveEdit = async () => { + setSaving(true) + const payload = { ...detail, ...editData, source_refs: detail.source_refs ?? [] } + const r = await fetch(`/api/market-events/${event.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + setSaving(false) + if (r.ok) { + setEditing(false) + await loadDetail() + onUpdated({ ...detail, ...editData } as MarketEvent) + } + } + + const del = async () => { + if (!confirm(`Supprimer "${detail.name}" ?`)) return + await fetch(`/api/market-events/${event.id}`, { method: 'DELETE' }) + onDeleted(event.id) + onClose() + } + + const impacts = detail.impacts || [] + const evalScore = detail.impact_score + + return ( +
+ {/* Header */} +
+
+ {editing ? ( + setEditData(d => ({ ...d, name: e.target.value }))} + className="bg-dark-900 border border-amber-500/50 rounded px-3 py-1.5 text-slate-100 font-bold text-sm w-full" + /> + ) : ( +

{detail.name}

+ )} +
+ + {detail.sub_type && {detail.sub_type}} + {detail.level} + {detail.start_date} + {detail.end_date && → {detail.end_date}} +
+
+
+ + + +
+
+ + {/* Scrollable body */} +
+ + {/* Impact score + absorption */} +
+
+
Score impact
+ +
+ {detail.absorption_pct != null && ( +
+
Absorption marché
+
+
+
+
+ {detail.absorption_pct}% +
+
+ )} +
+ + {/* Description */} + {editing ? ( +