diff --git a/backend/routers/timeline.py b/backend/routers/timeline.py index b17b0d6..26dece8 100644 --- a/backend/routers/timeline.py +++ b/backend/routers/timeline.py @@ -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"} diff --git a/backend/services/database.py b/backend/services/database.py index 55c0f71..6969318 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -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: diff --git a/frontend/src/components/EventManager.tsx b/frontend/src/components/EventManager.tsx new file mode 100644 index 0000000..73ef020 --- /dev/null +++ b/frontend/src/components/EventManager.tsx @@ -0,0 +1,434 @@ +import { useState } from 'react' +import axios from 'axios' +import clsx from 'clsx' +import { Plus, Edit2, Trash2, Sparkles, X, Save, ChevronDown, ChevronUp, Check } from 'lucide-react' + +const api = axios.create({ baseURL: '/api' }) + +interface Indicator { symbol: string; indicator: string; label: string; rationale?: string } + +interface MarketEvent { + id: number; name: string; start_date: string; end_date: string | null + level: string; category: string; description: string; market_impact: string + affected_assets: string; impact_score: number + absorption_pct: number | null; relevant_indicators: string +} + +interface Props { + events: MarketEvent[] + onRefresh: () => void +} + +const LEVELS = ['long', 'medium', 'short'] as const +const LEVEL_LABELS = { long: 'Long Terme', medium: 'Moyen Terme', short: 'Court Terme' } +const LEVEL_COLORS = { + long: { badge: 'bg-violet-900/60 text-violet-300 border-violet-700/50', dot: 'bg-violet-500' }, + medium: { badge: 'bg-blue-900/60 text-blue-300 border-blue-700/50', dot: 'bg-blue-500' }, + short: { badge: 'bg-emerald-900/60 text-emerald-300 border-emerald-700/50', dot: 'bg-emerald-500' }, +} + +const CATEGORIES = ['macro', 'monetary', 'geopolitical', 'market', 'fiscal'] + +const EMPTY_FORM = { + name: '', start_date: '', end_date: '', level: 'medium', category: 'macro', + description: '', market_impact: '', affected_assets: '', impact_score: 0.5, + absorption_pct: '', relevant_indicators: '', +} + +type FormState = typeof EMPTY_FORM + +function AbsorptionBadge({ pct }: { pct: number | null }) { + if (pct === null || pct === undefined) return + const color = pct >= 80 ? 'text-red-400' : pct >= 40 ? 'text-yellow-400' : 'text-emerald-400' + const label = pct >= 80 ? 'Fully priced' : pct >= 40 ? 'Partial' : 'Not yet' + return ( + + {pct.toFixed(0)}% ({label}) + + ) +} + +function fmtDate(d: string | null) { + if (!d) return '—' + return new Date(d).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: '2-digit' }) +} + +function parseIndicators(raw: string | null | undefined): Indicator[] { + try { return JSON.parse(raw || '[]') } catch { return [] } +} + +// ── Edit / Add Modal ─────────────────────────────────────────────────────────── + +function EventModal({ + event, + onClose, + onSaved, +}: { + event: MarketEvent | null // null = create new + onClose: () => void + onSaved: () => void +}) { + const [form, setForm] = useState(event ? { + name: event.name, + start_date: event.start_date, + end_date: event.end_date ?? '', + level: event.level, + category: event.category, + description: event.description, + market_impact: event.market_impact, + affected_assets: (() => { + try { return JSON.parse(event.affected_assets || '[]').join(', ') } catch { return '' } + })(), + impact_score: event.impact_score, + absorption_pct: event.absorption_pct !== null && event.absorption_pct !== undefined + ? String(event.absorption_pct) : '', + relevant_indicators: (() => { + try { + const inds = JSON.parse(event.relevant_indicators || '[]') + return inds.map((i: Indicator) => i.label).join(', ') + } catch { return '' } + })(), + } : { ...EMPTY_FORM }) + + const [saving, setSaving] = useState(false) + const [enriching, setEnriching] = useState(false) + const [enrichResult, setEnrichResult] = useState<{ absorption_pct: number; absorption_rationale: string; relevant_indicators: Indicator[] } | null>(null) + + const set = (k: keyof FormState, v: string | number) => + setForm(f => ({ ...f, [k]: v })) + + async function save() { + setSaving(true) + try { + const payload = { + name: form.name, + start_date: form.start_date, + end_date: form.end_date || null, + level: form.level, + category: form.category, + description: form.description, + market_impact: form.market_impact, + affected_assets: form.affected_assets.split(',').map(s => s.trim()).filter(Boolean), + impact_score: Number(form.impact_score), + absorption_pct: form.absorption_pct ? Number(form.absorption_pct) : null, + relevant_indicators: enrichResult?.relevant_indicators ?? [], + } + if (event) { + await api.put(`/timeline/events/${event.id}`, payload) + } else { + await api.post('/timeline/events', payload) + } + onSaved() + onClose() + } finally { + setSaving(false) + } + } + + async function enrich() { + if (!event) return + setEnriching(true) + try { + const res = await api.post(`/timeline/events/${event.id}/ai-enrich`) + const d = res.data + setEnrichResult(d) + set('absorption_pct', String(d.absorption_pct ?? '')) + } finally { + setEnriching(false) + } + } + + const indicators = enrichResult?.relevant_indicators ?? parseIndicators(event?.relevant_indicators) + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+ {event ? `Éditer — ${event.name}` : 'Nouvel événement'} +
+ +
+ +
+ {/* Name + Level + Category */} +
+
+ + set('name', e.target.value)} + className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-slate-500" /> +
+
+ + +
+
+ + +
+
+ + set('impact_score', e.target.value)} + className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none" /> +
+
+ + {/* Dates */} +
+
+ + set('start_date', e.target.value)} + className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none" /> +
+
+ + set('end_date', e.target.value)} + className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none" /> +
+
+ + {/* Description + Market impact */} +
+ +