From 78c60d5254695f22e308458b28ecf6e179c8ece5 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Mon, 22 Jun 2026 21:36:00 +0200 Subject: [PATCH] feat: Pattern Lab Discover + fix history run display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/routers/pattern_lab.py | 56 ++++++++ frontend/src/hooks/useApi.ts | 11 ++ frontend/src/pages/PatternLab.tsx | 223 +++++++++++++++++++++++------- 3 files changed, 238 insertions(+), 52 deletions(-) diff --git a/backend/routers/pattern_lab.py b/backend/routers/pattern_lab.py index d10b908..b4519e8 100644 --- a/backend/routers/pattern_lab.py +++ b/backend/routers/pattern_lab.py @@ -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.""" diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 29bc71b..58d2ccc 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -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({ diff --git a/frontend/src/pages/PatternLab.tsx b/frontend/src/pages/PatternLab.tsx index 5f9784b..48ca3a7 100644 --- a/frontend/src/pages/PatternLab.tsx +++ b/frontend/src/pages/PatternLab.tsx @@ -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([]) + const discoverInputRef = useRef(null) + // ── Event preset filters const [search, setSearch] = useState('') const [yearFilter, setYearFilter] = useState(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() { Instrument - {/* Search */} -
- - 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" - /> -
- {/* Year pills */} -
- {YEARS.map(y => ( - - ))} -
- {/* Category pills */} -
- {Object.entries(CATEGORIES).map(([key, { label }]) => ( - - ))} + {/* Left panel sub-tabs: Presets | Discover */} +
+ +
- {/* Preset list */} -
- {filteredPresets.map(p => ( - - ))} - {filteredPresets.length === 0 && ( -
No events match filters
- )} -
+
+ {YEARS.map(y => ( + + ))} +
+
+ {Object.entries(CATEGORIES).map(([key, { label }]) => ( + + ))} +
+ +
+ {filteredPresets.map(p => ( + + ))} + {filteredPresets.length === 0 && ( +
No events match filters
+ )} +
+ + )} + + {/* ── Discover tab ── */} + {leftTab === 'discover' && ( + <> +
+
+
+ + 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" + /> +
+ +
+

AI searches its knowledge for matching events → click to pre-fill the lab

+
+
+ {discovering && ( +
+ + Searching… +
+ )} + {!discovering && discoverResults.length === 0 && ( +
+ {discoverQuery ? 'No results yet — press Enter or click search' : 'Type a query to discover events'} +
+ )} + {!discovering && discoverResults.map((ev, i) => ( + + ))} +
+ + )} {/* Run history count */}
@@ -964,12 +1072,21 @@ export default function PatternLab() {
{ - // 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') }}>