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

@@ -11,6 +11,7 @@ import {
usePatternsByInstrument,
useLastScores,
} from '../hooks/useApi'
import { INSTRUMENTS, INSTRUMENT_CATEGORIES } from '../constants/instruments'
// ── Local types ───────────────────────────────────────────────────────────────
@@ -588,94 +589,130 @@ function InstrumentRow({ pattern, searchTerm }: { pattern: Pattern; searchTerm:
}
function InstrumentLens({ patterns }: { patterns: Pattern[] }) {
const [inputValue, setInputValue] = useState('')
const [searchTerm, setSearchTerm] = useState('')
const [searchTerm, setSearchTerm] = useState('')
const [activeCategory, setActiveCategory] = useState<string | null>(null)
const [customInput, setCustomInput] = 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])
// Selected ticker — either clicked from grid or typed
const [selectedTicker, setSelectedTicker] = useState('')
// Also use the dedicated hook when a ticker is typed
const { data: instrumentData } = usePatternsByInstrument(searchTerm)
const { data: instrumentData } = usePatternsByInstrument(selectedTicker)
const instrumentPatterns = instrumentData as Pattern[] | undefined
const results = useMemo(() => {
if (!searchTerm) return []
// Prefer dedicated API data if available, otherwise filter locally
if (!selectedTicker) return []
const source: Pattern[] = (instrumentPatterns && instrumentPatterns.length > 0)
? instrumentPatterns
: patterns.filter(p =>
(p.suggested_trades ?? []).some(t =>
t.underlying.toLowerCase().includes(searchTerm.toLowerCase())
t.underlying.toLowerCase().includes(selectedTicker.toLowerCase())
)
)
return [...source].sort((a, b) => b.probability - a.probability)
}, [searchTerm, instrumentPatterns, patterns])
}, [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 = () => {
const t = customInput.trim()
if (t) { setSelectedTicker(t); setCustomInput('') }
}
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"
/>
{/* ── 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>
{searchTerm && (
<button
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
onClick={() => { setInputValue(''); setSearchTerm('') }}
>
Clear
</button>
<input type="text" value={customInput} onChange={e => setCustomInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCustom()}
placeholder="Custom ticker…"
className="w-32 px-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-blue-600 font-mono" />
<button onClick={handleCustom}
className="px-2.5 py-1.5 text-xs bg-blue-700 hover:bg-blue-600 text-white rounded transition-colors">Go</button>
{selectedTicker && (
<button onClick={() => setSelectedTicker('')}
className="text-xs text-slate-500 hover:text-slate-300 transition-colors">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>
{/* ── 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.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
)}
{/* ── 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 flex-col gap-3">
{results.map(p => (
<InstrumentRow key={p.id} pattern={p} searchTerm={searchTerm} />
))}
</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>
)