Initial commit — GeoOptions Intelligence Cockpit v2.0
Stack: FastAPI + React/TypeScript + SQLite + GPT-4o Features: Radar géopolitique, Marchés, Régime Macro, Journal de Bord MTM, Rapport IA, Super Contexte (base de raisonnement évolutive), Boucle feedback IA. Deploy: Docker + docker-compose + nginx pour openfin.open-squared.tech Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
690
frontend/src/pages/PatternEditor.tsx
Normal file
690
frontend/src/pages/PatternEditor.tsx
Normal file
@@ -0,0 +1,690 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useAllPatterns, useSavePattern, useDeletePattern, useEvaluatePattern, useSuggestPattern, useAiStatus, useSuggestNewPatterns, useTogglePattern, usePatternSimilarity, useLastScores } from '../hooks/useApi'
|
||||
import clsx from 'clsx'
|
||||
import { Zap, Plus, Trash2, Edit3, Brain, Save, RotateCcw, Sparkles, X, Check, Eye, EyeOff } 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 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,
|
||||
}
|
||||
|
||||
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 PatternCard({ p, onEdit, onDelete, onToggle, similarTo, aiScore }: {
|
||||
p: any; onEdit: () => void; onDelete: () => void; onToggle: () => void
|
||||
similarTo?: Array<{ name: string; similarity: number }>
|
||||
aiScore?: number | null
|
||||
}) {
|
||||
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} />}
|
||||
</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.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.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>
|
||||
|
||||
{/* 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 [editing, setEditing] = useState<any>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [showAiSuggest, setShowAiSuggest] = useState(false)
|
||||
const [tab, setTab] = useState<'all' | 'custom' | 'builtin'>('all')
|
||||
|
||||
// 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 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 = (patterns ?? []).filter((p: any) => {
|
||||
if (tab === 'custom') return p.source === 'custom'
|
||||
if (tab === 'builtin') return p.source === 'builtin'
|
||||
return true
|
||||
})
|
||||
|
||||
const handleSave = (form: any) => {
|
||||
savePattern(form, { onSuccess: () => { setEditing(null); setCreating(false) } })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
{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 actifs ·{' '}
|
||||
{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>
|
||||
)}
|
||||
|
||||
<div className="flex gap-1 bg-dark-700 p-1 rounded w-fit">
|
||||
{[
|
||||
{ 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>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">{[1,2,3].map(i => <div key={i} className="card animate-pulse h-20 bg-dark-700" />)}</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{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}
|
||||
/>
|
||||
))}
|
||||
{displayPatterns.length === 0 && (
|
||||
<div className="card text-center py-10 text-slate-500">
|
||||
<Zap className="w-8 h-8 mx-auto mb-2 opacity-20" />
|
||||
<div>Aucun pattern personnalisé</div>
|
||||
<div className="text-xs mt-1">Créer un pattern avec l'aide de l'IA</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user