feat: instrument analysis

This commit is contained in:
OpenSquared
2026-06-28 16:36:28 +02:00
parent 0693d41d91
commit e4e17330b4
6 changed files with 715 additions and 649 deletions

View File

@@ -69,24 +69,12 @@ interface TemplateRef {
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 CAT_COLORS: Record<string, string> = {
event_calendar: 'bg-amber-500/20 text-amber-300 border-amber-500/30',
geopolitical: 'bg-red-500/20 text-red-300 border-red-500/30',
fundamental: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30',
report: 'bg-blue-500/20 text-blue-300 border-blue-500/30',
sentiment: 'bg-purple-500/20 text-purple-300 border-purple-500/30',
technical: 'bg-cyan-500/20 text-cyan-300 border-cyan-500/30',
}
const CAT_DOT: Record<string, string> = {
event_calendar: 'bg-amber-400',
geopolitical: 'bg-red-400',
fundamental: 'bg-emerald-400',
report: 'bg-blue-400',
sentiment: 'bg-purple-400',
technical: 'bg-cyan-400',
}
const LEVEL_COLORS: Record<string, string> = {
long: 'text-purple-400',
medium: 'text-amber-400',
@@ -115,8 +103,7 @@ const DIR_ICONS: Record<string, string> = {
bullish: '▲', bearish: '▼', neutral: '●', depends_on_outcome: '◆',
}
const CATEGORIES = ['event_calendar', 'geopolitical', 'fundamental', 'report', 'sentiment', 'technical']
const LEVELS = ['long', 'medium', 'short']
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',
@@ -137,15 +124,6 @@ function ScoreBar({ score, max = 1 }: { score: number; max?: number }) {
)
}
function CatBadge({ cat }: { cat: string }) {
return (
<span className={clsx('inline-flex items-center gap-1 px-2 py-0.5 rounded border text-xs font-medium', CAT_COLORS[cat] ?? 'bg-slate-700/50 text-slate-400 border-slate-700')}>
<span className={clsx('w-1.5 h-1.5 rounded-full', CAT_DOT[cat] ?? 'bg-slate-400')} />
{cat.replace('_', ' ')}
</span>
)
}
// ── Instrument impact row (inline editable) ───────────────────────────────────
function ImpactRow({
@@ -312,6 +290,79 @@ function AddInstrumentPanel({ eventId, onAdded }: { eventId: number; onAdded: ()
)
}
// ── 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: 250 }}>
<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({
@@ -327,20 +378,24 @@ function EventDetail({
const [editing, setEditing] = useState(false)
const [editData, setEditData] = useState<Partial<MarketEvent>>({})
const [saving, setSaving] = useState(false)
const [allCats, setAllCats] = useState<EventCategory[]>([])
const [catSaving, setCatSaving] = useState(false)
const [analyses, setAnalyses] = useState<CausalAnalysis[]>([])
// Inline analysis
const [templates, setTemplates] = useState<TemplateRef[]>([])
const [selTmpl, setSelTmpl] = useState(0)
const [instrument, setInstrument] = useState('EURUSD')
const [loadingRec, setLoadingRec] = useState(false)
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 [recMsg, setRecMsg] = useState<string | null>(null)
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> } | null>(null)
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>
} | null>(null)
const [graphData, setGraphData] = useState<GraphData | null>(null)
const loadDetail = useCallback(async () => {
const r = await fetch(`/api/market-events/${event.id}`)
@@ -360,34 +415,6 @@ function EventDetail({
loadAnalyses()
fetch('/api/causal-lab/templates').then(r => r.json()).then(d => setTemplates(Array.isArray(d) ? d : [])).catch(() => {})
}, [loadDetail, loadAnalyses])
useEffect(() => {
fetch('/api/impact/categories')
.then(r => r.json())
.then(d => setAllCats(Array.isArray(d) ? d : []))
.catch(() => {})
}, [])
// Quick-save sub_type without entering full edit mode
const assignCategory = async (catName: string) => {
const cat = allCats.find(c => c.name === catName)
setCatSaving(true)
const payload = {
...detail,
sub_type: catName,
category: cat?.type ?? detail.category,
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),
})
setCatSaving(false)
if (r.ok) {
setDetail(d => ({ ...d, sub_type: catName, category: cat?.type ?? d.category }))
onUpdated({ ...detail, sub_type: catName, category: cat?.type ?? detail.category })
}
}
const saveEdit = async () => {
setSaving(true)
@@ -429,7 +456,8 @@ function EventDetail({
}
const recommendTemplate = async () => {
setLoadingRec(true); setRecMsg(null); setAnResult(null); setInstInputs({}); setInstMsg(null)
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' },
@@ -443,25 +471,48 @@ function EventDetail({
setRecMsg(`${rec.template_name}${rec.confidence ? ` (${Math.round(rec.confidence * 100)}%)` : ''}`)
await instantiate(rec.template_id)
} else {
setRecMsg('Aucun template — créez-en un dans l\'Editeur')
setRecMsg('Aucun template adapté trouvé')
setNoTemplateFound(true)
}
} catch (e: any) { setRecMsg(`${(e as any).message}`) }
finally { setLoadingRec(false) }
}
const runAnalysis = async () => {
if (!selTmpl) return
setLoadingAn(true); setAnResult(null)
const createTemplateFromEvent = async () => {
setLoadingCreate(true); setRecMsg(null); setNoTemplateFound(false)
try {
const r = await fetch('/api/causal-lab/analyze', {
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, template_id: selTmpl, instrument, inputs: instInputs, coef_overrides: {} }),
body: JSON.stringify({ market_event_id: detail.id }),
})
const d = await r.json()
if (!r.ok) throw new Error(d.detail || r.statusText)
setAnResult({ score: d.activation?.score ?? null, preds: d.node_values ?? {} })
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 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)
setAnResult({ score: d.activation?.score ?? null, preds: d.node_values ?? {}, actuals: d.actual_moves ?? {} })
if (d.graph_json?.nodes?.length) setGraphData(d.graph_json)
await loadAnalyses()
} catch (e: any) { setAnResult({ score: null, preds: {} }) }
} catch (e: any) { setAnResult({ score: null, preds: {}, actuals: {} }) }
finally { setLoadingAn(false) }
}
@@ -482,7 +533,6 @@ function EventDetail({
<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">
<CatBadge cat={detail.category} />
<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>}
@@ -524,63 +574,6 @@ function EventDetail({
)}
</div>
{/* ── Catégorie IA ─────────────────────────────────────────────── */}
{(() => {
const currentSub = editing ? (editData.sub_type ?? detail.sub_type ?? '') : (detail.sub_type ?? '')
const matchedCat = allCats.find(c => c.name === currentSub)
const nDefaults = matchedCat?.default_impacts?.length ?? 0
return (
<div className="bg-slate-800/40 rounded-lg p-3 border border-slate-700/30">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-slate-500 uppercase tracking-wide">Catégorie IA</span>
{matchedCat && (
<span className="text-xs text-slate-600">
{nDefaults} impact{nDefaults !== 1 ? 's' : ''} par défaut
</span>
)}
</div>
{editing ? (
<select
value={editData.sub_type ?? detail.sub_type ?? ''}
onChange={e => {
const cat = allCats.find(c => c.name === e.target.value)
setEditData(d => ({
...d,
sub_type: e.target.value,
...(cat ? { category: cat.type } : {}),
}))
}}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1.5 text-slate-200 text-sm"
>
<option value=""> aucune catégorie </option>
{allCats.map(c => (
<option key={c.name} value={c.name}>{c.name} ({c.type})</option>
))}
</select>
) : (
<div className="flex items-center gap-2">
<select
value={currentSub}
onChange={e => assignCategory(e.target.value)}
disabled={catSaving}
className="flex-1 bg-dark-900 border border-slate-700 rounded px-2 py-1.5 text-slate-200 text-sm"
>
<option value=""> aucune catégorie </option>
{allCats.map(c => (
<option key={c.name} value={c.name}>{c.name} ({c.type})</option>
))}
</select>
{catSaving && <Loader2 size={13} className="animate-spin text-slate-500 shrink-0" />}
</div>
)}
{matchedCat?.description && (
<div className="text-xs text-slate-600 mt-1.5 italic">{matchedCat.description}</div>
)}
</div>
)
})()}
{/* Description */}
{editing ? (
<textarea
@@ -595,14 +588,6 @@ function EventDetail({
{editing ? (
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-xs text-slate-500 mb-1">Type</div>
<select defaultValue={detail.category}
onChange={e => setEditData(d => ({ ...d, category: e.target.value }))}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm">
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<div className="text-xs text-slate-500 mb-1">Niveau</div>
<select defaultValue={detail.level}
@@ -758,6 +743,17 @@ function EventDetail({
</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">
@@ -789,24 +785,15 @@ function EventDetail({
</div>
)}
{/* Instrument + analyze */}
{/* Analyze + open in CausalLab */}
<div className="flex gap-1.5">
<select
value={instrument}
onChange={e => setInstrument(e.target.value)}
className="bg-dark-900 border border-slate-700 rounded px-2 py-1.5 text-xs text-slate-300"
>
{['EURUSD', 'XAUUSD', 'SP500', 'BRENT', 'US10Y', 'EU10Y'].map(i => (
<option key={i} value={i}>{i}</option>
))}
</select>
<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'}
{loadingAn ? 'Analyse' : 'Lancer l\'analyse'}
</button>
<button
onClick={() => navigate(`/causal-lab?template=${selTmpl || ''}`)}
@@ -819,7 +806,8 @@ function EventDetail({
{/* Inline result */}
{anResult && (
<div className="bg-slate-900/60 rounded p-2 space-y-1">
<div className="bg-slate-900/60 rounded p-2 space-y-2">
{/* Activation score */}
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">Activation</span>
{anResult.score != null ? (
@@ -828,24 +816,46 @@ function EventDetail({
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-red-400">Erreur</span>}
) : <span className="text-xs text-slate-500">données manquantes</span>}
</div>
{Object.entries(anResult.preds)
.filter(([k]) => !k.includes('error'))
.sort(([, a], [, b]) => Math.abs(b) - Math.abs(a))
.slice(0, 4)
.map(([k, v]) => (
<div key={k} className="flex justify-between items-center">
<span className="text-xs text-slate-500 truncate">{k}</span>
<span className={clsx('text-xs font-mono', v > 0 ? 'text-emerald-400' : 'text-red-400')}>
{v > 0 ? '+' : ''}{Math.round(v)} pip
</span>
</div>
))}
{/* Per-instrument actual moves */}
{Object.keys(anResult.actuals).length > 0 && (
<div className="space-y-1">
{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">
<span className="text-xs font-mono text-slate-400 w-16 shrink-0">{inst}</span>
{pred !== undefined && (
<span className={clsx('text-xs font-mono', pred > 0 ? 'text-blue-400' : 'text-orange-400')}>
pred {pred > 0 ? '+' : ''}{Math.round(pred)}p
</span>
)}
<span className={clsx('text-xs font-mono font-bold ml-auto', move > 0 ? 'text-emerald-400' : 'text-red-400')}>
{move > 0 ? '+' : ''}{Math.round(move)}p
</span>
</div>
)
})}
</div>
)}
{/* Causal graph visualization */}
{graphData?.nodes?.length && (
<div className="border-t border-slate-700/50 pt-2">
<CausalGraphMini
nodes={graphData.nodes}
edges={graphData.edges}
nodeValues={anResult.preds}
/>
</div>
)}
</div>
)}
{templates.length === 0 && (
{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">
@@ -865,8 +875,7 @@ function EventDetail({
function CreateModal({ onCreated, onClose }: { onCreated: () => void; onClose: () => void }) {
const [form, setForm] = useState({
name: '', start_date: new Date().toISOString().slice(0, 10),
category: 'fundamental', level: 'short', sub_type: '',
description: '', impact_score: 0.5,
level: 'short', description: '', impact_score: 0.5,
})
const [saving, setSaving] = useState(false)
@@ -902,13 +911,6 @@ function CreateModal({ onCreated, onClose }: { onCreated: () => void; onClose: (
<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">Catégorie</div>
<select value={form.category} onChange={f('category')}
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm">
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<div className="text-xs text-slate-500 mb-1">Niveau</div>
<select value={form.level} onChange={f('level')}
@@ -916,11 +918,6 @@ function CreateModal({ onCreated, onClose }: { onCreated: () => void; onClose: (
{LEVELS.map(l => <option key={l} value={l}>{l}</option>)}
</select>
</div>
<div>
<div className="text-xs text-slate-500 mb-1">Sous-type</div>
<input value={form.sub_type} onChange={f('sub_type')} placeholder="ex: CPI, Conflit..."
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm" />
</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" />
@@ -956,7 +953,7 @@ function EventRow({
selected && 'bg-slate-800/60 border-l-2 border-l-blue-500',
)}
>
<span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', CAT_DOT[ev.category] ?? 'bg-slate-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">
@@ -984,327 +981,12 @@ function EventRow({
)
}
// ── Types catégorie ──────────────────────────────────────────────────────────
interface DefaultImpact {
instrument_id: string
sensitivity: number // 0-1
typical_direction: string
notes: string
}
interface EventCategory {
id?: number
name: string
type: string
sub_type: string
description: string
default_impacts: DefaultImpact[]
is_ai_generated?: number
updated_at?: string
}
// ── Category editor panel ─────────────────────────────────────────────────────
function CategoryEditor({
cat, onSaved, onDeleted, onClose,
}: {
cat: EventCategory | null
onSaved: () => void
onDeleted?: () => void
onClose: () => void
}) {
const isNew = !cat?.id
const [form, setForm] = useState<EventCategory>(cat ?? {
name: '', type: 'event_calendar', sub_type: '', description: '', default_impacts: [],
})
const [saving, setSaving] = useState(false)
const [deleting, setDel] = useState(false)
const save = async () => {
if (!form.name) return
setSaving(true)
await fetch(`/api/impact/categories/${encodeURIComponent(form.name)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: form.type, sub_type: form.sub_type,
description: form.description,
default_impacts: form.default_impacts,
}),
})
setSaving(false)
onSaved()
onClose()
}
const del = async () => {
if (!cat?.name || !confirm(`Supprimer la catégorie "${cat.name}" ?`)) return
setDel(true)
await fetch(`/api/impact/categories/${encodeURIComponent(cat.name)}`, { method: 'DELETE' })
setDel(false)
onDeleted?.()
onClose()
}
const setImpact = (idx: number, field: keyof DefaultImpact, value: any) => {
setForm(f => {
const impacts = [...f.default_impacts]
impacts[idx] = { ...impacts[idx], [field]: value }
return { ...f, default_impacts: impacts }
})
}
const addImpact = () => {
setForm(f => ({
...f,
default_impacts: [...f.default_impacts, { instrument_id: 'SPY', sensitivity: 0.5, typical_direction: 'depends_on_outcome', notes: '' }],
}))
}
const removeImpact = (idx: number) => {
setForm(f => ({ ...f, default_impacts: f.default_impacts.filter((_, i) => i !== idx) }))
}
return (
<div className="flex flex-col h-full overflow-hidden">
<div className="flex items-center gap-3 px-5 py-4 border-b border-slate-700/40 shrink-0">
<h3 className="text-slate-100 font-semibold text-sm flex-1">
{isNew ? 'Nouvelle catégorie' : `Éditer : ${cat!.name}`}
</h3>
{!isNew && (
<button onClick={del} disabled={deleting}
className="flex items-center gap-1 text-xs text-red-400 hover:text-red-300 border border-red-800/50 rounded px-2 py-1">
{deleting ? <Loader2 size={11} className="animate-spin" /> : <Trash2 size={11} />} Supprimer
</button>
)}
<button onClick={onClose} className="text-slate-500 hover:text-slate-300"><X size={14} /></button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
{/* Name + type */}
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-xs text-slate-500 mb-1">Nom *</div>
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
disabled={!isNew}
placeholder="ex: BOE Décision de taux"
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-1.5 text-slate-200 text-sm disabled:opacity-50" />
</div>
<div>
<div className="text-xs text-slate-500 mb-1">Type</div>
<select value={form.type} onChange={e => setForm(f => ({ ...f, type: e.target.value }))}
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-1.5 text-slate-200 text-sm">
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<div className="text-xs text-slate-500 mb-1">Sous-type</div>
<input value={form.sub_type} onChange={e => setForm(f => ({ ...f, sub_type: e.target.value }))}
placeholder="ex: BOE"
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-1.5 text-slate-200 text-sm" />
</div>
<div>
<div className="text-xs text-slate-500 mb-1">Description</div>
<input value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
placeholder="Résumé court..."
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-1.5 text-slate-200 text-sm" />
</div>
</div>
{/* Default impacts table */}
<div>
<div className="flex items-center justify-between mb-2">
<div className="text-xs text-slate-500 uppercase tracking-wide">
Impacts par défaut
<span className="text-slate-600 ml-1.5 normal-case">— l'IA s'appuie sur ces sensibilités comme base</span>
</div>
<button onClick={addImpact}
className="flex items-center gap-1 text-xs text-emerald-400 hover:text-emerald-300 border border-emerald-800/50 rounded px-2 py-1">
<Plus size={11} /> Ajouter
</button>
</div>
{form.default_impacts.length === 0 ? (
<div className="text-xs text-slate-600 italic px-3 py-3 bg-slate-800/20 rounded-lg border border-slate-800/40">
Aucun impact par défaut — cliquez sur "Ajouter" pour en définir.
</div>
) : (
<div className="bg-slate-800/30 rounded-lg overflow-hidden">
{/* Header */}
<div className="grid text-xs text-slate-600 px-3 py-1.5 border-b border-slate-800 bg-slate-800/50"
style={{ gridTemplateColumns: '7rem 8rem 2rem 10rem 1fr 1.5rem' }}>
<span>Instrument</span>
<span>Sensibilité</span>
<span></span>
<span>Direction type</span>
<span>Notes</span>
<span></span>
</div>
{form.default_impacts.map((imp, idx) => (
<div key={idx} className="grid items-center gap-2 px-3 py-2 border-b border-slate-800/60 last:border-0"
style={{ gridTemplateColumns: '7rem 8rem 2rem 10rem 1fr 1.5rem' }}>
<select value={imp.instrument_id}
onChange={e => setImpact(idx, 'instrument_id', e.target.value)}
className="bg-dark-900 border border-slate-700 rounded px-1.5 py-1 text-slate-200 text-xs w-full">
{ALL_INSTRUMENTS.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<div className="flex flex-col gap-0.5">
<input type="range" min="0" max="1" step="0.05" value={imp.sensitivity}
onChange={e => setImpact(idx, 'sensitivity', +e.target.value)}
className="accent-amber-400 w-full" />
<span className="text-xs text-center text-amber-300">{imp.sensitivity.toFixed(2)}</span>
</div>
<span className={clsx('text-sm font-bold text-center', DIR_COLORS[imp.typical_direction] ?? 'text-slate-400')}>
{DIR_ICONS[imp.typical_direction] ?? '●'}
</span>
<select value={imp.typical_direction}
onChange={e => setImpact(idx, 'typical_direction', e.target.value)}
className="bg-dark-900 border border-slate-700 rounded px-1.5 py-1 text-slate-200 text-xs w-full">
{['bullish','bearish','neutral','depends_on_outcome'].map(d => <option key={d} value={d}>{d}</option>)}
</select>
<input value={imp.notes} onChange={e => setImpact(idx, 'notes', e.target.value)}
placeholder="Note..."
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs w-full" />
<button onClick={() => removeImpact(idx)}
className="text-slate-600 hover:text-red-400"><Trash2 size={12} /></button>
</div>
))}
</div>
)}
</div>
</div>
<div className="px-5 pb-5 pt-3 border-t border-slate-700/30 flex gap-2 shrink-0">
<button onClick={save} 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" /> : <CheckCircle size={14} />}
{isNew ? 'Créer' : 'Sauvegarder'}
</button>
<button onClick={onClose} className="text-sm text-slate-500 hover:text-slate-300 px-4">Annuler</button>
</div>
</div>
)
}
// ── Categories panel ──────────────────────────────────────────────────────────
function CategoriesPanel() {
const [cats, setCats] = useState<EventCategory[]>([])
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<EventCategory | null>(null)
const [showNew, setShowNew] = useState(false)
const [search, setSearch] = useState('')
const load = useCallback(async () => {
setLoading(true)
const r = await fetch('/api/impact/categories')
if (r.ok) {
const data = await r.json()
setCats(data.map((c: any) => ({
...c,
default_impacts: typeof c.default_impacts === 'string'
? JSON.parse(c.default_impacts || '[]')
: (c.default_impacts || []),
})))
}
setLoading(false)
}, [])
useEffect(() => { load() }, [load])
const filtered = cats.filter(c =>
!search || c.name.toLowerCase().includes(search.toLowerCase()) ||
c.type.includes(search.toLowerCase()) || c.sub_type.toLowerCase().includes(search.toLowerCase())
)
// Group by type
const grouped = CATEGORIES.reduce((acc, t) => {
acc[t] = filtered.filter(c => c.type === t)
return acc
}, {} as Record<string, EventCategory[]>)
return (
<div className="flex h-full overflow-hidden">
{/* Left: list */}
<div className="w-72 shrink-0 flex flex-col border-r border-slate-700/40 overflow-hidden">
<div className="px-4 py-3 border-b border-slate-700/30 space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-slate-200 flex-1">Catégories ({cats.length})</span>
<button onClick={() => { setSelected(null); setShowNew(true) }}
className="flex items-center gap-1 bg-emerald-700 hover:bg-emerald-600 text-white text-xs rounded px-2 py-1">
<Plus size={11} /> Nouvelle
</button>
<button onClick={load} disabled={loading}
className="p-1 text-slate-500 hover:text-slate-300">
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
</button>
</div>
<div className="relative">
<Search size={12} 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="Filtrer..."
className="w-full bg-slate-800/60 border border-slate-700/50 rounded pl-7 pr-3 py-1.5 text-xs text-slate-200 placeholder:text-slate-600" />
</div>
</div>
<div className="flex-1 overflow-y-auto">
{CATEGORIES.map(type => {
const group = grouped[type] ?? []
if (group.length === 0) return null
return (
<div key={type}>
<div className={clsx('px-3 py-1.5 text-xs font-semibold uppercase tracking-wide sticky top-0 bg-dark-900/90',
CAT_COLORS[type]?.split(' ')[1] ?? 'text-slate-500')}>
{type.replace('_', ' ')} ({group.length})
</div>
{group.map(c => (
<div key={c.name}
onClick={() => { setSelected(c); setShowNew(false) }}
className={clsx(
'flex items-center gap-2 px-4 py-2.5 cursor-pointer border-b border-slate-800/40 hover:bg-slate-800/40',
selected?.name === c.name && 'bg-slate-800/60 border-l-2 border-l-blue-500',
)}>
<span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', CAT_DOT[c.type] ?? 'bg-slate-500')} />
<div className="flex-1 min-w-0">
<div className="text-xs text-slate-200 font-medium truncate">{c.name}</div>
<div className="text-xs text-slate-600">{c.default_impacts.length} instruments</div>
</div>
</div>
))}
</div>
)
})}
</div>
</div>
{/* Right: editor */}
<div className="flex-1 overflow-hidden">
{(showNew || selected) ? (
<CategoryEditor
key={showNew ? 'new' : selected!.name}
cat={showNew ? null : selected}
onSaved={() => { load(); setShowNew(false) }}
onDeleted={() => { load(); setSelected(null) }}
onClose={() => { setShowNew(false); setSelected(null) }}
/>
) : (
<div className="flex flex-col items-center justify-center h-full text-slate-600 gap-3">
<SlidersHorizontal size={36} className="opacity-30" />
<div className="text-sm">Sélectionne une catégorie pour l'éditer</div>
<div className="text-xs opacity-60">Les impacts par défaut sont utilisés par l'IA comme base de référence</div>
</div>
)}
</div>
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
// ── Main page ─────────────────────────────────────────────────────────────────
export default function MarketEvents() {
const [searchParams] = useSearchParams()
const [activeTab, setActiveTab] = useState<'events' | 'categories'>('events')
const [events, setEvents] = useState<MarketEvent[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
@@ -1316,7 +998,6 @@ export default function MarketEvents() {
// ── 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'>('')
@@ -1368,7 +1049,6 @@ export default function MarketEvents() {
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)
@@ -1395,7 +1075,7 @@ export default function MarketEvents() {
if (e.name === 'AbortError') return // superseded by a newer load
}
setLoading(false)
}, [search, category, level, minScore, dateFrom, dateTo, genFrom, genTo, evaluated,
}, [search, level, minScore, dateFrom, dateTo, genFrom, genTo, evaluated,
instTicker, instMinScore, instDir, sortBy, sortDir])
useEffect(() => {
@@ -1431,31 +1111,7 @@ export default function MarketEvents() {
return (
<div className="flex flex-col h-screen overflow-hidden">
{/* ── Tab bar ── */}
<div className="flex items-center gap-0 border-b border-slate-700/40 bg-dark-800/80 px-4 shrink-0">
<h1 className="text-sm font-bold text-slate-300 mr-6 py-3">Market Events</h1>
{(['events', 'categories'] as const).map(tab => (
<button key={tab} onClick={() => setActiveTab(tab)}
className={clsx(
'px-4 py-3 text-sm border-b-2 transition-colors',
activeTab === tab
? 'border-blue-500 text-blue-300 font-medium'
: 'border-transparent text-slate-500 hover:text-slate-300',
)}>
{tab === 'events' ? '📋 Événements' : '🏷️ Catégories & Defaults'}
</button>
))}
</div>
{/* ── Categories tab ── */}
{activeTab === 'categories' && (
<div className="flex-1 overflow-hidden">
<CategoriesPanel />
</div>
)}
{/* ── Events tab ── */}
{activeTab === 'events' && <div className="flex flex-1 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 ── */}
@@ -1505,18 +1161,6 @@ export default function MarketEvents() {
</div>
)}
{/* ── Category chips ── */}
<div className="flex flex-wrap gap-1">
{CATEGORIES.map(c => (
<button key={c} onClick={() => setCategory(category === c ? '' : c)}
className={clsx('px-2 py-0.5 rounded-full text-xs border transition-colors',
category === c
? (CAT_COLORS[c] ?? 'bg-slate-700 text-slate-200 border-slate-600')
: 'border-slate-800 text-slate-600 hover:border-slate-600 hover:text-slate-400')}>
{c.replace('_', ' ')}
</button>
))}
</div>
</div>
{/* ── Sort + more filters toggle ── */}
@@ -1673,9 +1317,9 @@ export default function MarketEvents() {
<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>}
{(category || level || search || minScore > 0 || evaluated || dateFrom || dateTo || genFrom || genTo || instTicker) && (
{(level || search || minScore > 0 || evaluated || dateFrom || dateTo || genFrom || genTo || instTicker) && (
<button onClick={() => {
setSearch(''); setCategory(''); setLevel(''); setMinScore(0)
setSearch(''); setLevel(''); setMinScore(0)
setEvaluated(''); setDateFrom(''); setDateTo(''); setDatePreset('all')
setGenFrom(''); setGenTo('')
setInstTicker(''); setInstDir(''); setInstMin(0.3); setSortBy('date')
@@ -1729,7 +1373,7 @@ export default function MarketEvents() {
</div>
{showCreate && <CreateModal onCreated={load} onClose={() => setShowCreate(false)} />}
</div>}
</div>
</div>
)
}