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:
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user