feat: instrument analysis

This commit is contained in:
OpenSquared
2026-06-28 13:04:24 +02:00
parent 79d4a9f741
commit b94291623d
3 changed files with 274 additions and 32 deletions

View File

@@ -440,17 +440,15 @@ def patch_template(template_id: int, body: dict):
@router.delete("/api/causal-lab/template/{template_id}")
def delete_template(template_id: int):
"""Supprime un template utilisateur (les templates system sont protégés)."""
"""Supprime un template (tous les templates peuvent être supprimés)."""
try:
from services.database import get_conn
conn = get_conn()
row = conn.execute(
"SELECT created_by FROM causal_graph_templates WHERE id = ?", (template_id,)
"SELECT id FROM causal_graph_templates WHERE id = ?", (template_id,)
).fetchone()
if not row:
conn.close(); raise HTTPException(404, "Template introuvable")
if row["created_by"] == "system":
conn.close(); raise HTTPException(403, "Les templates système ne peuvent pas être supprimés")
conn.execute("DELETE FROM causal_graph_templates WHERE id = ?", (template_id,))
conn.commit(); conn.close()
return {"ok": True}

View File

@@ -510,13 +510,10 @@ function TabLibrary() {
v{selected.heuristic_ver}
</span>
<button
onClick={selected.created_by !== 'system' ? deleteTemplate : undefined}
disabled={deleting || selected.created_by === 'system'}
title={selected.created_by === 'system' ? 'Template système — non supprimable' : 'Supprimer ce template'}
className={clsx('p-1 rounded transition-colors',
selected.created_by === 'system'
? 'text-slate-700 cursor-not-allowed'
: 'text-slate-500 hover:bg-red-900/30 hover:text-red-400 disabled:opacity-40')}>
onClick={deleteTemplate}
disabled={deleting}
title="Supprimer ce template"
className="p-1 rounded transition-colors text-slate-500 hover:bg-red-900/30 hover:text-red-400 disabled:opacity-40">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
Sparkles, RefreshCw, ChevronDown, TrendingUp, TrendingDown,
@@ -126,6 +126,14 @@ function pctN(a: number | undefined, b: number | undefined): number {
return ((a - b) / b) * 100
}
function isActiveAt(ev: SnapshotEvent, selectedDate: string | null): boolean {
if (!selectedDate) return false
if (ev.date > selectedDate) return false
if (ev.end_date) return ev.end_date >= selectedDate
const diffDays = (new Date(selectedDate).getTime() - new Date(ev.date).getTime()) / 86400000
return diffDays <= 30
}
// ── Driver type config ────────────────────────────────────────────────────────
const DRIVER_TYPES = ['event_calendar', 'report', 'geopolitical', 'fundamental', 'sentiment', 'technical'] as const
@@ -745,6 +753,184 @@ function MacroGaugePanel({ snap, dateLabel }: { snap: MacroGaugeSnap; dateLabel:
)
}
// ── EventTimeline ─────────────────────────────────────────────────────────────
function EventTimeline({
events, priceData, selectedDate, onNavigate,
}: {
events: SnapshotEvent[]
priceData: PriceCandle[]
selectedDate: string | null
onNavigate: (date: string) => void
}) {
const containerRef = useRef<HTMLDivElement>(null)
const [contWidth, setContWidth] = useState(800)
useEffect(() => {
const el = containerRef.current; if (!el) return
const obs = new ResizeObserver(entries => setContWidth(entries[0].contentRect.width))
obs.observe(el)
return () => obs.disconnect()
}, [])
if (!priceData.length || !events.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>
)
}
const minDate = priceData[0].time
const maxDate = priceData[priceData.length - 1].time
const minTs = new Date(minDate).getTime()
const maxTs = new Date(maxDate).getTime()
const PAD = 24
const usable = Math.max(contWidth - PAD * 2, 1)
function tsToX(d: string): number {
return PAD + ((new Date(d).getTime() - minTs) / (maxTs - minTs)) * usable
}
const visible = events.filter(ev => ev.date >= minDate && ev.date <= maxDate)
const HALF_W = 48
const ROW_H = 28
const MAX_ROWS = 5
const rowEnds: number[] = new Array(MAX_ROWS).fill(-Infinity)
const placed = visible.map(ev => {
const x = tsToX(ev.date)
let row = 0
for (let r = 0; r < MAX_ROWS; r++) {
if (rowEnds[r] <= x - HALF_W) { row = r; break }
row = r
}
rowEnds[row] = x + HALF_W
return { ev, x, row }
})
const maxRow = placed.length ? Math.max(...placed.map(p => p.row)) : 0
const containerH = (maxRow + 1) * ROW_H + 28
const crossX = selectedDate && selectedDate >= minDate && selectedDate <= maxDate
? tsToX(selectedDate) : null
return (
<div ref={containerRef} className="relative w-full overflow-hidden select-none" style={{ height: containerH }}>
<div className="absolute bottom-7 left-0 right-0 h-px bg-slate-700/40" />
{crossX !== null && (
<div className="absolute top-0 bottom-0 w-px bg-blue-400/40 pointer-events-none" style={{ left: crossX }} />
)}
{placed.map(({ ev, x, row }) => {
const active = isActiveAt(ev, selectedDate)
return (
<div
key={`${ev.date}-${ev.title}`}
onClick={() => onNavigate(ev.date)}
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%)' }}
>
<span className={clsx('text-sm leading-none', active ? 'text-amber-400' : 'text-slate-600 group-hover:text-slate-400')}></span>
<span className={clsx(
'absolute top-full mt-0.5 text-[9px] whitespace-nowrap px-1 rounded bg-dark-800/90 border border-slate-700/40 opacity-0 group-hover:opacity-100 transition-opacity z-10 pointer-events-none',
active ? 'text-amber-300 border-amber-800/40' : 'text-slate-400'
)}>
{ev.title.length > 24 ? ev.title.slice(0, 24) + '…' : ev.title}
</span>
</div>
)
})}
<div className="absolute bottom-0 left-0 right-0 flex justify-between px-6">
<span className="text-[10px] text-slate-700">{fmtDateFR(minDate)}</span>
<span className="text-[10px] text-slate-700">{fmtDateFR(maxDate)}</span>
</div>
</div>
)
}
// ── DriversValidation ─────────────────────────────────────────────────────────
function DriversValidation({
drivers, events, selectedDate,
}: {
drivers: Driver[]
events: SnapshotEvent[]
selectedDate: string | null
}) {
if (!drivers.length) {
return <div className="text-xs text-slate-600 italic py-2">Aucun driver configuré</div>
}
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{drivers.map(d => {
const matches = events.filter(ev => ev.category === d.type || ev.category === d.key)
const active = matches.some(ev => isActiveAt(ev, selectedDate))
const typeCfg = DRIVER_TYPE_CFG[d.type ?? '']
return (
<div
key={d.key}
className={clsx(
'rounded-lg border p-2.5 transition-colors',
active ? 'border-emerald-600/50 bg-emerald-900/20' : 'border-slate-700/30 bg-dark-700/40'
)}
>
<div className="flex items-start justify-between gap-1 mb-1.5">
<span className={clsx('text-xs font-semibold leading-tight', active ? 'text-emerald-300' : 'text-slate-400')}>
{d.label}
</span>
{typeCfg && (
<span className={clsx('shrink-0 text-[10px] px-1 py-0.5 rounded border', typeCfg.tw)}>
{typeCfg.short}
</span>
)}
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] text-slate-600">poids {d.weight.toFixed(2)}</span>
<span className={clsx('w-2 h-2 rounded-full', active ? 'bg-emerald-400' : 'bg-slate-600')} />
</div>
</div>
)
})}
</div>
)
}
// ── ExplanationScore ──────────────────────────────────────────────────────────
function ExplanationScore({
drivers, events, selectedDate,
}: {
drivers: Driver[]
events: SnapshotEvent[]
selectedDate: string | null
}) {
const totalWeight = drivers.reduce((s, d) => s + (d.weight || 0), 0)
const activeWeight = drivers
.filter(d => events.filter(ev => ev.category === d.type || ev.category === d.key).some(ev => isActiveAt(ev, selectedDate)))
.reduce((s, d) => s + (d.weight || 0), 0)
const score = totalWeight > 0 ? (activeWeight / totalWeight) * 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'
: score >= 30
? 'text-amber-400 bg-amber-900/30 border-amber-700/40'
: 'text-slate-400 bg-slate-800/60 border-slate-700/30'
const barCls = score >= 60 ? 'bg-emerald-500' : score >= 30 ? 'bg-amber-500' : 'bg-slate-600'
return (
<div className="flex items-center gap-3 px-4 py-2.5 rounded-xl border border-slate-700/30 bg-dark-800/50">
<span className="text-xs text-slate-500 whitespace-nowrap">Note globale</span>
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full transition-all', barCls)} style={{ width: `${score}%` }} />
</div>
<span className={clsx('text-xs font-semibold px-2 py-1 rounded-lg border whitespace-nowrap', badgeCls)}>
{Math.round(score)}% {verdict}
</span>
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
const PERIODS = [
@@ -764,6 +950,7 @@ export default function InstrumentDashboard() {
const [selectorOpen, setSelectorOpen] = useState(false)
const [selectedDate, setSelectedDate] = useState<string | null>(null)
const [editDrivers, setEditDrivers] = useState(false)
const [tabUnder, setTabUnder] = useState<'counters' | 'analyse'>('counters')
const [localDrivers, setLocalDrivers] = useState<Driver[] | null>(null)
const [macroAtDate, setMacroAtDate] = useState<MacroGaugeSnap | null>(null)
@@ -995,7 +1182,7 @@ export default function InstrumentDashboard() {
<InstrumentChart
priceData={snapshot.price_data}
indicators={snapshot.indicators}
events={snapshot.events}
events={[]}
height={420}
onDateHover={handleDateHover}
/>
@@ -1014,27 +1201,87 @@ export default function InstrumentDashboard() {
</div>
</div>
{/* 3-column grid */}
<div className="grid grid-cols-3 gap-4">
<RegimeCard
regime={snapshot.regime}
macroRegime={macroAtDate ? snapToMacroRegime(macroAtDate) : (snapshot.macro_regime ?? null)}
signalsAt={dateSignals}
dateLabel={dateLabel}
/>
<TrendCard
trend={dateTrend ?? snapshot.trend}
dateLabel={dateLabel}
/>
<EventsCard
events={snapshot.events}
selectedDate={effectiveDate}
/>
{/* ── Tabs sous la courbe ── */}
<div className="border-b border-slate-700/40 flex gap-1">
{([
{ key: 'counters', label: 'Compteurs' },
{ key: 'analyse', label: 'Analyse de la courbe' },
] as const).map(t => (
<button
key={t.key}
onClick={() => setTabUnder(t.key)}
className={clsx(
'px-4 py-2 text-xs font-medium border-b-2 -mb-px transition-colors',
tabUnder === t.key
? 'text-blue-300 border-blue-500'
: 'text-slate-500 border-transparent hover:text-slate-300 hover:border-slate-600'
)}
>
{t.label}
</button>
))}
</div>
{/* Macro gauge detail panel — shown when on a historical date */}
{macroAtDate && (
<MacroGaugePanel snap={macroAtDate} dateLabel={dateLabel} />
{tabUnder === 'counters' && (
<>
<div className="grid grid-cols-3 gap-4">
<RegimeCard
regime={snapshot.regime}
macroRegime={macroAtDate ? snapToMacroRegime(macroAtDate) : (snapshot.macro_regime ?? null)}
signalsAt={dateSignals}
dateLabel={dateLabel}
/>
<TrendCard
trend={dateTrend ?? snapshot.trend}
dateLabel={dateLabel}
/>
<EventsCard
events={snapshot.events}
selectedDate={effectiveDate}
/>
</div>
{macroAtDate && <MacroGaugePanel snap={macroAtDate} dateLabel={dateLabel} />}
</>
)}
{tabUnder === 'analyse' && (
<div className="space-y-3">
{/* Zone haute — frise des événements */}
<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>
</div>
<EventTimeline
events={snapshot.events}
priceData={snapshot.price_data}
selectedDate={effectiveDate}
onNavigate={date => navigate(`/timeline?date=${date}`)}
/>
</div>
{/* Zone basse — validation des drivers */}
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
<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">Validation des drivers</span>
<span className="text-xs text-slate-600 ml-auto">{fmtDateFR(effectiveDate)}</span>
</div>
<DriversValidation
drivers={activeDrivers}
events={snapshot.events}
selectedDate={effectiveDate}
/>
</div>
{/* Note globale */}
<ExplanationScore
drivers={activeDrivers}
events={snapshot.events}
selectedDate={effectiveDate}
/>
</div>
)}
{editDrivers && (