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() {
{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 ( ++ Contexte historique 3 temporalités — COVID (2020) → aujourd'hui +
+