Files
OpenFin/frontend/src/pages/PatternEditor.tsx
OpenSquared dcbc9f19fc feat: translate all UI strings to English for international release
Complete French→English translation across all frontend pages and backend
services — every label, button, header, empty state, toast, and nav item
is now in English. Build verified clean (tsc + vite). No i18n library
added; direct string replacement throughout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 09:06:37 +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: '⚡ Energy', metals: '🥇 Metals', agriculture: '🌾 Agri',
equities: '📈 Equities', indices: '📊 Indices', forex: '💱 Forex', rates: '📉 Rates',
}
const TRIGGER_LABELS: Record<string, string> = {
military: '⚔️ Military', sanctions: '🚫 Sanctions', elections: '🗳️ Elections',
natural_disaster: '🌪️ Disaster', health_crisis: '🏥 Health',
resource_scarcity: '⚠️ Resources', trade_war: '🤝 Trade',
energy: '⚡ Energy', political_speech: '🎙️ Speech', 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 ? 'Good' : 'Weak'
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">Disabled</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}d</div>
</div>
<button onClick={onEdit} title="Edit" className="text-slate-500 hover:text-blue-400 transition-colors">
<Edit3 className="w-4 h-4" />
</button>
<button onClick={onToggle} title={isActive ? 'Disable' : 'Enable'} 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="Delete" 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">
Similar to <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 AI
</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 ? '▲ Collapse' : '▼ See details'}
</button>
{expanded && (
<div className="mt-3 space-y-2">
{p.keywords?.length > 0 && (
<div>
<div className="text-xs text-slate-600 mb-1">Detection keywords</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">Historical instances</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">Suggested trades</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" /> Counter-thesis & Invalidation
</div>
{p.counter_thesis && (
<div className="text-xs text-slate-300 mb-1">
<span className="text-slate-500">Counter-thesis:</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">AI Evaluation</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" /> Suggest patterns with AI
</h2>
<p className="text-xs text-slate-500 mt-0.5">
GPT-4o analyzes geo news, prices, calendar and the <span className="text-blue-400">current macro regime</span> to propose patterns aligned with the dominant scenario
</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">
The AI will analyze current geopolitical news, price movements, and the economic calendar to propose patterns relevant <em>today</em>.
</p>
<button onClick={() => suggest()}
className="bg-blue-600 hover:bg-blue-500 text-white px-6 py-2 rounded text-sm font-semibold">
Run analysis (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 analyzing current context</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}d · {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" /> Saved</> : <><Save className="w-3 h-3" /> Save</>}
</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">
Similar to <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"> Counter-thesis:</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">
No patterns suggested. Try again.
</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} saved
</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" /> Regenerate
</button>
)}
<button onClick={onClose} className="border border-slate-700 text-slate-400 hover:text-slate-200 px-4 py-1.5 rounded text-xs">
Close
</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" /> Generate from context (AI)</div>
<div className="flex gap-2">
<textarea
value={suggestInput}
onChange={e => setSuggestInput(e.target.value)}
placeholder="Describe the geopolitical context... e.g. 'When China imposes restrictions on rare earths, what happens to semiconductor and metals markets?'"
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 ? '...' : '⚡ Generate'}
</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">Pattern name</label>
<input value={form.name} onChange={e => set('name', e.target.value)}
placeholder="E.g.: China Taiwan tensions → Semiconductors 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="Geopolitical mechanism → market impact..."
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">Triggers</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">Detection keywords (English)</label>
<div className="flex gap-2 mb-2">
<input value={kwInput} onChange={e => setKwInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addKeyword()}
placeholder="Add a keyword..."
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">Asset class</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">Expected move (%)</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">Probability (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 (days)</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>
{/* Counter-thesis & 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" /> Counter-thesis (main adverse scenario)
</label>
<textarea value={form.counter_thesis || ''} onChange={e => set('counter_thesis', e.target.value)}
rows={2} placeholder="E.g.: unexpected peace deal, inflation below 3%, Fed pivot…"
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">Invalidation trigger (precise &amp; measurable event)</label>
<input value={form.invalidation_trigger || ''} onChange={e => set('invalidation_trigger', e.target.value)}
placeholder="E.g.: oil price < $70/b for 3 consecutive days"
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">Invalidation prob. (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" /> AI Evaluation
</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"> Strengths</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"> Weaknesses</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">Keywords suggested by AI</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">Invalidation scenarios</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 ? 'AI Evaluation...' : 'Evaluate with AI'}
</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" /> Cancel
</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" /> Save 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" /> Geopolitical Patterns
</h1>
<p className="text-xs text-slate-500 mt-0.5">
{patterns?.length ?? 0} patterns ·{' '}
{patterns?.filter((p: any) => p.source === 'custom').length ?? 0} custom
</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" /> Suggest with AI
</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" /> Create pattern
</button>
</div>
</div>
{(creating || editing) && (
<div className="card border-blue-500/30">
<div className="text-sm font-semibold text-white mb-4">
{editing ? `Edit: ${editing.name}` : 'New geopolitical pattern'}
</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: `All (${patterns?.length ?? 0})` },
{ key: 'builtin', label: `Built-in (${patterns?.filter((p: any) => p.source === 'builtin').length ?? 0})` },
{ key: 'custom', label: `My 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: 'All' },
{ key: 7, label: '7d' },
{ key: 30, label: '30d' },
] 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',
})}>
All 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} result{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'
? `No patterns in "${ASSET_CLASS_LABELS[filterAsset]}"`
: filterDays !== null
? `No patterns in the last ${filterDays} days`
: 'No custom patterns'}
</div>
<div className="text-xs mt-1">Create a pattern with AI assistance</div>
</div>
)}
</div>
)}
</div>
)
}