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:
OpenSquared
2026-06-22 16:42:45 +02:00
parent acc8bef29d
commit 10ffc345d6
6 changed files with 1148 additions and 5 deletions

View File

@@ -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 />} />

View File

@@ -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'],

View 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>
)
}