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:
OpenSquared
2026-06-22 21:36:00 +02:00
parent a2315c3b78
commit 78c60d5254
3 changed files with 238 additions and 52 deletions

View File

@@ -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."""

View File

@@ -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({

View File

@@ -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', {