From f98ac112a3add769e44f692fdb56385d9410a0cd Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Mon, 22 Jun 2026 21:01:16 +0200 Subject: [PATCH] feat: replace tree with faceted search + delete pattern button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PatternExplorer: replace rigid taxonomy tree with FacetedSearch — combinable chip filters on Direction, Asset class, Category, Horizon, Source + full-text search (name/description/regime) + sort. No fixed hierarchy, scales with library size. - PatternCard: delete button (trash icon) for non-builtin patterns, two-step confirm/cancel to prevent accidental deletion. Shows #regime_tag chip inline. - RegimeCard and RegimeView unchanged. - Remove dead code: TaxonomyNode, TreeNodeRow, enrichTree, pathStartsWith, TreeView, usePatternTaxonomy import. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/pages/PatternExplorer.tsx | 478 +++++++++++-------------- 1 file changed, 206 insertions(+), 272 deletions(-) diff --git a/frontend/src/pages/PatternExplorer.tsx b/frontend/src/pages/PatternExplorer.tsx index 5f1b5a8..5d4ab35 100644 --- a/frontend/src/pages/PatternExplorer.tsx +++ b/frontend/src/pages/PatternExplorer.tsx @@ -4,29 +4,19 @@ import clsx from 'clsx' import { TreePine, Search, ChevronRight, ChevronDown, TrendingUp, TrendingDown, Zap, BookOpen, ExternalLink, RefreshCw, - Layers, GitMerge, Tag, + Layers, GitMerge, Tag, Trash2, } from 'lucide-react' import { useAllPatterns, - usePatternTaxonomy, usePatternsByInstrument, useLastScores, + useDeletePattern, validateTicker, } from '../hooks/useApi' import { INSTRUMENTS, INSTRUMENT_CATEGORIES } from '../constants/instruments' // ── Local types ─────────────────────────────────────────────────────────────── -interface TaxonomyNode { - id: string - label: string - icon?: string - description?: string - children?: TaxonomyNode[] - count?: number - path: string[] -} - interface SuggestedTrade { strategy: string underlying: string @@ -90,12 +80,6 @@ function probStars(prob: number): string { return '★'.repeat(n) + '☆'.repeat(3 - n) } -function pathStartsWith(full: string[] | undefined, prefix: string[]): boolean { - if (!full || prefix.length === 0) return true - if (full.length < prefix.length) return false - return prefix.every((seg, i) => seg === full[i]) -} - function formatTaxPath(path: string[] | undefined): string { if (!path || path.length === 0) return '' return path.join(' › ') @@ -156,11 +140,14 @@ function PatternCard({ pattern: Pattern highlightTrades?: string[] }) { - const [expanded, setExpanded] = useState(false) + const [expanded, setExpanded] = useState(false) + const [confirmDel, setConfirmDel] = useState(false) + const { mutate: deletePattern, isPending: deleting } = useDeletePattern() const trades = pattern.suggested_trades ?? [] const instances = pattern.historical_instances ?? [] const assetLabel = ASSET_CLASS_LABELS[pattern.asset_class] ?? pattern.asset_class + const isDeletable = pattern.source !== 'builtin' const moveSign = pattern.expected_move_pct >= 0 ? '+' : '' @@ -173,12 +160,44 @@ function PatternCard({ {pattern.id} {probStars(pattern.probability)} {pattern.category && } + {pattern.regime_tag && ( + #{pattern.regime_tag} + )} + +
+ {pattern.ai_quality_score != null && ( + + AI {pattern.ai_quality_score}/100 + + )} + {isDeletable && ( + confirmDel ? ( +
+ + +
+ ) : ( + + ) + )}
- {pattern.ai_quality_score != null && ( - - AI {pattern.ai_quality_score}/100 - - )} {/* Name + description */} @@ -254,277 +273,192 @@ function PatternCard({ ) } -// ── Taxonomy Tree Node ──────────────────────────────────────────────────────── - -function TreeNodeRow({ - node, - level, - selectedPath, - onSelect, - defaultExpandedDepth, -}: { - node: TaxonomyNode - level: number - selectedPath: string[] - onSelect: (path: string[]) => void - defaultExpandedDepth: number -}) { - const hasChildren = (node.children ?? []).length > 0 - const [expanded, setExpanded] = useState(level < defaultExpandedDepth) - - const isSelected = - selectedPath.length === node.path.length && - node.path.every((seg, i) => seg === selectedPath[i]) - - const handleClick = useCallback(() => { - onSelect(node.path) - if (hasChildren) setExpanded(v => !v) - }, [node.path, onSelect, hasChildren]) - - const indent = level * 16 +// ── Faceted Search ──────────────────────────────────────────────────────────── +function FilterChip({ + active, label, onClick, color, +}: { active: boolean; label: string; onClick: () => void; color?: string }) { return ( -
- - {hasChildren && expanded && ( -
- {node.children!.map(child => ( - - ))} -
+ ) } -// ── Helper: add computed path arrays to each node ──────────────────────────── -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function enrichTree(node: any, parentPath: string[] = []): TaxonomyNode { - const path = [...parentPath, node.id] - return { - ...node, - path, - children: (node.children ?? []).map((c: any) => enrichTree(c, path)), - } +const DIR_COLORS: Record = { + 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', } -// ── Tree View ───────────────────────────────────────────────────────────────── +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') -function TreeView({ patterns }: { patterns: Pattern[] }) { - const { data: taxonomyData } = usePatternTaxonomy() - // API returns { tree: {...}, patterns: [...] } — extract the tree and enrich nodes with path arrays. - // Start enriching from root's children with [] so paths match pattern taxonomy_path (no "root" prefix). - const taxonomy: TaxonomyNode | undefined = useMemo(() => { - const raw = (taxonomyData as any)?.tree - if (!raw) return undefined - return { ...raw, path: [], children: (raw.children ?? []).map((c: any) => enrichTree(c, [])) } - }, [taxonomyData]) + const allDirections = useMemo(() => { + const s = new Set(patterns.map(p => p.signal_direction).filter(Boolean) as string[]) + return Array.from(s).sort() + }, [patterns]) - const [selectedPath, setSelectedPath] = useState([]) - const [nameFilter, setNameFilter] = useState('') - const [categoryFilter, setCategoryFilter] = useState('') - const [sortBy, setSortBy] = useState<'ai_score' | 'probability' | 'expected_move' | 'date'>('ai_score') + const allAssets = useMemo(() => { + const s = new Set(patterns.map(p => p.asset_class).filter(Boolean)) + return Array.from(s).sort() + }, [patterns]) - // Patterns matching the selected taxonomy node + name filter - const nodePatterns = useMemo(() => { + 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 (selectedPath.length > 0) - list = list.filter(p => pathStartsWith(p.taxonomy_path, selectedPath)) - if (nameFilter.trim()) { - const q = nameFilter.trim().toLowerCase() - list = list.filter(p => p.name.toLowerCase().includes(q) || p.description.toLowerCase().includes(q)) + 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), + ) } - return list - }, [patterns, selectedPath, nameFilter]) - - // Available categories within current node (for filter pills) - const availableCategories = useMemo(() => { - const cats = new Set(nodePatterns.map(p => p.category).filter(Boolean) as string[]) - return Array.from(cats).sort() - }, [nodePatterns]) - - // Apply category filter + sort - const filteredPatterns = useMemo(() => { - let list = nodePatterns - if (categoryFilter) list = list.filter(p => p.category === categoryFilter) + 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') { - const sa = a.ai_quality_score ?? -1 - const sb = b.ai_quality_score ?? -1 - return sb - sa - } - if (sortBy === 'probability') return b.probability - a.probability + 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) - // date - const da = (a as any).created_at ?? '' - const db = (b as any).created_at ?? '' - return db.localeCompare(da) + return ((b as any).created_at ?? '').localeCompare((a as any).created_at ?? '') }) - }, [nodePatterns, categoryFilter, sortBy]) + }, [patterns, query, direction, assetClass, category, horizon, source, sortBy]) - const breadcrumb = selectedPath.length > 0 ? selectedPath.join(' › ') : 'All Patterns' - - const CATEGORY_COLORS: Record = { - 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', - } - const catColor = (cat: string) => - CATEGORY_COLORS[cat] ?? 'bg-slate-700/40 border-slate-600/40 text-slate-300' + const hasFilters = !!(query || direction || assetClass || category || horizon || source) + const toggle = (cur: string, val: string, set: (v: string) => void) => set(cur === val ? '' : val) return ( -
- {/* Left sidebar */} -
-
- Taxonomy +
+ {/* Search + sort */} +
+
+ + setQuery(e.target.value)} + className="input text-sm pl-9 py-2 w-full" + />
- - {/* Root "All" entry */} -
- {/* Right panel */} -
- {/* Row 1: breadcrumb + search + count */} -
-
- Path: - {breadcrumb} -
-
-
- - setNameFilter(e.target.value)} - className="input text-sm pl-8 py-1.5 w-52" - /> -
- {filteredPatterns.length} patterns -
-
- - {/* Row 2: category pills + sort */} + {/* Filter panel */} +
+ {/* Direction */}
- {availableCategories.length > 0 && ( - <> - Category: - - {availableCategories.map(cat => ( - - ))} - - )} -
- Sort: - -
+ Direction + setDirection('')} /> + {allDirections.map(d => ( + toggle(direction, d, setDirection)} + color={DIR_COLORS[d]} /> + ))}
- {/* Pattern grid */} - {filteredPatterns.length === 0 ? ( -
- No patterns match the selected filter -
- ) : ( -
- {filteredPatterns.map(p => ( - + {/* Asset class */} +
+ Asset + setAssetClass('')} /> + {allAssets.map(a => ( + toggle(assetClass, a, setAssetClass)} /> + ))} +
+ + {/* Category */} + {allCategories.length > 0 && ( +
+ Category + setCategory('')} /> + {allCategories.map(c => ( + toggle(category, c, setCategory)} + color={CATEGORY_COLORS[c] ?? 'bg-violet-900/40 border-violet-600/50 text-violet-300'} /> ))}
)} + + {/* Horizon */} +
+ Horizon + setHorizon('')} /> + toggle(horizon, 'short', setHorizon)} /> + toggle(horizon, 'medium', setHorizon)} /> + toggle(horizon, 'long', setHorizon)} /> +
+ + {/* Source */} +
+ Source + setSource('')} /> + toggle(source, 'builtin', setSource)} /> + toggle(source, 'backtested', setSource)} + color="bg-violet-900/40 border-violet-600/50 text-violet-300" /> +
+ + {/* Count + clear */} +
+ {filtered.length} pattern{filtered.length !== 1 ? 's' : ''} + {hasFilters && ( + + )} +
+ + {/* Pattern grid */} + {filtered.length === 0 ? ( +
+ No patterns match these filters +
+ ) : ( +
+ {filtered.map(p => )} +
+ )}
) } @@ -883,10 +817,10 @@ function RegimeView({ patterns }: { patterns: Pattern[] }) { // ── Page root ───────────────────────────────────────────────────────────────── -type ViewMode = 'tree' | 'instrument' | 'regime' +type ViewMode = 'search' | 'instrument' | 'regime' export default function PatternExplorer() { - const [view, setView] = useState('tree') + const [view, setView] = useState('search') const { data: patternsData, isLoading } = useAllPatterns() const { data: _scores } = useLastScores() @@ -921,14 +855,14 @@ export default function PatternExplorer() {