diff --git a/backend/services/instrument_service.py b/backend/services/instrument_service.py index 3637309..f3d4b11 100644 --- a/backend/services/instrument_service.py +++ b/backend/services/instrument_service.py @@ -527,8 +527,17 @@ def _get_relevant_events( Returns max 15 events sorted by start_date descending. """ try: - from services.database import get_all_market_events - all_events = get_all_market_events() + from services.database import get_conn + conn = get_conn() + rows = conn.execute(""" + SELECT e.*, + (SELECT a.template_id FROM causal_event_analyses a WHERE a.market_event_id = e.id ORDER BY a.id DESC LIMIT 1) AS template_id, + (SELECT a.id FROM causal_event_analyses a WHERE a.market_event_id = e.id ORDER BY a.id DESC LIMIT 1) AS analysis_id + FROM market_events e + ORDER BY e.start_date DESC + """).fetchall() + conn.close() + all_events = [dict(r) for r in rows] except Exception as e: logger.warning(f"[instrument_service] Could not load market events: {e}") return [] @@ -570,6 +579,8 @@ def _get_relevant_events( if keyword_hit or asset_hit: filtered.append({ + "id": ev.get("id"), + "template_id": ev.get("template_id"), "date": ev_start, "end_date": ev.get("end_date") or None, "title": ev.get("name") or ev.get("event_name") or "", diff --git a/frontend/src/pages/InstrumentDashboard.tsx b/frontend/src/pages/InstrumentDashboard.tsx index f17eb5b..b545640 100644 --- a/frontend/src/pages/InstrumentDashboard.tsx +++ b/frontend/src/pages/InstrumentDashboard.tsx @@ -30,6 +30,7 @@ interface PriceCandle { time: string; open: number; high: number; low: number; c interface LinePoint { time: string; value: number } interface SnapshotEvent { + id?: number; template_id?: number | null date: string; end_date: string | null; title: string; level: string category: string; sub_type?: string; description: string; impact_score: number expected_value?: string | null; actual_value?: string | null @@ -665,13 +666,15 @@ function MacroGaugePanel({ snap, dateLabel }: { snap: MacroGaugeSnap; dateLabel: // ── EventTimeline ───────────────────────────────────────────────────────────── function EventTimeline({ - events, priceData, selectedDate, onNavigate, + events, priceData, selectedDate, templates, causalInsts, }: { events: SnapshotEvent[] priceData: PriceCandle[] selectedDate: string | null - onNavigate: (date: string) => void + templates: CausalTemplate[] + causalInsts: string[] }) { + const navigate = useNavigate() const containerRef = useRef(null) const [contWidth, setContWidth] = useState(800) @@ -682,10 +685,17 @@ function EventTimeline({ return () => obs.disconnect() }, []) - if (!priceData.length || !events.length) { + // Only events that have a causal graph linked to this instrument + const linked = events.filter(ev => { + if (!ev.template_id) return false + const tmpl = templates.find(t => t.id === ev.template_id) + return tmpl != null && causalInsts.some(ci => tmpl.instruments.includes(ci)) + }) + + if (!priceData.length || !linked.length) { return ( -
- Aucun événement sur cette période +
+ Aucun événement avec graphe causal pour cet instrument
) } @@ -701,7 +711,7 @@ function EventTimeline({ return PAD + ((new Date(d).getTime() - minTs) / (maxTs - minTs)) * usable } - const visible = events.filter(ev => ev.date >= minDate && ev.date <= maxDate) + const visible = linked.filter(ev => ev.date >= minDate && ev.date <= maxDate) const HALF_W = 48 const ROW_H = 28 @@ -735,7 +745,7 @@ function EventTimeline({ return (
onNavigate(ev.date)} + onClick={() => ev.id && navigate(`/market-events?event=${ev.id}`)} title={`${ev.title}\n${fmtDateFR(ev.date)}`} className="absolute flex flex-col items-center cursor-pointer group" style={{ left: x, top: row * ROW_H, transform: 'translateX(-50%)' }} @@ -761,28 +771,35 @@ function EventTimeline({ // ── EventGraphCards ─────────────────────────────────────────────────────────── function EventGraphCards({ - events, templates, selectedDate, instrumentCategory, + events, templates, selectedDate, causalInsts, }: { events: SnapshotEvent[] templates: CausalTemplate[] selectedDate: string | null - instrumentCategory: string + causalInsts: string[] }) { - const navigate = useNavigate() - const causalInsts = CAT_TO_CAUSAL_INST[instrumentCategory] ?? [] + const navigate = useNavigate() - const allCats = new Set( - events.map(ev => EVENT_TO_CAUSAL_CAT[ev.category]).filter(Boolean) - ) - const activeCats = new Set( - events.filter(ev => isActiveAt(ev, selectedDate)) - .map(ev => EVENT_TO_CAUSAL_CAT[ev.category]).filter(Boolean) - ) + // Events that have a causal graph template touching this instrument + const linkedEvents = events.filter(ev => { + if (!ev.template_id) return false + const tmpl = templates.find(t => t.id === ev.template_id) + return tmpl != null && causalInsts.some(ci => tmpl.instruments.includes(ci)) + }) - // Only show templates where the event is in the period AND template touches this instrument - const relevant = templates.filter(t => - allCats.has(t.category) && causalInsts.some(ci => t.instruments.includes(ci)) - ) + // Unique templates from those linked events + const seenIds = new Set() + const relevant: { tmpl: CausalTemplate; eventsForTmpl: SnapshotEvent[] }[] = [] + for (const ev of linkedEvents) { + if (!ev.template_id || seenIds.has(ev.template_id)) continue + const tmpl = templates.find(t => t.id === ev.template_id) + if (!tmpl) continue + seenIds.add(ev.template_id) + relevant.push({ + tmpl, + eventsForTmpl: linkedEvents.filter(e => e.template_id === ev.template_id), + }) + } if (!relevant.length) { return ( @@ -794,15 +811,15 @@ function EventGraphCards({ return (
- {relevant.map(t => { - const eventActive = activeCats.has(t.category) + {relevant.map(({ tmpl, eventsForTmpl }) => { + const active = eventsForTmpl.some(ev => isActiveAt(ev, selectedDate)) return (
navigate(`/causal-lab?template=${t.id}`)} + key={tmpl.id} + onClick={() => navigate(`/causal-lab?template=${tmpl.id}`)} className={clsx( 'rounded-lg border p-2.5 cursor-pointer transition-colors group', - eventActive + active ? 'border-emerald-600/50 bg-emerald-900/20 hover:bg-emerald-900/30' : 'border-slate-700/30 bg-dark-700/40 hover:bg-dark-700/70' )} @@ -810,32 +827,36 @@ function EventGraphCards({
- {t.name} + {tmpl.name} - {t.category} + {tmpl.category}
-
- {t.instruments.map(inst => ( - {inst} + {/* Event titles linked to this template */} +
+ {eventsForTmpl.slice(0, 2).map(ev => ( +
+ {fmtDateFR(ev.date)} · {ev.title.length > 28 ? ev.title.slice(0, 28) + '…' : ev.title} +
))} + {eventsForTmpl.length > 2 && ( +
+{eventsForTmpl.length - 2} événements
+ )}
- - {eventActive ? '● actif' : '○ période'} + + {active ? '● actif' : '○ période'} - +
) @@ -847,24 +868,25 @@ function EventGraphCards({ // ── ExplanationScore ────────────────────────────────────────────────────────── function ExplanationScore({ - events, templates, selectedDate, instrumentCategory, + events, templates, selectedDate, causalInsts, }: { events: SnapshotEvent[] templates: CausalTemplate[] selectedDate: string | null - instrumentCategory: string + causalInsts: string[] }) { - const causalInsts = CAT_TO_CAUSAL_INST[instrumentCategory] ?? [] - const allCats = new Set(events.map(ev => EVENT_TO_CAUSAL_CAT[ev.category]).filter(Boolean)) - const activeCats = new Set( - events.filter(ev => isActiveAt(ev, selectedDate)) - .map(ev => EVENT_TO_CAUSAL_CAT[ev.category]).filter(Boolean) + // Templates from events that have a causal graph touching this instrument + const linkedEvents = events.filter(ev => { + if (!ev.template_id) return false + const tmpl = templates.find(t => t.id === ev.template_id) + return tmpl != null && causalInsts.some(ci => tmpl.instruments.includes(ci)) + }) + const linkedTemplateIds = new Set(linkedEvents.map(ev => ev.template_id!)) + const periodTmpl = templates.filter(t => linkedTemplateIds.has(t.id)) + const touchedActive = periodTmpl.filter(t => + linkedEvents.filter(ev => ev.template_id === t.id).some(ev => isActiveAt(ev, selectedDate)) ) - const periodTmpl = templates.filter(t => allCats.has(t.category)) - const touchedActive = templates.filter(t => - activeCats.has(t.category) && causalInsts.some(ci => t.instruments.includes(ci)) - ) - const score = periodTmpl.length > 0 ? (touchedActive.length / periodTmpl.length) * 100 : 0 + const score = periodTmpl.length > 0 ? (touchedActive.length / periodTmpl.length) * 100 : 0 const verdict = score >= 60 ? 'Bien expliqué' : score >= 30 ? 'Partiellement' : 'Peu lisible' const badgeCls = score >= 60 ? 'text-emerald-400 bg-emerald-900/30 border-emerald-700/40' @@ -1188,20 +1210,23 @@ export default function InstrumentDashboard() { )} - {tabUnder === 'analyse' && ( + {tabUnder === 'analyse' && (() => { + const causalInsts = CAT_TO_CAUSAL_INST[selected?.category ?? ''] ?? [] + return (
- {/* Zone haute — frise des événements */} + {/* Zone haute — frise des événements (seulement ceux avec graphe causal) */}
Frise des événements - survol = détail · clic = timeline + survol = détail · clic = market event
navigate(`/timeline?date=${date}`)} + templates={templates} + causalInsts={causalInsts} />
@@ -1210,15 +1235,13 @@ export default function InstrumentDashboard() {
Graphes causaux - - vert = actif + touche l'instrument · ambre = actif · gris = période seulement - + vert = actif · clic = bibliothèque
@@ -1227,10 +1250,11 @@ export default function InstrumentDashboard() { events={snapshot.events} templates={templates} selectedDate={effectiveDate} - instrumentCategory={selected?.category ?? ''} + causalInsts={causalInsts} />
- )} + ) + })()} ('events') const [events, setEvents] = useState([]) const [total, setTotal] = useState(0) @@ -1216,6 +1218,14 @@ export default function MarketEvents() { debounceRef.current = setTimeout(load, 250) }, [load]) + // Deep-link: ?event=ID — select that event once list is loaded + useEffect(() => { + const eventId = searchParams.get('event') + if (!eventId || !events.length) return + const found = events.find(e => e.id === parseInt(eventId)) + if (found && (!selected || selected.id !== found.id)) setSelected(found) + }, [events, searchParams]) + const bulkEvaluate = async () => { if (bulkIds.size === 0) return setBulking(true)