feat: Pattern Explorer — taxonomy tree + 23 historical patterns + instrument lens
- PatternExplorer.tsx: new page with two views:
• Tree View — 6-root taxonomy (geopolitical, monetary_policy, economic,
commodity, risk_off, market_structure) with collapsible sub-nodes;
pattern cards appear on node selection
• Instrument Lens — search any ticker (e.g. GLD, FXE) to see every
pattern + scenario that references it, with matching trades highlighted
- geo_analyzer.py: PATTERN_TAXONOMY tree constant + taxonomy_path on all
patterns; 15 new documented patterns P009-P023 (BoJ YCC, SVB crisis,
Taiwan semis, OPEC cuts, Fed pivot, Debt ceiling, Iran nuclear, DPRK,
VIX backwardation, ECB surprise, Extreme Fear contrarian, S. China Sea,
European energy, CPI hot print, Flash crash)
- patterns.py: GET /api/patterns/taxonomy, GET /api/patterns/by-instrument
- database.py: taxonomy_path migration + seed_builtin_patterns updates path
- App.tsx: /patterns → PatternExplorer, /patterns/edit → PatternEditor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import Backtest from './pages/Backtest'
|
||||
import CalendarPage from './pages/CalendarPage'
|
||||
import Portfolio from './pages/Portfolio'
|
||||
import PatternEditor from './pages/PatternEditor'
|
||||
import PatternExplorer from './pages/PatternExplorer'
|
||||
import JournalDeBord from './pages/JournalDeBord'
|
||||
import RapportIA from './pages/RapportIA'
|
||||
import SuperContexte from './pages/SuperContexte'
|
||||
@@ -40,7 +41,8 @@ export default function App() {
|
||||
<Route path="/markets" element={<Markets />} />
|
||||
<Route path="/macro" element={<MacroRegime />} />
|
||||
<Route path="/options" element={<OptionsLab />} />
|
||||
<Route path="/patterns" element={<PatternEditor />} />
|
||||
<Route path="/patterns" element={<PatternExplorer />} />
|
||||
<Route path="/patterns/edit" element={<PatternEditor />} />
|
||||
<Route path="/portfolio" element={<Portfolio />} />
|
||||
<Route path="/backtest" element={<Backtest />} />
|
||||
<Route path="/calendar" element={<CalendarPage />} />
|
||||
|
||||
@@ -251,6 +251,21 @@ export const useAllPatterns = () =>
|
||||
queryFn: () => api.get('/patterns/all').then(r => r.data),
|
||||
})
|
||||
|
||||
export const usePatternTaxonomy = () =>
|
||||
useQuery({
|
||||
queryKey: ['pattern-taxonomy'],
|
||||
queryFn: () => api.get('/patterns/taxonomy').then(r => r.data),
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
|
||||
export const usePatternsByInstrument = (ticker: string) =>
|
||||
useQuery({
|
||||
queryKey: ['patterns-by-instrument', ticker],
|
||||
queryFn: () => api.get('/patterns/by-instrument', { params: { ticker } }).then(r => r.data),
|
||||
enabled: !!ticker,
|
||||
staleTime: 2 * 60_000,
|
||||
})
|
||||
|
||||
export const usePatternSimilarity = () =>
|
||||
useQuery({
|
||||
queryKey: ['pattern-similarity'],
|
||||
|
||||
638
frontend/src/pages/PatternExplorer.tsx
Normal file
638
frontend/src/pages/PatternExplorer.tsx
Normal file
@@ -0,0 +1,638 @@
|
||||
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,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
useAllPatterns,
|
||||
usePatternTaxonomy,
|
||||
usePatternsByInstrument,
|
||||
useLastScores,
|
||||
} from '../hooks/useApi'
|
||||
|
||||
// ── Local types ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface TaxonomyNode {
|
||||
id: string
|
||||
label: string
|
||||
icon?: string
|
||||
description?: string
|
||||
children?: TaxonomyNode[]
|
||||
count?: number
|
||||
path: string[]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 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(' › ')
|
||||
}
|
||||
|
||||
// ── 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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Pattern Card ──────────────────────────────────────────────────────────────
|
||||
|
||||
function PatternCard({
|
||||
pattern,
|
||||
highlightTrades,
|
||||
}: {
|
||||
pattern: Pattern
|
||||
highlightTrades?: string[]
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const trades = pattern.suggested_trades ?? []
|
||||
const instances = pattern.historical_instances ?? []
|
||||
const assetLabel = ASSET_CLASS_LABELS[pattern.asset_class] ?? pattern.asset_class
|
||||
|
||||
const moveSign = pattern.expected_move_pct >= 0 ? '+' : ''
|
||||
|
||||
return (
|
||||
<div className="card hover:border-slate-600/50 transition-colors flex flex-col gap-2">
|
||||
{/* Header row */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<SignalBadge direction={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>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 w-full text-left px-2 py-1 rounded text-sm transition-colors',
|
||||
isSelected
|
||||
? 'bg-blue-600/30 text-blue-300 border border-blue-500/30'
|
||||
: 'text-slate-400 hover:bg-slate-700/30 hover:text-slate-200'
|
||||
)}
|
||||
style={{ paddingLeft: `${8 + indent}px` }}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{hasChildren ? (
|
||||
expanded
|
||||
? <ChevronDown className="w-3.5 h-3.5 shrink-0 text-slate-500" />
|
||||
: <ChevronRight className="w-3.5 h-3.5 shrink-0 text-slate-500" />
|
||||
) : (
|
||||
<span className="w-3.5 shrink-0" />
|
||||
)}
|
||||
{node.icon && <span className="text-sm">{node.icon}</span>}
|
||||
<span className="flex-1 truncate">{node.label}</span>
|
||||
{node.count != null && node.count > 0 && (
|
||||
<span className="text-[10px] font-mono bg-slate-700/50 text-slate-400 px-1.5 py-0.5 rounded-full ml-1 shrink-0">
|
||||
{node.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{hasChildren && expanded && (
|
||||
<div>
|
||||
{node.children!.map(child => (
|
||||
<TreeNodeRow
|
||||
key={child.id}
|
||||
node={child}
|
||||
level={level + 1}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
defaultExpandedDepth={defaultExpandedDepth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tree View ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function TreeView({ patterns }: { patterns: Pattern[] }) {
|
||||
const { data: taxonomyData } = usePatternTaxonomy()
|
||||
const taxonomy = taxonomyData as TaxonomyNode | undefined
|
||||
|
||||
const [selectedPath, setSelectedPath] = useState<string[]>([])
|
||||
const [nameFilter, setNameFilter] = useState('')
|
||||
|
||||
const filteredPatterns = 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))
|
||||
}
|
||||
return list
|
||||
}, [patterns, selectedPath, nameFilter])
|
||||
|
||||
const breadcrumb = selectedPath.length > 0 ? selectedPath.join(' › ') : 'All Patterns'
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 min-h-0 flex-1">
|
||||
{/* Left sidebar */}
|
||||
<div className="w-72 shrink-0 card flex flex-col gap-1 overflow-y-auto max-h-[calc(100vh-200px)]">
|
||||
<div className="text-[10px] uppercase tracking-widest text-slate-500 font-semibold px-2 pt-1 pb-2">
|
||||
Taxonomy
|
||||
</div>
|
||||
|
||||
{/* Root "All" entry */}
|
||||
<button
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 w-full text-left px-2 py-1 rounded text-sm transition-colors',
|
||||
selectedPath.length === 0
|
||||
? 'bg-blue-600/30 text-blue-300 border border-blue-500/30'
|
||||
: 'text-slate-400 hover:bg-slate-700/30 hover:text-slate-200'
|
||||
)}
|
||||
onClick={() => setSelectedPath([])}
|
||||
>
|
||||
<BookOpen className="w-3.5 h-3.5 shrink-0 text-slate-500" />
|
||||
<span className="flex-1">All Patterns</span>
|
||||
<span className="text-[10px] font-mono bg-slate-700/50 text-slate-400 px-1.5 py-0.5 rounded-full">
|
||||
{patterns.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Taxonomy tree */}
|
||||
{taxonomy ? (
|
||||
(taxonomy.children ?? []).map(node => (
|
||||
<TreeNodeRow
|
||||
key={node.id}
|
||||
node={node}
|
||||
level={0}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={setSelectedPath}
|
||||
defaultExpandedDepth={1}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-slate-600 px-2 py-4">Loading taxonomy…</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
<div className="flex-1 flex flex-col gap-3 min-w-0">
|
||||
{/* Breadcrumb + filter */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="text-sm text-slate-400 font-mono">
|
||||
<span className="text-slate-600">Path: </span>
|
||||
<span className="text-slate-300">{breadcrumb}</span>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<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"
|
||||
placeholder="Filter by name…"
|
||||
value={nameFilter}
|
||||
onChange={e => setNameFilter(e.target.value)}
|
||||
className="input text-sm pl-8 py-1.5 w-52"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 shrink-0">{filteredPatterns.length} patterns</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pattern grid */}
|
||||
{filteredPatterns.length === 0 ? (
|
||||
<div className="flex items-center justify-center flex-1 text-slate-600 text-sm py-16">
|
||||
No patterns match the selected filter
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3 overflow-y-auto max-h-[calc(100vh-220px)] pr-1">
|
||||
{filteredPatterns.map(p => (
|
||||
<PatternCard key={p.id} pattern={p} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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 [inputValue, setInputValue] = useState('')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Debounce the search term by 300 ms
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => setSearchTerm(inputValue.trim()), 300)
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
|
||||
}, [inputValue])
|
||||
|
||||
// Also use the dedicated hook when a ticker is typed
|
||||
const { data: instrumentData } = usePatternsByInstrument(searchTerm)
|
||||
const instrumentPatterns = instrumentData as Pattern[] | undefined
|
||||
|
||||
const results = useMemo(() => {
|
||||
if (!searchTerm) return []
|
||||
|
||||
// Prefer dedicated API data if available, otherwise filter locally
|
||||
const source: Pattern[] = (instrumentPatterns && instrumentPatterns.length > 0)
|
||||
? instrumentPatterns
|
||||
: patterns.filter(p =>
|
||||
(p.suggested_trades ?? []).some(t =>
|
||||
t.underlying.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
)
|
||||
|
||||
return [...source].sort((a, b) => b.probability - a.probability)
|
||||
}, [searchTerm, instrumentPatterns, patterns])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Search bar */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by underlying (EUR/USD, USO, GLD, CL=F…)"
|
||||
value={inputValue}
|
||||
onChange={e => setInputValue(e.target.value)}
|
||||
className="input text-sm pl-10 py-2 w-full"
|
||||
/>
|
||||
</div>
|
||||
{searchTerm && (
|
||||
<button
|
||||
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
||||
onClick={() => { setInputValue(''); setSearchTerm('') }}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{!searchTerm ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-slate-600 gap-3">
|
||||
<Search className="w-8 h-8 opacity-30" />
|
||||
<p className="text-sm">Type an underlying to find matching patterns</p>
|
||||
<div className="flex flex-wrap gap-2 mt-1 justify-center">
|
||||
{['EUR/USD', 'USO', 'GLD', 'CL=F', 'GC=F', 'SPY', 'TLT'].map(example => (
|
||||
<button
|
||||
key={example}
|
||||
className="text-xs px-2.5 py-1 rounded border border-slate-700/50 text-slate-500 hover:border-blue-600/50 hover:text-blue-400 transition-colors font-mono"
|
||||
onClick={() => setInputValue(example)}
|
||||
>
|
||||
{example}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : 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">{searchTerm}</span></p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm text-slate-400">
|
||||
<span className="font-semibold text-slate-200">{results.length}</span> pattern{results.length !== 1 ? 's' : ''} influence{' '}
|
||||
<span className="font-mono text-blue-400">{searchTerm}</span>, ranked by probability
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{results.map(p => (
|
||||
<InstrumentRow key={p.id} pattern={p} searchTerm={searchTerm} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Page root ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type ViewMode = 'tree' | 'instrument'
|
||||
|
||||
export default function PatternExplorer() {
|
||||
const [view, setView] = useState<ViewMode>('tree')
|
||||
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 === 'tree'
|
||||
? 'bg-blue-600/30 text-blue-300 border border-blue-500/30'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
)}
|
||||
onClick={() => setView('tree')}
|
||||
>
|
||||
<TreePine className="w-3.5 h-3.5" />
|
||||
Tree
|
||||
</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>
|
||||
</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 === 'tree' && <TreeView patterns={patterns} />}
|
||||
{view === 'instrument' && <InstrumentLens patterns={patterns} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user