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