feat: Timeline Navigator — contexte historique 3 temporalités COVID → aujourd'hui
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 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 pattern_lab as pattern_lab_router
|
||||||
from routers import specialist_desks as specialist_desks_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 logs as logs_router
|
||||||
from routers import var as var_router
|
from routers import var as var_router
|
||||||
from routers import reports as reports_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(institutional_router.router)
|
||||||
app.include_router(pattern_lab_router.router)
|
app.include_router(pattern_lab_router.router)
|
||||||
app.include_router(specialist_desks_router.router)
|
app.include_router(specialist_desks_router.router)
|
||||||
|
app.include_router(timeline_router.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
113
backend/routers/timeline.py
Normal file
113
backend/routers/timeline.py
Normal file
@@ -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"}
|
||||||
@@ -734,6 +734,34 @@ def init_db():
|
|||||||
UNIQUE(asset, fetch_date)
|
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)
|
# Seed desk defaults (idempotent)
|
||||||
_desk_count = c.execute("SELECT COUNT(*) FROM asset_class_configs").fetchone()[0]
|
_desk_count = c.execute("SELECT COUNT(*) FROM asset_class_configs").fetchone()[0]
|
||||||
if _desk_count == 0:
|
if _desk_count == 0:
|
||||||
@@ -4431,3 +4459,121 @@ def update_report_result(
|
|||||||
return True
|
return True
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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()
|
||||||
|
|||||||
396
backend/services/timeline_service.py
Normal file
396
backend/services/timeline_service.py
Normal file
@@ -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}
|
||||||
@@ -23,6 +23,7 @@ import VaRAnalysis from './pages/VaRAnalysis'
|
|||||||
import PositionHistory from './pages/PositionHistory'
|
import PositionHistory from './pages/PositionHistory'
|
||||||
import InstitutionalReports from './pages/InstitutionalReports'
|
import InstitutionalReports from './pages/InstitutionalReports'
|
||||||
import SpecialistDesks from './pages/SpecialistDesks'
|
import SpecialistDesks from './pages/SpecialistDesks'
|
||||||
|
import Timeline from './pages/Timeline'
|
||||||
import { useCycleWatcher } from './hooks/useApi'
|
import { useCycleWatcher } from './hooks/useApi'
|
||||||
|
|
||||||
function GlobalWatcher() {
|
function GlobalWatcher() {
|
||||||
@@ -61,6 +62,7 @@ export default function App() {
|
|||||||
<Route path="/logs" element={<SystemLogs />} />
|
<Route path="/logs" element={<SystemLogs />} />
|
||||||
<Route path="/institutional" element={<InstitutionalReports />} />
|
<Route path="/institutional" element={<InstitutionalReports />} />
|
||||||
<Route path="/specialist-desks" element={<SpecialistDesks />} />
|
<Route path="/specialist-desks" element={<SpecialistDesks />} />
|
||||||
|
<Route path="/timeline" element={<Timeline />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Globe, BarChart2, FlaskConical,
|
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'
|
} from 'lucide-react'
|
||||||
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
|
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
@@ -27,6 +27,7 @@ const nav = [
|
|||||||
{ to: '/calendar', icon: Calendar, label: 'Calendar' },
|
{ to: '/calendar', icon: Calendar, label: 'Calendar' },
|
||||||
{ to: '/institutional', icon: Building2, label: 'Inst. Reports' },
|
{ to: '/institutional', icon: Building2, label: 'Inst. Reports' },
|
||||||
{ to: '/specialist-desks', icon: Users, label: 'Specialist Desks' },
|
{ to: '/specialist-desks', icon: Users, label: 'Specialist Desks' },
|
||||||
|
{ to: '/timeline', icon: Layers, label: 'Timeline' },
|
||||||
{ to: '/logs', icon: ScrollText, label: 'System Logs' },
|
{ to: '/logs', icon: ScrollText, label: 'System Logs' },
|
||||||
{ to: '/config', icon: Settings, label: 'Configuration' },
|
{ to: '/config', icon: Settings, label: 'Configuration' },
|
||||||
]
|
]
|
||||||
|
|||||||
449
frontend/src/pages/Timeline.tsx
Normal file
449
frontend/src/pages/Timeline.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="relative h-10 bg-dark-800 rounded-lg border border-slate-700/40 overflow-hidden mt-3">
|
||||||
|
{/* Today marker */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 w-px bg-white/40 z-10"
|
||||||
|
style={{ left: `${((today.getTime() - start.getTime()) / 86_400_000 / totalDays) * 100}%` }}
|
||||||
|
/>
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={ev.id}
|
||||||
|
title={`[${ev.level.toUpperCase()}] ${ev.name}`}
|
||||||
|
className={clsx('absolute top-1/2 h-3 rounded-sm opacity-80 cursor-pointer', cfg.dot)}
|
||||||
|
style={{
|
||||||
|
left: `${left}%`,
|
||||||
|
width: `${width}%`,
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
top: ev.level === 'long' ? '25%' : ev.level === 'medium' ? '50%' : '75%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextPanel({
|
||||||
|
level,
|
||||||
|
event,
|
||||||
|
commentary,
|
||||||
|
refDate,
|
||||||
|
}: {
|
||||||
|
level: 'long' | 'medium' | 'short'
|
||||||
|
event: MarketEvent | null
|
||||||
|
commentary: string
|
||||||
|
refDate: string
|
||||||
|
}) {
|
||||||
|
const cfg = LEVEL_CONFIG[level]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx('rounded-xl border p-5 flex flex-col gap-3', cfg.bg, cfg.border)}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={clsx('w-2.5 h-2.5 rounded-full', cfg.dot)} />
|
||||||
|
<div>
|
||||||
|
<div className={clsx('text-sm font-semibold', cfg.color)}>{cfg.label}</div>
|
||||||
|
<div className="text-xs text-slate-500">{cfg.sublabel}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{event ? (
|
||||||
|
<>
|
||||||
|
{/* Event card */}
|
||||||
|
<div className="bg-dark-800/60 rounded-lg p-3 border border-slate-700/30">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className={clsx('text-sm font-bold', cfg.color)}>{event.name}</div>
|
||||||
|
<span className={clsx('text-xs px-1.5 py-0.5 rounded border shrink-0', cfg.badge)}>
|
||||||
|
{event.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-1.5 text-xs text-slate-400">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
J+{daysSince(refDate, event.start_date)} ({fmtDate(event.start_date)})
|
||||||
|
</span>
|
||||||
|
{event.end_date && (
|
||||||
|
<span className="text-slate-600">→ {fmtDate(event.end_date)}</span>
|
||||||
|
)}
|
||||||
|
{!event.end_date && (
|
||||||
|
<span className="text-amber-500/70 text-xs">en cours</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-2 leading-relaxed">{event.description}</p>
|
||||||
|
{event.market_impact && (
|
||||||
|
<p className="text-xs text-slate-500 mt-1.5 italic">{event.market_impact}</p>
|
||||||
|
)}
|
||||||
|
{/* Affected assets */}
|
||||||
|
{event.affected_assets && (() => {
|
||||||
|
try {
|
||||||
|
const assets = JSON.parse(event.affected_assets)
|
||||||
|
if (assets.length > 0) return (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{assets.map((a: string) => (
|
||||||
|
<span key={a} className="text-xs px-1.5 py-0.5 bg-dark-700 rounded text-slate-500 border border-slate-700/30">
|
||||||
|
{a}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return null
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Commentary */}
|
||||||
|
{commentary ? (
|
||||||
|
<div className="text-sm text-slate-300 leading-relaxed bg-dark-900/40 rounded-lg p-3 border border-slate-700/20">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-500 mb-2">
|
||||||
|
<Sparkles className="w-3 h-3" />
|
||||||
|
Analyse IA
|
||||||
|
</div>
|
||||||
|
{commentary}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-slate-600 italic text-center py-2">
|
||||||
|
Commentaire IA non généré — cliquez "Générer IA"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-slate-600 text-sm italic py-4">
|
||||||
|
Aucun événement {cfg.label.toLowerCase()} actif à cette date
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Timeline() {
|
||||||
|
const [refDate, setRefDate] = useState(todayStr())
|
||||||
|
const [ctx, setCtx] = useState<DayContext | null>(null)
|
||||||
|
const [allEvents, setAllEvents] = useState<MarketEvent[]>([])
|
||||||
|
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 (
|
||||||
|
<div className="p-6 space-y-5 max-w-6xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
<Layers className="w-5 h-5 text-violet-400" />
|
||||||
|
Timeline Navigator
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-500 mt-0.5">
|
||||||
|
Contexte historique 3 temporalités — COVID (2020) → aujourd'hui
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => bootstrap()}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-slate-400 border border-slate-700/40 rounded-lg hover:bg-dark-700 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3 h-3" />
|
||||||
|
Réinitialiser événements
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Navigator */}
|
||||||
|
<div className="bg-dark-800 rounded-xl border border-slate-700/40 p-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Prev buttons */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => navigate(-30)} className="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded transition-colors">-1M</button>
|
||||||
|
<button onClick={() => navigate(-7)} className="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded transition-colors">-7J</button>
|
||||||
|
<button onClick={() => navigate(-1)} className="p-1.5 text-slate-400 hover:text-white hover:bg-dark-700 rounded transition-colors">
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date input */}
|
||||||
|
<div className="flex-1 flex items-center justify-center gap-3">
|
||||||
|
<Calendar className="w-4 h-4 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={refDate}
|
||||||
|
min="2020-01-01"
|
||||||
|
max={todayStr()}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-400">{fmtDate(refDate)}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setRefDate(todayStr())}
|
||||||
|
className="text-xs text-violet-400 hover:text-violet-300 transition-colors"
|
||||||
|
>
|
||||||
|
Aujourd'hui
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Next buttons */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => navigate(1)} disabled={refDate >= todayStr()} className="p-1.5 text-slate-400 hover:text-white hover:bg-dark-700 rounded transition-colors disabled:opacity-30">
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => navigate(7)} disabled={refDate >= todayStr()} className="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded transition-colors disabled:opacity-30">+7J</button>
|
||||||
|
<button onClick={() => navigate(30)} disabled={refDate >= todayStr()} className="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded transition-colors disabled:opacity-30">+1M</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Generate AI */}
|
||||||
|
<button
|
||||||
|
onClick={generateAI}
|
||||||
|
disabled={generating || loading}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-violet-700/30 hover:bg-violet-700/50 text-violet-300 border border-violet-700/40 rounded-lg transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-3.5 h-3.5" />
|
||||||
|
{generating ? 'Génération...' : 'Générer IA'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline strip */}
|
||||||
|
{allEvents.length > 0 && (
|
||||||
|
<TimelineStrip events={allEvents} refDate={refDate} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Strip legend */}
|
||||||
|
<div className="flex items-center gap-4 mt-2">
|
||||||
|
{(['long', 'medium', 'short'] as const).map(l => (
|
||||||
|
<div key={l} className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||||
|
<div className={clsx('w-3 h-1.5 rounded', LEVEL_CONFIG[l].dot)} />
|
||||||
|
{LEVEL_CONFIG[l].label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-600 ml-auto">
|
||||||
|
<div className="w-px h-3 bg-white/40" />
|
||||||
|
Date sélectionnée
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3 context panels */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center text-slate-500 py-12">Chargement du contexte...</div>
|
||||||
|
) : ctx ? (
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<ContextPanel
|
||||||
|
level="long"
|
||||||
|
event={ctx.events?.long ?? null}
|
||||||
|
commentary={ctx.long_commentary}
|
||||||
|
refDate={refDate}
|
||||||
|
/>
|
||||||
|
<ContextPanel
|
||||||
|
level="medium"
|
||||||
|
event={ctx.events?.medium ?? null}
|
||||||
|
commentary={ctx.medium_commentary}
|
||||||
|
refDate={refDate}
|
||||||
|
/>
|
||||||
|
<ContextPanel
|
||||||
|
level="short"
|
||||||
|
event={ctx.events?.short ?? null}
|
||||||
|
commentary={ctx.short_commentary}
|
||||||
|
refDate={refDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-slate-500 py-12">Aucun contexte disponible</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Event list (compact) */}
|
||||||
|
{allEvents.length > 0 && (
|
||||||
|
<div className="bg-dark-800 rounded-xl border border-slate-700/40 p-4">
|
||||||
|
<h2 className="text-sm font-semibold text-slate-300 mb-3">
|
||||||
|
Catalogue d'événements ({allEvents.length})
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-1 max-h-72 overflow-y-auto">
|
||||||
|
{(['long', 'medium', 'short'] as const).map(lvl => (
|
||||||
|
<div key={lvl}>
|
||||||
|
<div className={clsx('text-xs font-semibold uppercase tracking-wider mb-1 mt-2', LEVEL_CONFIG[lvl].color)}>
|
||||||
|
{LEVEL_CONFIG[lvl].label}
|
||||||
|
</div>
|
||||||
|
{allEvents.filter(e => e.level === lvl).map(ev => (
|
||||||
|
<div
|
||||||
|
key={ev.id}
|
||||||
|
onClick={() => 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"
|
||||||
|
>
|
||||||
|
<div className={clsx('w-1.5 h-1.5 rounded-full shrink-0', LEVEL_CONFIG[lvl].dot)} />
|
||||||
|
<span className="text-slate-300 group-hover:text-white flex-1">{ev.name}</span>
|
||||||
|
<span className="text-slate-600">{fmtDate(ev.start_date)}</span>
|
||||||
|
{ev.end_date
|
||||||
|
? <span className="text-slate-600">→ {fmtDate(ev.end_date)}</span>
|
||||||
|
: <span className="text-amber-600 text-xs">en cours</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user