feat: Market Events — catégories, cleanup sidebar, rename

- Supprime Timeline & ImpactMonitor du routing (redirects /impact + /timeline → /market-events)
- Renomme 'Instrument Snap.' → 'Instrument Analysis' dans la sidebar
- Onglet 'Catégories & Defaults' dans Market Events: CRUD complet (créer/éditer/supprimer)
  chaque catégorie a une table d'impacts par défaut (instrument, sensibilité, direction, notes)
- impact_service: meilleur matching catégorie (sub_type fuzzy + type fallback)
- DB: delete_event_category() + DELETE /api/impact/categories/{name}

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-25 21:11:24 +02:00
parent bd28b6a73a
commit 0b1fcff49c
6 changed files with 401 additions and 26 deletions

View File

@@ -42,6 +42,15 @@ def list_categories() -> List[Dict[str, Any]]:
return cats
@router.delete("/categories/{name}")
def delete_category(name: str) -> Dict[str, Any]:
from services.database import delete_event_category
ok = delete_event_category(name)
if not ok:
raise HTTPException(404, f"Category '{name}' not found")
return {"status": "deleted", "name": name}
@router.put("/categories/{name}")
def update_category(name: str, body: CategoryUpdate) -> Dict[str, Any]:
from services.database import upsert_event_category

View File

@@ -4750,6 +4750,16 @@ def upsert_event_category(cat: Dict[str, Any]) -> int:
conn.close()
def delete_event_category(name: str) -> bool:
conn = get_conn()
try:
cur = conn.execute("DELETE FROM event_categories WHERE name=?", (name,))
conn.commit()
return cur.rowcount > 0
finally:
conn.close()
# ── Instrument impacts ────────────────────────────────────────────────────────
def save_instrument_impacts(impacts: List[Dict[str, Any]]) -> int:

View File

@@ -121,23 +121,39 @@ def evaluate_event_impacts(
if force and existing:
delete_impacts_for_source("event", event_id)
# Get category defaults for context
# Get category defaults — try exact name match first, then fuzzy by sub_type/type
category_defaults = None
cat_name = event.get("sub_type") or event.get("category", "")
if cat_name:
for possible in [
cat_name,
f"FOMC — Décision de taux" if "FOMC" in cat_name.upper() else None,
f"NFP — Non-Farm Payrolls" if "NFP" in cat_name.upper() else None,
]:
if possible:
cat = get_event_category(possible)
if cat:
try:
category_defaults = json.loads(cat.get("default_impacts") or "[]")
except Exception:
pass
break
from services.database import get_all_event_categories
all_cats = get_all_event_categories()
def _find_cat(candidates):
for c in candidates:
if c is None:
continue
row = next((x for x in all_cats if x["name"].lower() == c.lower()), None)
if row:
try:
return json.loads(row.get("default_impacts") or "[]")
except Exception:
pass
return None
sub = (event.get("sub_type") or "").strip()
cat = (event.get("category") or "").strip()
name = (event.get("name") or "").upper()
# Priority: exact sub_type name → fuzzy keyword in category name → type match
candidates = [sub]
for row in all_cats:
rname = row["name"].upper()
if sub and sub.upper() in rname:
candidates.append(row["name"])
elif any(k in name for k in ["FOMC", "FED"] if k in rname):
candidates.append(row["name"])
elif row.get("type") == cat and not sub:
candidates.append(row["name"])
category_defaults = _find_cat(candidates)
api_key = _get_api_key()
if not api_key:

View File

@@ -23,10 +23,8 @@ import VaRAnalysis from './pages/VaRAnalysis'
import PositionHistory from './pages/PositionHistory'
import InstitutionalReports from './pages/InstitutionalReports'
import SpecialistDesks from './pages/SpecialistDesks'
import Timeline from './pages/Timeline'
import ExternalSnapshot from './pages/ExternalSnapshot'
import InstrumentDashboard from './pages/InstrumentDashboard'
import ImpactMonitor from './pages/ImpactMonitor'
import CycleActions from './pages/CycleActions'
import MarketEvents from './pages/MarketEvents'
import { Navigate } from 'react-router-dom'
@@ -68,11 +66,12 @@ export default function App() {
<Route path="/logs" element={<SystemLogs />} />
<Route path="/institutional" element={<InstitutionalReports />} />
<Route path="/specialist-desks" element={<SpecialistDesks />} />
<Route path="/timeline" element={<Timeline />} />
<Route path="/snapshot" element={<ExternalSnapshot />} />
<Route path="/instruments" element={<Navigate to="/instruments/SPY" replace />} />
<Route path="/instruments/:id" element={<InstrumentDashboard />} />
<Route path="/impact" element={<ImpactMonitor />} />
{/* Legacy redirects */}
<Route path="/impact" element={<Navigate to="/market-events" replace />} />
<Route path="/timeline" element={<Navigate to="/market-events" replace />} />
<Route path="/market-events" element={<MarketEvents />} />
<Route path="/cycle-actions" element={<CycleActions />} />
</Routes>

View File

@@ -1,7 +1,7 @@
import { NavLink } from 'react-router-dom'
import {
LayoutDashboard, Globe, BarChart2, FlaskConical,
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, Layers, ScanEye, CandlestickChart, Crosshair, PlayCircle, Radio
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, ScanEye, CandlestickChart, PlayCircle, Radio
} from 'lucide-react'
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
import clsx from 'clsx'
@@ -27,12 +27,10 @@ const nav = [
{ to: '/calendar', icon: Calendar, label: 'Calendar' },
{ to: '/institutional', icon: Building2, label: 'Inst. Reports' },
{ to: '/specialist-desks', icon: Users, label: 'Specialist Desks' },
{ to: '/instruments', icon: CandlestickChart, label: 'Instrument Snap.' },
{ to: '/instruments', icon: CandlestickChart, label: 'Instrument Analysis' },
{ to: '/market-events', icon: Radio, label: 'Market Events' },
{ to: '/impact', icon: Crosshair, label: 'Impact Monitor' },
{ to: '/cycle-actions', icon: PlayCircle, label: 'Cycle Actions' },
{ to: '/snapshot', icon: ScanEye, label: 'Snapshot Externe' },
{ to: '/timeline', icon: Layers, label: 'Timeline' },
{ to: '/logs', icon: ScrollText, label: 'System Logs' },
{ to: '/config', icon: Settings, label: 'Configuration' },
]

View File

@@ -705,9 +705,326 @@ 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 ─────────────────────────────────────────────────────────────────
export default function MarketEvents() {
const [activeTab, setActiveTab] = useState<'events' | 'categories'>('events')
const [events, setEvents] = useState<MarketEvent[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
@@ -773,13 +1090,38 @@ export default function MarketEvents() {
const unevaluatedIds = events.filter(e => !e.evaluated).map(e => e.id)
return (
<div className="flex h-screen overflow-hidden">
<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">
{/* ── Left panel: filters + list ── */}
<div className="w-[380px] shrink-0 flex flex-col border-r border-slate-700/40 overflow-hidden">
{/* Top bar */}
<div className="px-4 pt-4 pb-3 border-b border-slate-700/30 space-y-2">
<div className="flex items-center gap-2">
<h1 className="text-base font-bold text-slate-100 flex-1">Market Events</h1>
<h1 className="text-base font-bold text-slate-100 flex-1">Événements</h1>
<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.5 py-1.5">
<Plus size={12} /> Nouveau
@@ -929,6 +1271,7 @@ export default function MarketEvents() {
</div>
{showCreate && <CreateModal onCreated={load} onClose={() => setShowCreate(false)} />}
</div>}
</div>
)
}