feat: instrument picker + Pattern Lab instrument scan mode

- instruments.ts: 90 IB-options-tradable instruments in 12 categories
  (US Indices, Europe, Asia, EM, Sectors, Forex, Bonds, Metals, Energy,
   Agriculture, Crypto, Volatility) — EUR/CHF, Cotton, etc. all included

- PatternExplorer: replace text input in Instrument Lens with categorised
  grid picker (category pill filters + search + custom ticker fallback)

- PatternLab: add Instrument Scan tab alongside Event Presets
  - Pick any instrument from the shared categorised picker
  - Set period (start/end date) + horizon per pattern
  - AI scans the full period: identifies 4-6 key pattern instances each with
    their own entry date, expected move, strategy
  - 'Evaluate outcomes' fetches actual price at T+horizon per pattern
  - 'Save pattern' promotes any instance to the Pattern Library

- backend/services/pattern_lab.py: run_instrument_scan() + evaluate_instrument_outcomes()
  (per-pattern analysis_date vs shared date in event mode)
- backend/routers/pattern_lab.py: POST /instrument-scan + POST /evaluate-instrument/{id}
- useApi.ts: useInstrumentScan + useEvaluateInstrumentScan hooks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-22 18:20:22 +02:00
parent cbf989502c
commit 303ecc2a3a
6 changed files with 850 additions and 80 deletions

View File

