Files
OpenFin/frontend/src/pages/PatternExplorer.tsx
OpenSquared a630cdc708 feat: Find Similar + Merge in Pattern Library
Backend (patterns.py):
- POST /api/patterns/find-similar — GPT-4o-mini compares a library pattern
  against all others; returns merge_as_instance | counter_scenario | new_pattern
- POST /api/patterns/merge — full transactional merge: remaps pattern_id in
  pattern_score_history, trade_entry_prices, ai_reasoning_traces, ai_call_logs,
  skipped_trades; unions historical_instances (dedup); sums backtest counters;
  deletes the discarded pattern

Frontend (PatternExplorer.tsx + useApi.ts):
- ScanSearch button on each non-builtin card triggers find-similar
- Inline result panel: duplicate → merge CTA with confirmation + destructive warning
  counter_scenario → apply regime_tag CTA; new_pattern → green "unique" badge
- useFindSimilarPattern + useMergePatterns hooks invalidate all-patterns on success

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 12:13:33 +02:00

1112 lines
49 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, useCallback, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import clsx from 'clsx'
import {
TreePine, Search, ChevronRight, ChevronDown,
TrendingUp, TrendingDown, Zap, BookOpen, ExternalLink, RefreshCw,
Layers, GitMerge, Tag, Trash2, Pencil, Check, X, ScanSearch, AlertTriangle, ArrowRightLeft,
} from 'lucide-react'
import {
useAllPatterns,
usePatternsByInstrument,
useLastScores,
useDeletePattern,
usePatchPattern,
useFindSimilarPattern,
useMergePatterns,
validateTicker,
type SimilarityResult,
} from '../hooks/useApi'
import { INSTRUMENTS, INSTRUMENT_CATEGORIES } from '../constants/instruments'
// ── Local types ───────────────────────────────────────────────────────────────
interface SuggestedTrade {
strategy: string
underlying: string
rationale: string
strike_guidance?: string
expected_move_pct?: number
}
interface HistoricalInstance {
date: string
event: string
[key: string]: unknown
}
interface Pattern {
id: string
name: string
description: string
triggers: string[]
keywords: string[]
historical_instances: HistoricalInstance[]
suggested_trades: SuggestedTrade[]
asset_class: string
expected_move_pct: number
probability: number
horizon_days: number
source: string
category?: string
signal_direction?: string
taxonomy_path?: string[]
ai_quality_score?: number
is_active?: number
regime_tag?: string
counter_of?: string
backtest_hits?: number
backtest_runs_count?: number
}
interface ScoreResult {
pattern_id: string
score?: number
[key: string]: unknown
}
// ── Constants ─────────────────────────────────────────────────────────────────
const ASSET_CLASS_LABELS: Record<string, string> = {
energy: '⚡ Energy',
metals: '🥇 Metals',
agriculture: '🌾 Agri',
equities: '📈 Equities',
indices: '📊 Indices',
forex: '💱 Forex',
rates: '📉 Rates',
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function probStars(prob: number): string {
const n = prob >= 0.8 ? 3 : prob >= 0.6 ? 2 : 1
return '★'.repeat(n) + '☆'.repeat(3 - n)
}
function formatTaxPath(path: string[] | undefined): string {
if (!path || path.length === 0) return ''
return path.join(' ')
}
// ── Signal Direction Badge ────────────────────────────────────────────────────
function SignalBadge({ direction }: { direction?: string }) {
if (direction === 'bullish') {
return (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-emerald-900/40 border border-emerald-700/40 text-emerald-400">
<TrendingUp className="w-3 h-3" /> Bullish
</span>
)
}
if (direction === 'bearish') {
return (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-red-900/40 border border-red-700/40 text-red-400">
<TrendingDown className="w-3 h-3" /> Bearish
</span>
)
}
return (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-amber-900/40 border border-amber-700/40 text-amber-400">
<Zap className="w-3 h-3" /> Vol
</span>
)
}
// ── Category Badge ────────────────────────────────────────────────────────────
const CATEGORY_COLORS: Record<string, string> = {
geopolitical: 'bg-red-900/40 border-red-700/40 text-red-300',
macro: 'bg-blue-900/40 border-blue-700/40 text-blue-300',
volatility: 'bg-amber-900/40 border-amber-700/40 text-amber-300',
'risk-off': 'bg-orange-900/40 border-orange-700/40 text-orange-300',
supply: 'bg-teal-900/40 border-teal-700/40 text-teal-300',
credit: 'bg-purple-900/40 border-purple-700/40 text-purple-300',
'geo-eco': 'bg-emerald-900/40 border-emerald-700/40 text-emerald-300',
sentiment: 'bg-yellow-900/40 border-yellow-700/40 text-yellow-300',
}
function CategoryBadge({ category }: { category: string }) {
const cls = CATEGORY_COLORS[category] ?? 'bg-violet-900/40 border-violet-700/40 text-violet-300'
return (
<span className={clsx('text-[10px] font-semibold px-1.5 py-0.5 rounded border capitalize', cls)}>
{category}
</span>
)
}
// ── Pattern Card ──────────────────────────────────────────────────────────────
function PatternCard({
pattern,
highlightTrades,
}: {
pattern: Pattern
highlightTrades?: string[]
}) {
const [expanded, setExpanded] = useState(false)
const [confirmDel, setConfirmDel] = useState(false)
const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(pattern.name)
const [editDesc, setEditDesc] = useState(pattern.description)
const [editCat, setEditCat] = useState(pattern.category ?? '')
const [editDir, setEditDir] = useState(pattern.signal_direction ?? '')
const [editRegime, setEditRegime] = useState(pattern.regime_tag ?? '')
const [simResult, setSimResult] = useState<SimilarityResult | null>(null)
const [confirmMerge, setConfirmMerge] = useState(false)
const { mutate: deletePattern, isPending: deleting } = useDeletePattern()
const { mutate: patchPattern, isPending: saving } = usePatchPattern()
const { mutate: findSimilar, isPending: scanning } = useFindSimilarPattern()
const { mutate: mergePatterns, isPending: merging } = useMergePatterns()
const trades = pattern.suggested_trades ?? []
const instances = pattern.historical_instances ?? []
const assetLabel = ASSET_CLASS_LABELS[pattern.asset_class] ?? pattern.asset_class
const isEditable = pattern.source !== 'builtin'
const moveSign = pattern.expected_move_pct >= 0 ? '+' : ''
const startEdit = () => {
setEditName(pattern.name)
setEditDesc(pattern.description)
setEditCat(pattern.category ?? '')
setEditDir(pattern.signal_direction ?? '')
setEditRegime(pattern.regime_tag ?? '')
setEditing(true)
}
const saveEdit = () => {
patchPattern({
id: pattern.id,
name: editName.trim() || pattern.name,
description: editDesc.trim() || pattern.description,
category: editCat || undefined,
signal_direction: editDir || undefined,
regime_tag: editRegime.trim() || undefined,
}, { onSuccess: () => setEditing(false) })
}
return (
<div className={clsx(
'card flex flex-col gap-2 transition-colors',
editing ? 'border-blue-600/40' : 'hover:border-slate-600/50',
)}>
{/* Header row */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap">
<SignalBadge direction={editing ? editDir : pattern.signal_direction} />
<span className="text-[10px] font-mono text-slate-500">{pattern.id}</span>
<span className="text-[10px] text-amber-400 tracking-wider">{probStars(pattern.probability)}</span>
{!editing && pattern.category && <CategoryBadge category={pattern.category} />}
{!editing && pattern.regime_tag && (
<span className="text-[10px] text-violet-400 font-mono">#{pattern.regime_tag}</span>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
{pattern.ai_quality_score != null && !editing && (
<span className="text-[10px] font-mono text-slate-500">
AI <span className="text-slate-300 font-semibold">{pattern.ai_quality_score}</span>/100
</span>
)}
{isEditable && !editing && !confirmDel && (
<>
<button
onClick={() => {
setSimResult(null)
setConfirmMerge(false)
findSimilar(pattern.id, { onSuccess: setSimResult })
}}
disabled={scanning}
className="text-slate-700 hover:text-violet-400 transition-colors p-0.5 rounded"
title="Find Similar / Deduplicate"
>
{scanning ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <ScanSearch className="w-3.5 h-3.5" />}
</button>
<button onClick={startEdit} className="text-slate-700 hover:text-blue-400 transition-colors p-0.5 rounded" title="Edit">
<Pencil className="w-3.5 h-3.5" />
</button>
</>
)}
{isEditable && editing && (
<>
<button onClick={saveEdit} disabled={saving}
className="text-slate-700 hover:text-emerald-400 transition-colors p-0.5 rounded" title="Save">
<Check className="w-3.5 h-3.5" />
</button>
<button onClick={() => setEditing(false)}
className="text-slate-700 hover:text-slate-300 transition-colors p-0.5 rounded" title="Cancel">
<X className="w-3.5 h-3.5" />
</button>
</>
)}
{isEditable && !editing && (
confirmDel ? (
<div className="flex items-center gap-1">
<button onClick={() => deletePattern(pattern.id)} disabled={deleting}
className="text-[10px] px-1.5 py-0.5 rounded bg-red-900/50 border border-red-700/60 text-red-400 hover:bg-red-900/70 transition-colors">
{deleting ? '…' : 'Confirm'}
</button>
<button onClick={() => setConfirmDel(false)}
className="text-[10px] px-1.5 py-0.5 rounded border border-slate-700 text-slate-500 hover:text-slate-300 transition-colors">
Cancel
</button>
</div>
) : (
<button onClick={() => setConfirmDel(true)}
className="text-slate-700 hover:text-red-400 transition-colors p-0.5 rounded" title="Delete">
<Trash2 className="w-3.5 h-3.5" />
</button>
)
)}
</div>
</div>
{/* Edit mode fields */}
{editing ? (
<div className="flex flex-col gap-2">
<input
value={editName}
onChange={e => setEditName(e.target.value)}
className="input text-sm font-semibold py-1.5 w-full"
placeholder="Pattern name"
/>
<textarea
value={editDesc}
onChange={e => setEditDesc(e.target.value)}
rows={3}
className="input text-xs py-1.5 w-full resize-none"
placeholder="Description"
/>
<div className="flex gap-2">
<select value={editDir} onChange={e => setEditDir(e.target.value)} className="input text-xs py-1.5 flex-1">
<option value="">Direction</option>
<option value="bullish"> Bullish</option>
<option value="bearish"> Bearish</option>
<option value="neutral"> Vol / Neutral</option>
</select>
<input
value={editCat}
onChange={e => setEditCat(e.target.value)}
className="input text-xs py-1.5 flex-1"
placeholder="Category (geopolitical…)"
/>
<input
value={editRegime}
onChange={e => setEditRegime(e.target.value)}
className="input text-xs py-1.5 flex-1 font-mono"
placeholder="#regime"
/>
</div>
</div>
) : (
<>
{/* Name + description */}
<div>
<div className="text-sm font-semibold text-white leading-snug">{pattern.name}</div>
<div className="text-xs text-slate-400 mt-0.5 line-clamp-2">{pattern.description}</div>
</div>
</>
)}
{/* Stats row */}
<div className="border-t border-slate-700/40 pt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-slate-400">
<span>
Expected:{' '}
<span className={clsx('font-mono font-medium', pattern.expected_move_pct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{moveSign}{pattern.expected_move_pct}%
</span>
</span>
<span>Horizon: <span className="text-slate-300">{pattern.horizon_days}d</span></span>
<span>Asset: <span className="text-slate-300">{assetLabel}</span></span>
<span>Prob: <span className="text-slate-300">{Math.round(pattern.probability * 100)}%</span></span>
</div>
{/* Trades row */}
{trades.length > 0 && (
<div className="border-t border-slate-700/40 pt-2">
<div className="text-[10px] text-slate-500 mb-1 uppercase tracking-wider">Trades</div>
<div className="flex flex-wrap gap-1">
{trades.map((t, i) => {
const isHighlighted =
highlightTrades && highlightTrades.some(u =>
t.underlying.toLowerCase().includes(u.toLowerCase())
)
return (
<span
key={i}
className={clsx(
'text-[10px] px-1.5 py-0.5 rounded border font-mono',
isHighlighted
? 'bg-blue-900/40 border-blue-600/50 text-blue-300'
: 'bg-dark-700/60 border-slate-700/30 text-slate-400'
)}
>
{t.strategy} {t.underlying}
</span>
)
})}
</div>
</div>
)}
{/* Historical instances (collapsible) */}
{instances.length > 0 && (
<div className="border-t border-slate-700/40 pt-2">
<button
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-300 transition-colors w-full text-left"
onClick={() => setExpanded(v => !v)}
>
{expanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
Historical instances ({instances.length})
</button>
{expanded && (
<ul className="mt-2 space-y-1">
{instances.map((inst, i) => (
<li key={i} className="text-xs text-slate-400 flex gap-2">
<span className="font-mono text-slate-500 shrink-0">{inst.date}</span>
<span>{inst.event}</span>
</li>
))}
</ul>
)}
</div>
)}
{/* Similarity result panel */}
{simResult && (
<div className={clsx(
'border-t pt-3 mt-1 space-y-2',
simResult.recommendation === 'merge_as_instance' ? 'border-violet-700/40' :
simResult.recommendation === 'counter_scenario' ? 'border-amber-700/40' :
'border-emerald-700/40',
)}>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
{simResult.recommendation === 'merge_as_instance' && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-0.5 rounded bg-violet-900/40 border border-violet-700/50 text-violet-300 uppercase tracking-wider">
<GitMerge className="w-3 h-3" /> Duplicate détecté
</span>
)}
{simResult.recommendation === 'counter_scenario' && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-0.5 rounded bg-amber-900/40 border border-amber-700/50 text-amber-300 uppercase tracking-wider">
<ArrowRightLeft className="w-3 h-3" /> Counter-scenario
</span>
)}
{simResult.recommendation === 'new_pattern' && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-0.5 rounded bg-emerald-900/40 border border-emerald-700/50 text-emerald-300 uppercase tracking-wider">
<Check className="w-3 h-3" /> Pattern unique
</span>
)}
<span className="text-[10px] text-slate-500">confiance {simResult.confidence}%</span>
</div>
<button onClick={() => { setSimResult(null); setConfirmMerge(false) }}
className="text-slate-600 hover:text-slate-400 p-0.5">
<X className="w-3.5 h-3.5" />
</button>
</div>
{simResult.match_name && (
<div className="text-xs text-slate-400">
Match : <span className="text-white font-medium">{simResult.match_name}</span>
{simResult.suggested_regime_tag && (
<span className="ml-2 text-violet-400 font-mono">#{simResult.suggested_regime_tag}</span>
)}
</div>
)}
<p className="text-xs text-slate-400 leading-relaxed">{simResult.reasoning}</p>
{/* Actions */}
{simResult.recommendation === 'merge_as_instance' && simResult.match_id && (
confirmMerge ? (
<div className="bg-red-900/20 border border-red-700/40 rounded p-2.5 space-y-2">
<div className="flex items-start gap-2 text-xs text-red-300">
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-0.5" />
<span>
<strong>"{pattern.name}"</strong> sera supprimé. Ses instances historiques, scores et trades seront fusionnés dans <strong>"{simResult.match_name}"</strong>. Cette action est irréversible.
</span>
</div>
<div className="flex gap-2">
<button
onClick={() => mergePatterns(
{ keep_id: simResult.match_id!, discard_id: pattern.id },
{ onSuccess: () => { setSimResult(null); setConfirmMerge(false) } }
)}
disabled={merging}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-violet-700/50 hover:bg-violet-600/60 border border-violet-600/50 text-violet-200 transition-colors disabled:opacity-50"
>
<GitMerge className="w-3 h-3" />
{merging ? 'Fusion…' : 'Confirmer la fusion'}
</button>
<button onClick={() => setConfirmMerge(false)}
className="px-3 py-1.5 text-xs rounded border border-slate-700 text-slate-500 hover:text-slate-300 transition-colors">
Annuler
</button>
</div>
</div>
) : (
<button
onClick={() => setConfirmMerge(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-violet-900/30 hover:bg-violet-800/40 border border-violet-700/50 text-violet-300 transition-colors"
>
<GitMerge className="w-3 h-3" />
Fusionner dans "{simResult.match_name}"
</button>
)
)}
{simResult.recommendation === 'counter_scenario' && simResult.match_id && (
<button
onClick={() => patchPattern(
{ id: pattern.id, regime_tag: simResult.suggested_regime_tag || pattern.regime_tag },
{ onSuccess: () => setSimResult(null) }
)}
disabled={saving}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-amber-900/30 hover:bg-amber-800/40 border border-amber-700/50 text-amber-300 transition-colors"
>
<Tag className="w-3 h-3" />
Appliquer le regime tag "{simResult.suggested_regime_tag}"
</button>
)}
</div>
)}
</div>
)
}
// ── Faceted Search ────────────────────────────────────────────────────────────
function FilterChip({
active, label, onClick, color,
}: { active: boolean; label: string; onClick: () => void; color?: string }) {
return (
<button
onClick={onClick}
className={clsx(
'text-xs px-2.5 py-1 rounded-full border transition-colors whitespace-nowrap',
active
? color ?? 'bg-blue-600/30 border-blue-500/50 text-blue-300'
: 'border-slate-700/40 text-slate-500 hover:text-slate-300 hover:border-slate-600',
)}
>
{label}
</button>
)
}
const DIR_COLORS: Record<string, string> = {
bullish: 'bg-emerald-900/40 border-emerald-600/50 text-emerald-300',
up: 'bg-emerald-900/40 border-emerald-600/50 text-emerald-300',
bearish: 'bg-red-900/40 border-red-600/50 text-red-300',
down: 'bg-red-900/40 border-red-600/50 text-red-300',
neutral: 'bg-amber-900/40 border-amber-600/50 text-amber-300',
}
function FacetedSearch({ patterns }: { patterns: Pattern[] }) {
const [query, setQuery] = useState('')
const [direction, setDirection] = useState('')
const [assetClass, setAssetClass] = useState('')
const [category, setCategory] = useState('')
const [horizon, setHorizon] = useState('')
const [source, setSource] = useState('')
const [sortBy, setSortBy] = useState<'ai_score' | 'probability' | 'expected_move' | 'date'>('ai_score')
const allDirections = useMemo(() => {
const s = new Set(patterns.map(p => p.signal_direction).filter(Boolean) as string[])
return Array.from(s).sort()
}, [patterns])
const allAssets = useMemo(() => {
const s = new Set(patterns.map(p => p.asset_class).filter(Boolean))
return Array.from(s).sort()
}, [patterns])
const allCategories = useMemo(() => {
const s = new Set(patterns.map(p => p.category).filter(Boolean) as string[])
return Array.from(s).sort()
}, [patterns])
const filtered = useMemo(() => {
let list = patterns
if (query.trim()) {
const q = query.trim().toLowerCase()
list = list.filter(p =>
p.name.toLowerCase().includes(q) ||
p.description.toLowerCase().includes(q) ||
(p.regime_tag ?? '').toLowerCase().includes(q),
)
}
if (direction) list = list.filter(p => p.signal_direction === direction)
if (assetClass) list = list.filter(p => p.asset_class === assetClass)
if (category) list = list.filter(p => p.category === category)
if (horizon === 'short') list = list.filter(p => p.horizon_days < 30)
if (horizon === 'medium') list = list.filter(p => p.horizon_days >= 30 && p.horizon_days <= 90)
if (horizon === 'long') list = list.filter(p => p.horizon_days > 90)
if (source) list = list.filter(p => p.source === source)
return [...list].sort((a, b) => {
if (sortBy === 'ai_score') return (b.ai_quality_score ?? -1) - (a.ai_quality_score ?? -1)
if (sortBy === 'probability') return b.probability - a.probability
if (sortBy === 'expected_move') return Math.abs(b.expected_move_pct) - Math.abs(a.expected_move_pct)
return ((b as any).created_at ?? '').localeCompare((a as any).created_at ?? '')
})
}, [patterns, query, direction, assetClass, category, horizon, source, sortBy])
const hasFilters = !!(query || direction || assetClass || category || horizon || source)
const toggle = (cur: string, val: string, set: (v: string) => void) => set(cur === val ? '' : val)
return (
<div className="flex flex-col gap-4">
{/* Search + sort */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-lg">
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
<input
type="text"
placeholder="Search name, description, or #regime…"
value={query}
onChange={e => setQuery(e.target.value)}
className="input text-sm pl-9 py-2 w-full"
/>
</div>
<select
value={sortBy}
onChange={e => setSortBy(e.target.value as typeof sortBy)}
className="input text-xs py-2 pr-6 pl-2 shrink-0"
>
<option value="ai_score">AI Score </option>
<option value="probability">Probability </option>
<option value="expected_move">Expected Move </option>
<option value="date">Date Added </option>
</select>
</div>
{/* Filter panel */}
<div className="flex flex-col gap-2.5 bg-dark-700/30 border border-slate-700/30 rounded-lg px-4 py-3">
{/* Direction */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] uppercase tracking-widest text-slate-600 w-20 shrink-0">Direction</span>
<FilterChip active={!direction} label="All" onClick={() => setDirection('')} />
{allDirections.map(d => (
<FilterChip key={d} active={direction === d}
label={d === 'bullish' || d === 'up' ? `${d}` : d === 'bearish' || d === 'down' ? `${d}` : `${d}`}
onClick={() => toggle(direction, d, setDirection)}
color={DIR_COLORS[d]} />
))}
</div>
{/* Asset class */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] uppercase tracking-widest text-slate-600 w-20 shrink-0">Asset</span>
<FilterChip active={!assetClass} label="All" onClick={() => setAssetClass('')} />
{allAssets.map(a => (
<FilterChip key={a} active={assetClass === a}
label={ASSET_CLASS_LABELS[a] ?? a}
onClick={() => toggle(assetClass, a, setAssetClass)} />
))}
</div>
{/* Category */}
{allCategories.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] uppercase tracking-widest text-slate-600 w-20 shrink-0">Category</span>
<FilterChip active={!category} label="All" onClick={() => setCategory('')} />
{allCategories.map(c => (
<FilterChip key={c} active={category === c} label={c}
onClick={() => toggle(category, c, setCategory)}
color={CATEGORY_COLORS[c] ?? 'bg-violet-900/40 border-violet-600/50 text-violet-300'} />
))}
</div>
)}
{/* Horizon */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] uppercase tracking-widest text-slate-600 w-20 shrink-0">Horizon</span>
<FilterChip active={!horizon} label="All" onClick={() => setHorizon('')} />
<FilterChip active={horizon === 'short'} label="< 30d" onClick={() => toggle(horizon, 'short', setHorizon)} />
<FilterChip active={horizon === 'medium'} label="3090d" onClick={() => toggle(horizon, 'medium', setHorizon)} />
<FilterChip active={horizon === 'long'} label="> 90d" onClick={() => toggle(horizon, 'long', setHorizon)} />
</div>
{/* Source */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] uppercase tracking-widest text-slate-600 w-20 shrink-0">Source</span>
<FilterChip active={!source} label="All" onClick={() => setSource('')} />
<FilterChip active={source === 'builtin'} label="Built-in" onClick={() => toggle(source, 'builtin', setSource)} />
<FilterChip active={source === 'backtested'} label="Backtested" onClick={() => toggle(source, 'backtested', setSource)}
color="bg-violet-900/40 border-violet-600/50 text-violet-300" />
</div>
</div>
{/* Count + clear */}
<div className="flex items-center gap-3">
<span className="text-xs text-slate-500">{filtered.length} pattern{filtered.length !== 1 ? 's' : ''}</span>
{hasFilters && (
<button
onClick={() => { setQuery(''); setDirection(''); setAssetClass(''); setCategory(''); setHorizon(''); setSource('') }}
className="text-xs text-slate-600 hover:text-slate-400 underline transition-colors"
>
Clear all filters
</button>
)}
</div>
{/* Pattern grid */}
{filtered.length === 0 ? (
<div className="flex items-center justify-center py-24 text-slate-600 text-sm">
No patterns match these filters
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-3">
{filtered.map(p => <PatternCard key={p.id} pattern={p} />)}
</div>
)}
</div>
)
}
// ── Instrument Lens ───────────────────────────────────────────────────────────
function InstrumentRow({ pattern, searchTerm }: { pattern: Pattern; searchTerm: string }) {
const matchingTrades = pattern.suggested_trades?.filter(t =>
t.underlying.toLowerCase().includes(searchTerm.toLowerCase())
) ?? []
return (
<div className="card hover:border-slate-600/50 transition-colors">
<div className="flex items-start justify-between gap-4 flex-wrap">
{/* Left: name + path */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-0.5">
<SignalBadge direction={pattern.signal_direction} />
<span className="text-sm font-semibold text-white">{pattern.name}</span>
</div>
{pattern.taxonomy_path && pattern.taxonomy_path.length > 0 && (
<div className="text-[10px] text-slate-500 font-mono mt-0.5">
{formatTaxPath(pattern.taxonomy_path)}
</div>
)}
{/* Matching trades highlighted */}
{matchingTrades.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{matchingTrades.map((t, i) => (
<div
key={i}
className="text-xs px-2 py-1 rounded border bg-blue-900/30 border-blue-600/40 text-blue-300"
>
<span className="font-semibold">{t.strategy}</span>
<span className="text-blue-400 mx-1">·</span>
<span className="font-mono">{t.underlying}</span>
{t.expected_move_pct != null && (
<span className="ml-2 text-slate-400">
{t.expected_move_pct >= 0 ? '+' : ''}{t.expected_move_pct}%
</span>
)}
{t.rationale && (
<span className="ml-2 text-slate-500 italic text-[10px]">{t.rationale}</span>
)}
</div>
))}
</div>
)}
</div>
{/* Right: stats */}
<div className="flex flex-col gap-1 items-end text-xs text-slate-400 shrink-0">
<span>
Prob:{' '}
<span className="font-semibold text-slate-300">{Math.round(pattern.probability * 100)}%</span>
</span>
<span>
Move:{' '}
<span className={clsx('font-mono font-semibold', pattern.expected_move_pct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{pattern.expected_move_pct >= 0 ? '+' : ''}{pattern.expected_move_pct}%
</span>
</span>
<span>Horizon: <span className="text-slate-300">{pattern.horizon_days}d</span></span>
</div>
</div>
</div>
)
}
function InstrumentLens({ patterns }: { patterns: Pattern[] }) {
const [searchTerm, setSearchTerm] = useState('')
const [activeCategory, setActiveCategory] = useState<string | null>(null)
const [customInput, setCustomInput] = useState('')
const [validating, setValidating] = useState(false)
const [tickerError, setTickerError] = useState<string | null>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Selected ticker — either clicked from grid or typed
const [selectedTicker, setSelectedTicker] = useState('')
const { data: instrumentData } = usePatternsByInstrument(selectedTicker)
const instrumentPatterns = instrumentData as Pattern[] | undefined
const results = useMemo(() => {
if (!selectedTicker) return []
const source: Pattern[] = (instrumentPatterns && instrumentPatterns.length > 0)
? instrumentPatterns
: patterns.filter(p =>
(p.suggested_trades ?? []).some(t =>
t.underlying.toLowerCase().includes(selectedTicker.toLowerCase())
)
)
return [...source].sort((a, b) => b.probability - a.probability)
}, [selectedTicker, instrumentPatterns, patterns])
// Deduplicate instruments list
const uniqueInstruments = useMemo(() => {
const seen = new Set<string>()
return INSTRUMENTS.filter(i => { if (seen.has(i.ticker)) return false; seen.add(i.ticker); return true })
}, [])
const filteredInstruments = useMemo(() => {
const byCategory = activeCategory
? uniqueInstruments.filter(i => i.category === activeCategory)
: uniqueInstruments
if (!searchTerm) return byCategory
const q = searchTerm.toLowerCase()
return byCategory.filter(i =>
i.ticker.toLowerCase().includes(q) || i.name.toLowerCase().includes(q)
)
}, [uniqueInstruments, activeCategory, searchTerm])
const handleCustom = async () => {
const sym = customInput.trim().toUpperCase()
if (!sym) return
setTickerError(null)
setValidating(true)
try {
const res = await validateTicker(sym)
if (res.valid) {
setSelectedTicker(sym)
setCustomInput('')
} else {
setTickerError(res.reason || `Ticker '${sym}' not found`)
}
} catch {
setTickerError(`Could not validate '${sym}' — check your connection`)
} finally {
setValidating(false)
}
}
return (
<div className="flex flex-col gap-4">
{/* ── Category pills ── */}
<div className="flex flex-wrap gap-1.5">
<button onClick={() => setActiveCategory(null)}
className={clsx('text-xs px-2.5 py-1 rounded-full border transition-colors', {
'bg-blue-600 border-blue-500 text-white': activeCategory === null,
'border-slate-700 text-slate-500 hover:text-slate-300': activeCategory !== null,
})}>All</button>
{INSTRUMENT_CATEGORIES.map(cat => (
<button key={cat} onClick={() => setActiveCategory(activeCategory === cat ? null : cat)}
className={clsx('text-xs px-2.5 py-1 rounded-full border transition-colors', {
'bg-blue-600 border-blue-500 text-white': activeCategory === cat,
'border-slate-700 text-slate-500 hover:text-slate-300': activeCategory !== cat,
})}>{cat}</button>
))}
</div>
{/* ── Search + custom ── */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500" />
<input type="text" value={searchTerm} onChange={e => setSearchTerm(e.target.value)}
placeholder="Search instruments…"
className="w-full pl-8 pr-3 py-1.5 text-xs bg-dark-700 border border-slate-700/50 rounded text-slate-200 placeholder-slate-600 focus:outline-none focus:border-blue-600" />
</div>
<div className="flex flex-col gap-1">
<div className="flex gap-1">
<input type="text" value={customInput}
onChange={e => { setCustomInput(e.target.value); setTickerError(null) }}
onKeyDown={e => e.key === 'Enter' && handleCustom()}
placeholder="Custom ticker…"
className={clsx(
'w-32 px-2 py-1.5 text-xs bg-dark-700 border rounded text-slate-200 placeholder-slate-600 focus:outline-none font-mono uppercase',
tickerError ? 'border-red-500/70 focus:border-red-400' : 'border-slate-700/50 focus:border-blue-600'
)} />
<button onClick={handleCustom} disabled={!customInput.trim() || validating}
className="px-2.5 py-1.5 text-xs bg-blue-700 hover:bg-blue-600 disabled:opacity-40 text-white rounded transition-colors flex items-center gap-1">
{validating ? <><RefreshCw className="w-3 h-3 animate-spin" /> </> : 'Go'}
</button>
</div>
{tickerError && (
<p className="text-[9px] text-red-400 max-w-[200px] leading-tight">{tickerError}</p>
)}
</div>
{selectedTicker && (
<button onClick={() => setSelectedTicker('')}
className="text-xs text-slate-500 hover:text-slate-300 transition-colors">Clear</button>
)}
</div>
{/* ── Instrument grid ── */}
{!selectedTicker && (
<div className="grid grid-cols-4 gap-1.5">
{filteredInstruments.map(inst => (
<button key={inst.ticker} onClick={() => setSelectedTicker(inst.ticker)}
className="text-left px-2.5 py-2 rounded border border-slate-700/40 bg-dark-700/40 hover:border-blue-600/60 hover:bg-blue-900/20 transition-colors group">
<div className="flex items-center gap-1 mb-0.5">
{inst.flag && <span className="text-sm">{inst.flag}</span>}
<span className="text-[10px] font-mono text-blue-400 group-hover:text-blue-300">{inst.ticker}</span>
</div>
<div className="text-[10px] text-slate-400 truncate">{inst.name}</div>
</button>
))}
{filteredInstruments.length === 0 && (
<div className="col-span-4 text-center py-8 text-xs text-slate-600">No instruments match</div>
)}
</div>
)}
{/* ── Results ── */}
{selectedTicker && (
results.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-600 text-sm gap-2">
<Zap className="w-6 h-6 opacity-30" />
<p>No patterns found for <span className="font-mono text-slate-500">{selectedTicker}</span></p>
<button onClick={() => setSelectedTicker('')} className="text-xs text-blue-500 hover:text-blue-400 mt-1"> Back to list</button>
</div>
) : (
<>
<div className="flex items-center gap-3 text-sm text-slate-400">
<button onClick={() => setSelectedTicker('')} className="text-xs text-blue-500 hover:text-blue-400"> Back</button>
<span className="font-semibold text-slate-200">{results.length}</span> pattern{results.length !== 1 ? 's' : ''} influence{' '}
<span className="font-mono text-blue-400">{selectedTicker}</span>, ranked by probability
</div>
<div className="flex flex-col gap-3">
{results.map(p => (
<InstrumentRow key={p.id} pattern={p} searchTerm={selectedTicker} />
))}
</div>
</>
)
)}
</div>
)
}
// ── Regime View ───────────────────────────────────────────────────────────────
function RegimeCard({ pattern, allPatterns }: { pattern: Pattern; allPatterns: Pattern[] }) {
const [open, setOpen] = useState(false)
const isCounter = !!pattern.counter_of
const counterOf = isCounter ? allPatterns.find(p => p.id === pattern.counter_of) : null
const instances = Array.isArray(pattern.historical_instances) ? pattern.historical_instances : []
const hitRate = pattern.backtest_runs_count
? Math.round(((pattern.backtest_hits ?? 0) / pattern.backtest_runs_count) * 100)
: null
return (
<div className={clsx(
'border rounded-lg overflow-hidden transition-colors',
isCounter ? 'border-orange-700/50 bg-orange-900/5' : 'border-slate-700/50 bg-dark-700/30'
)}>
<button className="w-full flex items-center gap-3 p-3 text-left hover:bg-slate-800/30 transition-colors"
onClick={() => setOpen(v => !v)}>
{open ? <ChevronDown className="w-3.5 h-3.5 text-slate-500 shrink-0" /> : <ChevronRight className="w-3.5 h-3.5 text-slate-500 shrink-0" />}
{isCounter
? <Layers className="w-4 h-4 text-orange-400 shrink-0" />
: <BookOpen className="w-4 h-4 text-violet-400 shrink-0" />}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-slate-100 truncate">{pattern.name}</span>
{isCounter && (
<span className="text-[10px] border border-orange-600 text-orange-400 rounded px-1.5 py-0.5 shrink-0">
counter of {counterOf?.name ?? pattern.counter_of}
</span>
)}
{pattern.signal_direction && (
<span className={clsx('text-[10px] rounded px-1.5 py-0.5 shrink-0',
pattern.signal_direction === 'up' ? 'bg-emerald-900/40 text-emerald-400' :
pattern.signal_direction === 'down' ? 'bg-red-900/40 text-red-400' : 'bg-slate-700 text-slate-400')}>
{pattern.signal_direction === 'up' ? '↑' : pattern.signal_direction === 'down' ? '↓' : '↕'} {pattern.signal_direction}
</span>
)}
{hitRate !== null && (
<span className={clsx('text-[10px] font-mono shrink-0',
hitRate >= 60 ? 'text-emerald-400' : hitRate >= 40 ? 'text-yellow-400' : 'text-red-400')}>
{hitRate}% ({pattern.backtest_hits}/{pattern.backtest_runs_count})
</span>
)}
</div>
<p className="text-[10px] text-slate-500 truncate mt-0.5">{pattern.description}</p>
</div>
<span className="text-[10px] text-slate-600 shrink-0 font-mono">{instances.length} events</span>
</button>
{open && instances.length > 0 && (
<div className="border-t border-slate-700/40 p-3 pt-2">
<div className="text-[10px] text-slate-500 mb-2 font-semibold uppercase tracking-wide">Historical instances</div>
<div className="flex flex-col gap-1.5">
{instances.map((inst: any, i: number) => (
<div key={i} className="flex items-center gap-3 text-[11px]">
<span className="text-slate-500 font-mono w-24 shrink-0">{inst.date ?? '—'}</span>
<span className="text-slate-300 flex-1 truncate">{inst.event_name ?? inst.event ?? inst.theme ?? '—'}</span>
{inst.hit !== undefined && (
<span className={clsx('shrink-0', inst.hit_type === 'full' || inst.hit === true ? 'text-emerald-400' : inst.hit_type === 'partial' ? 'text-amber-400' : 'text-red-400')}>
{inst.hit_type === 'full' || inst.hit === true ? '✓' : inst.hit_type === 'partial' ? '~' : '✗'}
</span>
)}
</div>
))}
</div>
</div>
)}
</div>
)
}
function RegimeView({ patterns }: { patterns: Pattern[] }) {
const groups = useMemo(() => {
const byRegime: Record<string, Pattern[]> = {}
const untagged: Pattern[] = []
for (const p of patterns) {
if (p.source !== 'backtested' && p.source !== 'user') continue
if (p.regime_tag) {
;(byRegime[p.regime_tag] ??= []).push(p)
} else {
untagged.push(p)
}
}
return { byRegime, untagged }
}, [patterns])
const regimes = Object.entries(groups.byRegime).sort((a, b) => b[1].length - a[1].length)
if (regimes.length === 0 && groups.untagged.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-32 gap-3 text-slate-600">
<Tag className="w-8 h-8" />
<p className="text-sm">No saved patterns yet.</p>
<p className="text-xs">Use "Find Matching" in Pattern Lab to classify and save patterns into regimes.</p>
</div>
)
}
return (
<div className="flex flex-col gap-6">
{regimes.map(([tag, pats]) => (
<div key={tag} className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Tag className="w-3.5 h-3.5 text-violet-400" />
<span className="text-sm font-semibold text-violet-300">#{tag}</span>
<span className="text-xs text-slate-500">{pats.length} pattern{pats.length > 1 ? 's' : ''}</span>
</div>
<div className="flex flex-col gap-2 pl-4 border-l border-violet-900/40">
{pats.map(p => <RegimeCard key={p.id} pattern={p} allPatterns={patterns} />)}
</div>
</div>
))}
{groups.untagged.length > 0 && (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Tag className="w-3.5 h-3.5 text-slate-500" />
<span className="text-sm font-semibold text-slate-500">Untagged</span>
<span className="text-xs text-slate-600">{groups.untagged.length} pattern{groups.untagged.length > 1 ? 's' : ''}</span>
</div>
<div className="flex flex-col gap-2 pl-4 border-l border-slate-700/40">
{groups.untagged.map(p => <RegimeCard key={p.id} pattern={p} allPatterns={patterns} />)}
</div>
</div>
)}
</div>
)
}
// ── Page root ─────────────────────────────────────────────────────────────────
type ViewMode = 'search' | 'instrument' | 'regime'
export default function PatternExplorer() {
const [view, setView] = useState<ViewMode>('search')
const { data: patternsData, isLoading } = useAllPatterns()
const { data: _scores } = useLastScores()
const patterns = useMemo<Pattern[]>(() => {
if (!Array.isArray(patternsData)) return []
return patternsData as Pattern[]
}, [patternsData])
return (
<div className="p-6 flex flex-col gap-5 min-h-screen">
{/* Page header */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<div className="flex items-center gap-3 mb-1">
<TreePine className="w-5 h-5 text-emerald-400 shrink-0" />
<h1 className="text-xl font-bold text-white">Pattern Library</h1>
<Link
to="/patterns/edit"
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded border border-slate-700/50 text-slate-400 hover:border-blue-600/50 hover:text-blue-400 transition-colors ml-2"
>
<ExternalLink className="w-3 h-3" />
Edit Library
</Link>
</div>
<p className="text-sm text-slate-500">
Browse {patterns.length > 0 ? patterns.length : '…'} geo-financial patterns by theme or by instrument
</p>
</div>
{/* View toggle */}
<div className="flex items-center gap-1 bg-dark-700/60 border border-slate-700/40 rounded-lg p-1">
<button
className={clsx(
'flex items-center gap-1.5 px-3 py-1.5 rounded text-sm font-medium transition-colors',
view === 'search'
? 'bg-blue-600/30 text-blue-300 border border-blue-500/30'
: 'text-slate-400 hover:text-slate-200'
)}
onClick={() => setView('search')}
>
<Search className="w-3.5 h-3.5" />
Search
</button>
<button
className={clsx(
'flex items-center gap-1.5 px-3 py-1.5 rounded text-sm font-medium transition-colors',
view === 'instrument'
? 'bg-blue-600/30 text-blue-300 border border-blue-500/30'
: 'text-slate-400 hover:text-slate-200'
)}
onClick={() => setView('instrument')}
>
<Search className="w-3.5 h-3.5" />
Instrument
</button>
<button
className={clsx(
'flex items-center gap-1.5 px-3 py-1.5 rounded text-sm font-medium transition-colors',
view === 'regime'
? 'bg-violet-600/30 text-violet-300 border border-violet-500/30'
: 'text-slate-400 hover:text-slate-200'
)}
onClick={() => setView('regime')}
>
<Layers className="w-3.5 h-3.5" />
By Regime
</button>
</div>
</div>
{/* Content area */}
{isLoading ? (
<div className="flex items-center justify-center py-32 text-slate-600 text-sm gap-2">
<div className="w-4 h-4 border-2 border-slate-600 border-t-blue-500 rounded-full animate-spin" />
Loading patterns
</div>
) : (
<>
{view === 'search' && <FacetedSearch patterns={patterns} />}
{view === 'instrument' && <InstrumentLens patterns={patterns} />}
{view === 'regime' && <RegimeView patterns={patterns} />}
</>
)}
</div>
)
}