feat: instrument analysis

This commit is contained in:
OpenSquared
2026-06-28 13:52:34 +02:00
parent b5d4eb71e7
commit 20f1897211
3 changed files with 111 additions and 66 deletions

View File

@@ -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 "",

View File

@@ -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<HTMLDivElement>(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 (
<div ref={containerRef} className="h-20 flex items-center justify-center text-xs text-slate-600 italic">
Aucun événement sur cette période
<div ref={containerRef} className="h-16 flex items-center justify-center text-xs text-slate-600 italic">
Aucun événement avec graphe causal pour cet instrument
</div>
)
}
@@ -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 (
<div
key={`${ev.date}-${ev.title}`}
onClick={() => 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<number>()
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 (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{relevant.map(t => {
const eventActive = activeCats.has(t.category)
{relevant.map(({ tmpl, eventsForTmpl }) => {
const active = eventsForTmpl.some(ev => isActiveAt(ev, selectedDate))
return (
<div
key={t.id}
onClick={() => 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({
<div className="flex items-start justify-between gap-1 mb-1.5">
<span className={clsx(
'text-xs font-semibold leading-tight group-hover:underline',
eventActive ? 'text-emerald-300' : 'text-slate-500'
active ? 'text-emerald-300' : 'text-slate-500'
)}>
{t.name}
{tmpl.name}
</span>
<span className={clsx(
'shrink-0 text-[9px] px-1 py-0.5 rounded border',
CAUSAL_CAT_TW[t.category] ?? 'text-slate-500 border-slate-700/30'
CAUSAL_CAT_TW[tmpl.category] ?? 'text-slate-500 border-slate-700/30'
)}>
{t.category}
{tmpl.category}
</span>
</div>
<div className="flex flex-wrap gap-1 mt-1 mb-1.5">
{t.instruments.map(inst => (
<span key={inst} className={clsx(
'text-[9px] px-1 py-0 rounded border',
causalInsts.includes(inst)
? 'text-emerald-400 bg-emerald-900/30 border-emerald-700/40'
: 'text-slate-600 border-slate-700/30'
)}>{inst}</span>
{/* Event titles linked to this template */}
<div className="space-y-0.5 mb-1.5">
{eventsForTmpl.slice(0, 2).map(ev => (
<div key={ev.id} className={clsx(
'text-[9px] truncate',
isActiveAt(ev, selectedDate) ? 'text-amber-400' : 'text-slate-600'
)}>
{fmtDateFR(ev.date)} · {ev.title.length > 28 ? ev.title.slice(0, 28) + '…' : ev.title}
</div>
))}
{eventsForTmpl.length > 2 && (
<div className="text-[9px] text-slate-700">+{eventsForTmpl.length - 2} événements</div>
)}
</div>
<div className="flex items-center justify-between">
<span className={clsx('text-[9px]', eventActive ? 'text-emerald-500' : 'text-slate-700')}>
{eventActive ? '● actif' : '○ période'}
<span className={clsx('text-[9px]', active ? 'text-emerald-500' : 'text-slate-700')}>
{active ? '● actif' : '○ période'}
</span>
<span className={clsx('w-2 h-2 rounded-full', eventActive ? 'bg-emerald-400' : 'bg-slate-700')} />
<span className={clsx('w-2 h-2 rounded-full', active ? 'bg-emerald-400' : 'bg-slate-700')} />
</div>
</div>
)
@@ -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 (
<div className="space-y-3">
{/* Zone haute — frise des événements */}
{/* Zone haute — frise des événements (seulement ceux avec graphe causal) */}
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
<div className="flex items-center gap-2 mb-3">
<Calendar className="w-4 h-4 text-amber-400" />
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Frise des événements</span>
<span className="text-xs text-slate-600 ml-auto">survol = détail · clic = timeline</span>
<span className="text-xs text-slate-600 ml-auto">survol = détail · clic = market event</span>
</div>
<EventTimeline
events={snapshot.events}
priceData={snapshot.price_data}
selectedDate={effectiveDate}
onNavigate={date => navigate(`/timeline?date=${date}`)}
templates={templates}
causalInsts={causalInsts}
/>
</div>
@@ -1210,15 +1235,13 @@ export default function InstrumentDashboard() {
<div className="flex items-center gap-2 mb-3">
<BarChart2 className="w-4 h-4 text-violet-400" />
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Graphes causaux</span>
<span className="text-xs text-slate-600 ml-auto">
vert = actif + touche l'instrument · ambre = actif · gris = période seulement
</span>
<span className="text-xs text-slate-600 ml-auto">vert = actif · clic = bibliothèque</span>
</div>
<EventGraphCards
events={snapshot.events}
templates={templates}
selectedDate={effectiveDate}
instrumentCategory={selected?.category ?? ''}
causalInsts={causalInsts}
/>
</div>
@@ -1227,10 +1250,11 @@ export default function InstrumentDashboard() {
events={snapshot.events}
templates={templates}
selectedDate={effectiveDate}
instrumentCategory={selected?.category ?? ''}
causalInsts={causalInsts}
/>
</div>
)}
)
})()}
<NarrativeCard
narrative={narrative}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useSearchParams } from 'react-router-dom'
import {
Search, RefreshCw, Plus, Trash2, Edit3, Sparkles, ExternalLink,
ChevronDown, ChevronUp, Loader2, CheckCircle, AlertCircle, X,
@@ -1117,6 +1118,7 @@ function CategoriesPanel() {
// ── Main page ─────────────────────────────────────────────────────────────────
export default function MarketEvents() {
const [searchParams] = useSearchParams()
const [activeTab, setActiveTab] = useState<'events' | 'categories'>('events')
const [events, setEvents] = useState<MarketEvent[]>([])
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)