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 */}
-
-
- {/* Taxonomy tree */}
- {taxonomy ? (
- (taxonomy.children ?? []).map(node => (
-
{ setSelectedPath(path); setCategoryFilter('') }}
- defaultExpandedDepth={1}
- />
- ))
- ) : (
- Loading taxonomy…
- )}
+
+
+
+
+
- {/* 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() {