From c6178c14d525cef650de667d119dc52e6d1f33e3 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Wed, 24 Jun 2026 17:45:35 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Timeline=20Navigator=20=E2=80=94=20cont?= =?UTF-8?q?exte=20historique=203=20temporalit=C3=A9s=20COVID=20=E2=86=92?= =?UTF-8?q?=20aujourd'hui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 2 nouvelles tables SQLite : market_events + timeline_context - 32 événements historiques seedés (long/medium/short de feb 2020 à juin 2026) - timeline_service.py : bootstrap, get_events_for_date, génération commentaires GPT-4o-mini - /api/timeline router : GET /day/{date}, GET /events, POST /generate/{date}, POST /bootstrap - Timeline.tsx : navigateur date avec strip visuel, 3 panneaux contextuels, catalogue d'événements - Sidebar : entrée Timeline avec icône Layers Co-Authored-By: Claude Sonnet 4.6 --- backend/main.py | 2 + backend/routers/timeline.py | 113 ++++++ backend/services/database.py | 146 +++++++ backend/services/timeline_service.py | 396 ++++++++++++++++++ frontend/src/App.tsx | 2 + frontend/src/components/layout/Sidebar.tsx | 3 +- frontend/src/pages/Timeline.tsx | 449 +++++++++++++++++++++ 7 files changed, 1110 insertions(+), 1 deletion(-) create mode 100644 backend/routers/timeline.py create mode 100644 backend/services/timeline_service.py create mode 100644 frontend/src/pages/Timeline.tsx diff --git a/backend/main.py b/backend/main.py index 2c8e654..2e13d2c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,6 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware from routers import market_data, geopolitical, options, backtest, ai, portfolio, config, patterns, journal, cycle as cycle_router, profiles as profiles_router, reasoning as reasoning_router, knowledge as knowledge_router, options_vol as options_vol_router, analytics as analytics_router, risk as risk_router from routers import pattern_lab as pattern_lab_router from routers import specialist_desks as specialist_desks_router +from routers import timeline as timeline_router from routers import logs as logs_router from routers import var as var_router from routers import reports as reports_router @@ -121,6 +122,7 @@ app.include_router(reports_router.router) app.include_router(institutional_router.router) app.include_router(pattern_lab_router.router) app.include_router(specialist_desks_router.router) +app.include_router(timeline_router.router) @app.get("/") diff --git a/backend/routers/timeline.py b/backend/routers/timeline.py new file mode 100644 index 0000000..b17b0d6 --- /dev/null +++ b/backend/routers/timeline.py @@ -0,0 +1,113 @@ +""" +Timeline Navigator — historical market context browser. +Prefix: /api/timeline +""" +import logging +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/timeline", tags=["timeline"]) + + +class EventCreate(BaseModel): + name: str + start_date: str + end_date: Optional[str] = None + level: str # long | medium | short + category: str = "macro" + description: str = "" + market_impact: str = "" + affected_assets: List[str] = [] + impact_score: float = 0.5 + parent_event_id: Optional[int] = None + + +class EventUpdate(EventCreate): + pass + + +@router.get("/events") +def list_events() -> List[Dict[str, Any]]: + from services.database import get_all_market_events + return get_all_market_events() + + +@router.post("/events") +def create_event(body: EventCreate) -> Dict[str, Any]: + from services.database import save_market_event + new_id = save_market_event(body.dict()) + return {"id": new_id, "status": "created"} + + +@router.put("/events/{event_id}") +def update_event(event_id: int, body: EventUpdate) -> Dict[str, Any]: + from services.database import update_market_event + ok = update_market_event(event_id, body.dict()) + if not ok: + raise HTTPException(404, "Event not found") + return {"status": "updated"} + + +@router.get("/day/{ref_date}") +def get_day_context(ref_date: str) -> Dict[str, Any]: + """Return cached or freshly generated timeline context for a date (YYYY-MM-DD).""" + from services.database import get_events_for_date, get_timeline_context + import json + + events = get_events_for_date(ref_date) + cached = get_timeline_context(ref_date) + + result: Dict[str, Any] = { + "ref_date": ref_date, + "events": events, + "long_commentary": "", + "medium_commentary": "", + "short_commentary": "", + "evidence_headlines": [], + "generated_at": None, + "has_commentary": False, + } + if cached: + result.update({ + "long_commentary": cached.get("long_commentary", ""), + "medium_commentary": cached.get("medium_commentary", ""), + "short_commentary": cached.get("short_commentary", ""), + "evidence_headlines": json.loads(cached.get("evidence_headlines", "[]") or "[]"), + "generated_at": cached.get("generated_at"), + "has_commentary": bool(cached.get("long_commentary")), + }) + return result + + +@router.post("/generate/{ref_date}") +def generate_commentary(ref_date: str) -> Dict[str, Any]: + """Generate (or regenerate) AI commentary for a specific date and cache it.""" + from services.timeline_service import get_or_generate_context + import json + + try: + ctx = get_or_generate_context(ref_date) + return { + "ref_date": ref_date, + "long_commentary": ctx.get("long_commentary", ""), + "medium_commentary": ctx.get("medium_commentary", ""), + "short_commentary": ctx.get("short_commentary", ""), + "evidence_headlines": json.loads(ctx.get("evidence_headlines", "[]") or "[]") if isinstance(ctx.get("evidence_headlines"), str) else ctx.get("evidence_headlines", []), + "events": ctx.get("events", {}), + "has_commentary": True, + } + except Exception as e: + logger.error(f"[Timeline] generate_commentary failed for {ref_date}: {e}") + raise HTTPException(500, str(e)) + + +@router.post("/bootstrap") +def bootstrap_events() -> Dict[str, Any]: + """Seed historical market events (idempotent — only runs if table is empty).""" + from services.timeline_service import bootstrap_events as _bootstrap + count = _bootstrap() + return {"seeded": count, "status": "ok" if count > 0 else "already_seeded"} diff --git a/backend/services/database.py b/backend/services/database.py index 364eae8..55c0f71 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -734,6 +734,34 @@ def init_db(): UNIQUE(asset, fetch_date) )""") + # ── Timeline Navigator ──────────────────────────────────────────────────── + c.execute("""CREATE TABLE IF NOT EXISTS market_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + start_date TEXT NOT NULL, + end_date TEXT, + level TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'macro', + description TEXT NOT NULL DEFAULT '', + market_impact TEXT DEFAULT '', + affected_assets TEXT DEFAULT '[]', + impact_score REAL DEFAULT 0.5, + parent_event_id INTEGER REFERENCES market_events(id), + created_at TEXT DEFAULT (datetime('now')) + )""") + + c.execute("""CREATE TABLE IF NOT EXISTS timeline_context ( + ref_date TEXT PRIMARY KEY, + long_event_id INTEGER REFERENCES market_events(id), + medium_event_id INTEGER REFERENCES market_events(id), + short_event_id INTEGER REFERENCES market_events(id), + long_commentary TEXT DEFAULT '', + medium_commentary TEXT DEFAULT '', + short_commentary TEXT DEFAULT '', + evidence_headlines TEXT DEFAULT '[]', + generated_at TEXT DEFAULT (datetime('now')) + )""") + # Seed desk defaults (idempotent) _desk_count = c.execute("SELECT COUNT(*) FROM asset_class_configs").fetchone()[0] if _desk_count == 0: @@ -4431,3 +4459,121 @@ def update_report_result( return True finally: conn.close() + + +# ── Timeline Navigator ──────────────────────────────────────────────────────── + +def save_market_event(ev: Dict[str, Any]) -> int: + import json + conn = get_conn() + try: + cur = conn.execute("""INSERT INTO market_events + (name, start_date, end_date, level, category, description, market_impact, + affected_assets, impact_score, parent_event_id) + 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"))) + conn.commit() + return cur.lastrowid + finally: + conn.close() + + +def update_market_event(event_id: int, ev: Dict[str, Any]) -> bool: + import json + conn = get_conn() + try: + conn.execute("""UPDATE market_events SET + name=?, start_date=?, end_date=?, level=?, category=?, description=?, + market_impact=?, affected_assets=?, impact_score=? + 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), event_id)) + conn.commit() + return True + finally: + conn.close() + + +def get_all_market_events() -> List[Dict[str, Any]]: + conn = get_conn() + try: + rows = conn.execute( + "SELECT * FROM market_events ORDER BY start_date DESC" + ).fetchall() + return [dict(r) for r in rows] + finally: + conn.close() + + +def get_events_for_date(ref_date: str) -> Dict[str, Any]: + """Return the active long/medium/short events for a given date.""" + conn = get_conn() + try: + def query_level(level: str) -> Optional[Dict]: + row = conn.execute(""" + SELECT * FROM market_events + WHERE level=? AND start_date<=? + AND (end_date IS NULL OR end_date>=?) + ORDER BY start_date DESC LIMIT 1""", + (level, ref_date, ref_date)).fetchone() + return dict(row) if row else None + + return { + "long": query_level("long"), + "medium": query_level("medium"), + "short": query_level("short"), + } + finally: + conn.close() + + +def get_timeline_context(ref_date: str) -> Optional[Dict[str, Any]]: + conn = get_conn() + try: + row = conn.execute( + "SELECT * FROM timeline_context WHERE ref_date=?", (ref_date,) + ).fetchone() + return dict(row) if row else None + finally: + conn.close() + + +def save_timeline_context(ctx: Dict[str, Any]) -> None: + import json + conn = get_conn() + try: + conn.execute("""INSERT INTO timeline_context + (ref_date, long_event_id, medium_event_id, short_event_id, + long_commentary, medium_commentary, short_commentary, + evidence_headlines, generated_at) + VALUES (?,?,?,?,?,?,?,?,datetime('now')) + ON CONFLICT(ref_date) DO UPDATE SET + long_event_id=excluded.long_event_id, + medium_event_id=excluded.medium_event_id, + short_event_id=excluded.short_event_id, + long_commentary=excluded.long_commentary, + medium_commentary=excluded.medium_commentary, + short_commentary=excluded.short_commentary, + evidence_headlines=excluded.evidence_headlines, + generated_at=datetime('now')""", + (ctx["ref_date"], + ctx.get("long_event_id"), ctx.get("medium_event_id"), ctx.get("short_event_id"), + ctx.get("long_commentary", ""), ctx.get("medium_commentary", ""), + ctx.get("short_commentary", ""), + json.dumps(ctx.get("evidence_headlines", [])))) + conn.commit() + finally: + conn.close() + + +def count_market_events() -> int: + conn = get_conn() + try: + return conn.execute("SELECT COUNT(*) FROM market_events").fetchone()[0] + finally: + conn.close() diff --git a/backend/services/timeline_service.py b/backend/services/timeline_service.py new file mode 100644 index 0000000..a0e16ae --- /dev/null +++ b/backend/services/timeline_service.py @@ -0,0 +1,396 @@ +""" +Timeline Navigator Service. + +Manages the historical market event database (COVID → today) and generates +3-level (long/medium/short) temporal context with AI commentary for any date. +""" +import json +import logging +from datetime import date, datetime, timedelta +from typing import Any, Dict, List, Optional + +from services.database import ( + count_market_events, + get_all_market_events, + get_events_for_date, + get_timeline_context, + save_market_event, + save_timeline_context, +) + +logger = logging.getLogger(__name__) + +# ── Seed event catalogue ────────────────────────────────────────────────────── +# Each entry: name, start_date, end_date (None=ongoing), level, category, +# description, market_impact, affected_assets (list), impact_score + +_SEED_EVENTS: List[Dict[str, Any]] = [ + # ───── LONG-TERM STRUCTURAL EVENTS ───── + { + "name": "COVID-19 Crash", "level": "long", "category": "macro", + "start_date": "2020-02-20", "end_date": "2020-03-23", + "description": "Pandémie mondiale — liquidation forcée de tous les actifs risqués, gel des marchés du crédit, volatilité extrême (VIX>80).", + "market_impact": "SPX -34% en 33 jours. VIX 85. Oil négatif avril. Fuite vers USD et T-Bills.", + "affected_assets": ["SPX", "VIX", "CL", "HYG", "USD", "Gold"], + "impact_score": 1.0, + }, + { + "name": "Fed QE Infinity & ZIRP", "level": "long", "category": "monetary", + "start_date": "2020-03-23", "end_date": "2022-03-16", + "description": "Fed à 0% + QE illimité. Injection de $4T+ en 2 ans. Régime de taux zéro et liquidité abondante pour tous les actifs.", + "market_impact": "Bull market actions/crypto/immobilier. Compression des primes de risque. Carry trades extrêmes.", + "affected_assets": ["SPX", "BTC", "Gold", "HYG", "TLT", "EUR/USD"], + "impact_score": 0.95, + }, + { + "name": "Inflation Surge", "level": "long", "category": "macro", + "start_date": "2021-04-01", "end_date": "2023-06-01", + "description": "CPI US monte de 2% à 9,1% (juin 2022). Chocs d'offre post-COVID + demande stimulée + énergie. Fin de l'ère TINA.", + "market_impact": "Fin du bull bond. Rotation value/energy. Pression sur multiples tech.", + "affected_assets": ["TLT", "GC", "CL", "XLE", "EUR/USD", "TIPS"], + "impact_score": 0.9, + }, + { + "name": "Fed Hike Cycle", "level": "long", "category": "monetary", + "start_date": "2022-03-16", "end_date": "2023-07-26", + "description": "525bps de hausse en 17 mois — cycle de resserrement le plus rapide depuis Volcker. Pivot du QE vers QT.", + "market_impact": "Krach obligataire 2022 (-25% TLT). Tech -35%. Strength USD. Recession fears.", + "affected_assets": ["TLT", "QQQ", "SPX", "USD", "HYG", "GC"], + "impact_score": 0.95, + }, + { + "name": "Fed Pause (Peak Rates)", "level": "long", "category": "monetary", + "start_date": "2023-07-26", "end_date": "2024-09-18", + "description": "Fed maintient 5,25-5,50% pendant 14 mois. Marché anticipe baisse, puis repousse sans cesse. Atterrissage en douceur.", + "market_impact": "Rally équités (SPX +26% 2023). Curve inversion. Gold accumulation.", + "affected_assets": ["SPX", "GC", "USD", "TLT", "2Y UST"], + "impact_score": 0.7, + }, + { + "name": "Fed Easing Cycle", "level": "long", "category": "monetary", + "start_date": "2024-09-18", "end_date": None, + "description": "Fed commence à baisser : -25bps sept 2024, -25bps nov 2024, -25bps déc 2024. Pause 2025. Nouveau cycle expansif.", + "market_impact": "USD faiblesse. Or new ATH. Courbe se dépente. Risque inflation secondaire.", + "affected_assets": ["USD", "GC", "TLT", "EUR/USD", "EM FX"], + "impact_score": 0.75, + }, + { + "name": "Trump 2.0 & Tariff Era", "level": "long", "category": "geopolitical", + "start_date": "2025-01-20", "end_date": None, + "description": "Retour de Trump. Tarifs universels 10% + tarifs China jusqu'à 145%. Restructuration de l'ordre commercial mondial.", + "market_impact": "VIX spike. USD strength initial. Disruption chaînes supply. Inflation tarif.", + "affected_assets": ["SPX", "USD", "GC", "CL", "EM FX", "ZC", "ZS"], + "impact_score": 0.85, + }, + { + "name": "US-Iran Military Conflict", "level": "long", "category": "geopolitical", + "start_date": "2025-06-13", "end_date": None, + "description": "Frappes US sur sites nucléaires iraniens. Escalade militaire directe USA-Iran en Moyen-Orient.", + "market_impact": "CL +15% initial. Gold safe haven. Risque Hormuz premium. Risk-off global.", + "affected_assets": ["CL", "NG", "GC", "USD", "VIX", "Tanker stocks"], + "impact_score": 0.9, + }, + + # ───── MEDIUM-TERM REGIME EVENTS ───── + { + "name": "COVID V-Bottom Recovery", "level": "medium", "category": "macro", + "start_date": "2020-03-23", "end_date": "2021-01-01", + "description": "Rebond historique post-crash. Fed backstop + stimulus fiscal. SPX récupère tous les pertes en 5 mois.", + "market_impact": "Tech FAANG leaders. Growth stocks premium. Small cap lagging.", + "affected_assets": ["SPX", "QQQ", "IWM", "Gold"], + "impact_score": 0.85, + }, + { + "name": "Pfizer Vaccine Rotation", "level": "medium", "category": "macro", + "start_date": "2020-11-09", "end_date": "2021-06-01", + "description": "Annonce vaccin Pfizer (+95% efficacité). Mega-rotation value vs growth. Cyclicals/energy reprennent.", + "market_impact": "Banks +20% en 2 sem. Energy recovery. Growth stocks stagnent.", + "affected_assets": ["XLF", "XLE", "IWM", "QQQ", "Oil"], + "impact_score": 0.7, + }, + { + "name": "GameStop & Meme Frenzy", "level": "medium", "category": "market", + "start_date": "2021-01-13", "end_date": "2021-02-05", + "description": "Short squeeze GameStop orchestré par retail Reddit. Systemic risk sur HF leverage. Brokers restriction d'achats.", + "market_impact": "GME +2700%. HF losses $10B+. Systemic risk perceptions élevées.", + "affected_assets": ["GME", "AMC", "HYG", "VIX"], + "impact_score": 0.5, + }, + { + "name": "Archegos Collapse", "level": "medium", "category": "market", + "start_date": "2021-03-26", "end_date": "2021-04-10", + "description": "Family office Archegos default sur $20B de swaps TRS. Forced selling sur VIACOM/Discovery/GS/MS/CS.", + "market_impact": "CS -15%, Nomura -14%. Leverage disclosure concerns. Media stocks -30%.", + "affected_assets": ["CS", "Nomura", "Media sector"], + "impact_score": 0.55, + }, + { + "name": "China Tech Crackdown", "level": "medium", "category": "geopolitical", + "start_date": "2021-07-01", "end_date": "2022-03-01", + "description": "Pékin régule les géants tech chinois (Alibaba, Didi, Tencent). Suspension IPO Didi. Education privée bannie.", + "market_impact": "KWEB -60%. Alibaba -70%. Risque réglementaire chinois systémique.", + "affected_assets": ["BABA", "BIDU", "JD", "KWEB", "FXI"], + "impact_score": 0.7, + }, + { + "name": "Evergrande Crisis", "level": "medium", "category": "macro", + "start_date": "2021-09-01", "end_date": "2022-01-01", + "description": "Evergrande (2ème promoteur immobilier chinois) en défaut sur $300B de dettes. Contagion secteur immo chinois.", + "market_impact": "HY spread China +500bps. CNY pressure. Contagion EM partielle.", + "affected_assets": ["CNY", "FXI", "HY China bonds", "Iron Ore"], + "impact_score": 0.65, + }, + { + "name": "Ukraine Invasion", "level": "medium", "category": "geopolitical", + "start_date": "2022-02-24", "end_date": None, + "description": "Invasion russe de l'Ukraine. Sanctions massives Russia. Disruption grains, gaz, engrais. Réarmement Europe.", + "market_impact": "NG Europe x10. Wheat +60%. Gold safe haven. EUR weakness. Defense rally.", + "affected_assets": ["NG", "ZW", "GC", "EUR/USD", "Defense stocks"], + "impact_score": 0.9, + }, + { + "name": "CPI Peak 9.1% & Jackson Hole 2022", "level": "medium", "category": "monetary", + "start_date": "2022-06-10", "end_date": "2022-10-01", + "description": "CPI US 9,1% — peak inflation 40 ans. Jackson Hole Powell hawkish extrême. Marchés capitulent sur pivot.", + "market_impact": "SPX -25% 2022. TLT -30%. EUR/USD parité. DXY 114.", + "affected_assets": ["SPX", "TLT", "EUR/USD", "USD", "GC"], + "impact_score": 0.85, + }, + { + "name": "SVB Collapse & Banking Crisis", "level": "medium", "category": "market", + "start_date": "2023-03-08", "end_date": "2023-05-01", + "description": "Silicon Valley Bank run sur les dépôts. FDIC intervention. Credit Suisse absorbe par UBS. Contagion régionale US.", + "market_impact": "KBE -25%. TLT +10% (flight to safety). VIX spike. Credit tightening.", + "affected_assets": ["KBE", "TLT", "VIX", "EUR/USD", "Gold"], + "impact_score": 0.7, + }, + { + "name": "AI Boom / Nvidia Mania", "level": "medium", "category": "market", + "start_date": "2023-05-25", "end_date": None, + "description": "Nvidia guidance x3 — début du mega-cycle IA. ChatGPT + Capex hyperscalers. Concentration marchés extrême.", + "market_impact": "NVDA +800%. Mag7 domination. SPX concentration record. Small cap underperform.", + "affected_assets": ["NVDA", "MSFT", "GOOGL", "QQQ", "SMH"], + "impact_score": 0.85, + }, + { + "name": "Israel-Hamas War", "level": "medium", "category": "geopolitical", + "start_date": "2023-10-07", "end_date": None, + "description": "Attaque Hamas + contre-offensive Gaza. Escalade Hezbollah, Yemen Houthis, Iran proxy. Risque Hormuz.", + "market_impact": "CL spike initial +5%. Gold safe haven $2100. Défense stocks +15%.", + "affected_assets": ["CL", "GC", "Defense", "ILS"], + "impact_score": 0.7, + }, + { + "name": "BOJ Fin du YCC", "level": "medium", "category": "monetary", + "start_date": "2024-03-19", "end_date": None, + "description": "Bank of Japan abandonne le Yield Curve Control et hausse taux à 0.1% (premier hike en 17 ans). JPY carry unwind.", + "market_impact": "JPY +5% en 1 mois. Carry trade unwind global. Nikkei -12% août 2024.", + "affected_assets": ["JPY", "Nikkei", "USD/JPY", "JGB", "EM carry"], + "impact_score": 0.75, + }, + { + "name": "Jackson Hole Pivot 2024", "level": "medium", "category": "monetary", + "start_date": "2024-08-23", "end_date": "2024-12-31", + "description": "Powell annonce officiellement la fin du cycle de resserrement et signale des baisses imminentes.", + "market_impact": "USD -2%. Gold ATH. Equities rally. Curve bull steepening.", + "affected_assets": ["USD", "GC", "SPX", "TLT", "EUR/USD"], + "impact_score": 0.75, + }, + { + "name": "Trump Tariff Shock (Liberation Day)", "level": "medium", "category": "geopolitical", + "start_date": "2025-04-02", "end_date": "2025-04-09", + "description": "Annonce tarifs universels 10% + tarifs pays spécifiques jusqu'à 50%. Pire choc tarifaire depuis 1930.", + "market_impact": "SPX -15% en 4 séances. VIX 60. Credit spreads +200bps.", + "affected_assets": ["SPX", "VIX", "USD", "GC", "HYG"], + "impact_score": 0.9, + }, + { + "name": "90-Day Tariff Pause", "level": "medium", "category": "geopolitical", + "start_date": "2025-04-09", "end_date": "2025-07-09", + "description": "Trump annonce pause 90 jours sur la majorité des tarifs réciproqués pour permettre des négociations.", + "market_impact": "SPX +9.5% en 1 séance (3ème meilleure journée historique). VIX collapse.", + "affected_assets": ["SPX", "VIX", "USD", "EUR/USD", "EM"], + "impact_score": 0.8, + }, + { + "name": "Iran Ceasefire Talks", "level": "medium", "category": "geopolitical", + "start_date": "2025-06-20", "end_date": None, + "description": "Négociations de cessez-le-feu US-Iran sous médiation Oman + Suisse. Dé-escalade progressive.", + "market_impact": "CL -8%. Hormuz risk premium réduit. Gold consolidation.", + "affected_assets": ["CL", "GC", "USD", "VIX"], + "impact_score": 0.7, + }, + + # ───── SHORT-TERM CATALYST EVENTS ───── + { + "name": "Fed Meeting — Pause Confirmée", "level": "short", "category": "monetary", + "start_date": "2025-05-07", "end_date": "2025-05-21", + "description": "FOMC maintient les taux — attend clarification tarifs avant de bouger. Powell data-dependent.", + "market_impact": "USD stable. Marchés attendent. Curve stable.", + "affected_assets": ["USD", "TLT", "SPX"], + "impact_score": 0.5, + }, + { + "name": "US-China Trade Truce", "level": "short", "category": "geopolitical", + "start_date": "2025-05-12", "end_date": "2025-08-12", + "description": "Réduction tarifs US-China : 145%→30% (US) et 125%→10% (China) pendant 90 jours suite aux négociations de Genève.", + "market_impact": "SPX +3%. USD -1%. Relief rally. Chaînes supply se reconstituent.", + "affected_assets": ["SPX", "USD", "CNY", "ZS", "ZC"], + "impact_score": 0.7, + }, + { + "name": "US Frappe Iran — Escalade", "level": "short", "category": "geopolitical", + "start_date": "2025-06-13", "end_date": "2025-06-20", + "description": "Frappes US-Israël sur Natanz et Fordow. Iran riposte sur bases US région. Risk-off maximal.", + "market_impact": "CL +15%. Gold +5%. VIX 35. USD strength.", + "affected_assets": ["CL", "GC", "VIX", "USD", "Defense"], + "impact_score": 0.9, + }, + { + "name": "Iran Ceasefire Signal", "level": "short", "category": "geopolitical", + "start_date": "2025-06-20", "end_date": "2025-07-10", + "description": "Signal de cessez-le-feu Iran-US via Oman. Début dé-escalade. Marchés absorbent la prime de risque.", + "market_impact": "CL -8% en 2 séances. VIX retombe. Risk-on partiel.", + "affected_assets": ["CL", "VIX", "GC", "SPX"], + "impact_score": 0.65, + }, + { + "name": "SNB Meeting — Easing Signalé", "level": "short", "category": "monetary", + "start_date": "2026-06-19", "end_date": "2026-07-03", + "description": "Swiss National Bank réunion — signal de poursuite du cycle d'assouplissement. CHF sous pression.", + "market_impact": "CHF -0.5%. EUR/CHF hausse. Francs positions réduites.", + "affected_assets": ["CHF", "EUR/CHF", "USD/CHF"], + "impact_score": 0.45, + }, +] + + +def bootstrap_events() -> int: + """Seed the market_events table. Idempotent — only runs if table is empty.""" + if count_market_events() > 0: + logger.info("[Timeline] market_events already seeded, skip bootstrap") + return 0 + saved = 0 + for ev in _SEED_EVENTS: + try: + save_market_event(ev) + saved += 1 + except Exception as e: + logger.warning(f"[Timeline] Failed to save event '{ev['name']}': {e}") + logger.info(f"[Timeline] Bootstrapped {saved} market events") + return saved + + +def _days_since(ref_date: str, start_date: str) -> int: + try: + d0 = datetime.strptime(start_date[:10], "%Y-%m-%d").date() + d1 = datetime.strptime(ref_date[:10], "%Y-%m-%d").date() + return (d1 - d0).days + except Exception: + return 0 + + +def generate_daily_commentary( + ref_date: str, + long_ev: Optional[Dict], + medium_ev: Optional[Dict], + short_ev: Optional[Dict], +) -> Dict[str, str]: + """Call GPT-4o-mini to generate 3 French trading commentaries for ref_date.""" + import os + from openai import OpenAI + + api_key = os.environ.get("OPENAI_API_KEY", "") + if not api_key: + return { + "long_commentary": "IA non configurée — ajoutez votre clé OpenAI dans la configuration.", + "medium_commentary": "", + "short_commentary": "", + } + + client = OpenAI(api_key=api_key) + + def _ev_summary(ev: Optional[Dict], level: str) -> str: + if not ev: + return f"[Pas d'événement {level} actif]" + days = _days_since(ref_date, ev["start_date"]) + return ( + f"Nom: {ev['name']}\n" + f"Depuis: {ev['start_date']} ({days} jours)\n" + f"Description: {ev['description']}\n" + f"Impact marché: {ev.get('market_impact', '')}" + ) + + prompt = f"""Tu es un analyste macro senior en trading d'options géopolitiques. +Date d'analyse: {ref_date} + +Contexte temporel à cette date: + +== LONG TERME (régime structurel) == +{_ev_summary(long_ev, 'long terme')} + +== MOYEN TERME (régime courant) == +{_ev_summary(medium_ev, 'moyen terme')} + +== COURT TERME (catalyseur récent) == +{_ev_summary(short_ev, 'court terme')} + +Pour chaque niveau temporel, rédige UN SEUL paragraphe de 3-4 phrases en français. +Explique ce que signifie ce contexte pour un trader d'options sur commodités/indices/forex à cette date précise. +Sois concret : implications pour le vol, le positionnement, les risques. + +FORMAT DE RÉPONSE (JSON strict): +{{ + "long_commentary": "...", + "medium_commentary": "...", + "short_commentary": "..." +}}""" + + try: + response = client.chat.completions.create( + model="gpt-4o-mini", + messages=[{"role": "user", "content": prompt}], + response_format={"type": "json_object"}, + temperature=0.4, + max_tokens=800, + ) + result = json.loads(response.choices[0].message.content) + return { + "long_commentary": result.get("long_commentary", ""), + "medium_commentary": result.get("medium_commentary", ""), + "short_commentary": result.get("short_commentary", ""), + } + except Exception as e: + logger.warning(f"[Timeline] GPT commentary failed for {ref_date}: {e}") + return { + "long_commentary": f"Erreur génération IA: {e}", + "medium_commentary": "", + "short_commentary": "", + } + + +def get_or_generate_context(ref_date: str) -> Dict[str, Any]: + """Return timeline context for a date — from cache or freshly generated.""" + cached = get_timeline_context(ref_date) + if cached and cached.get("long_commentary"): + events = get_events_for_date(ref_date) + return {**cached, "events": events} + + events = get_events_for_date(ref_date) + commentaries = generate_daily_commentary( + ref_date, + events.get("long"), + events.get("medium"), + events.get("short"), + ) + + ctx = { + "ref_date": ref_date, + "long_event_id": events["long"]["id"] if events.get("long") else None, + "medium_event_id": events["medium"]["id"] if events.get("medium") else None, + "short_event_id": events["short"]["id"] if events.get("short") else None, + **commentaries, + "evidence_headlines": [], + } + save_timeline_context(ctx) + return {**ctx, "events": events} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ae4ec76..51b2da1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,7 @@ import VaRAnalysis from './pages/VaRAnalysis' import PositionHistory from './pages/PositionHistory' import InstitutionalReports from './pages/InstitutionalReports' import SpecialistDesks from './pages/SpecialistDesks' +import Timeline from './pages/Timeline' import { useCycleWatcher } from './hooks/useApi' function GlobalWatcher() { @@ -61,6 +62,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 365db71..7d00276 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 + History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, Layers } from 'lucide-react' import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi' import clsx from 'clsx' @@ -27,6 +27,7 @@ const nav = [ { to: '/calendar', icon: Calendar, label: 'Calendar' }, { to: '/institutional', icon: Building2, label: 'Inst. Reports' }, { to: '/specialist-desks', icon: Users, label: 'Specialist Desks' }, + { to: '/timeline', icon: Layers, label: 'Timeline' }, { to: '/logs', icon: ScrollText, label: 'System Logs' }, { to: '/config', icon: Settings, label: 'Configuration' }, ] diff --git a/frontend/src/pages/Timeline.tsx b/frontend/src/pages/Timeline.tsx new file mode 100644 index 0000000..c1ce77d --- /dev/null +++ b/frontend/src/pages/Timeline.tsx @@ -0,0 +1,449 @@ +import { useState, useEffect, useCallback } from 'react' +import { ChevronLeft, ChevronRight, Sparkles, Calendar, Clock, Layers, RefreshCw } from 'lucide-react' +import clsx from 'clsx' + +const API = 'http://localhost:8000' + +interface MarketEvent { + id: number + name: string + start_date: string + end_date: string | null + level: 'long' | 'medium' | 'short' + category: string + description: string + market_impact: string + affected_assets: string + impact_score: number +} + +interface DayContext { + ref_date: string + events: { + long: MarketEvent | null + medium: MarketEvent | null + short: MarketEvent | null + } + long_commentary: string + medium_commentary: string + short_commentary: string + evidence_headlines: string[] + generated_at: string | null + has_commentary: boolean +} + +const LEVEL_CONFIG = { + long: { + label: 'Long Terme', + sublabel: 'Régime structurel', + color: 'text-violet-400', + border: 'border-violet-700/50', + bg: 'bg-violet-950/30', + dot: 'bg-violet-500', + badge: 'bg-violet-900/50 text-violet-300 border-violet-700/50', + }, + medium: { + label: 'Moyen Terme', + sublabel: 'Régime courant', + color: 'text-blue-400', + border: 'border-blue-700/50', + bg: 'bg-blue-950/30', + dot: 'bg-blue-500', + badge: 'bg-blue-900/50 text-blue-300 border-blue-700/50', + }, + short: { + label: 'Court Terme', + sublabel: 'Catalyseur récent', + color: 'text-emerald-400', + border: 'border-emerald-700/50', + bg: 'bg-emerald-950/30', + dot: 'bg-emerald-500', + badge: 'bg-emerald-900/50 text-emerald-300 border-emerald-700/50', + }, +} + +function daysSince(refDate: string, startDate: string): number { + const d0 = new Date(startDate) + const d1 = new Date(refDate) + return Math.floor((d1.getTime() - d0.getTime()) / 86_400_000) +} + +function fmtDate(d: string): string { + return new Date(d).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' }) +} + +function offsetDate(d: string, days: number): string { + const dt = new Date(d) + dt.setDate(dt.getDate() + days) + return dt.toISOString().split('T')[0] +} + +function todayStr(): string { + return new Date().toISOString().split('T')[0] +} + +// Simple mini timeline strip +function TimelineStrip({ events, refDate }: { events: MarketEvent[], refDate: string }) { + const today = new Date(refDate) + // show ±6 months window + const start = new Date(today); start.setMonth(start.getMonth() - 6) + const end = new Date(today); end.setMonth(end.getMonth() + 1) + const totalDays = (end.getTime() - start.getTime()) / 86_400_000 + + const relevant = events.filter(ev => { + const s = new Date(ev.start_date) + const e = ev.end_date ? new Date(ev.end_date) : end + return s <= end && e >= start + }) + + return ( +
+ {/* Today marker */} +
+ {relevant.map(ev => { + const s = Math.max(0, (new Date(ev.start_date).getTime() - start.getTime()) / 86_400_000) + const e_raw = ev.end_date ? new Date(ev.end_date) : end + const e = Math.min(totalDays, (e_raw.getTime() - start.getTime()) / 86_400_000) + const left = (s / totalDays) * 100 + const width = Math.max(0.5, ((e - s) / totalDays) * 100) + const cfg = LEVEL_CONFIG[ev.level as keyof typeof LEVEL_CONFIG] + return ( +
+ ) + })} +
+ ) +} + +function ContextPanel({ + level, + event, + commentary, + refDate, +}: { + level: 'long' | 'medium' | 'short' + event: MarketEvent | null + commentary: string + refDate: string +}) { + const cfg = LEVEL_CONFIG[level] + + return ( +
+ {/* Header */} +
+
+
+
{cfg.label}
+
{cfg.sublabel}
+
+
+ + {event ? ( + <> + {/* Event card */} +
+
+
{event.name}
+ + {event.category} + +
+
+ + + J+{daysSince(refDate, event.start_date)} ({fmtDate(event.start_date)}) + + {event.end_date && ( + → {fmtDate(event.end_date)} + )} + {!event.end_date && ( + en cours + )} +
+

{event.description}

+ {event.market_impact && ( +

{event.market_impact}

+ )} + {/* Affected assets */} + {event.affected_assets && (() => { + try { + const assets = JSON.parse(event.affected_assets) + if (assets.length > 0) return ( +
+ {assets.map((a: string) => ( + + {a} + + ))} +
+ ) + } catch { /* ignore */ } + return null + })()} +
+ + {/* AI Commentary */} + {commentary ? ( +
+
+ + Analyse IA +
+ {commentary} +
+ ) : ( +
+ Commentaire IA non généré — cliquez "Générer IA" +
+ )} + + ) : ( +
+ Aucun événement {cfg.label.toLowerCase()} actif à cette date +
+ )} +
+ ) +} + +export default function Timeline() { + const [refDate, setRefDate] = useState(todayStr()) + const [ctx, setCtx] = useState(null) + const [allEvents, setAllEvents] = useState([]) + const [loading, setLoading] = useState(false) + const [generating, setGenerating] = useState(false) + const [bootstrapped, setBootstrapped] = useState(false) + + const fetchDay = useCallback(async (d: string) => { + setLoading(true) + try { + const res = await fetch(`${API}/api/timeline/day/${d}`) + if (res.ok) setCtx(await res.json()) + } finally { + setLoading(false) + } + }, []) + + const fetchEvents = useCallback(async () => { + const res = await fetch(`${API}/api/timeline/events`) + if (res.ok) setAllEvents(await res.json()) + }, []) + + const bootstrap = useCallback(async () => { + const res = await fetch(`${API}/api/timeline/bootstrap`, { method: 'POST' }) + if (res.ok) { + setBootstrapped(true) + await fetchEvents() + await fetchDay(refDate) + } + }, [refDate, fetchDay, fetchEvents]) + + const generateAI = useCallback(async () => { + setGenerating(true) + try { + const res = await fetch(`${API}/api/timeline/generate/${refDate}`, { method: 'POST' }) + if (res.ok) { + const data = await res.json() + setCtx(prev => prev ? { + ...prev, + long_commentary: data.long_commentary, + medium_commentary: data.medium_commentary, + short_commentary: data.short_commentary, + has_commentary: true, + } : prev) + } + } finally { + setGenerating(false) + } + }, [refDate]) + + useEffect(() => { + fetchEvents() + }, [fetchEvents]) + + useEffect(() => { + fetchDay(refDate) + }, [refDate, fetchDay]) + + // Auto-bootstrap on first load if no events + useEffect(() => { + if (!bootstrapped && allEvents.length === 0) { + bootstrap() + } + }, [allEvents.length, bootstrapped, bootstrap]) + + const navigate = (days: number) => setRefDate(prev => offsetDate(prev, days)) + + return ( +
+ {/* Header */} +
+
+

+ + Timeline Navigator +

+

+ Contexte historique 3 temporalités — COVID (2020) → aujourd'hui +

+
+
+ +
+
+ + {/* Date Navigator */} +
+
+ {/* Prev buttons */} +
+ + + +
+ + {/* Date input */} +
+ + setRefDate(e.target.value)} + className="bg-dark-700 border border-slate-700/40 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:border-violet-500/50" + /> + {fmtDate(refDate)} + +
+ + {/* Next buttons */} +
+ + + +
+ + {/* Generate AI */} + +
+ + {/* Timeline strip */} + {allEvents.length > 0 && ( + + )} + + {/* Strip legend */} +
+ {(['long', 'medium', 'short'] as const).map(l => ( +
+
+ {LEVEL_CONFIG[l].label} +
+ ))} +
+
+ Date sélectionnée +
+
+
+ + {/* 3 context panels */} + {loading ? ( +
Chargement du contexte...
+ ) : ctx ? ( +
+ + + +
+ ) : ( +
Aucun contexte disponible
+ )} + + {/* Event list (compact) */} + {allEvents.length > 0 && ( +
+

+ Catalogue d'événements ({allEvents.length}) +

+
+ {(['long', 'medium', 'short'] as const).map(lvl => ( +
+
+ {LEVEL_CONFIG[lvl].label} +
+ {allEvents.filter(e => e.level === lvl).map(ev => ( +
setRefDate(ev.start_date)} + className="flex items-center gap-2 text-xs py-1 px-2 rounded hover:bg-dark-700/60 cursor-pointer group" + > +
+ {ev.name} + {fmtDate(ev.start_date)} + {ev.end_date + ? → {fmtDate(ev.end_date)} + : en cours + } +
+ ))} +
+ ))} +
+
+ )} +
+ ) +}