1646 lines
80 KiB
TypeScript
1646 lines
80 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react'
|
||
import { useSearchParams, useNavigate } from 'react-router-dom'
|
||
import {
|
||
Search, RefreshCw, Plus, Trash2, Edit3, Sparkles, ExternalLink,
|
||
ChevronDown, ChevronUp, Loader2, CheckCircle, AlertCircle, X,
|
||
SlidersHorizontal, BarChart2, Star, Users
|
||
} from 'lucide-react'
|
||
import clsx from 'clsx'
|
||
|
||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||
|
||
interface SourceRef {
|
||
title: string
|
||
source: string
|
||
url: string
|
||
date: string
|
||
original_score: number
|
||
}
|
||
|
||
interface InstrumentImpact {
|
||
id: number
|
||
instrument_id: string
|
||
impact_score: number
|
||
direction: 'bullish' | 'bearish' | 'neutral' | 'depends_on_outcome'
|
||
rationale: string
|
||
confidence: number
|
||
ai_generated: number
|
||
manually_adjusted: number
|
||
adjusted_score: number | null
|
||
adjusted_direction: string | null
|
||
override_rationale: string
|
||
}
|
||
|
||
interface MarketEvent {
|
||
id: number
|
||
name: string
|
||
start_date: string
|
||
end_date: string | null
|
||
level: 'long' | 'medium' | 'short'
|
||
category: string
|
||
sub_type: string
|
||
description: string
|
||
market_impact: string
|
||
affected_assets: string[]
|
||
impact_score: number
|
||
absorption_pct: number | null
|
||
source_refs: SourceRef[]
|
||
origin?: string
|
||
created_at?: string
|
||
impacts?: InstrumentImpact[]
|
||
evaluated?: boolean
|
||
}
|
||
|
||
interface CausalAnalysis {
|
||
id: number
|
||
market_event_id: number
|
||
template_id: number
|
||
template_name: string
|
||
instrument: string
|
||
activation_score: number | null
|
||
analyzed_at: string
|
||
inputs_json: Record<string, number>
|
||
prediction_json: Record<string, number>
|
||
actual_json: Record<string, number>
|
||
drift_json?: Record<string, any>
|
||
graph_json?: { nodes: GraphNode[]; edges: GraphEdge[] }
|
||
}
|
||
|
||
interface TemplateRef {
|
||
id: number
|
||
name: string
|
||
category: string
|
||
instruments: string[]
|
||
}
|
||
|
||
interface GraphNode { id: string; label: string; type: string; x: number; y: number; unit?: string; instrument?: string }
|
||
interface GraphEdge { from: string; to: string; style?: string; sign?: string }
|
||
interface GraphData { nodes: GraphNode[]; edges: GraphEdge[] }
|
||
|
||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||
|
||
const LEVEL_COLORS: Record<string, string> = {
|
||
long: 'text-purple-400',
|
||
medium: 'text-amber-400',
|
||
short: 'text-emerald-400',
|
||
}
|
||
const ORIGIN_META: Record<string, { label: string; icon: string; color: string; description: string }> = {
|
||
bootstrap_macro: { label: 'Bootstrap macro', icon: '📚', color: 'text-slate-400', description: 'Données historiques macro/géopolitiques chargées au démarrage' },
|
||
bootstrap_eco: { label: 'Bootstrap éco', icon: '📅', color: 'text-amber-400', description: 'Calendrier économique FRED historique (FOMC, CPI, NFP…)' },
|
||
bootstrap_ma: { label: 'Bootstrap MA', icon: '📈', color: 'text-cyan-400', description: 'Signal technique MA détecté sur données historiques (yfinance)' },
|
||
bootstrap_legacy: { label: 'Bootstrap legacy', icon: '🗄️', color: 'text-slate-500', description: 'Données initiales (bootstrap, origine exacte inconnue)' },
|
||
detector_news: { label: 'News RSS', icon: '📰', color: 'text-red-400', description: 'Généré depuis un flux RSS — validé par IA (GPT-4o-mini)' },
|
||
detector_eco: { label: 'Surprise FRED', icon: '📊', color: 'text-amber-400', description: 'Surprise économique FRED détectée par le cycle (z-score > seuil)' },
|
||
detector_technical: { label: 'Signal technique', icon: '📐', color: 'text-cyan-400', description: 'Croisement MA50/MA100/MA200 détecté automatiquement (yfinance)' },
|
||
detector_report: { label: 'Rapport instit.', icon: '📋', color: 'text-blue-400', description: 'Rapport institutionnel haute importance (COT, EIA, Fed…)' },
|
||
manual: { label: 'Manuel', icon: '✏️', color: 'text-emerald-400', description: 'Créé manuellement dans l\'interface' },
|
||
unknown: { label: 'Inconnu', icon: '❓', color: 'text-slate-600', description: 'Origine non tracée (données antérieures au versioning)' },
|
||
}
|
||
|
||
const DIR_COLORS: Record<string, string> = {
|
||
bullish: 'text-emerald-400',
|
||
bearish: 'text-red-400',
|
||
neutral: 'text-slate-400',
|
||
depends_on_outcome: 'text-amber-400',
|
||
}
|
||
const DIR_ICONS: Record<string, string> = {
|
||
bullish: '▲', bearish: '▼', neutral: '●', depends_on_outcome: '◆',
|
||
}
|
||
|
||
const LEVELS = ['long', 'medium', 'short']
|
||
const ALL_INSTRUMENTS = [
|
||
'SPY','QQQ','IWM','EEM','EFA','GLD','SLV','USO','UNG','TLT',
|
||
'HYG','EURUSD=X','USDJPY=X','GBPUSD=X','VXX','AAPL','NVDA','GS','XOM','BTC-USD',
|
||
]
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
function ScoreBar({ score, max = 1 }: { score: number; max?: number }) {
|
||
const pct = Math.round((score / max) * 100)
|
||
const color = score >= 0.7 ? 'bg-red-500' : score >= 0.5 ? 'bg-amber-500' : 'bg-emerald-500'
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
|
||
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
|
||
</div>
|
||
<span className="text-xs text-slate-400 w-8 text-right">{score.toFixed(2)}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Instrument impact row (inline editable) ───────────────────────────────────
|
||
|
||
function ImpactRow({
|
||
imp, eventId, onUpdated, onDeleted,
|
||
}: {
|
||
imp: InstrumentImpact
|
||
eventId: number
|
||
onUpdated: () => void
|
||
onDeleted: () => void
|
||
}) {
|
||
const [editing, setEditing] = useState(false)
|
||
const [score, setScore] = useState(String(imp.adjusted_score ?? imp.impact_score))
|
||
const [dir, setDir] = useState(imp.adjusted_direction || imp.direction)
|
||
const [note, setNote] = useState(imp.override_rationale || '')
|
||
const [saving, setSaving] = useState(false)
|
||
|
||
const effectiveScore = imp.adjusted_score ?? imp.impact_score
|
||
const effectiveDir = imp.adjusted_direction || imp.direction
|
||
|
||
const save = async () => {
|
||
setSaving(true)
|
||
await fetch(`/api/market-events/${eventId}/impacts/${imp.id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ adjusted_score: +score, adjusted_direction: dir, override_rationale: note }),
|
||
})
|
||
setSaving(false)
|
||
setEditing(false)
|
||
onUpdated()
|
||
}
|
||
|
||
const del = async () => {
|
||
if (!confirm(`Supprimer l'impact sur ${imp.instrument_id} ?`)) return
|
||
await fetch(`/api/market-events/${eventId}/impacts/${imp.id}`, { method: 'DELETE' })
|
||
onDeleted()
|
||
}
|
||
|
||
return (
|
||
<div className="border-b border-slate-800/60 last:border-0">
|
||
<div className="flex items-center gap-3 py-2 px-3 hover:bg-slate-800/20">
|
||
{/* Ticker */}
|
||
<span className="font-mono text-sm text-slate-200 w-24 shrink-0">{imp.instrument_id}</span>
|
||
{/* Direction */}
|
||
<span className={clsx('text-sm font-bold w-6 shrink-0', DIR_COLORS[effectiveDir])}>
|
||
{DIR_ICONS[effectiveDir] ?? '●'}
|
||
</span>
|
||
{/* Score bar */}
|
||
<div className="w-28 shrink-0">
|
||
<ScoreBar score={effectiveScore} />
|
||
</div>
|
||
{/* Confidence */}
|
||
<span className="text-xs text-slate-500 w-10 shrink-0">{Math.round(imp.confidence * 100)}%</span>
|
||
{/* Rationale */}
|
||
<span className="text-xs text-slate-400 flex-1 min-w-0 truncate">{imp.rationale}</span>
|
||
{/* AI badge */}
|
||
{imp.ai_generated ? (
|
||
<span className="text-xs text-blue-400 shrink-0">AI</span>
|
||
) : (
|
||
<span className="text-xs text-slate-600 shrink-0">manual</span>
|
||
)}
|
||
{imp.manually_adjusted ? <span className="text-xs text-amber-400 shrink-0">adj.</span> : null}
|
||
{/* Actions */}
|
||
<button onClick={() => setEditing(x => !x)} className="text-slate-500 hover:text-slate-300 shrink-0"><Edit3 size={12} /></button>
|
||
<button onClick={del} className="text-slate-600 hover:text-red-400 shrink-0"><Trash2 size={12} /></button>
|
||
</div>
|
||
{editing && (
|
||
<div className="px-3 pb-3 grid grid-cols-3 gap-2 bg-slate-800/30">
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-xs text-slate-500">Score ajusté</span>
|
||
<input type="range" min="0" max="1" step="0.05"
|
||
value={score} onChange={e => setScore(e.target.value)}
|
||
className="w-full accent-amber-400" />
|
||
<span className="text-xs text-center text-amber-300">{(+score).toFixed(2)}</span>
|
||
</label>
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-xs text-slate-500">Direction</span>
|
||
<select value={dir} onChange={e => setDir(e.target.value)}
|
||
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm">
|
||
{['bullish','bearish','neutral','depends_on_outcome'].map(d => (
|
||
<option key={d} value={d}>{d}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-xs text-slate-500">Note</span>
|
||
<input value={note} onChange={e => setNote(e.target.value)}
|
||
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm"
|
||
placeholder="Raison de l'ajustement..." />
|
||
</label>
|
||
<div className="col-span-3 flex gap-2 mt-1">
|
||
<button onClick={save} disabled={saving}
|
||
className="flex items-center gap-1 bg-emerald-700 hover:bg-emerald-600 text-white text-xs rounded px-3 py-1">
|
||
{saving ? <Loader2 size={11} className="animate-spin" /> : <CheckCircle size={11} />}
|
||
Sauvegarder
|
||
</button>
|
||
<button onClick={() => setEditing(false)} className="text-xs text-slate-500 hover:text-slate-300 px-2">Annuler</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Add instrument panel ──────────────────────────────────────────────────────
|
||
|
||
function AddInstrumentPanel({ eventId, onAdded }: { eventId: number; onAdded: () => void }) {
|
||
const [open, setOpen] = useState(false)
|
||
const [ticker, setTicker] = useState('')
|
||
const [score, setScore] = useState(0.5)
|
||
const [dir, setDir] = useState('neutral')
|
||
const [rationale, setRat] = useState('')
|
||
const [saving, setSaving] = useState(false)
|
||
|
||
const submit = async () => {
|
||
if (!ticker) return
|
||
setSaving(true)
|
||
await fetch(`/api/market-events/${eventId}/impacts`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ instrument_id: ticker, impact_score: score, direction: dir, rationale, confidence: 0.8 }),
|
||
})
|
||
setSaving(false)
|
||
setOpen(false)
|
||
setTicker('')
|
||
setRat('')
|
||
onAdded()
|
||
}
|
||
|
||
if (!open) {
|
||
return (
|
||
<button onClick={() => setOpen(true)}
|
||
className="flex items-center gap-1.5 text-xs text-slate-500 hover:text-emerald-400 py-2 px-3 border-t border-slate-800/60">
|
||
<Plus size={12} /> Ajouter instrument
|
||
</button>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="border-t border-slate-800/60 px-3 py-3 bg-slate-800/30 grid grid-cols-4 gap-2">
|
||
<select value={ticker} onChange={e => setTicker(e.target.value)}
|
||
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm col-span-1">
|
||
<option value="">Ticker...</option>
|
||
{ALL_INSTRUMENTS.map(t => <option key={t} value={t}>{t}</option>)}
|
||
</select>
|
||
<select value={dir} onChange={e => setDir(e.target.value)}
|
||
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm">
|
||
{['bullish','bearish','neutral','depends_on_outcome'].map(d => <option key={d} value={d}>{d}</option>)}
|
||
</select>
|
||
<div className="flex flex-col gap-0.5">
|
||
<input type="range" min="0" max="1" step="0.05" value={score}
|
||
onChange={e => setScore(+e.target.value)} className="accent-emerald-400" />
|
||
<span className="text-xs text-center text-emerald-300">{score.toFixed(2)}</span>
|
||
</div>
|
||
<input value={rationale} onChange={e => setRat(e.target.value)} placeholder="Rationale..."
|
||
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm" />
|
||
<div className="col-span-4 flex gap-2">
|
||
<button onClick={submit} disabled={saving || !ticker}
|
||
className="flex items-center gap-1 bg-emerald-700 hover:bg-emerald-600 disabled:bg-slate-700 disabled:text-slate-500 text-white text-xs rounded px-3 py-1">
|
||
{saving ? <Loader2 size={11} className="animate-spin" /> : <Plus size={11} />} Ajouter
|
||
</button>
|
||
<button onClick={() => setOpen(false)} className="text-xs text-slate-500 hover:text-slate-300 px-2">Annuler</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── CausalGraphMini — SVG inline du graphe instancié ─────────────────────────
|
||
|
||
function CausalGraphMini({ nodes, edges, nodeValues }: {
|
||
nodes: GraphNode[]
|
||
edges: GraphEdge[]
|
||
nodeValues: Record<string, number>
|
||
}) {
|
||
if (!nodes.length) return null
|
||
const xs = nodes.map(n => n.x)
|
||
const ys = nodes.map(n => n.y)
|
||
const minX = Math.min(...xs) - 70
|
||
const maxX = Math.max(...xs) + 70
|
||
const minY = Math.min(...ys) - 30
|
||
const maxY = Math.max(...ys) + 30
|
||
const vw = Math.max(maxX - minX, 200)
|
||
const vh = Math.max(maxY - minY, 100)
|
||
const nodeMap = Object.fromEntries(nodes.map(n => [n.id, n]))
|
||
const BOX_W = 90, BOX_H = 36, SHORTEN = 21
|
||
|
||
return (
|
||
<svg viewBox={`${minX} ${minY} ${vw} ${vh}`} className="w-full" style={{ maxHeight: 400, minHeight: 200 }}>
|
||
<defs>
|
||
{(['positive', 'negative', 'neutral'] as const).map(s => (
|
||
<marker key={s} id={`cgm-arr-${s}`} markerWidth={7} markerHeight={7} refX={6} refY={3} orient="auto">
|
||
<path d="M0,0 L0,6 L7,3 z" fill={s === 'positive' ? '#14b8a6' : s === 'negative' ? '#f97316' : '#64748b'} />
|
||
</marker>
|
||
))}
|
||
</defs>
|
||
|
||
{edges.map((e, i) => {
|
||
const from = nodeMap[e.from], to = nodeMap[e.to]
|
||
if (!from || !to) return null
|
||
const dx = to.x - from.x, dy = to.y - from.y
|
||
const len = Math.sqrt(dx * dx + dy * dy) || 1
|
||
const sign = e.sign || 'neutral'
|
||
const color = sign === 'positive' ? '#14b8a6' : sign === 'negative' ? '#f97316' : '#64748b'
|
||
return (
|
||
<line key={i}
|
||
x1={from.x + (dx / len) * SHORTEN} y1={from.y + (dy / len) * SHORTEN}
|
||
x2={to.x - (dx / len) * (SHORTEN + 5)} y2={to.y - (dy / len) * (SHORTEN + 5)}
|
||
stroke={color} strokeWidth={1.5}
|
||
strokeDasharray={e.style === 'dashed' ? '5,3' : undefined}
|
||
markerEnd={`url(#cgm-arr-${sign})`} />
|
||
)
|
||
})}
|
||
|
||
{nodes.map(n => {
|
||
const val = nodeValues[n.id]
|
||
const isOut = n.type === 'market_asset' || n.type === 'output'
|
||
const isIn = n.type === 'input'
|
||
const fill = isOut ? (val > 0 ? '#052e16' : val < 0 ? '#450a0a' : '#1e293b') : '#0f172a'
|
||
const stroke = isIn ? '#3b82f6'
|
||
: isOut ? (val > 0 ? '#16a34a' : val < 0 ? '#dc2626' : '#64748b') : '#475569'
|
||
const valColor = val > 0 ? '#4ade80' : val < 0 ? '#f87171' : '#94a3b8'
|
||
const lbl = n.label.length > 14 ? n.label.slice(0, 13) + '…' : n.label
|
||
const valStr = val !== undefined
|
||
? `${val > 0 ? '+' : ''}${val.toFixed(n.unit === 'pips' ? 0 : 1)}${n.unit === 'pips' ? 'p' : n.unit === '%' ? '%' : ''}`
|
||
: '—'
|
||
return (
|
||
<g key={n.id} transform={`translate(${n.x},${n.y})`}>
|
||
<rect x={-BOX_W/2} y={-BOX_H/2} width={BOX_W} height={BOX_H} rx={4}
|
||
fill={fill} stroke={stroke} strokeWidth={isOut ? 2 : 1} />
|
||
<text y={-7} textAnchor="middle" fill="#94a3b8" fontSize={8} fontFamily="ui-monospace,monospace">{lbl}</text>
|
||
<text y={9} textAnchor="middle" fill={val !== undefined ? valColor : '#475569'}
|
||
fontSize={10} fontWeight="bold" fontFamily="ui-monospace,monospace">{valStr}</text>
|
||
</g>
|
||
)
|
||
})}
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
|
||
// ── Event detail panel (right side) ──────────────────────────────────────────
|
||
|
||
function EventDetail({
|
||
event, onClose, onUpdated, onDeleted,
|
||
}: {
|
||
event: MarketEvent
|
||
onClose: () => void
|
||
onUpdated: (ev: MarketEvent) => void
|
||
onDeleted: (id: number) => void
|
||
}) {
|
||
const navigate = useNavigate()
|
||
const [detail, setDetail] = useState<MarketEvent>(event)
|
||
const [editing, setEditing] = useState(false)
|
||
const [editData, setEditData] = useState<Partial<MarketEvent>>({})
|
||
const [saving, setSaving] = useState(false)
|
||
const [analyses, setAnalyses] = useState<CausalAnalysis[]>([])
|
||
// Inline analysis
|
||
const [templates, setTemplates] = useState<TemplateRef[]>([])
|
||
const [selTmpl, setSelTmpl] = useState(0)
|
||
const [loadingRec, setLoadingRec] = useState(false)
|
||
const [loadingInst, setLoadingInst] = useState(false)
|
||
const [loadingAn, setLoadingAn] = useState(false)
|
||
const [loadingCreate, setLoadingCreate] = useState(false)
|
||
const [recMsg, setRecMsg] = useState<string | null>(null)
|
||
const [noTemplateFound, setNoTemplateFound] = useState(false)
|
||
const [instInputs, setInstInputs] = useState<Record<string, number>>({})
|
||
const [instMsg, setInstMsg] = useState<string | null>(null)
|
||
const [anResult, setAnResult] = useState<{
|
||
score: number | null
|
||
preds: Record<string, number>
|
||
actuals: Record<string, number>
|
||
drift?: Record<string, { pre_pips: number | null; post_pips: number | null; lag_min: number; lag_days: number }>
|
||
pricesMode?: string
|
||
lagMin?: number
|
||
lagDays?: number
|
||
lagDebug?: { output_ids: string[]; edges_to_output: number; edges_with_lag_days: number; used_all_edges: boolean }
|
||
} | null>(null)
|
||
const [graphData, setGraphData] = useState<GraphData | null>(null)
|
||
|
||
const loadDetail = useCallback(async () => {
|
||
const r = await fetch(`/api/market-events/${event.id}`)
|
||
if (r.ok) {
|
||
const d = await r.json()
|
||
setDetail(d)
|
||
}
|
||
}, [event.id])
|
||
|
||
const loadAnalyses = useCallback(async () => {
|
||
const r = await fetch(`/api/causal-lab/analyses?market_event_id=${event.id}&limit=20`)
|
||
if (r.ok) {
|
||
const data: CausalAnalysis[] = await r.json()
|
||
setAnalyses(data)
|
||
if (data.length > 0) {
|
||
const latest = data[0]
|
||
if (latest.template_id) setSelTmpl(latest.template_id)
|
||
if (Object.keys(latest.inputs_json || {}).length > 0) setInstInputs(latest.inputs_json)
|
||
const driftRaw = latest.drift_json || {}
|
||
// drift_json peut être { EURUSD: {...}, XAUUSD: {...} } (nouveau) ou { post_pips: ..., ... } (ancien)
|
||
const isDriftByInst = driftRaw && !('post_pips' in driftRaw) && Object.keys(driftRaw).length > 0
|
||
const driftByInst = isDriftByInst
|
||
? driftRaw
|
||
: latest.instrument && Object.keys(driftRaw).length > 0
|
||
? { [latest.instrument]: driftRaw }
|
||
: undefined
|
||
setAnResult({
|
||
score: latest.activation_score,
|
||
preds: latest.prediction_json || {},
|
||
actuals: latest.actual_json || {},
|
||
drift: driftByInst,
|
||
pricesMode: undefined,
|
||
lagMin: undefined,
|
||
lagDays: undefined,
|
||
lagDebug: undefined,
|
||
})
|
||
if (latest.graph_json?.nodes?.length) {
|
||
setGraphData(latest.graph_json as GraphData)
|
||
}
|
||
}
|
||
}
|
||
}, [event.id])
|
||
|
||
useEffect(() => {
|
||
loadDetail()
|
||
loadAnalyses()
|
||
fetch('/api/causal-lab/templates').then(r => r.json()).then(d => setTemplates(Array.isArray(d) ? d : [])).catch(() => {})
|
||
}, [loadDetail, loadAnalyses])
|
||
|
||
const saveEdit = async () => {
|
||
setSaving(true)
|
||
const payload = { ...detail, ...editData, source_refs: detail.source_refs ?? [] }
|
||
const r = await fetch(`/api/market-events/${event.id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
})
|
||
setSaving(false)
|
||
if (r.ok) {
|
||
setEditing(false)
|
||
await loadDetail()
|
||
onUpdated({ ...detail, ...editData } as MarketEvent)
|
||
}
|
||
}
|
||
|
||
const del = async () => {
|
||
if (!confirm(`Supprimer "${detail.name}" ?`)) return
|
||
await fetch(`/api/market-events/${event.id}`, { method: 'DELETE' })
|
||
onDeleted(event.id)
|
||
onClose()
|
||
}
|
||
|
||
const instantiate = async (tmplId: number) => {
|
||
if (!tmplId) return
|
||
setLoadingInst(true); setInstMsg(null); setInstInputs({})
|
||
try {
|
||
const r = await fetch('/api/causal-lab/instantiate', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ market_event_id: detail.id, template_id: tmplId }),
|
||
})
|
||
const d = await r.json()
|
||
if (!r.ok) throw new Error(d.detail || r.statusText)
|
||
setInstInputs(d.inputs || {})
|
||
setInstMsg(d.rationale || null)
|
||
} catch (e: any) { setInstMsg(`✗ ${(e as any).message}`) }
|
||
finally { setLoadingInst(false) }
|
||
}
|
||
|
||
const recommendTemplate = async () => {
|
||
setLoadingRec(true); setRecMsg(null); setAnResult(null); setGraphData(null)
|
||
setInstInputs({}); setInstMsg(null); setNoTemplateFound(false)
|
||
try {
|
||
const r = await fetch('/api/causal-lab/recommend', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ market_event_id: detail.id }),
|
||
})
|
||
const d = await r.json()
|
||
if (!r.ok) throw new Error(d.detail || r.statusText)
|
||
const rec = d.recommendation
|
||
if (rec?.template_id) {
|
||
setSelTmpl(rec.template_id)
|
||
setRecMsg(`✓ ${rec.template_name}${rec.confidence ? ` (${Math.round(rec.confidence * 100)}%)` : ''}`)
|
||
await instantiate(rec.template_id)
|
||
} else {
|
||
// Aucun template adapté — création automatique d'un nouveau
|
||
setRecMsg('Aucun template adapté — création en cours…')
|
||
setNoTemplateFound(false)
|
||
setLoadingCreate(true)
|
||
try {
|
||
const cr = await fetch('/api/causal-lab/create-from-event', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ market_event_id: detail.id }),
|
||
})
|
||
const cd = await cr.json()
|
||
if (!cr.ok) throw new Error(cd.detail || cr.statusText)
|
||
const newTmplId = cd.id ?? cd.template?.id ?? cd.template_id
|
||
if (newTmplId) {
|
||
setTemplates(prev => [...prev, { id: newTmplId, name: cd.name, category: cd.category, graph_json: cd.graph_json, instruments: cd.instruments, calibration_json: {} }])
|
||
setSelTmpl(newTmplId)
|
||
setRecMsg(`✦ Nouveau template créé : ${cd.name ?? 'template'}`)
|
||
await instantiate(newTmplId)
|
||
} else {
|
||
setRecMsg('Template créé mais ID manquant')
|
||
}
|
||
} catch (ce: any) {
|
||
setRecMsg(`✗ Création échouée : ${ce.message}`)
|
||
setNoTemplateFound(true)
|
||
} finally {
|
||
setLoadingCreate(false)
|
||
}
|
||
}
|
||
} catch (e: any) { setRecMsg(`✗ ${(e as any).message}`) }
|
||
finally { setLoadingRec(false) }
|
||
}
|
||
|
||
const createTemplateFromEvent = async () => {
|
||
setLoadingCreate(true); setRecMsg(null); setNoTemplateFound(false)
|
||
try {
|
||
const r = await fetch('/api/causal-lab/create-from-event', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ market_event_id: detail.id }),
|
||
})
|
||
const d = await r.json()
|
||
if (!r.ok) throw new Error(d.detail || r.statusText)
|
||
const tmplRes = await fetch('/api/causal-lab/templates')
|
||
const tmplData = await tmplRes.json()
|
||
setTemplates(Array.isArray(tmplData) ? tmplData : [])
|
||
if (d.id) {
|
||
setSelTmpl(d.id)
|
||
setRecMsg(`✓ Template créé : ${d.name}`)
|
||
await instantiate(d.id)
|
||
}
|
||
} catch (e: any) { setRecMsg(`✗ ${(e as any).message}`) }
|
||
finally { setLoadingCreate(false) }
|
||
}
|
||
|
||
const deleteAnalysis = async (analysisId: number) => {
|
||
if (!confirm('Supprimer cette instanciation ?')) return
|
||
await fetch(`/api/causal-lab/analyses/${analysisId}`, { method: 'DELETE' })
|
||
setAnResult(null); setGraphData(null); setInstInputs({})
|
||
await loadAnalyses()
|
||
}
|
||
|
||
const runAnalysis = async () => {
|
||
if (!selTmpl) return
|
||
setLoadingAn(true); setAnResult(null); setGraphData(null)
|
||
try {
|
||
const r = await fetch('/api/causal-lab/analyze', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ market_event_id: detail.id, template_id: selTmpl, inputs: instInputs, coef_overrides: {} }),
|
||
})
|
||
const d = await r.json()
|
||
if (!r.ok) throw new Error(d.detail || r.statusText)
|
||
const freshResult = {
|
||
score: d.activation?.score ?? null,
|
||
preds: d.node_values ?? {},
|
||
actuals: d.actual_moves ?? {},
|
||
drift: d.drift_by_inst ?? (d.drift ? { [d.instrument]: d.drift } : undefined),
|
||
pricesMode: d.prices_mode,
|
||
lagMin: d.effective_lag_min,
|
||
lagDays: d.effective_lag_days,
|
||
lagDebug: d.lag_debug,
|
||
}
|
||
setAnResult(freshResult)
|
||
if (d.graph_json?.nodes?.length) setGraphData(d.graph_json)
|
||
await loadAnalyses()
|
||
setAnResult(freshResult) // loadAnalyses() écrase anResult — on restaure le résultat frais
|
||
} catch (e: any) { setAnResult({ score: null, preds: {}, actuals: {} }) }
|
||
finally { setLoadingAn(false) }
|
||
}
|
||
|
||
const evalScore = detail.impact_score
|
||
|
||
return (
|
||
<div className="flex flex-col h-full overflow-hidden">
|
||
{/* Header */}
|
||
<div className="flex items-start gap-3 px-5 py-4 border-b border-slate-700/40 shrink-0">
|
||
<div className="flex-1 min-w-0">
|
||
{editing ? (
|
||
<input
|
||
defaultValue={detail.name}
|
||
onChange={e => setEditData(d => ({ ...d, name: e.target.value }))}
|
||
className="bg-dark-900 border border-amber-500/50 rounded px-3 py-1.5 text-slate-100 font-bold text-sm w-full"
|
||
/>
|
||
) : (
|
||
<h2 className="text-slate-100 font-bold text-sm leading-snug">{detail.name}</h2>
|
||
)}
|
||
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||
<span className={clsx('text-xs font-semibold', LEVEL_COLORS[detail.level])}>{detail.level}</span>
|
||
<span className="text-xs text-slate-500">{detail.start_date}</span>
|
||
{detail.end_date && <span className="text-xs text-slate-600">→ {detail.end_date}</span>}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-1.5 shrink-0">
|
||
<button onClick={() => { setEditing(x => !x); setEditData({}) }}
|
||
className={clsx('p-1.5 rounded hover:bg-slate-700/50', editing ? 'text-amber-400' : 'text-slate-500 hover:text-slate-300')}>
|
||
<Edit3 size={14} />
|
||
</button>
|
||
<button onClick={del} className="p-1.5 rounded text-slate-600 hover:text-red-400 hover:bg-red-900/20">
|
||
<Trash2 size={14} />
|
||
</button>
|
||
<button onClick={onClose} className="p-1.5 rounded text-slate-500 hover:text-slate-300 hover:bg-slate-700/50">
|
||
<X size={14} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Scrollable body */}
|
||
<div className="flex-1 overflow-y-auto space-y-4 px-5 py-4">
|
||
|
||
{/* Impact score + absorption */}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="bg-slate-800/40 rounded-lg p-3">
|
||
<div className="text-xs text-slate-500 mb-1">Score impact</div>
|
||
<ScoreBar score={evalScore} />
|
||
</div>
|
||
{detail.absorption_pct != null && (
|
||
<div className="bg-slate-800/40 rounded-lg p-3">
|
||
<div className="text-xs text-slate-500 mb-1">Absorption marché</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
|
||
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${detail.absorption_pct}%` }} />
|
||
</div>
|
||
<span className="text-xs text-blue-300">{detail.absorption_pct}%</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Description */}
|
||
{editing ? (
|
||
<textarea
|
||
defaultValue={detail.description}
|
||
onChange={e => setEditData(d => ({ ...d, description: e.target.value }))}
|
||
rows={3}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-300 text-sm resize-none"
|
||
/>
|
||
) : detail.description ? (
|
||
<p className="text-sm text-slate-300 leading-relaxed">{detail.description}</p>
|
||
) : null}
|
||
|
||
{editing ? (
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<div className="text-xs text-slate-500 mb-1">Niveau</div>
|
||
<select defaultValue={detail.level}
|
||
onChange={e => setEditData(d => ({ ...d, level: e.target.value as any }))}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm">
|
||
{LEVELS.map(l => <option key={l} value={l}>{l}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-500 mb-1">Score impact</div>
|
||
<input type="number" step="0.05" min="0" max="1" defaultValue={detail.impact_score}
|
||
onChange={e => setEditData(d => ({ ...d, impact_score: +e.target.value }))}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm" />
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{editing && (
|
||
<div className="flex gap-2">
|
||
<button onClick={saveEdit} disabled={saving}
|
||
className="flex items-center gap-1.5 bg-emerald-700 hover:bg-emerald-600 text-white text-sm rounded-lg px-4 py-1.5">
|
||
{saving ? <Loader2 size={13} className="animate-spin" /> : <CheckCircle size={13} />} Sauvegarder
|
||
</button>
|
||
<button onClick={() => setEditing(false)} className="text-sm text-slate-500 hover:text-slate-300 px-3">Annuler</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Origin + Sources */}
|
||
<div>
|
||
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">Provenance</div>
|
||
{/* Origin badge */}
|
||
{(() => {
|
||
const meta = ORIGIN_META[detail.origin ?? 'unknown'] ?? ORIGIN_META['unknown']
|
||
return (
|
||
<div className="flex items-start gap-2 bg-slate-800/50 rounded-lg px-3 py-2 mb-2 border border-slate-700/30">
|
||
<span className="text-base shrink-0">{meta.icon}</span>
|
||
<div className="flex-1 min-w-0">
|
||
<div className={`text-xs font-semibold ${meta.color}`}>{meta.label}</div>
|
||
<div className="text-xs text-slate-500 mt-0.5">{meta.description}</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
{/* Source refs */}
|
||
{(detail.source_refs?.length ?? 0) > 0 ? (
|
||
<div className="space-y-1.5">
|
||
{detail.source_refs!.map((ref, i) => (
|
||
<div key={i} className="flex items-start gap-2 bg-slate-800/30 rounded-lg px-3 py-2 border border-slate-800/60">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-xs text-slate-200 font-medium">{ref.title}</div>
|
||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||
<span className="text-xs text-slate-500">{ref.source}</span>
|
||
<span className="text-xs text-slate-600">{ref.date}</span>
|
||
{ref.original_score > 0 && (
|
||
<span className="text-xs text-slate-600">
|
||
score brut: <span className="text-slate-400">{ref.original_score.toFixed(2)}</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{ref.url && (
|
||
<a href={ref.url} target="_blank" rel="noreferrer"
|
||
className="text-slate-500 hover:text-blue-400 shrink-0 mt-0.5">
|
||
<ExternalLink size={12} />
|
||
</a>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="text-xs text-slate-600 italic px-3 py-2 bg-slate-800/20 rounded-lg border border-slate-800/40">
|
||
Aucune source liée — cet événement ne référence pas de document ou d'article spécifique.
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Causal graphs */}
|
||
<div className="space-y-3">
|
||
<div className="text-xs text-slate-500 uppercase tracking-wide">
|
||
Graphes causaux
|
||
{analyses.length > 0 && <span className="text-slate-600 ml-1.5 normal-case">({analyses.length})</span>}
|
||
</div>
|
||
|
||
{/* Existing analyses */}
|
||
{analyses.length > 0 && (
|
||
<div className="space-y-1.5">
|
||
{analyses.map(a => {
|
||
const score = a.activation_score != null ? Math.round(a.activation_score * 100) : null
|
||
const topPreds = Object.entries(a.prediction_json || {})
|
||
.filter(([k]) => !k.includes('error'))
|
||
.sort(([, v1], [, v2]) => Math.abs(v2) - Math.abs(v1))
|
||
.slice(0, 3)
|
||
return (
|
||
<div key={a.id} className="flex items-stretch gap-1">
|
||
<button
|
||
onClick={() => navigate(`/causal-lab?template=${a.template_id}`)}
|
||
className="flex-1 text-left bg-slate-800/40 hover:bg-slate-800/70 border border-slate-700/40 hover:border-blue-700/40 rounded-lg px-3 py-2 transition-colors"
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs font-medium text-slate-200 truncate">{a.template_name}</span>
|
||
{score != null && (
|
||
<span className={clsx(
|
||
'text-xs font-mono px-1.5 py-0.5 rounded shrink-0 ml-2',
|
||
score >= 70 ? 'bg-emerald-500/20 text-emerald-400' :
|
||
score >= 40 ? 'bg-amber-500/20 text-amber-400' :
|
||
'bg-slate-700/40 text-slate-400'
|
||
)}>{score}%</span>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2 flex-wrap mt-0.5">
|
||
<span className="text-xs text-slate-500">{a.instrument}</span>
|
||
{topPreds.map(([k, v]) => (
|
||
<span key={k} className={clsx('text-xs font-mono', v > 0 ? 'text-emerald-500' : 'text-red-500')}>
|
||
{k}: {v > 0 ? '+' : ''}{Math.round(v)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</button>
|
||
<button
|
||
onClick={() => deleteAnalysis(a.id)}
|
||
className="px-2 rounded-lg text-slate-600 hover:text-red-400 hover:bg-red-900/20 border border-slate-700/40 transition-colors shrink-0"
|
||
title="Supprimer cette instanciation"
|
||
>
|
||
<Trash2 size={11} />
|
||
</button>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Inline analysis panel */}
|
||
<div className="bg-slate-800/30 rounded-lg border border-slate-700/30 p-3 space-y-2">
|
||
<div className="text-xs text-slate-400 font-medium">
|
||
{analyses.length === 0 ? 'Aucun graphe — lancez une analyse' : 'Nouvelle analyse'}
|
||
</div>
|
||
|
||
{/* AI recommend + template picker */}
|
||
<div className="flex gap-1.5">
|
||
<button
|
||
onClick={recommendTemplate}
|
||
disabled={loadingRec}
|
||
className="flex items-center gap-1 text-xs bg-purple-700/40 hover:bg-purple-700/60 border border-purple-700/40 text-purple-300 rounded px-2 py-1.5 shrink-0 disabled:opacity-50"
|
||
>
|
||
{loadingRec ? <Loader2 size={10} className="animate-spin" /> : <Sparkles size={10} />}
|
||
IA
|
||
</button>
|
||
<select
|
||
value={selTmpl}
|
||
onChange={e => { setSelTmpl(Number(e.target.value)); setRecMsg(null); setAnResult(null) }}
|
||
className="flex-1 bg-dark-900 border border-slate-700 rounded px-2 py-1.5 text-xs text-slate-300 min-w-0"
|
||
>
|
||
<option value={0}>— Choisir un template —</option>
|
||
{templates.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||
</select>
|
||
</div>
|
||
|
||
{recMsg && (
|
||
<div className={clsx('text-xs px-2 py-1 rounded', recMsg.startsWith('✓') ? 'text-emerald-400 bg-emerald-900/20' : recMsg.startsWith('✗') ? 'text-red-400 bg-red-900/20' : 'text-amber-400 bg-amber-900/20')}>
|
||
{recMsg}
|
||
</div>
|
||
)}
|
||
|
||
{noTemplateFound && (
|
||
<button
|
||
onClick={createTemplateFromEvent}
|
||
disabled={loadingCreate}
|
||
className="flex items-center gap-1.5 text-xs bg-purple-800/30 hover:bg-purple-700/50 border border-purple-700/40 text-purple-300 rounded px-2 py-1.5 w-full justify-center disabled:opacity-50"
|
||
>
|
||
{loadingCreate ? <Loader2 size={10} className="animate-spin" /> : <Sparkles size={10} />}
|
||
{loadingCreate ? 'Création IA en cours…' : "L'IA peut créer un template"}
|
||
</button>
|
||
)}
|
||
|
||
{/* Instantiation status + inputs preview */}
|
||
{selTmpl > 0 && (
|
||
<div className="space-y-1">
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => instantiate(selTmpl)}
|
||
disabled={loadingInst}
|
||
className="flex items-center gap-1 text-xs text-slate-500 hover:text-purple-400 disabled:opacity-50 transition-colors"
|
||
>
|
||
{loadingInst ? <Loader2 size={9} className="animate-spin" /> : <Sparkles size={9} />}
|
||
{loadingInst ? 'Calibration IA…' : 'Calibrer (IA)'}
|
||
</button>
|
||
{instMsg && !instMsg.startsWith('✗') && (
|
||
<span className="text-xs text-slate-600 truncate">{instMsg}</span>
|
||
)}
|
||
</div>
|
||
{Object.keys(instInputs).length > 0 && (
|
||
<div className="space-y-1">
|
||
{Object.entries(instInputs).map(([k, v]) => (
|
||
<div key={k} className="flex items-center gap-1.5">
|
||
<span className="text-xs font-mono text-slate-500 flex-1 truncate" title={k}>{k}</span>
|
||
<input
|
||
type="number"
|
||
step="any"
|
||
value={v}
|
||
onChange={e => {
|
||
const n = parseFloat(e.target.value)
|
||
if (!isNaN(n)) setInstInputs(prev => ({ ...prev, [k]: n }))
|
||
}}
|
||
className={clsx(
|
||
'w-20 text-xs font-mono text-right rounded px-1.5 py-0.5 border bg-dark-900 focus:outline-none focus:ring-1 focus:ring-blue-500/50',
|
||
v > 0 ? 'text-emerald-400 border-emerald-800/60'
|
||
: v < 0 ? 'text-red-400 border-red-800/60'
|
||
: 'text-slate-400 border-slate-700/60'
|
||
)}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Analyze + open in CausalLab */}
|
||
<div className="flex gap-1.5">
|
||
<button
|
||
onClick={runAnalysis}
|
||
disabled={!selTmpl || loadingAn}
|
||
className="flex-1 flex items-center justify-center gap-1.5 text-xs bg-blue-700/50 hover:bg-blue-700/70 border border-blue-700/40 text-blue-200 rounded px-3 py-1.5 disabled:opacity-40"
|
||
>
|
||
{loadingAn ? <Loader2 size={10} className="animate-spin" /> : null}
|
||
{loadingAn ? 'Analyse…' : 'Lancer l\'analyse'}
|
||
</button>
|
||
<button
|
||
onClick={() => navigate(`/causal-lab?template=${selTmpl || ''}`)}
|
||
className="text-xs text-slate-500 hover:text-blue-400 px-2 py-1.5 rounded hover:bg-slate-700/30 transition-colors"
|
||
title="Ouvrir dans CausalLab"
|
||
>
|
||
<ExternalLink size={12} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Bandeau de détail du calcul réel */}
|
||
{anResult && (anResult.pricesMode || anResult.drift) && (() => {
|
||
const mode = anResult.pricesMode
|
||
const lagMin = anResult.lagMin ?? 0
|
||
const lagDays = anResult.lagDays ?? 0
|
||
const driftEntries = Object.entries(anResult.drift ?? {})
|
||
const dbg = anResult.lagDebug
|
||
return (
|
||
<div className="bg-slate-900/70 border border-slate-700/40 rounded px-3 py-2 space-y-1.5 text-[10px] font-mono">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className={clsx('px-1.5 py-0.5 rounded font-semibold',
|
||
mode === 'intraday_5m' ? 'bg-cyan-900/40 text-cyan-400 border border-cyan-700/40'
|
||
: 'bg-amber-900/40 text-amber-400 border border-amber-700/40')}>
|
||
{mode === 'intraday_5m' ? '5m intraday' : mode === 'daily' ? 'journalier' : mode ?? '—'}
|
||
</span>
|
||
{mode === 'intraday_5m' && lagMin > 0 && (
|
||
<span className="text-slate-400">lag <span className="text-cyan-300">{lagMin}min</span></span>
|
||
)}
|
||
{mode !== 'intraday_5m' && lagDays > 0 && (
|
||
<span className="text-slate-400">lag <span className="text-amber-300">{lagDays}j</span></span>
|
||
)}
|
||
{mode !== 'intraday_5m' && lagDays === 0 && (
|
||
<span className="text-slate-600 italic">lag J = 0 (jour J)</span>
|
||
)}
|
||
{dbg && dbg.used_all_edges && (
|
||
<span className="text-yellow-500/80 italic">⚠ fallback edges (output_ids vides)</span>
|
||
)}
|
||
{dbg && (
|
||
<span className="text-slate-600">
|
||
edges→out: <span className="text-slate-400">{dbg.edges_to_output}</span>
|
||
{' '}| edges lag_j: <span className="text-slate-400">{dbg.edges_with_lag_days}</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
{driftEntries.map(([inst, d]) => (
|
||
<div key={inst} className="flex items-center gap-2 flex-wrap border-t border-slate-800/60 pt-1.5">
|
||
<span className="text-slate-400 w-16 truncate">{inst}</span>
|
||
{d.pre_pips != null && (
|
||
<span className="text-slate-500">
|
||
pré: <span className={d.pre_pips > 0 ? 'text-emerald-500' : 'text-red-500'}>{d.pre_pips > 0 ? '+' : ''}{d.pre_pips}p</span>
|
||
</span>
|
||
)}
|
||
<span className="text-slate-500">
|
||
post: <span className={d.post_pips == null ? 'text-slate-600' : d.post_pips > 0 ? 'text-emerald-400' : 'text-red-400'}>
|
||
{d.post_pips == null ? '—' : `${d.post_pips > 0 ? '+' : ''}${d.post_pips}p`}
|
||
</span>
|
||
</span>
|
||
{d.post_pips == null && (
|
||
<span className="text-red-500 italic">données manquantes (yfinance)</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
})()}
|
||
|
||
{/* Inline result — full-width graph + left recap */}
|
||
{anResult && graphData?.nodes?.length && (() => {
|
||
const inputNodes = graphData.nodes.filter(n => n.type === 'input')
|
||
const outputNodes = graphData.nodes.filter(n => n.type === 'market_asset' || n.type === 'output')
|
||
return (
|
||
<div className="bg-slate-900/60 rounded p-2">
|
||
{/* Activation row */}
|
||
<div className="flex items-center gap-3 mb-2">
|
||
<span className="text-xs text-slate-500">Activation</span>
|
||
{anResult.score != null ? (
|
||
<span className={clsx(
|
||
'text-xs font-mono font-bold',
|
||
anResult.score >= 0.7 ? 'text-emerald-400' :
|
||
anResult.score >= 0.4 ? 'text-amber-400' : 'text-red-400'
|
||
)}>{Math.round(anResult.score * 100)}%</span>
|
||
) : <span className="text-xs text-slate-500">données manquantes</span>}
|
||
</div>
|
||
{/* Side-by-side: recap left + graph right */}
|
||
<div className="flex gap-3">
|
||
{/* ── Recap panel ── */}
|
||
<div className="w-40 shrink-0 space-y-3 text-xs">
|
||
{inputNodes.length > 0 && (
|
||
<div>
|
||
<div className="text-[10px] text-slate-600 uppercase tracking-wide mb-1">Entrées</div>
|
||
{inputNodes.map(n => {
|
||
const val = anResult.preds[n.id] ?? instInputs[n.id]
|
||
return (
|
||
<div key={n.id} className="flex items-center justify-between gap-1 py-0.5 border-b border-slate-800/60 last:border-0">
|
||
<span className="text-slate-400 truncate max-w-[80px]" title={n.label}>{n.label}</span>
|
||
<span className={clsx('font-mono shrink-0', val == null ? 'text-slate-600' : val > 0 ? 'text-emerald-400' : val < 0 ? 'text-red-400' : 'text-slate-500')}>
|
||
{val != null ? `${val > 0 ? '+' : ''}${val.toFixed(1)}` : '—'}
|
||
</span>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
{outputNodes.length > 0 && (
|
||
<div>
|
||
<div className="text-[10px] text-slate-600 uppercase tracking-wide mb-1">Prédictions</div>
|
||
{(() => {
|
||
const nodeScores = outputNodes
|
||
.map(n => {
|
||
const pred = anResult.preds[n.id]
|
||
const act = anResult.actuals[n.instrument || n.id] ?? anResult.actuals[n.id]
|
||
if (pred == null || act == null) return null
|
||
if (pred === 0 && act === 0) return 100
|
||
if (pred === 0) return 40
|
||
const sameDir = pred * act > 0
|
||
if (!sameDir) return 0
|
||
const ratio = Math.min(Math.abs(act), Math.abs(pred)) / Math.max(Math.abs(act), Math.abs(pred))
|
||
return Math.round(50 + ratio * 50)
|
||
})
|
||
.filter((s): s is number => s != null)
|
||
const graphScore = nodeScores.length > 0
|
||
? Math.round(nodeScores.reduce((a, b) => a + b, 0) / nodeScores.length)
|
||
: null
|
||
if (graphScore == null) return null
|
||
const barCls = graphScore >= 70 ? 'bg-emerald-500' : graphScore >= 40 ? 'bg-amber-500' : 'bg-red-500'
|
||
const txtCls = graphScore >= 70 ? 'text-emerald-400' : graphScore >= 40 ? 'text-amber-400' : 'text-red-400'
|
||
return (
|
||
<div className="mb-2 pb-2 border-b border-slate-800/60">
|
||
<div className="flex items-center justify-between text-[9px] text-slate-600 mb-0.5">
|
||
<span>Précision</span>
|
||
<span className={txtCls}>{graphScore}%</span>
|
||
</div>
|
||
<div className="h-1 bg-slate-800 rounded-full overflow-hidden">
|
||
<div className={clsx('h-full rounded-full', barCls)} style={{ width: `${graphScore}%` }} />
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
{outputNodes.map(n => {
|
||
const pred = anResult.preds[n.id]
|
||
const actual = anResult.actuals[n.instrument || n.id] ?? anResult.actuals[n.id]
|
||
return (
|
||
<div key={n.id} className="py-0.5 border-b border-slate-800/60 last:border-0">
|
||
<div className="flex items-center justify-between gap-1">
|
||
<span className="text-slate-400 truncate max-w-[70px]" title={n.label}>{n.label}</span>
|
||
<span className={clsx('font-mono shrink-0', pred == null ? 'text-slate-600' : pred > 0 ? 'text-emerald-400' : pred < 0 ? 'text-red-400' : 'text-slate-500')}>
|
||
{pred != null ? `${pred > 0 ? '+' : ''}${Math.round(pred)}${n.unit === 'pips' ? 'p' : n.unit === '%' ? '%' : ''}` : '—'}
|
||
</span>
|
||
</div>
|
||
{actual != null && (
|
||
<div className="flex items-center justify-between gap-1 mt-0.5 opacity-70">
|
||
<span className="text-slate-600 text-[10px]">réel</span>
|
||
<span className={clsx('font-mono text-[10px] shrink-0', actual > 0 ? 'text-emerald-300' : 'text-red-300')}>
|
||
{actual > 0 ? '+' : ''}{Math.round(actual)}p
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* ── Graph ── */}
|
||
<div className="flex-1 min-w-0">
|
||
<CausalGraphMini
|
||
nodes={graphData.nodes}
|
||
edges={graphData.edges}
|
||
nodeValues={anResult.preds}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
{anResult && !graphData?.nodes?.length && (
|
||
<div className="bg-slate-900/60 rounded p-2 space-y-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs text-slate-500">Activation</span>
|
||
{anResult.score != null ? (
|
||
<span className={clsx('text-xs font-mono font-bold',
|
||
anResult.score >= 0.7 ? 'text-emerald-400' : anResult.score >= 0.4 ? 'text-amber-400' : 'text-red-400'
|
||
)}>{Math.round(anResult.score * 100)}%</span>
|
||
) : <span className="text-xs text-slate-500">données manquantes</span>}
|
||
</div>
|
||
{Object.entries(anResult.actuals).map(([inst, move]) => {
|
||
const pred = Object.entries(anResult.preds).find(([k]) => k.toLowerCase().includes(inst.toLowerCase()))?.[1]
|
||
return (
|
||
<div key={inst} className="flex items-center justify-between gap-2 text-xs">
|
||
<span className="font-mono text-slate-400">{inst}</span>
|
||
{pred !== undefined && <span className={clsx('font-mono', pred > 0 ? 'text-blue-400' : 'text-orange-400')}>pred {pred > 0 ? '+' : ''}{Math.round(pred)}p</span>}
|
||
<span className={clsx('font-mono font-bold ml-auto', move > 0 ? 'text-emerald-400' : 'text-red-400')}>{move > 0 ? '+' : ''}{Math.round(move)}p</span>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{templates.length === 0 && !noTemplateFound && (
|
||
<p className="text-xs text-slate-600 italic">
|
||
Aucun template disponible —{' '}
|
||
<button onClick={() => navigate('/causal-lab')} className="text-blue-500 hover:text-blue-400 underline">
|
||
créer dans CausalLab
|
||
</button>
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Create event modal ────────────────────────────────────────────────────────
|
||
|
||
function CreateModal({ onCreated, onClose }: { onCreated: () => void; onClose: () => void }) {
|
||
const [form, setForm] = useState({
|
||
name: '', start_date: new Date().toISOString().slice(0, 10),
|
||
level: 'short', description: '', impact_score: 0.5,
|
||
})
|
||
const [saving, setSaving] = useState(false)
|
||
|
||
const submit = async () => {
|
||
if (!form.name || !form.start_date) return
|
||
setSaving(true)
|
||
await fetch('/api/market-events', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ ...form, source_refs: [] }),
|
||
})
|
||
setSaving(false)
|
||
onCreated()
|
||
onClose()
|
||
}
|
||
|
||
const f = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
|
||
setForm(d => ({ ...d, [k]: e.target.value }))
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center">
|
||
<div className="bg-dark-800 border border-slate-700/50 rounded-xl w-full max-w-lg shadow-2xl">
|
||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-700/40">
|
||
<h3 className="text-slate-100 font-semibold">Nouvel événement marché</h3>
|
||
<button onClick={onClose} className="text-slate-500 hover:text-slate-300"><X size={16} /></button>
|
||
</div>
|
||
<div className="p-5 space-y-3">
|
||
<input value={form.name} onChange={f('name')} placeholder="Nom de l'événement *"
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm" />
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<div className="text-xs text-slate-500 mb-1">Date début</div>
|
||
<input type="date" value={form.start_date} onChange={f('start_date')}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm" />
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-500 mb-1">Niveau</div>
|
||
<select value={form.level} onChange={f('level')}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm">
|
||
{LEVELS.map(l => <option key={l} value={l}>{l}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<textarea value={form.description} onChange={f('description')} placeholder="Description..." rows={3}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm resize-none" />
|
||
<div>
|
||
<div className="text-xs text-slate-500 mb-1">Score impact: {form.impact_score.toFixed(2)}</div>
|
||
<input type="range" min="0" max="1" step="0.05" value={form.impact_score}
|
||
onChange={e => setForm(d => ({ ...d, impact_score: +e.target.value }))}
|
||
className="w-full accent-amber-400" />
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2 px-5 pb-5">
|
||
<button onClick={submit} disabled={saving || !form.name}
|
||
className="flex items-center gap-2 bg-emerald-700 hover:bg-emerald-600 disabled:bg-slate-700 disabled:text-slate-500 text-white rounded-lg px-5 py-2 text-sm font-medium">
|
||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />} Créer
|
||
</button>
|
||
<button onClick={onClose} className="text-sm text-slate-500 hover:text-slate-300 px-4">Annuler</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Event list row ────────────────────────────────────────────────────────────
|
||
|
||
function EventRow({
|
||
ev, selected, onClick,
|
||
}: { ev: MarketEvent; selected: boolean; onClick: () => void }) {
|
||
return (
|
||
<div
|
||
onClick={onClick}
|
||
className={clsx(
|
||
'flex items-center gap-3 px-4 py-3 cursor-pointer border-b border-slate-800/50 hover:bg-slate-800/40 transition-colors',
|
||
selected && 'bg-slate-800/60 border-l-2 border-l-blue-500',
|
||
)}
|
||
>
|
||
<span className="w-1.5 h-1.5 rounded-full shrink-0 bg-slate-500" />
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-sm text-slate-200 truncate font-medium">{ev.name}</div>
|
||
<div className="flex items-center gap-2 mt-0.5">
|
||
<span className="text-xs text-slate-500">{ev.start_date}</span>
|
||
{ev.sub_type && <span className="text-xs text-slate-600">{ev.sub_type}</span>}
|
||
{ev.origin && (
|
||
<span className="text-xs shrink-0" title={(ORIGIN_META[ev.origin] ?? ORIGIN_META['unknown']).description}>
|
||
{(ORIGIN_META[ev.origin] ?? ORIGIN_META['unknown']).icon}
|
||
</span>
|
||
)}
|
||
{ev.created_at && (
|
||
<span className="text-[10px] text-slate-700 shrink-0" title={`Généré le ${ev.created_at}`}>
|
||
⚡{ev.created_at.slice(5, 10)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="shrink-0 text-right">
|
||
<div className="text-xs font-bold" style={{ color: ev.impact_score >= 0.7 ? '#ef4444' : ev.impact_score >= 0.5 ? '#f59e0b' : '#10b981' }}>
|
||
{ev.impact_score.toFixed(2)}
|
||
</div>
|
||
<div className={clsx('text-xs', LEVEL_COLORS[ev.level])}>{ev.level}</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||
|
||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||
|
||
export default function MarketEvents() {
|
||
const [searchParams] = useSearchParams()
|
||
const [events, setEvents] = useState<MarketEvent[]>([])
|
||
const [total, setTotal] = useState(0)
|
||
const [loading, setLoading] = useState(false)
|
||
const [selected, setSelected] = useState<MarketEvent | null>(null)
|
||
const [showCreate, setShowCreate] = useState(false)
|
||
const [bulkIds, setBulkIds] = useState<Set<number>>(new Set())
|
||
const [bulking, setBulking] = useState(false)
|
||
const [bulkMsg, setBulkMsg] = useState<string | null>(null)
|
||
|
||
// ── Filter state ─────────────────────────────────────────────────────────
|
||
const [search, setSearch] = useState('')
|
||
const [category, setCategory] = useState('')
|
||
const [level, setLevel] = useState('')
|
||
const [minScore, setMinScore] = useState(0)
|
||
const [evaluated, setEvaluated] = useState<'' | 'true' | 'false'>('')
|
||
const [dateFrom, setDateFrom] = useState('')
|
||
const [dateTo, setDateTo] = useState('')
|
||
const [datePreset, setDatePreset] = useState<string>('all') // 7d|30d|90d|6m|1y|all|custom
|
||
// Generation date filter (created_at)
|
||
const [genFrom, setGenFrom] = useState('')
|
||
const [genTo, setGenTo] = useState('')
|
||
// Instrument impact filter
|
||
const [instTicker, setInstTicker] = useState('')
|
||
const [instMinScore, setInstMin] = useState(0.3)
|
||
const [instDir, setInstDir] = useState('')
|
||
const [showInstFilter, setShowInst] = useState(false)
|
||
// Sort
|
||
const [sortBy, setSortBy] = useState<'date'|'score'|'name'|'instrument_score'>('date')
|
||
const [sortDir, setSortDir] = useState<'desc'|'asc'>('desc')
|
||
// UI
|
||
const [showFilters, setShowFilters] = useState(false)
|
||
|
||
// ── Date preset helper ────────────────────────────────────────────────────
|
||
const applyPreset = useCallback((preset: string) => {
|
||
setDatePreset(preset)
|
||
if (preset === 'custom' || preset === 'all') {
|
||
setDateFrom(''); setDateTo('')
|
||
return
|
||
}
|
||
const now = new Date()
|
||
const to = now.toISOString().slice(0, 10)
|
||
const from = new Date(now)
|
||
if (preset === '7d') from.setDate(now.getDate() - 7)
|
||
if (preset === '30d') from.setDate(now.getDate() - 30)
|
||
if (preset === '90d') from.setDate(now.getDate() - 90)
|
||
if (preset === '6m') from.setMonth(now.getMonth() - 6)
|
||
if (preset === '1y') from.setFullYear(now.getFullYear() - 1)
|
||
setDateFrom(from.toISOString().slice(0, 10))
|
||
setDateTo(to)
|
||
}, [])
|
||
|
||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||
const abortRef = useRef<AbortController | null>(null)
|
||
|
||
const load = useCallback(async () => {
|
||
// Cancel any in-flight fetch
|
||
abortRef.current?.abort()
|
||
abortRef.current = new AbortController()
|
||
const signal = abortRef.current.signal
|
||
|
||
setLoading(true)
|
||
const params = new URLSearchParams()
|
||
if (search) params.set('search', search)
|
||
if (category) params.set('category', category)
|
||
if (level) params.set('level', level)
|
||
if (minScore) params.set('min_score', String(minScore))
|
||
if (dateFrom) params.set('date_from', dateFrom)
|
||
if (dateTo) params.set('date_to', dateTo)
|
||
if (genFrom) params.set('gen_date_from', genFrom)
|
||
if (genTo) params.set('gen_date_to', genTo)
|
||
if (evaluated) params.set('evaluated', evaluated)
|
||
if (instTicker) {
|
||
params.set('instrument', instTicker)
|
||
if (instMinScore > 0) params.set('min_instrument_score', String(instMinScore))
|
||
if (instDir) params.set('instrument_direction', instDir)
|
||
}
|
||
params.set('sort_by', instTicker ? 'instrument_score' : sortBy)
|
||
params.set('sort_dir', sortDir)
|
||
params.set('limit', '500')
|
||
try {
|
||
const r = await fetch(`/api/market-events?${params}`, { signal })
|
||
if (r.ok) {
|
||
const d = await r.json()
|
||
setEvents(d.events)
|
||
setTotal(d.total)
|
||
}
|
||
} catch (e: any) {
|
||
if (e.name === 'AbortError') return // superseded by a newer load
|
||
}
|
||
setLoading(false)
|
||
}, [search, category, level, minScore, dateFrom, dateTo, genFrom, genTo, evaluated,
|
||
instTicker, instMinScore, instDir, sortBy, sortDir])
|
||
|
||
useEffect(() => {
|
||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||
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)
|
||
setBulkMsg(null)
|
||
const r = await fetch('/api/market-events/bulk-evaluate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify([...bulkIds]),
|
||
})
|
||
const d = await r.json()
|
||
setBulkMsg(`✓ ${d.evaluated} évalués${d.errors > 0 ? `, ${d.errors} erreurs` : ''}`)
|
||
setBulkIds(new Set())
|
||
setBulking(false)
|
||
load()
|
||
}
|
||
|
||
const unevaluatedIds = events.filter(e => !e.evaluated).map(e => e.id)
|
||
|
||
return (
|
||
<div className="flex flex-col h-full overflow-hidden">
|
||
<div className="flex flex-1 overflow-hidden">
|
||
{/* ── Left panel: filters + list ── */}
|
||
<div className="w-96 shrink-0 flex flex-col border-r border-slate-700/40 overflow-hidden">
|
||
{/* ── Search + actions ── */}
|
||
<div className="px-3 pt-3 pb-2 border-b border-slate-700/30 space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<div className="relative flex-1">
|
||
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500" />
|
||
<input value={search} onChange={e => setSearch(e.target.value)}
|
||
placeholder="Recherche nom, description..."
|
||
className="w-full bg-slate-800/60 border border-slate-700/50 rounded-lg pl-8 pr-3 py-1.5 text-sm text-slate-200 placeholder:text-slate-600" />
|
||
</div>
|
||
<button onClick={() => setShowCreate(true)}
|
||
className="flex items-center gap-1 bg-emerald-700 hover:bg-emerald-600 text-white text-xs rounded-lg px-2 py-1.5 shrink-0">
|
||
<Plus size={12} />
|
||
</button>
|
||
<button onClick={load} disabled={loading}
|
||
className="p-1.5 text-slate-500 hover:text-slate-300 rounded shrink-0">
|
||
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* ── Date presets ── */}
|
||
<div className="flex items-center gap-1 flex-wrap">
|
||
<span className="text-xs text-slate-600 mr-1">Période:</span>
|
||
{[['7d','7j'],['30d','30j'],['90d','3m'],['6m','6m'],['1y','1an'],['all','Tout'],['custom','Perso']] .map(([v, lbl]) => (
|
||
<button key={v} onClick={() => applyPreset(v)}
|
||
className={clsx('px-2 py-0.5 rounded text-xs border transition-colors',
|
||
datePreset === v
|
||
? 'bg-blue-600/30 border-blue-500/50 text-blue-300'
|
||
: 'border-slate-800 text-slate-600 hover:border-slate-600 hover:text-slate-400')}>
|
||
{lbl}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{datePreset === 'custom' && (
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<div className="text-xs text-slate-600 mb-0.5">Depuis</div>
|
||
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs" />
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-600 mb-0.5">Jusqu'à</div>
|
||
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs" />
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
|
||
{/* ── Quick category filter ── */}
|
||
<div className="px-3 py-1.5 border-b border-slate-800/40 flex items-center gap-1 flex-wrap">
|
||
{([
|
||
{ key: '', label: 'Tout', dot: 'bg-slate-500' },
|
||
{ key: 'event_calendar', label: '★ Cal.', dot: 'bg-amber-500' },
|
||
{ key: 'geopolitical', label: '★ Géo.', dot: 'bg-red-500' },
|
||
{ key: 'fundamental', label: '★ Fund.', dot: 'bg-emerald-500' },
|
||
{ key: 'report', label: '★ Rep.', dot: 'bg-blue-500' },
|
||
{ key: 'sentiment', label: '★ Sent.', dot: 'bg-violet-500' },
|
||
{ key: 'technical', label: '★ Tech.', dot: 'bg-cyan-500' },
|
||
] as const).map(({ key, label, dot }) => (
|
||
<button
|
||
key={key}
|
||
onClick={() => setCategory(key)}
|
||
className={clsx(
|
||
'flex items-center gap-1 px-2 py-0.5 rounded text-[10px] border transition-colors',
|
||
category === key
|
||
? 'bg-slate-700/80 border-slate-500/60 text-slate-200'
|
||
: 'border-slate-800/60 text-slate-600 hover:border-slate-600 hover:text-slate-400'
|
||
)}
|
||
>
|
||
<span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', dot)} />
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── Sort + more filters toggle ── */}
|
||
<div className="px-3 py-1.5 border-b border-slate-800/40 flex items-center gap-2">
|
||
<span className="text-xs text-slate-600">Tri:</span>
|
||
<select value={sortBy} onChange={e => setSortBy(e.target.value as any)}
|
||
className="bg-transparent border-0 text-xs text-slate-400 cursor-pointer focus:outline-none">
|
||
<option value="date">Date</option>
|
||
<option value="score">Score global</option>
|
||
<option value="name">Nom</option>
|
||
</select>
|
||
<button onClick={() => setSortDir(d => d === 'desc' ? 'asc' : 'desc')}
|
||
className="text-xs text-slate-500 hover:text-slate-300 border border-slate-700 rounded px-1.5 py-0.5">
|
||
{sortDir === 'desc' ? '↓' : '↑'}
|
||
</button>
|
||
<div className="flex-1" />
|
||
<button onClick={() => setShowFilters(x => !x)}
|
||
className={clsx('flex items-center gap-1 text-xs border rounded px-2 py-0.5 transition-colors',
|
||
(showFilters || instTicker)
|
||
? 'bg-slate-700 border-slate-600 text-slate-300'
|
||
: 'border-slate-800 text-slate-600 hover:border-slate-600')}>
|
||
<SlidersHorizontal size={10} />
|
||
Filtres {(category || level || minScore > 0 || evaluated || instTicker) ? '●' : ''}
|
||
</button>
|
||
</div>
|
||
|
||
{/* ── Extended filters ── */}
|
||
{showFilters && (
|
||
<div className="px-3 py-2 border-b border-slate-800/40 space-y-2 bg-dark-900/50">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<div className="text-xs text-slate-600 mb-0.5">Niveau</div>
|
||
<select value={level} onChange={e => setLevel(e.target.value)}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs">
|
||
<option value="">Tous</option>
|
||
{LEVELS.map(l => <option key={l} value={l}>{l}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-600 mb-0.5">Évaluation</div>
|
||
<select value={evaluated} onChange={e => setEvaluated(e.target.value as any)}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs">
|
||
<option value="">Tous</option>
|
||
<option value="true">Évalués IA</option>
|
||
<option value="false">Non évalués</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-600 mb-0.5">Score global min: <span className="text-slate-400">{minScore.toFixed(2)}</span></div>
|
||
<input type="range" min="0" max="1" step="0.05" value={minScore}
|
||
onChange={e => setMinScore(+e.target.value)} className="w-full accent-slate-400" />
|
||
</div>
|
||
|
||
{/* ── Instrument impact filter ── */}
|
||
<div className="border-t border-slate-800/60 pt-2">
|
||
<button onClick={() => setShowInst(x => !x)}
|
||
className={clsx('flex items-center gap-1.5 text-xs font-semibold mb-2 transition-colors',
|
||
instTicker ? 'text-amber-400' : 'text-slate-500 hover:text-slate-300')}>
|
||
<BarChart2 size={11} />
|
||
Impact sur instrument {instTicker ? `(${instTicker})` : ''}
|
||
{showInstFilter ? ' ▲' : ' ▼'}
|
||
</button>
|
||
{showInstFilter && (
|
||
<div className="space-y-2">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<div className="text-xs text-slate-600 mb-0.5">Instrument</div>
|
||
<select value={instTicker} onChange={e => { setInstTicker(e.target.value); if (e.target.value) setSortBy('instrument_score') }}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs">
|
||
<option value="">Tous</option>
|
||
{ALL_INSTRUMENTS.map(t => <option key={t} value={t}>{t}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-600 mb-0.5">Direction</div>
|
||
<select value={instDir} onChange={e => setInstDir(e.target.value)}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs">
|
||
<option value="">Toutes</option>
|
||
<option value="bullish">🔼 Bullish</option>
|
||
<option value="bearish">🔽 Bearish</option>
|
||
<option value="neutral">● Neutre</option>
|
||
<option value="depends_on_outcome">◆ Conditionnel</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-600 mb-0.5">
|
||
Score impact min: <span className="text-amber-300">{instMinScore.toFixed(2)}</span>
|
||
</div>
|
||
<input type="range" min="0" max="1" step="0.05" value={instMinScore}
|
||
onChange={e => setInstMin(+e.target.value)} className="w-full accent-amber-400" />
|
||
</div>
|
||
{instTicker && (
|
||
<div className="text-xs text-amber-300 bg-amber-900/20 rounded px-2 py-1 border border-amber-800/30">
|
||
★ Tri automatique par impact sur {instTicker}
|
||
</div>
|
||
)}
|
||
{instTicker && (
|
||
<button onClick={() => { setInstTicker(''); setInstDir(''); setInstMin(0.3); setSortBy('date') }}
|
||
className="text-xs text-slate-500 hover:text-red-400">
|
||
× Effacer filtre instrument
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Generation date filter ── */}
|
||
<div className="border-t border-slate-800/60 pt-2">
|
||
<div className="flex items-center gap-1.5 text-xs font-semibold mb-2 text-slate-500">
|
||
⚡ Date de génération
|
||
{(genFrom || genTo) && (
|
||
<button onClick={() => { setGenFrom(''); setGenTo('') }}
|
||
className="ml-auto text-slate-600 hover:text-red-400 font-normal">× effacer</button>
|
||
)}
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<div className="text-xs text-slate-600 mb-0.5">Généré depuis</div>
|
||
<input type="date" value={genFrom} onChange={e => setGenFrom(e.target.value)}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs" />
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-600 mb-0.5">Généré jusqu'à</div>
|
||
<input type="date" value={genTo} onChange={e => setGenTo(e.target.value)}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs" />
|
||
</div>
|
||
</div>
|
||
{(genFrom || genTo) && (
|
||
<div className="text-xs text-blue-300 bg-blue-900/20 rounded px-2 py-1 border border-blue-800/30 mt-1.5">
|
||
⚡ Filtre actif sur date de génération (created_at)
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Bulk evaluate ── */}
|
||
{unevaluatedIds.length > 0 && (
|
||
<div className="px-3 py-1.5 border-b border-slate-800/50 flex items-center gap-2 bg-blue-900/10">
|
||
<span className="text-xs text-blue-300 flex-1">{unevaluatedIds.length} non évalués</span>
|
||
{bulkMsg && <span className="text-xs text-emerald-400">{bulkMsg}</span>}
|
||
<button onClick={() => { setBulkIds(new Set(unevaluatedIds)); setTimeout(bulkEvaluate, 50) }}
|
||
disabled={bulking}
|
||
className="flex items-center gap-1 text-xs bg-blue-700/50 hover:bg-blue-700/70 text-blue-200 rounded px-2 py-1">
|
||
{bulking ? <Loader2 size={10} className="animate-spin" /> : <Sparkles size={10} />}
|
||
Évaluer tous
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Count bar ── */}
|
||
<div className="px-3 py-1 text-xs text-slate-600 border-b border-slate-800/30 flex items-center gap-2">
|
||
<span>{loading ? '...' : total} événement{total !== 1 ? 's' : ''}</span>
|
||
{instTicker && <span className="text-amber-400">· impact {instTicker}</span>}
|
||
{(level || search || minScore > 0 || evaluated || dateFrom || dateTo || genFrom || genTo || instTicker) && (
|
||
<button onClick={() => {
|
||
setSearch(''); setLevel(''); setMinScore(0)
|
||
setEvaluated(''); setDateFrom(''); setDateTo(''); setDatePreset('all')
|
||
setGenFrom(''); setGenTo('')
|
||
setInstTicker(''); setInstDir(''); setInstMin(0.3); setSortBy('date')
|
||
}} className="ml-auto text-xs text-slate-600 hover:text-red-400">× tout effacer</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── List ── */}
|
||
<div className="flex-1 overflow-y-auto">
|
||
{loading && events.length === 0 ? (
|
||
<div className="flex items-center justify-center h-32 text-slate-500 text-sm">
|
||
<Loader2 size={16} className="animate-spin mr-2" /> Chargement...
|
||
</div>
|
||
) : events.length === 0 ? (
|
||
<div className="text-center py-12 text-slate-600 text-sm">Aucun événement trouvé</div>
|
||
) : (
|
||
events.map(ev => (
|
||
<EventRow
|
||
key={ev.id}
|
||
ev={ev}
|
||
selected={selected?.id === ev.id}
|
||
onClick={() => setSelected(ev)}
|
||
/>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Right panel: detail ── */}
|
||
<div className="flex-1 overflow-hidden bg-dark-900/40">
|
||
{selected ? (
|
||
<EventDetail
|
||
key={selected.id}
|
||
event={selected}
|
||
onClose={() => setSelected(null)}
|
||
onUpdated={updated => {
|
||
setEvents(evs => evs.map(e => e.id === updated.id ? { ...e, ...updated } : e))
|
||
}}
|
||
onDeleted={id => {
|
||
setEvents(evs => evs.filter(e => e.id !== id))
|
||
setSelected(null)
|
||
}}
|
||
/>
|
||
) : (
|
||
<div className="flex flex-col items-center justify-center h-full text-slate-600 gap-3">
|
||
<BarChart2 size={40} className="opacity-30" />
|
||
<div className="text-sm">Sélectionne un événement pour voir le détail</div>
|
||
<div className="text-xs opacity-60">Sources · Impacts par instrument · Analyse IA</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{showCreate && <CreateModal onCreated={load} onClose={() => setShowCreate(false)} />}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|