@@ -2,13 +2,15 @@ import { useState, useMemo } from 'react'
import {
useRunPatternLab, useEvaluatePatternLab, useSaveLabPattern,
usePatternLabRuns, useDeleteLabRun,
useInstrumentScan, useEvaluateInstrumentScan,
} from '../hooks/useApi'
import {
FlaskConical, Play, ChevronRight, CheckCircle2, XCircle,
Clock, Trash2, Save, RefreshCw, Search, CalendarDays,
TrendingUp, TrendingDown, Zap, BarChart2, AlertTriangle,
FlaskConical, Play, CheckCircle2, XCircle,
Trash2, Save, RefreshCw, Search, CalendarDays,
TrendingUp, TrendingDown, Zap, BarChart2, ScanLine,
} from 'lucide-react'
import clsx from 'clsx'
import { INSTRUMENTS, INSTRUMENT_CATEGORIES } from '../constants/instruments'
// ── Preset catalogue (2015-2025, 34 events) ────────────────────────────────────
@@ -85,28 +87,109 @@ function MoveBadge({ move, dir }: { move: number; dir: string }) {
// ── Main page ──────────────────────────────────────────────────────────────────
// ── Shared instrument picker ───────────────────────────────────────────────────
function InstrumentPicker({ onSelect }: { onSelect: (ticker: string, name: string) => void }) {
const [search, setSearch] = useState('')
const [cat, setCat] = useState<string | null>(null)
const [custom, setCustom] = useState('')
const seen = new Set<string>()
const unique = INSTRUMENTS.filter(i => { if (seen.has(i.ticker)) return false; seen.add(i.ticker); return true })
const filtered = unique.filter(i => {
if (cat && i.category !== cat) return false
if (search) {
const q = search.toLowerCase()
return i.ticker.toLowerCase().includes(q) || i.name.toLowerCase().includes(q)
}
return true
})
return (
<div className="space-y-2">
{/* Category pills */}
<div className="flex flex-wrap gap-1">
<button onClick={() => setCat(null)}
className={clsx('text-[10px] px-2 py-0.5 rounded-full border transition-colors',
cat === null ? 'bg-violet-600 border-violet-500 text-white' : 'border-slate-700 text-slate-500 hover:text-slate-300')}>
All
</button>
{INSTRUMENT_CATEGORIES.map(c => (
<button key={c} onClick={() => setCat(cat === c ? null : c)}
className={clsx('text-[10px] px-2 py-0.5 rounded-full border transition-colors',
cat === c ? 'bg-violet-600 border-violet-500 text-white' : 'border-slate-700 text-slate-500 hover:text-slate-300')}>
{c}
</button>
))}
</div>
{/* Search + custom */}
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="w-3 h-3 absolute left-2 top-1/2 -translate-y-1/2 text-slate-500" />
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search…"
className="w-full pl-6 pr-2 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-violet-500" />
</div>
<input value={custom} onChange={e => setCustom(e.target.value)}
onKeyDown={e => e.key === 'Enter' && custom.trim() && onSelect(custom.trim(), custom.trim())}
placeholder="Custom ticker"
className="w-28 px-2 py-1.5 text-xs bg-dark-700 border border-slate-700/50 rounded font-mono text-slate-200 placeholder-slate-600 focus:outline-none focus:border-violet-500" />
</div>
{/* Grid */}
<div className="grid grid-cols-3 gap-1 max-h-60 overflow-y-auto pr-1">
{filtered.map(inst => (
<button key={inst.ticker} onClick={() => onSelect(inst.ticker, inst.name)}
className="text-left px-2 py-1.5 rounded border border-slate-700/40 bg-dark-700/30 hover:border-violet-600/60 hover:bg-violet-900/20 transition-colors">
<div className="text-[10px] font-mono text-violet-400">{inst.ticker}</div>
<div className="text-[9px] text-slate-500 truncate">{inst.name}</div>
</button>
))}
{filtered.length === 0 && (
<div className="col-span-3 text-center py-4 text-[10px] text-slate-600">No match</div>
)}
</div>
</div>
)
}
// ── Main page ──────────────────────────────────────────────────────────────────
export default function PatternLab() {
const { data: runsData, isLoading: runsLoading } = usePatternLabRuns()
const { mutateAsync: runBacktest, isPending: running } = useRunPatternLab()
const { mutateAsync: evaluateRun, isPending: evaluating } = useEvaluatePatternLab()
const { mutateAsync: savePattern } = useSaveLabPattern()
const { mutate: deleteRun } = useDeleteLabRun()
const { data: runsData } = usePatternLabRuns()
const { mutateAsync: runBacktest, isPending: running } = useRunPatternLab()
const { mutateAsync: evaluateRun, isPending: evaluating } = useEvaluatePatternLab()
const { mutateAsync: savePattern } = useSaveLabPattern()
const { mutate: deleteRun } = useDeleteLabRun()
const { mutateAsync: runInstScan, isPending: scanning } = useInstrumentScan()
const { mutateAsync: evaluateInst, isPending: evalInst } = useEvaluateInstrumentScan()
const runs: any[] = runsData ?? []
// ── Filters
// ── Mode tabs
const [mode, setMode] = useState<'events' | 'instrument'>('events')
// ── Event preset filters
const [search, setSearch] = useState('')
const [yearFilter, setYearFilter] = useState<number | null>(null)
const [catFilter, setCatFilter] = useState<string | null>(null)
// ── Active experiment
// ── Active experiment (events mode)
const [selected, setSelected] = useState<Preset | null>(null)
const [editDate, setEditDate] = useState('')
const [editHorizon, setEditHorizon] = useState(90)
const [editAssets, setEditAssets] = useState('')
const [editHint, setEditHint] = useState('')
// ── Current run state
// ── Instrument scan mode
const [instTicker, setInstTicker] = useState('')
const [instName, setInstName] = useState('')
const [instStart, setInstStart] = useState('2020-01-01')
const [instEnd, setInstEnd] = useState('2023-12-31')
const [instHorizon, setInstHorizon] = useState(90)
const [instRun, setInstRun] = useState<any | null>(null)
const [instSavedIdx, setInstSavedIdx] = useState<Set<number>>(new Set())
// ── Current run state (events mode)
const [activeRun, setActiveRun] = useState<any | null>(null)
const [savedIdx, setSavedIdx] = useState<Set<number>>(new Set())
const [toast, setToast] = useState<string | null>(null)
@@ -181,6 +264,45 @@ export default function PatternLab() {
}
}
// ── Instrument scan handlers
const handleInstScan = async () => {
if (!instTicker) return
try {
const result = await runInstScan({
ticker: instTicker, instrument_name: instName || instTicker,
start_date: instStart, end_date: instEnd, horizon_days: instHorizon,
})
setInstRun(result)
setInstSavedIdx(new Set())
} catch (e: any) {
showToast(`Error: ${e?.response?.data?.detail ?? e?.message ?? 'unknown'}`)
}
}
const handleInstEvaluate = async () => {
if (!instRun?.run_id) return
try {
const result = await evaluateInst(instRun.run_id)
setInstRun((prev: any) => ({ ...prev, outcome: result.outcomes }))
} catch (e: any) {
showToast(`Error: ${e?.response?.data?.detail ?? e?.message ?? 'unknown'}`)
}
}
const handleInstSave = async (idx: number) => {
if (!instRun?.run_id) return
const pat = instRun.ai_result?.patterns?.[idx]
if (!pat) return
try {
await savePattern({ run_id: instRun.run_id, pattern_index: idx, name: pat.name, category: pat.category, signal_direction: pat.signal_direction })
setInstSavedIdx(prev => new Set([...prev, idx]))
showToast(`"${pat.name}" saved to Pattern Library`)
} catch (e: any) {
showToast(`Save failed: ${e?.response?.data?.detail ?? e?.message}`)
}
}
// ── Event mode helpers
const aiPatterns: any[] = activeRun?.ai_result?.patterns ?? []
const outcomes: any[] = activeRun?.outcome ?? []
const hasOutcomes = outcomes.length > 0
@@ -192,9 +314,18 @@ export default function PatternLab() {
? outcomes.filter((o: any) => o.hit === true).length / outcomes.filter((o: any) => 'hit' in o).length
: null
// ── Instrument mode helpers
const instPatterns: any[] = instRun?.ai_result?.patterns ?? []
const instOutcomes: any[] = instRun?.outcome ?? []
const instHasOut = instOutcomes.length > 0
const instOutcomeFor = (name: string) => instOutcomes.find((o: any) => o.pattern_name === name)
const instHitRate = instHasOut
? instOutcomes.filter((o: any) => o.hit === true).length / instOutcomes.filter((o: any) => 'hit' in o).length
: null
return (
<div className="flex h-screen bg-dark-900 text-slate-200 overflow-hidden">
{/* ── Left sidebar: preset selector ──────────────────────────────────── */}
{/* ── Left sidebar ────────────────────────────────────────────────────── */}
<div className="w-72 flex-shrink-0 border-r border-slate-700/40 flex flex-col overflow-hidden">
<div className="p-3 border-b border-slate-700/40">
<div className="flex items-center gap-2 mb-2">
@@ -202,6 +333,23 @@ export default function PatternLab() {
<span className="font-semibold text-sm text-slate-100">Pattern Lab</span>
<span className="ml-auto text-[10px] bg-slate-700 rounded px-1.5 py-0.5 text-slate-400">{PRESETS.length} events</span>
</div>
{/* Mode tabs */}
<div className="flex gap-1 mb-2 bg-dark-700 rounded p-0.5">
<button onClick={() => setMode('events')}
className={clsx('flex-1 text-xs py-1 rounded transition-colors flex items-center justify-center gap-1', {
'bg-violet-700 text-white': mode === 'events',
'text-slate-500 hover:text-slate-300': mode !== 'events',
})}>
<CalendarDays className="w-3 h-3" /> Events
</button>
<button onClick={() => setMode('instrument')}
className={clsx('flex-1 text-xs py-1 rounded transition-colors flex items-center justify-center gap-1', {
'bg-teal-700 text-white': mode === 'instrument',
'text-slate-500 hover:text-slate-300': mode !== 'instrument',
})}>
<ScanLine className="w-3 h-3" /> Instrument
</button>
</div>
{/* Search */}
<div className="relative">
<Search className="w-3.5 h-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-slate-500" />
@@ -262,7 +410,153 @@ export default function PatternLab() {
{/* ── Main content ────────────────────────────────────────────────────── */}
<div className="flex-1 overflow-y-auto">
{!selected ? (
{/* ══ INSTRUMENT SCAN MODE ══════════════════════════════════════════ */}
{mode === 'instrument' && (
<div className="p-5 space-y-5">
<div className="flex items-center gap-2">
<ScanLine className="w-4 h-4 text-teal-400" />
<h1 className="text-lg font-bold text-slate-100">Instrument Historical Scan</h1>
</div>
<p className="text-xs text-slate-400">
Pick an instrument the AI scans a historical period, identifies the key patterns/events that drove it, and validates them against actual price data.
</p>
{/* Instrument picker */}
<div className="bg-dark-800 border border-slate-700/40 rounded-lg p-4">
<div className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-3">
{instTicker
? <span className="flex items-center gap-2">Selected: <span className="font-mono text-teal-300">{instTicker}</span> {instName} <button onClick={() => { setInstTicker(''); setInstName(''); setInstRun(null) }} className="text-slate-500 hover:text-slate-300 text-[10px] ml-1">change</button></span>
: 'Select Instrument'}
</div>
{!instTicker && (
<InstrumentPicker onSelect={(t, n) => { setInstTicker(t); setInstName(n) }} />
)}
</div>
{/* Period + horizon */}
{instTicker && (
<div className="bg-dark-800 border border-slate-700/40 rounded-lg p-4">
<div className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-3">Scan Parameters</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-[10px] text-slate-500 mb-1 block">Period Start</label>
<input type="date" value={instStart} onChange={e => setInstStart(e.target.value)}
className="w-full px-2 py-1.5 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-200 focus:outline-none focus:border-teal-500" />
</div>
<div>
<label className="text-[10px] text-slate-500 mb-1 block">Period End</label>
<input type="date" value={instEnd} onChange={e => setInstEnd(e.target.value)}
className="w-full px-2 py-1.5 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-200 focus:outline-none focus:border-teal-500" />
</div>
<div>
<label className="text-[10px] text-slate-500 mb-1 block">Horizon per pattern</label>
<div className="flex items-center gap-2">
<input type="range" min={14} max={365} value={instHorizon}
onChange={e => setInstHorizon(Number(e.target.value))}
className="flex-1 accent-teal-600" />
<span className="text-xs font-mono text-teal-300 w-8 text-right">{instHorizon}d</span>
</div>
</div>
</div>
<div className="mt-3">
<button onClick={handleInstScan} disabled={scanning}
className="flex items-center gap-2 bg-teal-700 hover:bg-teal-600 disabled:opacity-50 text-white px-4 py-2 rounded text-xs font-semibold transition-colors">
{scanning
? <><RefreshCw className="w-3.5 h-3.5 animate-spin" /> Fetching data + AI analysis</>
: <><ScanLine className="w-3.5 h-3.5" /> Scan History</>}
</button>
</div>
</div>
)}
{/* Instrument scan results */}
{instPatterns.length > 0 && (
<div className="bg-dark-800 border border-slate-700/40 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="text-xs font-semibold text-slate-400 uppercase tracking-wide">
{instPatterns.length} Pattern Instances <span className="text-teal-300 font-mono">{instTicker}</span>
<span className="text-slate-600 ml-2">{instStart} {instEnd}</span>
</div>
<div className="flex items-center gap-2">
{instHasOut && instHitRate !== null && (
<span className={clsx('text-sm font-bold', instHitRate >= 0.6 ? 'text-emerald-400' : instHitRate >= 0.4 ? 'text-yellow-400' : 'text-red-400')}>
{Math.round(instHitRate * 100)}% hit rate
</span>
)}
{!instHasOut && (
<button onClick={handleInstEvaluate} disabled={evalInst}
className="flex items-center gap-1.5 bg-teal-700 hover:bg-teal-600 disabled:opacity-50 text-white px-3 py-1.5 rounded text-xs font-semibold transition-colors">
{evalInst
? <><RefreshCw className="w-3 h-3 animate-spin" /> Evaluating</>
: <><BarChart2 className="w-3 h-3" /> Evaluate outcomes</>}
</button>
)}
</div>
</div>
<div className="space-y-3">
{instPatterns.map((pat: any, idx: number) => {
const out = instOutcomeFor(pat.name)
const isSaved = instSavedIdx.has(idx)
const DirIcon = pat.signal_direction === 'bullish' ? TrendingUp : pat.signal_direction === 'bearish' ? TrendingDown : Zap
const dirCol = pat.signal_direction === 'bullish' ? 'text-emerald-400' : pat.signal_direction === 'bearish' ? 'text-red-400' : 'text-violet-400'
return (
<div key={idx} className={clsx('border rounded-lg p-3',
out?.hit === true ? 'border-emerald-700/60 bg-emerald-900/10' :
out?.hit === false ? 'border-red-700/40 bg-red-900/10' :
'border-slate-700/40 bg-dark-700/30')}>
<div className="flex items-start gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-0.5">
<DirIcon className={`w-3.5 h-3.5 ${dirCol}`} />
<span className="text-xs font-semibold text-slate-100">{pat.name}</span>
<span className="text-[10px] bg-teal-900/40 border border-teal-700/30 text-teal-300 rounded px-1.5 font-mono">{pat.analysis_date}</span>
{pat.category && <span className="text-[10px] text-slate-500">{pat.category}</span>}
</div>
<p className="text-xs text-slate-400 mb-1.5">{pat.description}</p>
<div className="flex items-center gap-4 text-[10px] text-slate-500">
<span>Strategy: <span className="text-slate-300">{pat.strategy}</span></span>
<span>Expected: <span className={clsx('font-semibold', dirCol)}>
{pat.expected_direction === 'up' ? '+' : pat.expected_direction === 'down' ? '-' : '±'}{pat.expected_move_pct}%
</span></span>
<span>Horizon: <span className="text-slate-300">{pat.horizon_days}d</span></span>
<span>Confidence: <span className={clsx('font-semibold', pat.confidence >= 70 ? 'text-emerald-400' : pat.confidence >= 50 ? 'text-yellow-400' : 'text-slate-400')}>{pat.confidence}</span></span>
</div>
{pat.rationale && <p className="text-[10px] text-slate-500 mt-1 italic">{pat.rationale}</p>}
{out && (
<div className={clsx('mt-2 pt-2 border-t flex items-center gap-3 text-[10px]',
out.hit ? 'border-emerald-700/30 text-emerald-300' : 'border-red-700/30 text-red-300')}>
{out.hit ? <CheckCircle2 className="w-3.5 h-3.5 text-emerald-400" /> : <XCircle className="w-3.5 h-3.5 text-red-400" />}
<span className="font-semibold">{out.hit ? 'HIT' : 'MISS'}</span>
<span className="text-slate-400">Actual:</span>
<span className={clsx('font-mono font-semibold', out.actual_move_pct > 0 ? 'text-emerald-400' : 'text-red-400')}>
{out.actual_move_pct > 0 ? '+' : ''}{out.actual_move_pct?.toFixed(1)}%
</span>
<span className="text-slate-500">{out.entry_date} {out.exit_date}</span>
</div>
)}
</div>
<div className="flex-shrink-0">
{isSaved
? <span className="flex items-center gap-1 text-emerald-400 text-xs"><CheckCircle2 className="w-3.5 h-3.5" /> Saved</span>
: <button onClick={() => handleInstSave(idx)}
className="flex items-center gap-1.5 border border-slate-600 hover:border-teal-500 hover:text-teal-300 text-slate-400 px-2.5 py-1.5 rounded text-xs transition-colors">
<Save className="w-3 h-3" /> Save
</button>
}
</div>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
)}
{/* ══ EVENT PRESET MODE ════════════════════════════════════════════ */}
{mode === 'events' && !selected && (
<div className="flex items-center justify-center h-full text-center">
<div>
<FlaskConical className="w-12 h-12 text-slate-700 mx-auto mb-3" />
@@ -270,7 +564,8 @@ export default function PatternLab() {
<p className="text-slate-600 text-xs mt-1">The AI will analyse what it would have done at that date using real market data</p>
</div>
</div>
) : (
)}
{mode === 'events' && selected && (
<div className="p-5 space-y-5">
{/* ── Header ── */}
<div className="flex items-start gap-3">