feat: Pattern Lab Discover + fix history run display
Fix: history runs now always show results — create synthetic selected preset from run data when no matching preset found (was broken for custom events). Also force mode='events' and reset matchResults on history load. Discover tab: new "Discover" panel in left sidebar (AI knowledge search). - GPT-4o generates 6 matching events from a free-text query (date, assets, hint) - Confidence score + category badge per event - Click → pre-fills experiment form exactly like a preset → ready to Run - Backend: POST /api/pattern-lab/discover (DiscoverRequest, sorted by confidence) - Frontend: useDiscoverEvents hook + DiscoveredEvent type + Discover UI with Enter-to-search, spinner, empty states Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,11 @@ class FindMatchingRequest(BaseModel):
|
||||
pattern_index: int
|
||||
|
||||
|
||||
class DiscoverRequest(BaseModel):
|
||||
query: str
|
||||
n: int = 6
|
||||
|
||||
|
||||
class InstrumentScanRequest(BaseModel):
|
||||
ticker: str
|
||||
instrument_name: str
|
||||
@@ -270,6 +275,57 @@ def evaluate_instrument_run(run_id: str):
|
||||
return {"run_id": run_id, "outcomes": outcomes}
|
||||
|
||||
|
||||
@router.post("/discover")
|
||||
async def discover_events(req: DiscoverRequest):
|
||||
"""Use GPT-4o to generate historical/recent event suggestions matching the query."""
|
||||
from openai import AsyncOpenAI
|
||||
key = get_config("openai_api_key") or ""
|
||||
if not key:
|
||||
raise HTTPException(400, "OpenAI API key not configured")
|
||||
client = AsyncOpenAI(api_key=key)
|
||||
|
||||
prompt = f"""You are a financial markets research expert specialized in geopolitical and macro events.
|
||||
|
||||
Query: "{req.query}"
|
||||
|
||||
Identify {req.n} specific real events (2000-2025) that match this query and are relevant for options/derivatives trading analysis.
|
||||
Prioritize events with clear market impact and dateable start points.
|
||||
|
||||
Return ONLY valid JSON:
|
||||
{{
|
||||
"events": [
|
||||
{{
|
||||
"label": "Concise event name",
|
||||
"date": "YYYY-MM-DD",
|
||||
"category": "geopolitical|macro|volatility|commodities|fx_macro|credit",
|
||||
"horizon_days": 60,
|
||||
"assets": ["TICKER1", "TICKER2", "TICKER3"],
|
||||
"hint": "2-3 sentence market context for a trader: what happened, why it matters, key dynamics.",
|
||||
"confidence": 90
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
Rules:
|
||||
- Use valid Yahoo Finance tickers (SPY, GLD, USO, EURUSD=X, BTC-USD, etc.)
|
||||
- horizon_days: typical holding period to capture the event's full market impact
|
||||
- confidence: 0-100, how well-documented and dateable this event is
|
||||
- Diversify dates and instruments across the {req.n} results"""
|
||||
|
||||
resp = await client.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
response_format={"type": "json_object"},
|
||||
temperature=0.3,
|
||||
timeout=30,
|
||||
)
|
||||
result = json.loads(resp.choices[0].message.content)
|
||||
events = result.get("events", [])
|
||||
# Sort by confidence desc
|
||||
events.sort(key=lambda e: e.get("confidence", 0), reverse=True)
|
||||
return {"events": events, "source": "ai", "query": req.query}
|
||||
|
||||
|
||||
@router.post("/find-matching")
|
||||
async def find_matching_pattern(req: FindMatchingRequest):
|
||||
"""Ask GPT-4o-mini if this lab pattern matches / counter-scenario an existing library pattern."""
|
||||
|
||||
@@ -1036,6 +1036,17 @@ export const useFindMatchingPattern = () =>
|
||||
api.post('/pattern-lab/find-matching', body).then(r => r.data),
|
||||
})
|
||||
|
||||
export type DiscoveredEvent = {
|
||||
label: string; date: string; category: string; horizon_days: number
|
||||
assets: string[]; hint: string; confidence: number
|
||||
}
|
||||
|
||||
export const useDiscoverEvents = () =>
|
||||
useMutation({
|
||||
mutationFn: (body: { query: string; n?: number }): Promise<{ events: DiscoveredEvent[]; source: string; query: string }> =>
|
||||
api.post('/pattern-lab/discover', body).then(r => r.data),
|
||||
})
|
||||
|
||||
export const useDeleteLabRun = () => {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useState, useMemo, useRef } from 'react'
|
||||
import {
|
||||
useRunPatternLab, useEvaluatePatternLab, useSaveLabPattern,
|
||||
usePatternLabRuns, useDeleteLabRun,
|
||||
useInstrumentScan, useEvaluateInstrumentScan,
|
||||
useFindMatchingPattern, MatchResult,
|
||||
useDiscoverEvents, DiscoveredEvent,
|
||||
validateTicker,
|
||||
} from '../hooks/useApi'
|
||||
import {
|
||||
FlaskConical, Play, CheckCircle2, XCircle, MinusCircle,
|
||||
Trash2, Save, RefreshCw, Search, CalendarDays,
|
||||
TrendingUp, TrendingDown, Zap, BarChart2, ScanLine, DollarSign,
|
||||
GitMerge, Layers, Plus,
|
||||
GitMerge, Layers, Plus, Globe, Sparkles,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { INSTRUMENTS, INSTRUMENT_CATEGORIES } from '../constants/instruments'
|
||||
@@ -258,12 +259,19 @@ export default function PatternLab() {
|
||||
const { mutateAsync: runInstScan, isPending: scanning } = useInstrumentScan()
|
||||
const { mutateAsync: evaluateInst, isPending: evalInst } = useEvaluateInstrumentScan()
|
||||
const { mutateAsync: findMatching } = useFindMatchingPattern()
|
||||
const { mutateAsync: discoverEvents, isPending: discovering } = useDiscoverEvents()
|
||||
|
||||
const runs: any[] = runsData ?? []
|
||||
|
||||
// ── Mode tabs
|
||||
const [mode, setMode] = useState<'events' | 'instrument'>('events')
|
||||
|
||||
// ── Left panel tabs: presets vs discover
|
||||
const [leftTab, setLeftTab] = useState<'presets' | 'discover'>('presets')
|
||||
const [discoverQuery, setDiscoverQuery] = useState('')
|
||||
const [discoverResults, setDiscoverResults] = useState<DiscoveredEvent[]>([])
|
||||
const discoverInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// ── Event preset filters
|
||||
const [search, setSearch] = useState('')
|
||||
const [yearFilter, setYearFilter] = useState<number | null>(null)
|
||||
@@ -312,6 +320,32 @@ export default function PatternLab() {
|
||||
setEditHint(p.hint)
|
||||
setActiveRun(null)
|
||||
setSavedIdx(new Set())
|
||||
setMatchResults({})
|
||||
}
|
||||
|
||||
const handleSelectDiscovered = (ev: DiscoveredEvent) => {
|
||||
const synth: Preset = {
|
||||
id: `discovered-${Date.now()}`,
|
||||
year: parseInt(ev.date.slice(0, 4)),
|
||||
label: ev.label,
|
||||
date: ev.date,
|
||||
category: ev.category,
|
||||
horizon_days: ev.horizon_days,
|
||||
assets: ev.assets,
|
||||
hint: ev.hint,
|
||||
}
|
||||
handleSelectPreset(synth)
|
||||
setLeftTab('presets')
|
||||
}
|
||||
|
||||
const handleDiscover = async () => {
|
||||
if (!discoverQuery.trim()) return
|
||||
try {
|
||||
const res = await discoverEvents({ query: discoverQuery.trim(), n: 6 })
|
||||
setDiscoverResults(res.events)
|
||||
} catch (e: any) {
|
||||
showToast(`Discover failed: ${e?.response?.data?.detail ?? e?.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRun = async () => {
|
||||
@@ -475,57 +509,131 @@ export default function PatternLab() {
|
||||
<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" />
|
||||
<input
|
||||
value={search} onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search events..."
|
||||
className="w-full pl-7 pr-2 py-1.5 text-xs bg-dark-700 border border-slate-700/40 rounded text-slate-200 placeholder-slate-500 focus:outline-none focus:border-violet-600"
|
||||
/>
|
||||
</div>
|
||||
{/* Year pills */}
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{YEARS.map(y => (
|
||||
<button key={y} onClick={() => setYearFilter(yearFilter === y ? null : y)}
|
||||
className={clsx('text-[10px] px-1.5 py-0.5 rounded border transition-colors', {
|
||||
'bg-violet-700 border-violet-600 text-white': yearFilter === y,
|
||||
'border-slate-700 text-slate-500 hover:text-slate-300': yearFilter !== y,
|
||||
})}>{y}</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Category pills */}
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{Object.entries(CATEGORIES).map(([key, { label }]) => (
|
||||
<button key={key} onClick={() => setCatFilter(catFilter === key ? null : key)}
|
||||
className={clsx('text-[10px] px-1.5 py-0.5 rounded transition-colors', {
|
||||
'bg-slate-600 text-white': catFilter === key,
|
||||
'text-slate-500 hover:text-slate-300': catFilter !== key,
|
||||
})}>{label}</button>
|
||||
))}
|
||||
{/* Left panel sub-tabs: Presets | Discover */}
|
||||
<div className="flex gap-0.5 mt-1 bg-dark-700 rounded p-0.5">
|
||||
<button onClick={() => setLeftTab('presets')}
|
||||
className={clsx('flex-1 text-[10px] py-1 rounded transition-colors flex items-center justify-center gap-1', {
|
||||
'bg-slate-600 text-slate-100': leftTab === 'presets',
|
||||
'text-slate-500 hover:text-slate-300': leftTab !== 'presets',
|
||||
})}>
|
||||
<CalendarDays className="w-3 h-3" /> Presets
|
||||
</button>
|
||||
<button onClick={() => { setLeftTab('discover'); setTimeout(() => discoverInputRef.current?.focus(), 50) }}
|
||||
className={clsx('flex-1 text-[10px] py-1 rounded transition-colors flex items-center justify-center gap-1', {
|
||||
'bg-violet-700 text-white': leftTab === 'discover',
|
||||
'text-slate-500 hover:text-slate-300': leftTab !== 'discover',
|
||||
})}>
|
||||
<Globe className="w-3 h-3" /> Discover
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preset list */}
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{filteredPresets.map(p => (
|
||||
<button key={p.id} onClick={() => handleSelectPreset(p)}
|
||||
className={clsx('w-full text-left px-3 py-2 border-b border-slate-700/20 hover:bg-dark-700/60 transition-colors', {
|
||||
'bg-violet-900/30 border-l-2 border-l-violet-500': selected?.id === p.id,
|
||||
})}>
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<span className="text-[10px] text-slate-500 font-mono">{p.year}</span>
|
||||
<CategoryBadge cat={p.category} />
|
||||
<span className="text-[10px] text-slate-600 ml-auto">{p.horizon_days}d</span>
|
||||
{/* ── Presets tab ── */}
|
||||
{leftTab === 'presets' && (
|
||||
<>
|
||||
<div className="px-3 pt-2 pb-1 border-b border-slate-700/30">
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
value={search} onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search events..."
|
||||
className="w-full pl-7 pr-2 py-1.5 text-xs bg-dark-700 border border-slate-700/40 rounded text-slate-200 placeholder-slate-500 focus:outline-none focus:border-violet-600"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-slate-200 leading-tight">{p.label}</div>
|
||||
<div className="text-[10px] text-slate-500 mt-0.5">{p.date}</div>
|
||||
</button>
|
||||
))}
|
||||
{filteredPresets.length === 0 && (
|
||||
<div className="p-4 text-center text-xs text-slate-600">No events match filters</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{YEARS.map(y => (
|
||||
<button key={y} onClick={() => setYearFilter(yearFilter === y ? null : y)}
|
||||
className={clsx('text-[10px] px-1.5 py-0.5 rounded border transition-colors', {
|
||||
'bg-violet-700 border-violet-600 text-white': yearFilter === y,
|
||||
'border-slate-700 text-slate-500 hover:text-slate-300': yearFilter !== y,
|
||||
})}>{y}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{Object.entries(CATEGORIES).map(([key, { label }]) => (
|
||||
<button key={key} onClick={() => setCatFilter(catFilter === key ? null : key)}
|
||||
className={clsx('text-[10px] px-1.5 py-0.5 rounded transition-colors', {
|
||||
'bg-slate-600 text-white': catFilter === key,
|
||||
'text-slate-500 hover:text-slate-300': catFilter !== key,
|
||||
})}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{filteredPresets.map(p => (
|
||||
<button key={p.id} onClick={() => handleSelectPreset(p)}
|
||||
className={clsx('w-full text-left px-3 py-2 border-b border-slate-700/20 hover:bg-dark-700/60 transition-colors', {
|
||||
'bg-violet-900/30 border-l-2 border-l-violet-500': selected?.id === p.id,
|
||||
})}>
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<span className="text-[10px] text-slate-500 font-mono">{p.year}</span>
|
||||
<CategoryBadge cat={p.category} />
|
||||
<span className="text-[10px] text-slate-600 ml-auto">{p.horizon_days}d</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-200 leading-tight">{p.label}</div>
|
||||
<div className="text-[10px] text-slate-500 mt-0.5">{p.date}</div>
|
||||
</button>
|
||||
))}
|
||||
{filteredPresets.length === 0 && (
|
||||
<div className="p-4 text-center text-xs text-slate-600">No events match filters</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Discover tab ── */}
|
||||
{leftTab === 'discover' && (
|
||||
<>
|
||||
<div className="px-3 pt-2 pb-2 border-b border-slate-700/30">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="relative flex-1">
|
||||
<Globe className="w-3 h-3 absolute left-2 top-1/2 -translate-y-1/2 text-violet-400" />
|
||||
<input
|
||||
ref={discoverInputRef}
|
||||
value={discoverQuery}
|
||||
onChange={e => setDiscoverQuery(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleDiscover()}
|
||||
placeholder="e.g. Middle East crisis, dollar collapse…"
|
||||
className="w-full pl-6 pr-2 py-1.5 text-xs bg-dark-700 border border-violet-700/40 rounded text-slate-200 placeholder-slate-500 focus:outline-none focus:border-violet-500"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={handleDiscover} disabled={discovering || !discoverQuery.trim()}
|
||||
className="flex items-center gap-1 bg-violet-700 hover:bg-violet-600 disabled:opacity-50 text-white px-2.5 py-1.5 rounded text-xs font-semibold transition-colors">
|
||||
{discovering ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-600 mt-1.5">AI searches its knowledge for matching events → click to pre-fill the lab</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{discovering && (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2 text-slate-600">
|
||||
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||
<span className="text-xs">Searching…</span>
|
||||
</div>
|
||||
)}
|
||||
{!discovering && discoverResults.length === 0 && (
|
||||
<div className="p-4 text-center text-xs text-slate-600">
|
||||
{discoverQuery ? 'No results yet — press Enter or click search' : 'Type a query to discover events'}
|
||||
</div>
|
||||
)}
|
||||
{!discovering && discoverResults.map((ev, i) => (
|
||||
<button key={i} onClick={() => handleSelectDiscovered(ev)}
|
||||
className="w-full text-left px-3 py-2.5 border-b border-slate-700/20 hover:bg-violet-900/20 transition-colors group">
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<span className="text-[10px] text-slate-500 font-mono">{ev.date.slice(0, 4)}</span>
|
||||
<CategoryBadge cat={ev.category} />
|
||||
<span className="text-[10px] text-slate-600 ml-auto">{ev.horizon_days}d</span>
|
||||
<span className={clsx('text-[10px] font-mono', ev.confidence >= 80 ? 'text-emerald-500' : 'text-slate-600')}>
|
||||
{ev.confidence}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-200 leading-tight group-hover:text-violet-300 transition-colors">{ev.label}</div>
|
||||
<div className="text-[10px] text-slate-500 mt-0.5 truncate">{ev.date} · {ev.assets.slice(0, 3).join(', ')}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Run history count */}
|
||||
<div className="p-2 border-t border-slate-700/40 text-[10px] text-slate-600">
|
||||
@@ -964,12 +1072,21 @@ export default function PatternLab() {
|
||||
<div key={run.id}
|
||||
className="px-3 py-2 border-b border-slate-700/20 hover:bg-dark-700/40 cursor-pointer group"
|
||||
onClick={() => {
|
||||
// Rehydrate run into active state
|
||||
const preset = PRESETS.find(p => p.id === run.preset_id)
|
||||
if (preset) setSelected(preset)
|
||||
// Always set selected — fall back to a synthetic preset from run data
|
||||
setSelected(preset ?? {
|
||||
id: `run-${run.id}`,
|
||||
year: parseInt((run.analysis_date ?? '2000').slice(0, 4)),
|
||||
label: run.theme ?? 'Custom event',
|
||||
date: run.analysis_date,
|
||||
category: 'custom',
|
||||
horizon_days: run.horizon_days,
|
||||
assets: Array.isArray(run.assets) ? run.assets : [],
|
||||
hint: run.theme_hint ?? run.theme ?? '',
|
||||
})
|
||||
setEditDate(run.analysis_date)
|
||||
setEditHorizon(run.horizon_days)
|
||||
setEditAssets((run.assets ?? []).join(', '))
|
||||
setEditAssets((Array.isArray(run.assets) ? run.assets : []).join(', '))
|
||||
setEditHint(run.theme_hint ?? '')
|
||||
setActiveRun({
|
||||
run_id: run.id,
|
||||
@@ -978,6 +1095,8 @@ export default function PatternLab() {
|
||||
outcome: run.outcome,
|
||||
})
|
||||
setSavedIdx(new Set())
|
||||
setMatchResults({})
|
||||
setMode('events')
|
||||
}}>
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full flex-shrink-0', {
|
||||
|
||||
Reference in New Issue
Block a user