feat: frise sub-lanes + event manager + MA indicators + absorption
Frise chronologique:
- Sub-lane stacking (assignSubLanes) — overlapping events se décalent verticalement
- Zone d'overlap semi-transparente sur la période commune entre 2 événements
- Hauteur dynamique selon nb de sub-lanes par niveau
- Événements en cours avec flèche ▶ à droite, gradient de fin
- Tri par start_date pour placement greedy
Event Manager (composant EventManager.tsx):
- Tableau filtrable par niveau (Long/Moyen/Court)
- Edit modal complet : tous les champs + absorption_pct éditable
- Bouton "IA — Enrichir" par événement → POST /api/timeline/events/{id}/ai-enrich
→ GPT-4o-mini suggère absorption_pct + indicateurs pertinents par niveau temporel
- Delete avec confirmation double-clic
- Expand row pour voir description + indicateurs
- Intégré Timeline page via bouton "Gérer événements"
Backend:
- Nouvelles colonnes market_events: absorption_pct + relevant_indicators (ALTER idempotent)
- DELETE /api/timeline/events/{id}
- POST /api/timeline/events/{id}/ai-enrich
Snapshot Externe:
- AbsorptionBar par événement dans cellule Géopolitique
- MA indicators : fetch 200j history, compute MA10/MA20/MA100 per level (short/med/long)
- Affichage prix vs MA + % écart dans CellMarkets
- Si relevant_indicators configurés sur l'event → utilise ces symbols au lieu des défauts
- Calendar : horizons exclusifs (short 0-7j, medium 8-30j, long 31-90j) — bug corrigé
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,9 @@
|
||||
Timeline Navigator — historical market context browser.
|
||||
Prefix: /api/timeline
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
@@ -23,6 +25,8 @@ class EventCreate(BaseModel):
|
||||
market_impact: str = ""
|
||||
affected_assets: List[str] = []
|
||||
impact_score: float = 0.5
|
||||
absorption_pct: Optional[float] = None
|
||||
relevant_indicators: List[Dict[str, Any]] = []
|
||||
parent_event_id: Optional[int] = None
|
||||
|
||||
|
||||
@@ -52,11 +56,108 @@ def update_event(event_id: int, body: EventUpdate) -> Dict[str, Any]:
|
||||
return {"status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/events/{event_id}")
|
||||
def delete_event(event_id: int) -> Dict[str, Any]:
|
||||
from services.database import delete_market_event
|
||||
delete_market_event(event_id)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/events/{event_id}/ai-enrich")
|
||||
def ai_enrich_event(event_id: int) -> Dict[str, Any]:
|
||||
"""Ask GPT-4o-mini to suggest absorption_pct + relevant_indicators for this event."""
|
||||
from services.database import get_all_market_events, update_market_event
|
||||
from datetime import datetime
|
||||
|
||||
api_key = os.environ.get("OPENAI_API_KEY", "")
|
||||
if not api_key:
|
||||
raise HTTPException(400, "OpenAI API key not configured")
|
||||
|
||||
# Fetch event
|
||||
all_evs = get_all_market_events()
|
||||
ev = next((e for e in all_evs if e["id"] == event_id), None)
|
||||
if not ev:
|
||||
raise HTTPException(404, "Event not found")
|
||||
|
||||
from openai import OpenAI
|
||||
client = OpenAI(api_key=api_key)
|
||||
|
||||
today = datetime.utcnow().strftime("%Y-%m-%d")
|
||||
days_elapsed = max(0, (datetime.strptime(today, "%Y-%m-%d") -
|
||||
datetime.strptime(ev["start_date"][:10], "%Y-%m-%d")).days)
|
||||
is_ongoing = not ev.get("end_date")
|
||||
|
||||
prompt = f"""Tu es un analyste macro senior spécialisé en options géopolitiques.
|
||||
|
||||
Événement à analyser:
|
||||
- Nom: {ev['name']}
|
||||
- Niveau: {ev['level']} (long=structurel, medium=régime, short=catalyseur)
|
||||
- Catégorie: {ev['category']}
|
||||
- Début: {ev['start_date']}
|
||||
- Fin: {ev.get('end_date') or 'en cours'}
|
||||
- Jours écoulés depuis début: {days_elapsed}j
|
||||
- Description: {ev['description']}
|
||||
- Impact marché: {ev.get('market_impact', '')}
|
||||
- Assets affectés: {ev.get('affected_assets', '[]')}
|
||||
|
||||
Ta mission:
|
||||
1. Estime le taux d'absorption (0-100%) de cet événement par le marché à ce jour.
|
||||
Absorption = à quel point l'impact attendu est déjà "pricé" par les marchés.
|
||||
Exemples: 0%=pas encore pricé, 50%=à moitié absorbé, 100%=fully priced in.
|
||||
|
||||
2. Suggère 3-5 indicateurs techniques PERTINENTS pour suivre cet événement dans le contexte d'un trader d'options.
|
||||
Pour chaque indicateur:
|
||||
- symbol: ticker yfinance (ex: "CL=F", "GC=F", "SPY", "^VIX")
|
||||
- indicator: type ("MA10", "MA20", "MA50", "MA100", "price", "RSI14")
|
||||
- label: nom affiché (ex: "Brent MA100", "Gold prix spot", "VIX 20j avg")
|
||||
- rationale: 1 phrase pourquoi cet indicateur est pertinent POUR CET ÉVÉNEMENT PRÉCIS
|
||||
|
||||
Règle: choisis les indicateurs selon le niveau temporel:
|
||||
- Long terme → préfère MA100, MA50 (tendances structurelles)
|
||||
- Moyen terme → MA20, MA50
|
||||
- Court terme → MA10, MA20, prix spot
|
||||
|
||||
FORMAT JSON STRICT:
|
||||
{{
|
||||
"absorption_pct": <0-100>,
|
||||
"absorption_rationale": "<1 phrase expliquant pourquoi ce niveau>",
|
||||
"relevant_indicators": [
|
||||
{{"symbol": "...", "indicator": "...", "label": "...", "rationale": "..."}}
|
||||
]
|
||||
}}"""
|
||||
|
||||
try:
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
response_format={"type": "json_object"},
|
||||
temperature=0.3,
|
||||
max_tokens=600,
|
||||
)
|
||||
result = json.loads(response.choices[0].message.content)
|
||||
|
||||
# Update event in DB
|
||||
ev_update = dict(ev)
|
||||
ev_update["affected_assets"] = json.loads(ev.get("affected_assets", "[]") or "[]")
|
||||
ev_update["relevant_indicators"] = result.get("relevant_indicators", [])
|
||||
ev_update["absorption_pct"] = result.get("absorption_pct")
|
||||
update_market_event(event_id, ev_update)
|
||||
|
||||
return {
|
||||
"event_id": event_id,
|
||||
"absorption_pct": result.get("absorption_pct"),
|
||||
"absorption_rationale": result.get("absorption_rationale", ""),
|
||||
"relevant_indicators": result.get("relevant_indicators", []),
|
||||
"status": "enriched",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[Timeline] ai_enrich failed for event {event_id}: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@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)
|
||||
@@ -85,9 +186,7 @@ def get_day_context(ref_date: str) -> Dict[str, Any]:
|
||||
|
||||
@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)
|
||||
@@ -96,7 +195,7 @@ def generate_commentary(ref_date: str) -> Dict[str, Any]:
|
||||
"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", []),
|
||||
"evidence_headlines": ctx.get("evidence_headlines", []),
|
||||
"events": ctx.get("events", {}),
|
||||
"has_commentary": True,
|
||||
}
|
||||
@@ -107,7 +206,6 @@ def generate_commentary(ref_date: str) -> Dict[str, Any]:
|
||||
|
||||
@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"}
|
||||
|
||||
@@ -746,9 +746,20 @@ def init_db():
|
||||
market_impact TEXT DEFAULT '',
|
||||
affected_assets TEXT DEFAULT '[]',
|
||||
impact_score REAL DEFAULT 0.5,
|
||||
absorption_pct REAL DEFAULT NULL,
|
||||
relevant_indicators TEXT DEFAULT '[]',
|
||||
parent_event_id INTEGER REFERENCES market_events(id),
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)""")
|
||||
# Add columns to existing DBs (idempotent via catch)
|
||||
for _col, _def in [
|
||||
("absorption_pct", "REAL DEFAULT NULL"),
|
||||
("relevant_indicators", "TEXT DEFAULT '[]'"),
|
||||
]:
|
||||
try:
|
||||
c.execute(f"ALTER TABLE market_events ADD COLUMN {_col} {_def}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
c.execute("""CREATE TABLE IF NOT EXISTS timeline_context (
|
||||
ref_date TEXT PRIMARY KEY,
|
||||
@@ -4487,12 +4498,25 @@ def update_market_event(event_id: int, ev: Dict[str, Any]) -> bool:
|
||||
try:
|
||||
conn.execute("""UPDATE market_events SET
|
||||
name=?, start_date=?, end_date=?, level=?, category=?, description=?,
|
||||
market_impact=?, affected_assets=?, impact_score=?
|
||||
market_impact=?, affected_assets=?, impact_score=?,
|
||||
absorption_pct=?, relevant_indicators=?
|
||||
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))
|
||||
ev.get("impact_score", 0.5),
|
||||
ev.get("absorption_pct"), json.dumps(ev.get("relevant_indicators", [])),
|
||||
event_id))
|
||||
conn.commit()
|
||||
return True
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_market_event(event_id: int) -> bool:
|
||||
conn = get_conn()
|
||||
try:
|
||||
conn.execute("DELETE FROM market_events WHERE id=?", (event_id,))
|
||||
conn.commit()
|
||||
return True
|
||||
finally:
|
||||
|
||||
Reference in New Issue
Block a user