feat: instrument analysis
This commit is contained in:
@@ -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 "",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user