diff --git a/backend/routers/impact.py b/backend/routers/impact.py index ecf2fff..5ed9711 100644 --- a/backend/routers/impact.py +++ b/backend/routers/impact.py @@ -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 diff --git a/backend/services/database.py b/backend/services/database.py index 837c707..81cb8b4 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -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: diff --git a/backend/services/impact_service.py b/backend/services/impact_service.py index f27e134..cd3d25b 100644 --- a/backend/services/impact_service.py +++ b/backend/services/impact_service.py @@ -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: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0b41b1c..c3db465 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> - } /> } /> } /> } /> - } /> + {/* Legacy redirects */} + } /> + } /> } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 8dd65ec..dd8d647 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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' }, ] diff --git a/frontend/src/pages/MarketEvents.tsx b/frontend/src/pages/MarketEvents.tsx index 8374811..a7bf3bd 100644 --- a/frontend/src/pages/MarketEvents.tsx +++ b/frontend/src/pages/MarketEvents.tsx @@ -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(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 ( +
+
+

+ {isNew ? 'Nouvelle catégorie' : `Éditer : ${cat!.name}`} +

+ {!isNew && ( + + )} + +
+ +
+ {/* Name + type */} +
+
+
Nom *
+ 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" /> +
+
+
Type
+ +
+
+
Sous-type
+ 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" /> +
+
+
Description
+ 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" /> +
+
+ + {/* Default impacts table */} +
+
+
+ Impacts par défaut + — l'IA s'appuie sur ces sensibilités comme base +
+ +
+ + {form.default_impacts.length === 0 ? ( +
+ Aucun impact par défaut — cliquez sur "Ajouter" pour en définir. +
+ ) : ( +
+ {/* Header */} +
+ Instrument + Sensibilité + + Direction type + Notes + +
+ {form.default_impacts.map((imp, idx) => ( +
+ +
+ setImpact(idx, 'sensitivity', +e.target.value)} + className="accent-amber-400 w-full" /> + {imp.sensitivity.toFixed(2)} +
+ + {DIR_ICONS[imp.typical_direction] ?? '●'} + + + 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" /> + +
+ ))} +
+ )} +
+
+ +
+ + +
+
+ ) +} + +// ── Categories panel ────────────────────────────────────────────────────────── + +function CategoriesPanel() { + const [cats, setCats] = useState([]) + const [loading, setLoading] = useState(false) + const [selected, setSelected] = useState(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) + + return ( +
+ {/* Left: list */} +
+
+
+ Catégories ({cats.length}) + + +
+
+ + 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" /> +
+
+ +
+ {CATEGORIES.map(type => { + const group = grouped[type] ?? [] + if (group.length === 0) return null + return ( +
+
+ {type.replace('_', ' ')} ({group.length}) +
+ {group.map(c => ( +
{ 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', + )}> + +
+
{c.name}
+
{c.default_impacts.length} instruments
+
+
+ ))} +
+ ) + })} +
+
+ + {/* Right: editor */} +
+ {(showNew || selected) ? ( + { load(); setShowNew(false) }} + onDeleted={() => { load(); setSelected(null) }} + onClose={() => { setShowNew(false); setSelected(null) }} + /> + ) : ( +
+ +
Sélectionne une catégorie pour l'éditer
+
Les impacts par défaut sont utilisés par l'IA comme base de référence
+
+ )} +
+
+ ) +} + // ── Main page ───────────────────────────────────────────────────────────────── export default function MarketEvents() { + const [activeTab, setActiveTab] = useState<'events' | 'categories'>('events') const [events, setEvents] = useState([]) 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 ( -
+
+ {/* ── Tab bar ── */} +
+

Market Events

+ {(['events', 'categories'] as const).map(tab => ( + + ))} +
+ + {/* ── Categories tab ── */} + {activeTab === 'categories' && ( +
+ +
+ )} + + {/* ── Events tab ── */} + {activeTab === 'events' &&
{/* ── Left panel: filters + list ── */}
{/* Top bar */}
-

Market Events

+

Événements

{showCreate && setShowCreate(false)} />} +
}
) }