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>
890 lines
42 KiB
TypeScript
890 lines
42 KiB
TypeScript
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 & 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>
|
||
)
|
||
}
|