DB (database.py): - 3 new columns on custom_patterns: calibrated_expected_move, calibration_weight, observed_avg_win_pct - update_bayesian_posteriors() now also computes credibility blend w=n/(n+5): calibrated = (1-w)*ai_estimate + w*observed_avg_win_pct (only when wins exist) - log_trade_entries() prefers calibrated_expected_move when w>10% - get_calibration_summary() returns per-pattern state (source: pure_ai/early/mixed/data_driven) Backend (patterns.py, auto_cycle.py): - GET /api/patterns/calibration endpoint - calibration_report block in cycle report: counts by source, avg weight, per-pattern detail Frontend (PatternExplorer.tsx, RapportIA.tsx, useApi.ts): - MaturityBadge on each PatternCard: blend bar (AI→observed), win rate, AI estimate vs calibrated - usePatternCalibration hook - Cycle report: calibration section with global bar + per-pattern table (weight%, n_trades, WR, AI→calibrated) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1196 lines
52 KiB
TypeScript
1196 lines
52 KiB
TypeScript
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,
|
||
usePatternCalibration,
|
||
validateTicker,
|
||
type SimilarityResult,
|
||
type PatternCalibration,
|
||
} 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>
|
||
)
|
||
}
|
||
|
||
// ── Maturity / Calibration Badge ─────────────────────────────────────────────
|
||
|
||
function MaturityBadge({ cal }: { cal: PatternCalibration }) {
|
||
const w = cal.calibration_weight_pct
|
||
const n = cal.n_mature_trades
|
||
const hasData = n > 0
|
||
|
||
const sourceColor = {
|
||
pure_ai: 'text-slate-500 border-slate-700/40',
|
||
early: 'text-sky-400 border-sky-700/40',
|
||
mixed: 'text-violet-400 border-violet-700/40',
|
||
data_driven: 'text-emerald-400 border-emerald-700/40',
|
||
}[cal.source]
|
||
|
||
const sourceLabel = {
|
||
pure_ai: 'Pure AI',
|
||
early: 'Early data',
|
||
mixed: 'Mixed',
|
||
data_driven: 'Data-driven',
|
||
}[cal.source]
|
||
|
||
return (
|
||
<div className="border-t border-slate-700/30 pt-2 mt-1 space-y-1.5">
|
||
<div className="flex items-center justify-between text-[10px]">
|
||
<span className={clsx('font-semibold px-1.5 py-0.5 rounded border', sourceColor)}>
|
||
{sourceLabel}
|
||
</span>
|
||
<span className="text-slate-500">
|
||
{hasData ? `${n} trade${n > 1 ? 's' : ''} matures` : 'Aucun trade mature'}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Blend bar */}
|
||
<div className="space-y-0.5">
|
||
<div className="flex justify-between text-[10px] text-slate-500">
|
||
<span>IA pure</span>
|
||
<span className="text-slate-400 font-mono">{w.toFixed(0)}% observé</span>
|
||
<span>Observé</span>
|
||
</div>
|
||
<div className="h-1.5 rounded-full bg-slate-700/60 overflow-hidden">
|
||
<div
|
||
className="h-full rounded-full bg-gradient-to-r from-slate-500 to-violet-500 transition-all duration-500"
|
||
style={{ width: `${Math.max(w, 2)}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{hasData && (
|
||
<div className="grid grid-cols-3 gap-1 text-[10px]">
|
||
<div className="bg-dark-700/60 rounded px-1.5 py-1 text-center">
|
||
<div className="text-slate-400 font-mono">{(cal.bayes_win_rate * 100).toFixed(0)}%</div>
|
||
<div className="text-slate-600">Win rate</div>
|
||
</div>
|
||
<div className="bg-dark-700/60 rounded px-1.5 py-1 text-center">
|
||
<div className="text-slate-400 font-mono">
|
||
{cal.ai_estimate != null ? `${cal.ai_estimate.toFixed(0)}%` : '—'}
|
||
</div>
|
||
<div className="text-slate-600">IA estim.</div>
|
||
</div>
|
||
<div className={clsx('rounded px-1.5 py-1 text-center', cal.calibrated_expected_move ? 'bg-violet-900/30' : 'bg-dark-700/60')}>
|
||
<div className={clsx('font-mono', cal.calibrated_expected_move ? 'text-violet-300' : 'text-slate-600')}>
|
||
{cal.calibrated_expected_move != null ? `${cal.calibrated_expected_move.toFixed(0)}%` : '—'}
|
||
</div>
|
||
<div className="text-slate-600">Calibré</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Pattern Card ──────────────────────────────────────────────────────────────
|
||
|
||
function PatternCard({
|
||
pattern,
|
||
highlightTrades,
|
||
calibration,
|
||
}: {
|
||
pattern: Pattern
|
||
highlightTrades?: string[]
|
||
calibration?: PatternCalibration
|
||
}) {
|
||
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>
|
||
)}
|
||
|
||
{/* Maturity & calibration */}
|
||
{calibration && <MaturityBadge cal={calibration} />}
|
||
|
||
{/* 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, calibrationMap = {} }: { patterns: Pattern[]; calibrationMap?: Record<string, PatternCalibration> }) {
|
||
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="30–90d" 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} calibration={calibrationMap[p.id]} />)}
|
||
</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 { data: calibrationData } = usePatternCalibration()
|
||
|
||
const calibrationMap = useMemo<Record<string, PatternCalibration>>(() => {
|
||
if (!calibrationData) return {}
|
||
return Object.fromEntries(calibrationData.map(c => [c.pattern_id, c]))
|
||
}, [calibrationData])
|
||
|
||
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} calibrationMap={calibrationMap} />}
|
||
{view === 'instrument' && <InstrumentLens patterns={patterns} />}
|
||
{view === 'regime' && <RegimeView patterns={patterns} />}
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|