Files
OpenFin/frontend/src/pages/PatternEditor.tsx
OpenSquared 58c3767a9d feat: simulation portfolio surveillance + patterns grid/filter UI
Portfolio Monitor (v4.4):
- New portfolio_risk.py service: concentration by asset_class, directional
  conflict detection (same underlying, opposite directions), overweight alerts
- AI agent (Step 7b) runs GPT-4o-mini after each cycle log: assessment +
  prioritized actions + rebalance suggestion, persisted in system_logs
- GET /api/journal/portfolio-risk — full risk breakdown + latest AI monitor reco
- POST /api/journal/trade-check — pre-entry conflict & concentration check
- asset_class column added to trade_entry_prices (auto-migration + populated at INSERT)
- Journal: new "Risque Sim." tab with concentration bars, conflict alerts,
  AI recommendations; red badge on tab when danger alerts exist

PatternEditor:
- Grid view default (2-3 cols responsive), list toggle
- Asset class filter chips (energy/metals/agri/equities/indices/forex/rates)
- Sort: Date (default) / Score IA / Prob.
- Period filter: Tout / 7j / 30j
- Result count badge when filters active

Doc: v4.3 → v4.4, updated Journal/PatternEditor/cycle steps/schema/glossary

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 15:49:46 +02:00

890 lines
42 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, useMemo } from 'react'
import { useAllPatterns, useSavePattern, useDeletePattern, useEvaluatePattern, useSuggestPattern, useAiStatus, useSuggestNewPatterns, useTogglePattern, usePatternSimilarity, useLastScores, usePatternReliability } from '../hooks/useApi'
import clsx from 'clsx'
import { Zap, Plus, Trash2, Edit3, Brain, Save, RotateCcw, Sparkles, X, Check, Eye, EyeOff, ShieldAlert, LayoutGrid, List, SlidersHorizontal } from 'lucide-react'
function jaccard(a: string[], b: string[]): number {
if (!a.length && !b.length) return 0
const setA = new Set(a.map(x => x.toLowerCase()))
const setB = new Set(b.map(x => x.toLowerCase()))
let intersection = 0
for (const x of setA) if (setB.has(x)) intersection++
const union = setA.size + setB.size - intersection
return union === 0 ? 0 : intersection / union
}
const TRIGGERS = ['military', 'sanctions', 'elections', 'natural_disaster', 'health_crisis', 'resource_scarcity', 'trade_war', 'energy', 'political_speech', 'financial_crisis']
const ASSET_CLASSES = ['energy', 'metals', 'agriculture', 'equities', 'indices', 'forex', 'rates']
const ASSET_CLASS_LABELS: Record<string, string> = {
energy: '⚡ Énergie', metals: '🥇 Métaux', agriculture: '🌾 Agri',
equities: '📈 Actions', indices: '📊 Indices', forex: '💱 Forex', rates: '📉 Taux',
}
const TRIGGER_LABELS: Record<string, string> = {
military: '⚔️ Militaire', sanctions: '🚫 Sanctions', elections: '🗳️ Élections',
natural_disaster: '🌪️ Catastrophe', health_crisis: '🏥 Santé',
resource_scarcity: '⚠️ Ressources', trade_war: '🤝 Commerce',
energy: '⚡ Énergie', political_speech: '🎙️ Discours', financial_crisis: '💸 Finance',
}
const EMPTY_PATTERN = {
name: '', description: '', triggers: [] as string[], keywords: [] as string[],
historical_instances: [] as any[], suggested_trades: [] as any[],
asset_class: 'energy', expected_move_pct: 10, probability: 0.6, horizon_days: 30,
counter_thesis: '', invalidation_trigger: '', invalidation_probability: undefined as number | undefined,
}
function QualityBadge({ score }: { score: number }) {
const color = score >= 75 ? 'badge-green' : score >= 50 ? 'badge-yellow' : 'badge-red'
const label = score >= 75 ? 'Excellent' : score >= 50 ? 'Bon' : 'Faible'
return <span className={clsx('badge', color)}>{score}/100 {label}</span>
}
function ReliabilityBadge({ rel }: { rel: any }) {
if (!rel || rel.trade_count < 3) return null
const wr = rel.win_rate_pct
const color = wr >= 60 ? 'text-emerald-400 border-emerald-700/40 bg-emerald-900/20'
: wr >= 40 ? 'text-amber-400 border-amber-700/40 bg-amber-900/20'
: 'text-red-400 border-red-700/40 bg-red-900/20'
return (
<span className={clsx('inline-flex items-center gap-1 text-[10px] border rounded px-1.5 py-0.5 font-mono', color)}>
📊 {wr}% WR ({rel.trade_count}t · {rel.avg_pnl_pct > 0 ? '+' : ''}{rel.avg_pnl_pct}%)
</span>
)
}
function PatternCard({ p, onEdit, onDelete, onToggle, similarTo, aiScore, reliability }: {
p: any; onEdit: () => void; onDelete: () => void; onToggle: () => void
similarTo?: Array<{ name: string; similarity: number }>
aiScore?: number | null
reliability?: any
}) {
const [expanded, setExpanded] = useState(false)
const isCustom = p.source === 'custom'
const isActive = p.is_active !== 0
return (
<div className={clsx('card hover:border-slate-600/50 transition-colors', {
'border-blue-500/30': isCustom,
'opacity-50': !isActive,
})}>
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0 mr-2">
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
<div className="text-sm font-semibold text-white">{p.name}</div>
{isCustom && <span className="badge badge-purple text-xs">Custom</span>}
{!isActive && <span className="badge badge-red text-xs">Désactivé</span>}
{p.ai_quality_score && <QualityBadge score={p.ai_quality_score} />}
<ReliabilityBadge rel={reliability} />
</div>
<div className="text-xs text-slate-500">{p.description}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="text-right">
<div className={clsx('text-sm font-bold', p.expected_move_pct > 0 ? 'positive' : 'negative')}>
{p.expected_move_pct > 0 ? '+' : ''}{p.expected_move_pct}%
</div>
<div className="text-xs text-slate-600">{p.horizon_days}j</div>
</div>
<button onClick={onEdit} title="Modifier" className="text-slate-500 hover:text-blue-400 transition-colors">
<Edit3 className="w-4 h-4" />
</button>
<button onClick={onToggle} title={isActive ? 'Désactiver' : 'Activer'} className={clsx('transition-colors', isActive ? 'text-slate-500 hover:text-yellow-400' : 'text-yellow-500 hover:text-yellow-300')}>
{isActive ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
</button>
<button onClick={onDelete} title="Supprimer" className="text-slate-500 hover:text-red-400 transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{similarTo && similarTo.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{similarTo.map((s, i) => (
<span key={i} className="inline-flex items-center gap-1 text-[10px] bg-amber-900/30 border border-amber-600/30 text-amber-400 rounded px-1.5 py-0.5">
Similaire à <span className="font-semibold max-w-[140px] truncate">{s.name}</span>
<span className="font-mono">{Math.round(s.similarity * 100)}%</span>
</span>
))}
</div>
)}
<div className="flex flex-wrap gap-1 mb-2">
{(p.triggers || []).map((t: string) => (
<span key={t} className="badge badge-orange text-xs">{TRIGGER_LABELS[t] ?? t}</span>
))}
<span className="badge badge-blue text-xs">{p.asset_class}</span>
{aiScore != null ? (
<span className={clsx('badge text-xs font-bold', aiScore >= 50 ? 'badge-green' : aiScore >= 25 ? 'badge-yellow' : 'badge-red')}>
{aiScore}/100 IA
</span>
) : (
<span className="badge badge-purple text-xs">{Math.round(p.probability * 100)}% prob.</span>
)}
</div>
<button onClick={() => setExpanded(!expanded)} className="text-xs text-slate-600 hover:text-slate-400">
{expanded ? '▲ Réduire' : '▼ Voir détails'}
</button>
{expanded && (
<div className="mt-3 space-y-2">
{p.keywords?.length > 0 && (
<div>
<div className="text-xs text-slate-600 mb-1">Mots-clés de détection</div>
<div className="flex flex-wrap gap-1">
{p.keywords.map((kw: string) => (
<span key={kw} className="text-xs bg-dark-700 text-slate-400 px-1.5 py-0.5 rounded">#{kw}</span>
))}
</div>
</div>
)}
{p.historical_instances?.length > 0 && (
<div>
<div className="text-xs text-slate-600 mb-1">Instances historiques</div>
{p.historical_instances.map((h: any, i: number) => (
<div key={i} className="card-sm mb-1">
<span className="text-xs text-slate-500">{h.date}</span>
<span className="text-xs text-slate-300 ml-2">{h.event || h.outcome}</span>
</div>
))}
</div>
)}
{p.suggested_trades?.length > 0 && (
<div>
<div className="text-xs text-slate-600 mb-1">Trades suggérés</div>
{p.suggested_trades.map((t: any, i: number) => (
<div key={i} className="flex items-center gap-2 text-xs mb-1 flex-wrap">
<span className="badge badge-green">{t.strategy}</span>
<span className="text-white font-mono">{t.underlying}</span>
{t.asset_class && <span className="badge badge-blue">{t.asset_class}</span>}
<span className="text-slate-500"> {t.rationale}</span>
</div>
))}
</div>
)}
{(p.counter_thesis || p.invalidation_trigger) && (
<div className="card-sm border-red-700/30 bg-red-900/10">
<div className="text-xs text-red-400 font-semibold mb-1 flex items-center gap-1">
<ShieldAlert className="w-3 h-3" /> Contre-thèse & Invalidation
</div>
{p.counter_thesis && (
<div className="text-xs text-slate-300 mb-1">
<span className="text-slate-500">Contre-thèse :</span> {p.counter_thesis}
</div>
)}
{p.invalidation_trigger && (
<div className="text-xs text-orange-300/80">
<span className="text-slate-500">Trigger :</span> {p.invalidation_trigger}
{p.invalidation_probability != null && (
<span className="ml-2 font-mono text-slate-500">({Math.round(p.invalidation_probability * 100)}% prob.)</span>
)}
</div>
)}
</div>
)}
{p.ai_evaluation && Object.keys(p.ai_evaluation).length > 0 && (
<div className="card-sm border-blue-700/30">
<div className="text-xs text-blue-400 font-semibold mb-1">Évaluation IA</div>
{p.ai_evaluation.strengths?.length > 0 && (
<div className="text-xs text-emerald-400 mb-1"> {p.ai_evaluation.strengths[0]}</div>
)}
{p.ai_evaluation.weaknesses?.length > 0 && (
<div className="text-xs text-red-400"> {p.ai_evaluation.weaknesses[0]}</div>
)}
{p.ai_evaluation.overall_recommendation && (
<div className="text-xs text-slate-400 mt-1">{p.ai_evaluation.overall_recommendation}</div>
)}
</div>
)}
</div>
)}
</div>
)
}
function AiSuggestModal({ onClose, onSaveAll, allPatterns }: { onClose: () => void; onSaveAll: (patterns: any[]) => void; allPatterns: any[] }) {
const { mutate: suggest, isPending, data } = useSuggestNewPatterns()
const [saved, setSaved] = useState<Set<number>>(new Set())
const { mutate: savePattern } = useSavePattern()
const suggestions: any[] = (data as any)?.suggested_patterns ?? []
const handleSave = (p: any, idx: number) => {
savePattern(p, { onSuccess: () => setSaved(prev => new Set(prev).add(idx)) })
}
// For each suggestion, find existing patterns with keyword overlap >= 25%
const suggestionSimilarities = useMemo(() =>
suggestions.map(s => {
const kws = s.keywords ?? []
return allPatterns
.map(ep => ({ name: ep.name, similarity: jaccard(kws, ep.keywords ?? []) }))
.filter(x => x.similarity >= 0.25)
.sort((a, b) => b.similarity - a.similarity)
.slice(0, 3)
}),
[suggestions, allPatterns]
)
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-dark-800 border border-slate-700/50 rounded-xl w-full max-w-4xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-700/30">
<div>
<h2 className="text-base font-semibold text-white flex items-center gap-2">
<Sparkles className="w-4 h-4 text-blue-400" /> Suggérer des patterns par l'IA
</h2>
<p className="text-xs text-slate-500 mt-0.5">
GPT-4o analyse l'actualité géo, les prix, le calendrier et le <span className="text-blue-400">régime macro actuel</span> pour proposer des patterns cohérents avec le scénario dominant
</p>
</div>
<button onClick={onClose} className="text-slate-500 hover:text-white"><X className="w-5 h-5" /></button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{!data && !isPending && (
<div className="text-center py-12">
<Sparkles className="w-10 h-10 text-blue-400/40 mx-auto mb-3" />
<p className="text-sm text-slate-400 mb-4">
L'IA va analyser les news géopolitiques actuelles, les mouvements de prix et le calendrier économique pour proposer des patterns pertinents <em>aujourd'hui</em>.
</p>
<button onClick={() => suggest()}
className="bg-blue-600 hover:bg-blue-500 text-white px-6 py-2 rounded text-sm font-semibold">
Lancer l'analyse (GPT-4o)
</button>
</div>
)}
{isPending && (
<div className="text-center py-12">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-3"></div>
<p className="text-sm text-slate-400">GPT-4o analyse le contexte du moment…</p>
</div>
)}
{suggestions.map((p: any, idx: number) => {
const score = Math.round((p.probability ?? 0.5) * 100)
const similars = suggestionSimilarities[idx] ?? []
return (
<div key={idx} className={clsx('card', saved.has(idx) ? 'border-emerald-700/40 opacity-60' : similars.length > 0 ? 'border-amber-700/30' : 'border-blue-700/20')}>
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-white">{p.name}</div>
<div className="flex gap-1 mt-0.5 flex-wrap">
<span className="badge badge-blue text-xs">{p.asset_class}</span>
<span className={clsx('badge text-xs font-bold', score >= 50 ? 'badge-green' : score >= 25 ? 'badge-yellow' : 'badge-red')}>
{score}/100 IA
</span>
<span className="text-xs text-slate-500">{p.horizon_days}j · {p.expected_move_pct > 0 ? '+' : ''}{p.expected_move_pct}%</span>
</div>
</div>
<button
onClick={() => handleSave(p, idx)}
disabled={saved.has(idx)}
className={clsx('shrink-0 ml-3 flex items-center gap-1 px-3 py-1.5 rounded text-xs font-semibold transition-all', {
'bg-emerald-700/30 border border-emerald-700/40 text-emerald-400 cursor-default': saved.has(idx),
'bg-blue-600 hover:bg-blue-500 text-white': !saved.has(idx),
})}>
{saved.has(idx) ? <><Check className="w-3 h-3" /> Sauvegardé</> : <><Save className="w-3 h-3" /> Sauvegarder</>}
</button>
</div>
{similars.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{similars.map((s, i) => (
<span key={i} className="inline-flex items-center gap-1 text-[10px] bg-amber-900/30 border border-amber-600/30 text-amber-400 rounded px-1.5 py-0.5">
⚠ Similaire à <span className="font-semibold max-w-[140px] truncate">{s.name}</span>
<span className="font-mono">{Math.round(s.similarity * 100)}%</span>
</span>
))}
</div>
)}
<p className="text-xs text-slate-400 mb-2">{p.description}</p>
{p.macro_fit && (
<div className="flex items-start gap-1.5 mb-2 text-[11px] text-blue-300/80 bg-blue-900/20 border border-blue-700/20 rounded px-2 py-1.5">
<span className="shrink-0 mt-px">🌐</span>
<span>{p.macro_fit}</span>
</div>
)}
{(p.counter_thesis || p.invalidation_trigger) && (
<div className="mb-2 text-[11px] bg-red-900/10 border border-red-700/20 rounded px-2 py-1.5 space-y-0.5">
{p.counter_thesis && (
<div className="text-slate-300"><span className="text-red-400/80">⚠ Contre-thèse :</span> {p.counter_thesis}</div>
)}
{p.invalidation_trigger && (
<div className="text-orange-300/70"><span className="text-slate-500">Trigger :</span> {p.invalidation_trigger}{p.invalidation_probability != null ? ` (${Math.round(p.invalidation_probability * 100)}%)` : ''}</div>
)}
</div>
)}
{p.suggested_trades?.length > 0 && (
<div className="space-y-1">
{p.suggested_trades.map((t: any, ti: number) => (
<div key={ti} className="flex items-center gap-2 text-xs flex-wrap">
<span className="badge badge-green">{t.strategy}</span>
<span className="text-white font-mono">{t.underlying}</span>
<span className="text-slate-500">— {t.rationale}</span>
</div>
))}
</div>
)}
</div>
)
})}
{data && suggestions.length === 0 && (
<div className="card text-center py-8 text-slate-500 text-sm">
Aucun pattern suggéré. Réessayer.
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-slate-700/30 flex justify-between items-center">
{suggestions.length > 0 && (
<span className="text-xs text-slate-500">
{saved.size}/{suggestions.length} sauvegardé{saved.size > 1 ? 's' : ''}
</span>
)}
<div className="flex gap-2 ml-auto">
{data && suggestions.length > 0 && (
<button onClick={() => suggest()} disabled={isPending}
className="flex items-center gap-1 border border-slate-700 text-slate-400 hover:text-slate-200 px-3 py-1.5 rounded text-xs">
<RotateCcw className="w-3 h-3" /> Régénérer
</button>
)}
<button onClick={onClose} className="border border-slate-700 text-slate-400 hover:text-slate-200 px-4 py-1.5 rounded text-xs">
Fermer
</button>
</div>
</div>
</div>
</div>
)
}
function PatternForm({ initial, onSave, onCancel }: { initial?: any; onSave: (p: any) => void; onCancel: () => void }) {
const [form, setForm] = useState(initial ? {
...initial,
triggers: Array.isArray(initial.triggers) ? initial.triggers : [],
keywords: Array.isArray(initial.keywords) ? initial.keywords : [],
} : { ...EMPTY_PATTERN })
const [kwInput, setKwInput] = useState('')
const [aiResult, setAiResult] = useState<any>(null)
const [aiLoading, setAiLoading] = useState(false)
const [suggestInput, setSuggestInput] = useState('')
const [suggestLoading, setSuggestLoading] = useState(false)
const { mutateAsync: evaluate } = useEvaluatePattern()
const { mutateAsync: suggest } = useSuggestPattern()
const { data: aiStatus } = useAiStatus()
const set = (k: string, v: unknown) => setForm((f: any) => ({ ...f, [k]: v }))
const toggleTrigger = (t: string) => {
set('triggers', form.triggers.includes(t) ? form.triggers.filter((x: string) => x !== t) : [...form.triggers, t])
}
const addKeyword = () => {
if (kwInput.trim() && !form.keywords.includes(kwInput.trim())) {
set('keywords', [...form.keywords, kwInput.trim()])
setKwInput('')
}
}
const evaluateWithAI = async () => {
setAiLoading(true)
try {
const result = await evaluate(form)
setAiResult(result)
if (result.quality_score) set('ai_quality_score', result.quality_score)
set('ai_evaluation', result)
} catch (e) { console.error(e) }
setAiLoading(false)
}
const suggestFromContext = async () => {
setSuggestLoading(true)
try {
const result = await suggest(suggestInput)
if (result && !result.error) {
setForm((f: any) => ({
...f,
name: result.name || f.name,
description: result.description || f.description,
triggers: result.triggers || f.triggers,
keywords: result.keywords || f.keywords,
historical_instances: result.historical_instances || f.historical_instances,
suggested_trades: result.suggested_trades || f.suggested_trades,
asset_class: result.asset_class || f.asset_class,
expected_move_pct: result.expected_move_pct ?? f.expected_move_pct,
probability: result.probability ?? f.probability,
horizon_days: result.horizon_days ?? f.horizon_days,
}))
}
} catch (e) { console.error(e) }
setSuggestLoading(false)
}
return (
<div className="space-y-4">
{/* AI Suggest from context */}
{aiStatus?.enabled && (
<div className="card border-blue-500/30">
<div className="section-title flex items-center gap-1"><Brain className="w-3 h-3 text-blue-400" /> Générer depuis un contexte (IA)</div>
<div className="flex gap-2">
<textarea
value={suggestInput}
onChange={e => setSuggestInput(e.target.value)}
placeholder="Décris le contexte géopolitique... ex: 'Quand la Chine impose des restrictions sur les terres rares, que se passe-t-il sur le marché des semi-conducteurs et des métaux ?'"
rows={2}
className="flex-1 bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500 resize-none"
/>
<button onClick={suggestFromContext} disabled={suggestLoading || !suggestInput}
className="shrink-0 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-3 rounded text-xs">
{suggestLoading ? '...' : ' Générer'}
</button>
</div>
</div>
)}
{/* Form fields */}
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label className="text-xs text-slate-500 mb-1 block">Nom du pattern</label>
<input value={form.name} onChange={e => set('name', e.target.value)}
placeholder="Ex: Chine tensions Taiwan → Semi-conducteurs spike"
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
</div>
<div className="col-span-2">
<label className="text-xs text-slate-500 mb-1 block">Description</label>
<textarea value={form.description} onChange={e => set('description', e.target.value)}
rows={2} placeholder="Mécanisme géopolitique → impact marché..."
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500 resize-none" />
</div>
</div>
{/* Triggers */}
<div>
<label className="text-xs text-slate-500 mb-2 block">Déclencheurs</label>
<div className="flex flex-wrap gap-1.5">
{TRIGGERS.map(t => (
<button key={t} onClick={() => toggleTrigger(t)}
className={clsx('px-2 py-1 rounded border text-xs transition-all', {
'bg-orange-900/30 border-orange-700/50 text-orange-300': form.triggers.includes(t),
'border-slate-700/40 text-slate-500 hover:border-slate-500': !form.triggers.includes(t),
})}>
{TRIGGER_LABELS[t]}
</button>
))}
</div>
</div>
{/* Keywords */}
<div>
<label className="text-xs text-slate-500 mb-1 block">Mots-clés de détection (anglais)</label>
<div className="flex gap-2 mb-2">
<input value={kwInput} onChange={e => setKwInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addKeyword()}
placeholder="Ajouter un mot-clé..."
className="flex-1 bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
<button onClick={addKeyword} className="bg-dark-600 border border-slate-700 hover:border-slate-500 text-slate-300 px-3 rounded text-xs">+</button>
</div>
<div className="flex flex-wrap gap-1">
{form.keywords.map((kw: string) => (
<span key={kw} className="flex items-center gap-1 text-xs bg-dark-700 text-slate-300 px-2 py-0.5 rounded border border-slate-700/40">
#{kw}
<button onClick={() => set('keywords', form.keywords.filter((k: string) => k !== kw))}
className="text-slate-600 hover:text-red-400 ml-0.5">×</button>
</span>
))}
</div>
</div>
{/* Metrics */}
<div className="grid grid-cols-4 gap-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Classe d'actif</label>
<select value={form.asset_class} onChange={e => set('asset_class', e.target.value)}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500">
{ASSET_CLASSES.map(a => <option key={a}>{a}</option>)}
</select>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Move attendu (%)</label>
<input type="number" value={form.expected_move_pct} onChange={e => set('expected_move_pct', Number(e.target.value))}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Probabilité (0-1)</label>
<input type="number" step="0.05" min="0" max="1" value={form.probability} onChange={e => set('probability', Number(e.target.value))}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Horizon (jours)</label>
<input type="number" value={form.horizon_days} onChange={e => set('horizon_days', Number(e.target.value))}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
</div>
</div>
{/* Contre-thèse & Invalidation */}
<div className="space-y-2">
<div>
<label className="text-xs text-slate-500 mb-1 block flex items-center gap-1">
<ShieldAlert className="w-3 h-3 text-red-400" /> Contre-thèse (scénario adverse principal)
</label>
<textarea value={form.counter_thesis || ''} onChange={e => set('counter_thesis', e.target.value)}
rows={2} placeholder="Ex: accord de paix inattendu, données inflation sous 3%, pivot Fed…"
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-red-500/50 resize-none" />
</div>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<label className="text-xs text-slate-500 mb-1 block">Trigger d'invalidation (événement précis &amp; mesurable)</label>
<input value={form.invalidation_trigger || ''} onChange={e => set('invalidation_trigger', e.target.value)}
placeholder="Ex: prix pétrole < 70$/b 3j consécutifs"
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-red-500/50" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Prob. invalidation (0-1)</label>
<input type="number" step="0.05" min="0" max="1" value={form.invalidation_probability ?? ''}
onChange={e => set('invalidation_probability', e.target.value ? Number(e.target.value) : undefined)}
placeholder="0.20"
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-red-500/50" />
</div>
</div>
</div>
{/* AI Evaluation result */}
{aiResult && (
<div className="card border-blue-700/40">
<div className="flex items-center justify-between mb-2">
<div className="text-sm font-semibold text-blue-400 flex items-center gap-1">
<Brain className="w-4 h-4" /> Évaluation IA
</div>
<QualityBadge score={aiResult.quality_score || 0} />
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<div className="text-slate-500 mb-1 font-semibold">✓ Points forts</div>
{aiResult.strengths?.map((s: string, i: number) => (
<div key={i} className="text-emerald-400 mb-0.5">• {s}</div>
))}
</div>
<div>
<div className="text-slate-500 mb-1 font-semibold">⚠ Faiblesses</div>
{aiResult.weaknesses?.map((w: string, i: number) => (
<div key={i} className="text-orange-400 mb-0.5">• {w}</div>
))}
</div>
</div>
{aiResult.suggested_improvements?.additional_keywords?.length > 0 && (
<div className="mt-2">
<div className="text-xs text-slate-500 mb-1">Mots-clés suggérés par l'IA</div>
<div className="flex flex-wrap gap-1">
{aiResult.suggested_improvements.additional_keywords.map((kw: string) => (
<button key={kw} onClick={() => set('keywords', [...form.keywords, kw])}
className="text-xs bg-blue-900/30 border border-blue-700/40 text-blue-300 px-1.5 py-0.5 rounded hover:bg-blue-800/40">
+{kw}
</button>
))}
</div>
</div>
)}
{aiResult.overall_recommendation && (
<div className="mt-2 text-xs text-slate-400 border-t border-slate-700/40 pt-2">
{aiResult.overall_recommendation}
</div>
)}
{aiResult.counter_scenarios?.length > 0 && (
<div className="mt-2">
<div className="text-xs text-slate-500 mb-1">Scénarios d'invalidation</div>
{aiResult.counter_scenarios.map((s: string, i: number) => (
<div key={i} className="text-xs text-red-400 mb-0.5">✗ {s}</div>
))}
</div>
)}
</div>
)}
<div className="flex gap-2">
{aiStatus?.enabled && (
<button onClick={evaluateWithAI} disabled={aiLoading || !form.name}
className="flex items-center gap-1.5 border border-blue-500/50 text-blue-400 hover:bg-blue-900/20 px-3 py-1.5 rounded text-sm transition-colors disabled:opacity-40">
<Brain className="w-3.5 h-3.5" />
{aiLoading ? 'Évaluation IA...' : 'Évaluer avec IA'}
</button>
)}
<button onClick={onCancel} className="flex items-center gap-1.5 border border-slate-700 text-slate-400 hover:text-slate-200 px-3 py-1.5 rounded text-sm">
<RotateCcw className="w-3.5 h-3.5" /> Annuler
</button>
<button onClick={() => onSave(form)} disabled={!form.name || form.triggers.length === 0}
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-4 py-1.5 rounded text-sm font-semibold ml-auto">
<Save className="w-3.5 h-3.5" /> Sauvegarder le pattern
</button>
</div>
</div>
)
}
export default function PatternEditor() {
const { data: patterns, isLoading } = useAllPatterns()
const { mutate: savePattern } = useSavePattern()
const { mutate: deletePattern } = useDeletePattern()
const { mutate: togglePattern } = useTogglePattern()
const { data: aiStatus } = useAiStatus()
const { data: simData } = usePatternSimilarity()
const { data: lastScoresData } = useLastScores()
const { data: reliabilityData } = usePatternReliability()
const [editing, setEditing] = useState<any>(null)
const [creating, setCreating] = useState(false)
const [showAiSuggest, setShowAiSuggest] = useState(false)
const [tab, setTab] = useState<'all' | 'custom' | 'builtin'>('all')
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [sortBy, setSortBy] = useState<'date' | 'score' | 'prob'>('date')
const [filterAsset, setFilterAsset] = useState<string>('all')
const [filterDays, setFilterDays] = useState<number | null>(null)
// Build AI score map: patternId → best effective score (same logic as cockpit panel)
const aiScoreMap = useMemo(() => {
const map: Record<string, number> = {}
for (const sp of (lastScoresData as any)?.scored_patterns ?? []) {
const pid = sp.pattern_id
if (!pid) continue
const base = sp.score ?? 0
const best = Math.max(base, ...((sp.trade_rankings ?? []) as any[]).map((r: any) =>
Math.max(0, Math.min(100, base + (r.score_delta ?? 0)))))
map[pid] = best
}
return map
}, [lastScoresData])
// Build reliability map: patternId → reliability record
const reliabilityMap = useMemo(() => {
const map: Record<string, any> = {}
for (const r of (reliabilityData as any)?.reliability ?? []) {
map[r.pattern_id] = r
}
return map
}, [reliabilityData])
// Build a map: patternId → [{name, similarity}] for each side of a similar pair
const similarityMap = useMemo(() => {
const pairs: any[] = (simData as any)?.pairs ?? []
const map: Record<string, Array<{ name: string; similarity: number }>> = {}
for (const pair of pairs) {
if (!map[pair.id_a]) map[pair.id_a] = []
map[pair.id_a].push({ name: pair.name_b, similarity: pair.similarity })
if (!map[pair.id_b]) map[pair.id_b] = []
map[pair.id_b].push({ name: pair.name_a, similarity: pair.similarity })
}
return map
}, [simData])
const displayPatterns = useMemo(() => {
let result = (patterns ?? []).filter((p: any) => {
if (tab === 'custom') return p.source === 'custom'
if (tab === 'builtin') return p.source === 'builtin'
return true
})
if (filterAsset !== 'all') {
result = result.filter((p: any) => p.asset_class === filterAsset)
}
if (filterDays !== null) {
const cutoff = new Date()
cutoff.setDate(cutoff.getDate() - filterDays)
result = result.filter((p: any) => p.created_at && new Date(p.created_at) >= cutoff)
}
switch (sortBy) {
case 'score':
return [...result].sort((a, b) => (aiScoreMap[b.id] ?? 0) - (aiScoreMap[a.id] ?? 0))
case 'prob':
return [...result].sort((a, b) => (b.probability ?? 0) - (a.probability ?? 0))
default:
return [...result].sort((a, b) => (b.created_at ?? '').localeCompare(a.created_at ?? ''))
}
}, [patterns, tab, filterAsset, filterDays, sortBy, aiScoreMap])
const handleSave = (form: any) => {
savePattern(form, { onSuccess: () => { setEditing(null); setCreating(false) } })
}
const gridClass = viewMode === 'grid'
? 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3'
: 'space-y-3'
return (
<div className="p-6 space-y-4">
{showAiSuggest && (
<AiSuggestModal
onClose={() => setShowAiSuggest(false)}
onSaveAll={() => setShowAiSuggest(false)}
allPatterns={patterns ?? []}
/>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<Zap className="w-5 h-5 text-blue-400" /> Patterns Géopolitiques
</h1>
<p className="text-xs text-slate-500 mt-0.5">
{patterns?.length ?? 0} patterns ·{' '}
{patterns?.filter((p: any) => p.source === 'custom').length ?? 0} personnalisés
</p>
</div>
<div className="flex gap-2">
{aiStatus?.enabled && (
<button onClick={() => setShowAiSuggest(true)}
className="flex items-center gap-1.5 border border-blue-500/50 text-blue-400 hover:bg-blue-900/20 px-3 py-1.5 rounded text-sm font-semibold transition-colors">
<Sparkles className="w-4 h-4" /> Suggérer par l'IA
</button>
)}
<button onClick={() => { setCreating(true); setEditing(null) }}
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 text-white px-3 py-1.5 rounded text-sm font-semibold">
<Plus className="w-4 h-4" /> Créer un pattern
</button>
</div>
</div>
{(creating || editing) && (
<div className="card border-blue-500/30">
<div className="text-sm font-semibold text-white mb-4">
{editing ? `Modifier: ${editing.name}` : 'Nouveau pattern géopolitique'}
</div>
<PatternForm
initial={editing}
onSave={handleSave}
onCancel={() => { setEditing(null); setCreating(false) }}
/>
</div>
)}
{/* Source tabs + controls */}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex gap-1 bg-dark-700 p-1 rounded">
{[
{ key: 'all', label: `Tous (${patterns?.length ?? 0})` },
{ key: 'builtin', label: `Intégrés (${patterns?.filter((p: any) => p.source === 'builtin').length ?? 0})` },
{ key: 'custom', label: `Mes patterns (${patterns?.filter((p: any) => p.source === 'custom').length ?? 0})` },
].map(({ key, label }) => (
<button key={key} onClick={() => setTab(key as any)}
className={clsx('px-3 py-1.5 rounded text-sm', {
'bg-blue-600 text-white': tab === key,
'text-slate-400 hover:text-slate-200': tab !== key,
})}>
{label}
</button>
))}
</div>
<div className="flex items-center gap-2 flex-wrap">
{/* Sort */}
<div className="flex gap-1 bg-dark-700 p-1 rounded items-center">
<SlidersHorizontal className="w-3.5 h-3.5 text-slate-500 ml-1 mr-0.5" />
{([
{ key: 'date', label: 'Date' },
{ key: 'score', label: 'Score IA' },
{ key: 'prob', label: 'Prob.' },
] as const).map(({ key, label }) => (
<button key={key} onClick={() => setSortBy(key)}
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
'bg-slate-600 text-white': sortBy === key,
'text-slate-400 hover:text-slate-200': sortBy !== key,
})}>
{label}
</button>
))}
</div>
{/* Period filter */}
<div className="flex gap-1 bg-dark-700 p-1 rounded">
{([
{ key: null, label: 'Tout' },
{ key: 7, label: '7j' },
{ key: 30, label: '30j' },
] as const).map(({ key, label }) => (
<button key={String(key)} onClick={() => setFilterDays(key)}
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
'bg-slate-600 text-white': filterDays === key,
'text-slate-400 hover:text-slate-200': filterDays !== key,
})}>
{label}
</button>
))}
</div>
{/* View mode */}
<div className="flex gap-1 bg-dark-700 p-1 rounded">
<button onClick={() => setViewMode('grid')}
className={clsx('p-1.5 rounded transition-colors', viewMode === 'grid' ? 'bg-slate-600 text-white' : 'text-slate-400 hover:text-slate-200')}>
<LayoutGrid className="w-3.5 h-3.5" />
</button>
<button onClick={() => setViewMode('list')}
className={clsx('p-1.5 rounded transition-colors', viewMode === 'list' ? 'bg-slate-600 text-white' : 'text-slate-400 hover:text-slate-200')}>
<List className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
{/* Asset class filter chips */}
<div className="flex flex-wrap gap-1.5 items-center">
<button onClick={() => setFilterAsset('all')}
className={clsx('px-2.5 py-1 rounded border text-xs transition-all', {
'bg-slate-600/40 border-slate-500/60 text-white': filterAsset === 'all',
'border-slate-700/40 text-slate-500 hover:border-slate-500 hover:text-slate-300': filterAsset !== 'all',
})}>
Tous types
</button>
{ASSET_CLASSES.map(ac => (
<button key={ac} onClick={() => setFilterAsset(filterAsset === ac ? 'all' : ac)}
className={clsx('px-2.5 py-1 rounded border text-xs transition-all', {
'bg-blue-600/30 border-blue-500/60 text-blue-300': filterAsset === ac,
'border-slate-700/40 text-slate-500 hover:border-slate-500 hover:text-slate-300': filterAsset !== ac,
})}>
{ASSET_CLASS_LABELS[ac]}
</button>
))}
{(filterAsset !== 'all' || filterDays !== null) && (
<span className="text-xs text-slate-600 ml-1">
{displayPatterns.length} résultat{displayPatterns.length !== 1 ? 's' : ''}
</span>
)}
</div>
{isLoading ? (
<div className={gridClass}>
{[1,2,3,4,5,6].map(i => <div key={i} className="card animate-pulse h-24 bg-dark-700" />)}
</div>
) : (
<div className={gridClass}>
{displayPatterns.map((p: any) => (
<PatternCard key={p.id}
p={p}
onEdit={() => { setEditing(p); setCreating(false) }}
onDelete={() => deletePattern(p.id)}
onToggle={() => togglePattern(p.id)}
similarTo={similarityMap[p.id]}
aiScore={aiScoreMap[p.id] ?? null}
reliability={reliabilityMap[p.id]}
/>
))}
{displayPatterns.length === 0 && (
<div className="card text-center py-10 text-slate-500 col-span-full">
<Zap className="w-8 h-8 mx-auto mb-2 opacity-20" />
<div>
{filterAsset !== 'all'
? `Aucun pattern dans "${ASSET_CLASS_LABELS[filterAsset]}"`
: filterDays !== null
? `Aucun pattern dans les ${filterDays} derniers jours`
: 'Aucun pattern personnalisé'}
</div>
<div className="text-xs mt-1">Créer un pattern avec l'aide de l'IA</div>
</div>
)}
</div>
)}
</div>
)
}