feat: instrument analysis

This commit is contained in:
OpenSquared
2026-06-28 13:26:14 +02:00
parent b94291623d
commit 6d31ec973a

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
Sparkles, RefreshCw, ChevronDown, TrendingUp, TrendingDown,
Minus, BarChart2, Clock, Calendar, AlertCircle, Pencil, Save, X, Plus, Trash2,
Minus, BarChart2, Clock, Calendar, AlertCircle,
} from 'lucide-react'
import axios from 'axios'
import clsx from 'clsx'
@@ -12,13 +12,15 @@ const api = axios.create({ baseURL: '/api' })
// ── Types ─────────────────────────────────────────────────────────────────────
interface Driver {
key: string; label: string; weight: number; keywords: string[]; type?: string
interface CausalTemplate {
id: number; name: string; category: string; sub_type: string
instruments: string[]; description: string
graph_json: { nodes: { id: string; type: string; label: string; instrument?: string }[]; edges: any[] }
}
interface InstrumentConfig {
id: string; name: string; yf_ticker: string; category: string; currency: string
description: string; drivers: Driver[]
description: string
regime_labels: string[]
chart: { ma_periods: number[]; show_volume: boolean }
correlation_instruments: string[]
@@ -134,18 +136,41 @@ function isActiveAt(ev: SnapshotEvent, selectedDate: string | null): boolean {
return diffDays <= 30
}
// ── Driver type config ───────────────────────────────────────────────────────
// ── Causal graph config ───────────────────────────────────────────────────────
const DRIVER_TYPES = ['event_calendar', 'report', 'geopolitical', 'fundamental', 'sentiment', 'technical'] as const
type DriverType = typeof DRIVER_TYPES[number]
// Maps snapshot event category → causal template category
const EVENT_TO_CAUSAL_CAT: Record<string, string> = {
macro_us: 'macro_us',
macro_eu: 'macro_eu',
geopolitical: 'geopolitical',
report: 'report',
sentiment: 'sentiment',
commodity: 'commodity',
fundamental: 'macro_us',
event_calendar: 'macro_us',
}
const DRIVER_TYPE_CFG: Record<string, { short: string; tw: string }> = {
event_calendar: { short: 'Cal.', tw: 'text-amber-400 bg-amber-900/30 border-amber-700/30' },
report: { short: 'Rep.', tw: 'text-blue-400 bg-blue-900/30 border-blue-700/30' },
geopolitical: { short: 'Geo.', tw: 'text-red-400 bg-red-900/30 border-red-700/30' },
fundamental: { short: 'Fund.', tw: 'text-emerald-400 bg-emerald-900/30 border-emerald-700/30' },
sentiment: { short: 'Sent.', tw: 'text-violet-400 bg-violet-900/30 border-violet-700/30' },
technical: { short: 'Tech.', tw: 'text-cyan-400 bg-cyan-900/30 border-cyan-700/30' },
// Maps instrument dashboard category → causal lab instrument keys
const CAT_TO_CAUSAL_INST: Record<string, string[]> = {
equity_index: ['SP500'],
equity_intl: ['SP500'],
metal: ['XAUUSD'],
energy: ['BRENT'],
fx: ['EURUSD'],
bond: ['US10Y', 'EU10Y'],
credit: ['US10Y'],
volatility: ['SP500'],
stock: ['SP500'],
crypto: [],
}
const CAUSAL_CAT_TW: Record<string, string> = {
macro_us: 'text-blue-400 border-blue-700/30 bg-blue-900/20',
macro_eu: 'text-cyan-400 border-cyan-700/30 bg-cyan-900/20',
geopolitical: 'text-red-400 border-red-700/30 bg-red-900/20',
report: 'text-amber-400 border-amber-700/30 bg-amber-900/20',
sentiment: 'text-violet-400 border-violet-700/30 bg-violet-900/20',
commodity: 'text-orange-400 border-orange-700/30 bg-orange-900/20',
}
// ── Macro regime colour mapping ───────────────────────────────────────────────
@@ -525,122 +550,6 @@ function NarrativeCard({ narrative, loading, onLoad, instrument }: {
)
}
// ── DriversPanel — édition inline ────────────────────────────────────────────
function DriversPanel({ instrumentId, drivers, onSave, onClose }: {
instrumentId: string
drivers: Driver[]
onSave: (drivers: Driver[]) => void
onClose: () => void
}) {
const [local, setLocal] = useState<Driver[]>(() => drivers.map(d => ({ ...d, keywords: [...(d.keywords ?? [])] })))
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
function updateDriver(i: number, field: keyof Driver, val: any) {
setLocal(prev => prev.map((d, idx) => idx === i ? { ...d, [field]: val } : d))
}
function updateKeywords(i: number, raw: string) {
const kws = raw.split(',').map(s => s.trim()).filter(Boolean)
updateDriver(i, 'keywords', kws)
}
function addDriver() {
setLocal(prev => [...prev, {
key: `driver_${Date.now()}`, label: 'Nouveau driver',
weight: 0.5, keywords: [], type: 'fundamental',
}])
}
function removeDriver(i: number) {
setLocal(prev => prev.filter((_, idx) => idx !== i))
}
async function save() {
setSaving(true); setError('')
try {
await api.put(`/instruments/${instrumentId}/drivers`, { drivers: local })
onSave(local)
onClose()
} catch (e: any) {
setError(e?.response?.data?.detail ?? 'Erreur de sauvegarde')
} finally {
setSaving(false)
}
}
return (
<div className="rounded-xl border border-blue-700/40 bg-dark-800/80 p-4 space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-white">Éditer les drivers {instrumentId}</span>
<div className="flex items-center gap-2">
<button onClick={addDriver}
className="flex items-center gap-1 text-xs px-2.5 py-1 bg-blue-800/30 hover:bg-blue-800/50 text-blue-300 border border-blue-700/40 rounded-lg transition-colors">
<Plus className="w-3 h-3" /> Ajouter
</button>
<button onClick={save} disabled={saving}
className="flex items-center gap-1.5 text-xs px-3 py-1 bg-emerald-800/30 hover:bg-emerald-800/50 text-emerald-300 border border-emerald-700/40 rounded-lg transition-colors disabled:opacity-40">
{saving ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Save className="w-3 h-3" />}
{saving ? 'Sauvegarde...' : 'Sauvegarder'}
</button>
<button onClick={onClose} className="p-1 text-slate-500 hover:text-white transition-colors">
<X className="w-4 h-4" />
</button>
</div>
</div>
{error && <div className="text-xs text-red-400 bg-red-900/20 border border-red-700/30 rounded-lg px-3 py-1.5">{error}</div>}
<div className="space-y-2 max-h-[420px] overflow-y-auto pr-1">
{local.map((d, i) => (
<div key={i} className="rounded-lg border border-slate-700/30 bg-dark-700/40 p-3 space-y-2">
<div className="flex items-center gap-2">
<input
className="flex-1 text-xs bg-dark-600 border border-slate-700/40 rounded px-2 py-1 text-white placeholder-slate-600"
placeholder="Label"
value={d.label}
onChange={e => updateDriver(i, 'label', e.target.value)}
/>
{/* Type selector */}
<select
className="text-xs bg-dark-600 border border-slate-700/40 rounded px-1.5 py-1 text-slate-300"
value={d.type ?? 'fundamental'}
onChange={e => updateDriver(i, 'type', e.target.value)}
>
{DRIVER_TYPES.map(t => (
<option key={t} value={t}>{DRIVER_TYPE_CFG[t]?.short ?? t}</option>
))}
</select>
<div className="flex items-center gap-1.5">
<span className="text-xs text-slate-500">Poids</span>
<input
type="number" min="0" max="1" step="0.05"
className="w-16 text-xs bg-dark-600 border border-slate-700/40 rounded px-2 py-1 text-white"
value={d.weight}
onChange={e => updateDriver(i, 'weight', parseFloat(e.target.value))}
/>
</div>
<button onClick={() => removeDriver(i)} className="p-1 text-slate-600 hover:text-red-400 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div>
<label className="text-xs text-slate-600 mb-0.5 block">Mots-clés (séparés par virgule)</label>
<input
className="w-full text-xs bg-dark-600 border border-slate-700/40 rounded px-2 py-1 text-slate-300 placeholder-slate-600"
placeholder="Fed, FOMC, pivot, QE, taux directeur..."
value={(d.keywords ?? []).join(', ')}
onChange={e => updateKeywords(i, e.target.value)}
/>
</div>
</div>
))}
</div>
</div>
)
}
// ── Macro gauge helpers ───────────────────────────────────────────────────────
const SCENARIO_META: Record<string, { label: string; emoji: string; color: string }> = {
@@ -849,45 +758,89 @@ function EventTimeline({
)
}
// ── DriversValidation ─────────────────────────────────────────────────────────
// ── EventGraphCards ───────────────────────────────────────────────────────────
function DriversValidation({
drivers, events, selectedDate,
function EventGraphCards({
events, templates, selectedDate, instrumentCategory,
}: {
drivers: Driver[]
events: SnapshotEvent[]
templates: CausalTemplate[]
selectedDate: string | null
instrumentCategory: string
}) {
if (!drivers.length) {
return <div className="text-xs text-slate-600 italic py-2">Aucun driver configuré</div>
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)
)
const relevant = templates.filter(t => allCats.has(t.category))
if (!relevant.length) {
return (
<div className="text-xs text-slate-600 italic py-3">
Aucun graphe causal associé aux événements de cette période
</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 ?? '']
{relevant.map(t => {
const eventActive = activeCats.has(t.category)
const instrumentTouched = causalInsts.some(ci => t.instruments.includes(ci))
return (
<div
key={d.key}
key={t.id}
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'
eventActive && instrumentTouched
? 'border-emerald-600/50 bg-emerald-900/20'
: eventActive
? 'border-amber-600/40 bg-amber-900/10'
: '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 className={clsx(
'text-xs font-semibold leading-tight',
eventActive && instrumentTouched ? 'text-emerald-300'
: eventActive ? 'text-amber-300'
: 'text-slate-500'
)}>
{t.name}
</span>
{typeCfg && (
<span className={clsx('shrink-0 text-[10px] px-1 py-0.5 rounded border', typeCfg.tw)}>
{typeCfg.short}
</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'
)}>
{t.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>
))}
</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')} />
<span className={clsx('text-[9px]', eventActive ? 'text-amber-500' : 'text-slate-700')}>
{eventActive ? '● actif' : '○ période'}
</span>
<span className={clsx(
'w-2 h-2 rounded-full',
eventActive && instrumentTouched ? 'bg-emerald-400'
: eventActive ? 'bg-amber-500'
: 'bg-slate-700'
)} />
</div>
</div>
)
@@ -899,17 +852,24 @@ function DriversValidation({
// ── ExplanationScore ──────────────────────────────────────────────────────────
function ExplanationScore({
drivers, events, selectedDate,
events, templates, selectedDate, instrumentCategory,
}: {
drivers: Driver[]
events: SnapshotEvent[]
templates: CausalTemplate[]
selectedDate: string | null
instrumentCategory: string
}) {
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 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)
)
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 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'
@@ -927,6 +887,11 @@ function ExplanationScore({
<span className={clsx('text-xs font-semibold px-2 py-1 rounded-lg border whitespace-nowrap', badgeCls)}>
{Math.round(score)}% {verdict}
</span>
{periodTmpl.length > 0 && (
<span className="text-[10px] text-slate-600 whitespace-nowrap">
{touchedActive.length}/{periodTmpl.length} graphes
</span>
)}
</div>
)
}
@@ -949,15 +914,15 @@ export default function InstrumentDashboard() {
const [loadingNarr, setLoadingNarr] = useState(false)
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)
const [tabUnder, setTabUnder] = useState<'counters' | 'analyse'>('counters')
const [templates, setTemplates] = useState<CausalTemplate[]>([])
const [macroAtDate, setMacroAtDate] = useState<MacroGaugeSnap | null>(null)
const instrumentId = id.toUpperCase()
useEffect(() => {
api.get('/instruments').then(r => setInstruments(r.data)).catch(() => {})
api.get('/causal-lab/templates').then(r => setTemplates(r.data)).catch(() => {})
}, [])
useEffect(() => {
@@ -965,8 +930,6 @@ export default function InstrumentDashboard() {
setSnapshot(null)
setNarrative('')
setSelectedDate(null)
setLocalDrivers(null)
setEditDrivers(false)
setMacroAtDate(null)
api.get(`/instruments/${instrumentId}/snapshot?period=${period}`)
.then(r => {
@@ -1075,8 +1038,6 @@ export default function InstrumentDashboard() {
const dateLabel = effectiveDate ? fmtDateFR(effectiveDate) : '—'
const displayPrice = dateTrend?.current_price ?? snapshot?.current_price
const activeDrivers = localDrivers ?? snapshot?.instrument?.drivers ?? []
return (
<div className="p-6 max-w-screen-xl mx-auto space-y-4">
@@ -1137,18 +1098,6 @@ export default function InstrumentDashboard() {
)}
<div className="ml-auto flex items-center gap-2">
{snapshot && (
<button onClick={() => setEditDrivers(e => !e)}
className={clsx('flex items-center gap-1.5 text-xs px-3 py-1.5 border rounded-lg transition-colors',
editDrivers
? 'bg-blue-800/40 text-blue-300 border-blue-700/40'
: 'bg-dark-800 text-slate-400 border-slate-700/40 hover:text-white hover:bg-dark-700'
)}
>
<Pencil className="w-3 h-3" />
Drivers
</button>
)}
<div className="flex items-center gap-1 bg-dark-800 border border-slate-700/40 rounded-xl p-1">
{PERIODS.map(p => (
<button key={p.key} onClick={() => setPeriod(p.key)}
@@ -1261,38 +1210,33 @@ export default function InstrumentDashboard() {
/>
</div>
{/* Zone basse — validation des drivers */}
{/* Zone basse — graphes causaux liés aux é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">
<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>
<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>
</div>
<DriversValidation
drivers={activeDrivers}
<EventGraphCards
events={snapshot.events}
templates={templates}
selectedDate={effectiveDate}
instrumentCategory={selected?.category ?? ''}
/>
</div>
{/* Note globale */}
<ExplanationScore
drivers={activeDrivers}
events={snapshot.events}
templates={templates}
selectedDate={effectiveDate}
instrumentCategory={selected?.category ?? ''}
/>
</div>
)}
{editDrivers && (
<DriversPanel
instrumentId={instrumentId}
drivers={activeDrivers}
onSave={saved => { setLocalDrivers(saved); setEditDrivers(false) }}
onClose={() => setEditDrivers(false)}
/>
)}
<NarrativeCard
narrative={narrative}
loading={loadingNarr}