Files
OpenFin/frontend/src/pages/ImpactMonitor.tsx
OpenSquared a68896cf67 feat: Impact Monitor — AI impact evaluation for eco events & geopolitical news
- New DB tables: event_categories (18 bootstrap categories) + instrument_impacts
- impact_categories_bootstrap.py: FOMC/NFP/CPI/GDP/PCE/ISM/BOJ/ECB/BOE/OPEC+ + 7 géopolitical categories with per-instrument sensitivity & direction defaults
- impact_service.py: GPT-4o-mini evaluation (0-1 score, direction, rationale) + monitor data aggregation
- routers/impact.py: GET/POST endpoints for evaluate/bulk/monitor/adjust/categories
- ImpactMonitor.tsx: two-tab page (Évalués / À évaluer), top instruments bar, inline score override modal, category browser
- Startup auto-bootstrap for impact categories
- Route /impact + sidebar nav entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 16:26:13 +02:00

644 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback } from 'react'
import {
Zap, RefreshCw, ChevronDown, ChevronRight, AlertCircle,
TrendingUp, TrendingDown, Minus, Settings, Play, Check, Sliders,
} from 'lucide-react'
import axios from 'axios'
import clsx from 'clsx'
const api = axios.create({ baseURL: '/api' })
// ── Types ─────────────────────────────────────────────────────────────────────
interface Impact {
id: number
instrument_id: string
impact_score: number
direction: string
rationale: string
confidence: number
ai_generated: number
manually_adjusted: number
adjusted_score?: number
adjusted_direction?: string
override_rationale?: string
}
interface EvaluatedSource {
source_type: string
source_id: number
source_name: string
source_date: string
category_name: string
max_score: number
n_instruments: number
evaluated_at: string
impacts: Impact[]
}
interface UnevaluatedEvent {
id: number
name: string
start_date: string
category: string
sub_type: string
impact_score: number
}
interface TopInstrument {
instrument_id: string
avg_score: number
n_events: number
}
interface MonitorData {
evaluated: EvaluatedSource[]
unevaluated: UnevaluatedEvent[]
top_instruments: TopInstrument[]
period_days: number
min_score: number
}
interface DefaultImpact {
instrument_id: string
sensitivity: number
typical_direction: string
notes: string
}
interface Category {
id: number
name: string
type: string
sub_type: string
description: string
default_impacts: DefaultImpact[]
}
// ── Helpers ───────────────────────────────────────────────────────────────────
const DIR_CFG: Record<string, { icon: React.ReactNode; color: string; short: string }> = {
bullish: { icon: <TrendingUp className="w-3 h-3" />, color: 'text-emerald-400', short: '↑' },
bearish: { icon: <TrendingDown className="w-3 h-3" />, color: 'text-red-400', short: '↓' },
neutral: { icon: <Minus className="w-3 h-3" />, color: 'text-slate-400', short: '—' },
depends_on_outcome: { icon: <Minus className="w-3 h-3" />, color: 'text-amber-400', short: '?' },
bullish_if_beat: { icon: <TrendingUp className="w-3 h-3" />, color: 'text-emerald-300', short: '↑?' },
bearish_if_hawkish: { icon: <TrendingDown className="w-3 h-3" />, color: 'text-red-300', short: '↓H' },
bullish_if_surprise: { icon: <TrendingUp className="w-3 h-3" />, color: 'text-violet-400', short: '↑!' },
}
const TYPE_CFG: Record<string, { color: string; label: string }> = {
event_calendar: { color: 'text-amber-400 bg-amber-900/30 border-amber-700/30', label: 'Calendrier' },
geopolitical: { color: 'text-red-400 bg-red-900/30 border-red-700/30', label: 'Géopolitique' },
}
function scoreBar(score: number, color = 'bg-blue-500') {
return (
<div className="flex items-center gap-1.5">
<div className="w-16 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full', color)} style={{ width: `${score * 100}%` }} />
</div>
<span className="text-xs tabular-nums text-slate-400">{(score * 100).toFixed(0)}</span>
</div>
)
}
function scoreColor(score: number) {
if (score >= 0.7) return 'bg-red-500'
if (score >= 0.5) return 'bg-orange-500'
if (score >= 0.3) return 'bg-amber-500'
return 'bg-slate-500'
}
function dirColor(dir: string) {
return DIR_CFG[dir]?.color ?? 'text-slate-400'
}
// ── AdjustModal ───────────────────────────────────────────────────────────────
function AdjustModal({
impact, onClose, onSave,
}: {
impact: Impact
onClose: () => void
onSave: (id: number, score: number, dir: string, rationale: string) => void
}) {
const [score, setScore] = useState(impact.adjusted_score ?? impact.impact_score)
const [dir, setDir] = useState(impact.adjusted_direction ?? impact.direction)
const [rationale, setRationale] = useState(impact.override_rationale ?? '')
const DIRS = ['bullish','bearish','neutral','depends_on_outcome']
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
<div className="bg-dark-800 border border-slate-700/40 rounded-xl p-5 w-[400px] space-y-4 shadow-2xl">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-white">Ajuster l'impact — {impact.instrument_id}</span>
<button onClick={onClose} className="text-slate-500 hover:text-white"><span className="text-lg">×</span></button>
</div>
<div>
<div className="text-xs text-slate-500 mb-1">Score IA : {(impact.impact_score * 100).toFixed(0)}</div>
<label className="text-xs text-slate-400 mb-1 block">Score ajusté : {(score * 100).toFixed(0)}</label>
<input type="range" min={0} max={1} step={0.05} value={score}
onChange={e => setScore(parseFloat(e.target.value))}
className="w-full accent-blue-500" />
</div>
<div>
<label className="text-xs text-slate-400 mb-1 block">Direction</label>
<div className="flex gap-1.5">
{DIRS.map(d => (
<button key={d} onClick={() => setDir(d)}
className={clsx('flex-1 text-xs py-1 rounded border transition-colors',
dir === d
? 'border-blue-500 bg-blue-900/30 text-blue-300'
: 'border-slate-700/40 text-slate-500 hover:text-white'
)}>
{d === 'depends_on_outcome' ? '?' : d.slice(0,4)}
</button>
))}
</div>
</div>
<div>
<label className="text-xs text-slate-400 mb-1 block">Commentaire</label>
<textarea
className="w-full text-xs bg-dark-700 border border-slate-700/40 rounded px-2 py-1.5 text-slate-300 placeholder-slate-600 resize-none"
rows={2} placeholder="Pourquoi ajuster..."
value={rationale} onChange={e => setRationale(e.target.value)}
/>
</div>
<div className="flex gap-2">
<button onClick={onClose}
className="flex-1 text-xs py-1.5 border border-slate-700/40 rounded-lg text-slate-400 hover:text-white">
Annuler
</button>
<button onClick={() => { onSave(impact.id, score, dir, rationale); onClose() }}
className="flex-1 text-xs py-1.5 bg-blue-700/40 hover:bg-blue-700/60 text-blue-300 border border-blue-600/40 rounded-lg">
Sauvegarder
</button>
</div>
</div>
</div>
)
}
// ── ImpactRow ─────────────────────────────────────────────────────────────────
function ImpactRow({
impact, onAdjust,
}: {
impact: Impact
onAdjust: (imp: Impact) => void
}) {
const score = impact.adjusted_score ?? impact.impact_score
const dir = impact.adjusted_direction ?? impact.direction
const dc = DIR_CFG[dir] ?? DIR_CFG['neutral']
return (
<div className="flex items-center gap-2 py-0.5 group">
<span className="text-xs font-mono text-slate-300 w-20 shrink-0">{impact.instrument_id}</span>
{scoreBar(score, scoreColor(score))}
<span className={clsx('text-xs flex items-center gap-0.5 w-16 shrink-0', dc.color)}>
{dc.icon}{dc.short}
</span>
<span className="text-xs text-slate-600 truncate flex-1">{impact.rationale}</span>
{impact.manually_adjusted === 1 && (
<span className="text-xs text-violet-500 shrink-0">✎</span>
)}
<button
onClick={() => onAdjust(impact)}
className="opacity-0 group-hover:opacity-100 p-0.5 text-slate-600 hover:text-white transition-opacity"
title="Ajuster">
<Sliders className="w-3 h-3" />
</button>
</div>
)
}
// ── EvaluatedCard ─────────────────────────────────────────────────────────────
function EvaluatedCard({
source, onAdjust, onReEval,
}: {
source: EvaluatedSource
onAdjust: (imp: Impact) => void
onReEval: (id: number) => void
}) {
const [open, setOpen] = useState(false)
const catType = source.impacts[0] ? 'event_calendar' : 'geopolitical'
const tcfg = TYPE_CFG[source.source_type === 'news' ? 'geopolitical' : 'event_calendar']
const sorted = [...source.impacts].sort((a, b) =>
(b.adjusted_score ?? b.impact_score) - (a.adjusted_score ?? a.impact_score)
)
return (
<div className="rounded-xl border border-slate-700/30 bg-dark-800/60 overflow-hidden">
<div
className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-dark-700/40 transition-colors"
onClick={() => setOpen(o => !o)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-sm font-medium text-white truncate">{source.source_name}</span>
</div>
<div className="flex items-center gap-2 text-xs text-slate-500">
<span>{source.source_date}</span>
{source.category_name && (
<span className={clsx('px-1.5 py-0 rounded border', tcfg?.color ?? 'text-slate-400 border-slate-700/30')}
style={{ fontSize: 9, paddingTop: 1, paddingBottom: 1 }}>
{source.category_name}
</span>
)}
<span>{source.n_instruments} instruments</span>
</div>
</div>
<div className="flex items-center gap-3 shrink-0">
{/* Top 3 instruments mini-badges */}
<div className="flex gap-1">
{sorted.slice(0, 3).map(imp => (
<span key={imp.instrument_id}
className={clsx('text-xs px-1.5 py-0.5 rounded font-mono',
(imp.adjusted_score ?? imp.impact_score) >= 0.7
? 'bg-red-900/30 text-red-300'
: (imp.adjusted_score ?? imp.impact_score) >= 0.5
? 'bg-orange-900/30 text-orange-300'
: 'bg-slate-700/40 text-slate-400'
)}>
{imp.instrument_id}
</span>
))}
</div>
<div className="flex items-center gap-1">
<div className="w-8 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full', scoreColor(source.max_score))}
style={{ width: `${source.max_score * 100}%` }} />
</div>
<span className="text-xs text-slate-400">{(source.max_score * 100).toFixed(0)}</span>
</div>
<button
onClick={e => { e.stopPropagation(); onReEval(source.source_id) }}
className="p-1 text-slate-600 hover:text-amber-400 transition-colors"
title="Re-évaluer">
<RefreshCw className="w-3 h-3" />
</button>
{open ? <ChevronDown className="w-4 h-4 text-slate-500" /> : <ChevronRight className="w-4 h-4 text-slate-500" />}
</div>
</div>
{open && (
<div className="px-4 pb-3 border-t border-slate-700/20 pt-2 space-y-0.5">
{sorted.map(imp => (
<ImpactRow key={imp.id} impact={imp} onAdjust={onAdjust} />
))}
</div>
)}
</div>
)
}
// ── CategoryPanel ─────────────────────────────────────────────────────────────
function CategoryPanel({ onClose }: { onClose: () => void }) {
const [categories, setCategories] = useState<Category[]>([])
const [selected, setSelected] = useState<Category | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
api.get('/impact/categories').then(r => {
setCategories(r.data)
setLoading(false)
}).catch(() => setLoading(false))
}, [])
return (
<div className="rounded-xl border border-slate-700/40 bg-dark-800/80 p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-white">Catégories & Impacts par défaut</span>
<button onClick={onClose} className="text-slate-500 hover:text-white text-lg">×</button>
</div>
{loading ? (
<div className="text-xs text-slate-500">Chargement...</div>
) : (
<div className="grid grid-cols-2 gap-3">
{/* Category list */}
<div className="space-y-1 max-h-[500px] overflow-y-auto pr-1">
{categories.map(cat => (
<div key={cat.name}
className={clsx('rounded-lg border px-3 py-2 cursor-pointer transition-colors',
selected?.name === cat.name
? 'border-blue-600/50 bg-blue-900/20'
: 'border-slate-700/30 bg-dark-700/40 hover:bg-dark-700/70'
)}
onClick={() => setSelected(cat)}
>
<div className="flex items-center gap-2">
<span className={clsx('text-xs px-1.5 py-0 rounded border',
TYPE_CFG[cat.type]?.color ?? 'text-slate-400 border-slate-700/30'
)} style={{ fontSize: 8, paddingTop: 1, paddingBottom: 1 }}>
{TYPE_CFG[cat.type]?.label ?? cat.type}
</span>
<span className="text-xs text-slate-300 truncate">{cat.name}</span>
</div>
<div className="text-xs text-slate-600 mt-0.5">{cat.default_impacts.length} instruments</div>
</div>
))}
</div>
{/* Default impacts detail */}
<div className="border border-slate-700/30 rounded-lg p-3 max-h-[500px] overflow-y-auto">
{selected ? (
<div className="space-y-2">
<div className="text-xs font-semibold text-white">{selected.name}</div>
<div className="text-xs text-slate-500">{selected.description}</div>
<div className="space-y-1 mt-2">
{[...selected.default_impacts]
.sort((a, b) => b.sensitivity - a.sensitivity)
.map(di => {
const dc = DIR_CFG[di.typical_direction] ?? DIR_CFG['neutral']
return (
<div key={di.instrument_id} className="flex items-center gap-2 py-0.5">
<span className="text-xs font-mono text-slate-300 w-20 shrink-0">{di.instrument_id}</span>
{scoreBar(di.sensitivity, scoreColor(di.sensitivity))}
<span className={clsx('text-xs shrink-0', dc.color)}>{dc.short}</span>
<span className="text-xs text-slate-600 truncate">{di.notes}</span>
</div>
)
})}
</div>
</div>
) : (
<div className="text-xs text-slate-600 italic">Sélectionne une catégorie</div>
)}
</div>
</div>
)}
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function ImpactMonitor() {
const [data, setData] = useState<MonitorData | null>(null)
const [loading, setLoading] = useState(false)
const [evaluating, setEvaluating] = useState<Set<number>>(new Set())
const [days, setDays] = useState(14)
const [minScore, setMinScore] = useState(0.3)
const [adjusting, setAdjusting] = useState<Impact | null>(null)
const [showCategories, setShowCategories] = useState(false)
const [tab, setTab] = useState<'evaluated' | 'pending'>('evaluated')
const load = useCallback(() => {
setLoading(true)
api.get(`/impact/monitor?days=${days}&min_score=${minScore}`)
.then(r => setData(r.data))
.catch(() => {})
.finally(() => setLoading(false))
}, [days, minScore])
useEffect(() => { load() }, [load])
async function evaluateEvent(eventId: number, force = false) {
setEvaluating(s => new Set(s).add(eventId))
try {
await api.post(`/impact/evaluate/event/${eventId}?force=${force}`)
load()
} catch (e) {
console.error(e)
} finally {
setEvaluating(s => { const n = new Set(s); n.delete(eventId); return n })
}
}
async function evaluateAllPending() {
if (!data?.unevaluated.length) return
const ids = data.unevaluated.slice(0, 10).map(e => e.id)
setEvaluating(new Set(ids))
try {
await api.post('/impact/evaluate/bulk', ids)
load()
} finally {
setEvaluating(new Set())
}
}
async function saveAdjustment(id: number, score: number, dir: string, rationale: string) {
await api.put(`/impact/adjust/${id}`, {
adjusted_score: score,
adjusted_direction: dir,
override_rationale: rationale,
})
load()
}
const evaluated = data?.evaluated ?? []
const unevaluated = data?.unevaluated ?? []
const topInstr = data?.top_instruments ?? []
return (
<div className="p-6 max-w-screen-xl mx-auto space-y-5">
{/* Header */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<Zap className="w-5 h-5 text-amber-400" />
<h1 className="text-xl font-bold text-white">Impact Monitor</h1>
<span className="text-xs text-slate-500 bg-dark-700 border border-slate-700/40 px-2 py-0.5 rounded-full">
Événements Éco + Géopolitique
</span>
</div>
<div className="ml-auto flex items-center gap-2">
{/* Days selector */}
<div className="flex items-center gap-1 bg-dark-800 border border-slate-700/40 rounded-xl p-1">
{[7, 14, 30, 90].map(d => (
<button key={d} onClick={() => setDays(d)}
className={clsx('px-3 py-1 text-xs rounded-lg transition-colors',
days === d ? 'bg-blue-700/50 text-blue-300' : 'text-slate-400 hover:text-white hover:bg-dark-700'
)}>
{d}j
</button>
))}
</div>
{/* Min score */}
<div className="flex items-center gap-1.5 text-xs text-slate-400">
<span>Seuil</span>
<select
className="bg-dark-700 border border-slate-700/40 rounded px-1.5 py-1 text-xs text-slate-300"
value={minScore}
onChange={e => setMinScore(parseFloat(e.target.value))}
>
{[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7].map(v => (
<option key={v} value={v}>{(v * 100).toFixed(0)}</option>
))}
</select>
</div>
<button onClick={load} disabled={loading}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-dark-800 border border-slate-700/40 rounded-lg text-slate-400 hover:text-white transition-colors disabled:opacity-40">
<RefreshCw className={clsx('w-3 h-3', loading && 'animate-spin')} />
Actualiser
</button>
<button onClick={() => setShowCategories(s => !s)}
className={clsx('flex items-center gap-1.5 text-xs px-3 py-1.5 border rounded-lg transition-colors',
showCategories
? 'bg-slate-700/50 text-white border-slate-600/50'
: 'bg-dark-800 border-slate-700/40 text-slate-400 hover:text-white'
)}>
<Settings className="w-3 h-3" />
Catégories
</button>
</div>
</div>
{/* Categories panel */}
{showCategories && <CategoryPanel onClose={() => setShowCategories(false)} />}
{/* Top instruments bar */}
{topInstr.length > 0 && (
<div className="rounded-xl border border-slate-700/30 bg-dark-800/60 px-4 py-3">
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">
Instruments les plus impactés — {days}j
</div>
<div className="flex flex-wrap gap-2">
{topInstr.map(ti => (
<div key={ti.instrument_id}
className="flex items-center gap-1.5 bg-dark-700/60 border border-slate-700/30 rounded-lg px-2.5 py-1">
<span className="text-xs font-mono font-semibold text-white">{ti.instrument_id}</span>
<div className="w-12 h-1 bg-slate-800 rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full', scoreColor(ti.avg_score))}
style={{ width: `${ti.avg_score * 100}%` }} />
</div>
<span className="text-xs text-slate-500">{ti.n_events}×</span>
</div>
))}
</div>
</div>
)}
{/* Tabs */}
<div className="flex items-center gap-1 border-b border-slate-700/30">
<button onClick={() => setTab('evaluated')}
className={clsx('px-4 py-2 text-sm transition-colors border-b-2',
tab === 'evaluated'
? 'text-white border-blue-500'
: 'text-slate-500 border-transparent hover:text-white'
)}>
Évalués <span className="ml-1.5 text-xs text-slate-600">({evaluated.length})</span>
</button>
<button onClick={() => setTab('pending')}
className={clsx('px-4 py-2 text-sm transition-colors border-b-2',
tab === 'pending'
? 'text-white border-amber-500'
: 'text-slate-500 border-transparent hover:text-white'
)}>
À évaluer
{unevaluated.length > 0 && (
<span className="ml-1.5 text-xs px-1.5 py-0.5 bg-amber-900/40 text-amber-400 rounded-full border border-amber-700/30">
{unevaluated.length}
</span>
)}
</button>
</div>
{/* Loading */}
{loading && (
<div className="space-y-3">
{[1,2,3].map(i => (
<div key={i} className="h-16 rounded-xl bg-dark-800/60 border border-slate-700/30 animate-pulse" />
))}
</div>
)}
{/* Evaluated tab */}
{!loading && tab === 'evaluated' && (
<div className="space-y-2">
{evaluated.length === 0 ? (
<div className="text-center py-16 text-slate-500">
<AlertCircle className="w-8 h-8 mx-auto mb-3 opacity-30" />
<p>Aucun événement évalué sur les {days} derniers jours</p>
<p className="text-xs mt-1">Passe à l'onglet "À évaluer" pour lancer les évaluations IA</p>
</div>
) : (
evaluated.map(src => (
<EvaluatedCard
key={`${src.source_type}-${src.source_id}`}
source={src}
onAdjust={imp => setAdjusting(imp)}
onReEval={id => evaluateEvent(id, true)}
/>
))
)}
</div>
)}
{/* Pending tab */}
{!loading && tab === 'pending' && (
<div className="space-y-3">
{unevaluated.length > 0 && (
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500">
{unevaluated.length} événement(s) récents sans évaluation IA
</span>
<button
onClick={evaluateAllPending}
disabled={evaluating.size > 0}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-amber-800/30 hover:bg-amber-800/50 text-amber-300 border border-amber-700/40 rounded-lg transition-colors disabled:opacity-40">
<Play className="w-3 h-3" />
Évaluer les 10 premiers (IA)
</button>
</div>
)}
{unevaluated.length === 0 ? (
<div className="text-center py-16 text-slate-500">
<Check className="w-8 h-8 mx-auto mb-3 opacity-30 text-emerald-400" />
<p>Tous les événements récents ont é évalués</p>
</div>
) : (
<div className="space-y-2">
{unevaluated.map(ev => (
<div key={ev.id}
className="flex items-center gap-3 rounded-xl border border-slate-700/30 bg-dark-800/60 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="text-sm text-slate-300 truncate">{ev.name}</div>
<div className="flex items-center gap-2 mt-0.5 text-xs text-slate-500">
<span>{ev.start_date}</span>
{ev.sub_type && (
<span className="px-1.5 py-0 rounded bg-slate-700/40 border border-slate-600/30"
style={{ fontSize: 9, paddingTop: 1, paddingBottom: 1 }}>
{ev.sub_type}
</span>
)}
</div>
</div>
<button
onClick={() => evaluateEvent(ev.id)}
disabled={evaluating.has(ev.id)}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-blue-800/30 hover:bg-blue-800/50 text-blue-300 border border-blue-700/40 rounded-lg transition-colors disabled:opacity-40">
{evaluating.has(ev.id)
? <><RefreshCw className="w-3 h-3 animate-spin" /> Analyse IA...</>
: <><Zap className="w-3 h-3" /> Évaluer IA</>
}
</button>
</div>
))}
</div>
)}
</div>
)}
{/* Adjust modal */}
{adjusting && (
<AdjustModal
impact={adjusting}
onClose={() => setAdjusting(null)}
onSave={saveAdjustment}
/>
)}
</div>
)
}