diff --git a/backend/routers/pattern_lab.py b/backend/routers/pattern_lab.py index ed74ce3..d10b908 100644 --- a/backend/routers/pattern_lab.py +++ b/backend/routers/pattern_lab.py @@ -37,6 +37,16 @@ class SavePatternRequest(BaseModel): category: Optional[str] = None signal_direction: Optional[str] = None asset_class: Optional[str] = "indices" + # Regime architecture + action: Optional[str] = "new" # "new" | "instance" | "counter" + target_id: Optional[str] = None # existing pattern id for instance/counter + regime_tag: Optional[str] = None # short generic label (e.g. "Health Crisis") + event_name: Optional[str] = None # human-readable event for historical_instance + + +class FindMatchingRequest(BaseModel): + run_id: str + pattern_index: int class InstrumentScanRequest(BaseModel): @@ -260,6 +270,110 @@ def evaluate_instrument_run(run_id: str): return {"run_id": run_id, "outcomes": outcomes} +@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.""" + from services.database import get_custom_patterns + from openai import AsyncOpenAI + + openai_key = get_config("openai_api_key") or "" + if not openai_key: + raise HTTPException(400, "OpenAI API key not configured") + + run = _get_run(req.run_id) + ai_result = json.loads(run.get("ai_result") or "{}") + patterns = ai_result.get("patterns", []) + if req.pattern_index >= len(patterns): + raise HTTPException(400, "pattern_index out of range") + + pat = patterns[req.pattern_index] + + # Outcome for this pattern if evaluated + outcome_list: list = [] + if run.get("outcome"): + try: + outcome_list = json.loads(run["outcome"]) if isinstance(run["outcome"], str) else run["outcome"] + except Exception: + pass + outcome = next((o for o in outcome_list if o.get("pattern_name") == pat.get("name")), None) + + # Build compact library (id, name, category, underlying, direction, description, regime_tag) + library = get_custom_patterns() + if not library: + return { + "recommendation": "new_pattern", "match_id": None, "match_name": None, + "confidence": 100, "reasoning": "Library is empty — save as new pattern.", + "suggested_regime_tag": pat.get("category", ""), + } + + lib_compact = [] + for p in library: + trades = p.get("suggested_trades") or [] + underlying = trades[0].get("underlying", "") if trades else "" + lib_compact.append({ + "id": p["id"], + "name": p["name"], + "regime_tag": p.get("regime_tag") or "", + "category": p.get("category") or "", + "underlying": underlying, + "signal_direction": p.get("signal_direction") or "", + "description": (p.get("description") or "")[:150], + }) + + outcome_str = "not evaluated" + if outcome: + ht = outcome.get("hit_type") or ("FULL HIT" if outcome.get("hit") else "MISS") + outcome_str = f"{ht.upper()} — actual move {outcome.get('actual_move_pct',0):+.1f}%" + + prompt = f"""You are a quantitative macro pattern analyst. Determine if a new backtest pattern matches an existing library pattern. + +## NEW PATTERN (from Pattern Lab backtest) +Name: {pat.get('name')} +Category: {pat.get('category')} +Description: {pat.get('description')} +Underlying: {pat.get('underlying')} +Strategy: {pat.get('strategy')} +Signal direction: {pat.get('signal_direction')} ({pat.get('expected_direction')}) +Expected move: {pat.get('expected_direction','?')}{pat.get('expected_move_pct',0)}% +Backtest outcome: {outcome_str} + +## EXISTING LIBRARY PATTERNS +{json.dumps(lib_compact, ensure_ascii=False)} + +## DECISION RULES +**merge_as_instance**: Same macro REGIME + same INSTRUMENT + same DIRECTION (both bullish, or both bearish, or both volatility/neutral). The new event is just another historical proof of the same thesis. + +**counter_scenario**: Same macro REGIME + same INSTRUMENT + OPPOSITE DIRECTION. Both are valid — they capture different scenarios within the same regime (e.g. pandemic bullish USD vs pandemic bearish USD). + +**new_pattern**: Different regime, different instrument, or no meaningful structural match. + +CRITICAL: Never merge opposite directions as a single pattern — use counter_scenario instead. +Suggest a short generic regime_tag (2-5 words) capturing the macro theme, not the specific event (e.g. "Health Crisis", "Fed Dovish Pivot", "EM Currency Stress", "Geopolitical Energy Shock"). + +Return ONLY valid JSON: +{{ + "recommendation": "merge_as_instance" | "counter_scenario" | "new_pattern", + "match_id": "existing pattern id or null", + "match_name": "existing pattern name or null", + "confidence": 0-100, + "reasoning": "2-3 sentences explaining the decision", + "suggested_regime_tag": "short generic label" +}}""" + + client = AsyncOpenAI(api_key=openai_key) + try: + resp = await client.chat.completions.create( + model="gpt-4o-mini", + messages=[{"role": "user", "content": prompt}], + temperature=0.1, + response_format={"type": "json_object"}, + timeout=30, + ) + return json.loads(resp.choices[0].message.content or "{}") + except Exception as e: + raise HTTPException(500, f"Matching failed: {e}") + + @router.post("/save-pattern") def save_pattern_from_run(req: SavePatternRequest): """Promote one AI-suggested pattern (with its outcome) to the pattern library.""" @@ -283,53 +397,85 @@ def save_pattern_from_run(req: SavePatternRequest): hit = bool(outcome.get("hit")) if outcome else None actual_move = outcome.get("actual_move_pct") if outcome else None - pat_name = req.name or pat.get("name", "Unnamed Pattern") + pat_name = req.name or pat.get("name", "Unnamed Pattern") + action = req.action or "new" + regime_tag = req.regime_tag or pat.get("category", "") - # Check if same name already exists → update reliability counters - conn = get_conn() - existing = conn.execute( - "SELECT id, backtest_hits, backtest_runs_count FROM custom_patterns WHERE name=? AND source='backtested'", - (pat_name,) - ).fetchone() - - if existing: - new_hits = (existing["backtest_hits"] or 0) + (1 if hit else 0) - new_runs = (existing["backtest_runs_count"] or 0) + 1 - conn.execute("""UPDATE custom_patterns - SET backtest_hits=?, backtest_runs_count=?, updated_at=datetime('now') - WHERE id=?""", (new_hits, new_runs, existing["id"])) - conn.commit() - conn.close() - return {"saved": existing["id"], "action": "updated", "backtest_hits": new_hits, "backtest_runs_count": new_runs} - - # New pattern - new_id = f"P_LAB_{uuid.uuid4().hex[:6].upper()}" - historical_instance = { - "date": run["analysis_date"], - "horizon": run["horizon_days"], - "theme": run["theme"], - "hit": hit, + new_instance = { + "date": run["analysis_date"], + "event_name": req.event_name or run["theme"], + "horizon": run["horizon_days"], + "run_id": req.run_id, + "hit": hit, + "hit_type": "full" if hit else "miss", "actual_move_pct": actual_move, } + conn = get_conn() + + # ── ACTION: merge as historical instance of an existing pattern ──────────── + if action == "instance" and req.target_id: + existing = conn.execute("SELECT * FROM custom_patterns WHERE id=?", (req.target_id,)).fetchone() + if not existing: + conn.close() + raise HTTPException(404, "Target pattern not found") + instances = json.loads(existing["historical_instances"] or "[]") + instances.append(new_instance) + new_hits = (existing["backtest_hits"] or 0) + (1 if hit else 0) + new_runs = (existing["backtest_runs_count"] or 0) + 1 + conn.execute("""UPDATE custom_patterns SET + historical_instances=?, backtest_hits=?, backtest_runs_count=?, + regime_tag=COALESCE(regime_tag, ?), updated_at=datetime('now') + WHERE id=?""", (json.dumps(instances), new_hits, new_runs, regime_tag, req.target_id)) + conn.commit() + conn.close() + return {"saved": req.target_id, "action": "merged_instance", + "backtest_hits": new_hits, "backtest_runs_count": new_runs} + + # ── ACTION: counter-scenario (new pattern linked to existing) ───────────── + # Also tag the parent with regime_tag if not already set + if action == "counter" and req.target_id: + conn.execute("""UPDATE custom_patterns SET + regime_tag=COALESCE(NULLIF(regime_tag,''), ?) + WHERE id=?""", (regime_tag, req.target_id)) + + # ── ACTION: new or counter — check for exact name duplicate first ───────── + name_match = conn.execute( + "SELECT id, backtest_hits, backtest_runs_count FROM custom_patterns WHERE name=? AND source='backtested'", + (pat_name,) + ).fetchone() + if name_match and action == "new": + new_hits = (name_match["backtest_hits"] or 0) + (1 if hit else 0) + new_runs = (name_match["backtest_runs_count"] or 0) + 1 + conn.execute("""UPDATE custom_patterns SET + backtest_hits=?, backtest_runs_count=?, + regime_tag=COALESCE(NULLIF(regime_tag,''), ?), updated_at=datetime('now') + WHERE id=?""", (new_hits, new_runs, regime_tag, name_match["id"])) + conn.commit() + conn.close() + return {"saved": name_match["id"], "action": "updated", + "backtest_hits": new_hits, "backtest_runs_count": new_runs} + + # ── Create new pattern ──────────────────────────────────────────────────── + new_id = f"P_LAB_{uuid.uuid4().hex[:6].upper()}" conn.execute("""INSERT INTO custom_patterns ( id, name, description, triggers, keywords, historical_instances, suggested_trades, asset_class, expected_move_pct, probability, horizon_days, source, category, signal_direction, - backtest_hits, backtest_runs_count, + backtest_hits, backtest_runs_count, regime_tag, counter_of, is_active, taxonomy_path, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,'backtested',?,?,?,?,1,'[]',datetime('now'))""", ( + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,'backtested',?,?,?,?,?,?,1,'[]',datetime('now'))""", ( new_id, pat_name, pat.get("description", ""), json.dumps([]), json.dumps([]), - json.dumps([historical_instance]), + json.dumps([new_instance]), json.dumps([{ - "underlying": pat.get("underlying", ""), - "strategy": pat.get("strategy", ""), - "expected_move_pct": pat.get("expected_move_pct", 0), - "asset_class": req.asset_class, + "underlying": pat.get("underlying", ""), + "strategy": pat.get("strategy", ""), + "expected_move_pct": pat.get("expected_move_pct", 0), + "asset_class": req.asset_class, }]), req.asset_class or "indices", pat.get("expected_move_pct", 0), @@ -339,8 +485,11 @@ def save_pattern_from_run(req: SavePatternRequest): req.signal_direction or pat.get("signal_direction", ""), 1 if hit else 0, 1, + regime_tag, + req.target_id if action == "counter" else None, )) conn.commit() conn.close() - return {"saved": new_id, "action": "created", "backtest_hits": 1 if hit else 0, "backtest_runs_count": 1} + return {"saved": new_id, "action": action, + "backtest_hits": 1 if hit else 0, "backtest_runs_count": 1} diff --git a/backend/services/database.py b/backend/services/database.py index ee4f1ee..b893c73 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -88,6 +88,9 @@ def init_db(): "ALTER TABLE custom_patterns ADD COLUMN backtest_runs_count INTEGER DEFAULT 0", # Remove all built-in patterns (no proof of legitimacy) "DELETE FROM custom_patterns WHERE source = 'builtin'", + # Regime / counter-scenario architecture + "ALTER TABLE custom_patterns ADD COLUMN regime_tag TEXT", + "ALTER TABLE custom_patterns ADD COLUMN counter_of TEXT", ]: try: c.execute(_sql) diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 983589c..cdd2360 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -996,6 +996,8 @@ export const useSaveLabPattern = () => { mutationFn: (body: { run_id: string; pattern_index: number; name?: string; category?: string; signal_direction?: string; asset_class?: string; + action?: 'new' | 'instance' | 'counter'; + target_id?: string; regime_tag?: string; event_name?: string; }) => api.post('/pattern-lab/save-pattern', body).then(r => r.data), onSuccess: () => { qc.invalidateQueries({ queryKey: ['all-patterns'] }) @@ -1004,6 +1006,21 @@ export const useSaveLabPattern = () => { }) } +export type MatchResult = { + recommendation: 'merge_as_instance' | 'counter_scenario' | 'new_pattern' + match_id?: string + match_name?: string + confidence: number + reasoning: string + suggested_regime_tag?: string +} + +export const useFindMatchingPattern = () => + useMutation({ + mutationFn: (body: { run_id: string; pattern_index: number }): Promise => + api.post('/pattern-lab/find-matching', body).then(r => r.data), + }) + export const useDeleteLabRun = () => { const qc = useQueryClient() return useMutation({ diff --git a/frontend/src/pages/PatternExplorer.tsx b/frontend/src/pages/PatternExplorer.tsx index 153e1cf..5f1b5a8 100644 --- a/frontend/src/pages/PatternExplorer.tsx +++ b/frontend/src/pages/PatternExplorer.tsx @@ -4,6 +4,7 @@ import clsx from 'clsx' import { TreePine, Search, ChevronRight, ChevronDown, TrendingUp, TrendingDown, Zap, BookOpen, ExternalLink, RefreshCw, + Layers, GitMerge, Tag, } from 'lucide-react' import { useAllPatterns, @@ -58,6 +59,10 @@ interface Pattern { taxonomy_path?: string[] ai_quality_score?: number is_active?: number + regime_tag?: string + counter_of?: string + backtest_hits?: number + backtest_runs_count?: number } interface ScoreResult { @@ -749,9 +754,136 @@ function InstrumentLens({ patterns }: { patterns: Pattern[] }) { ) } +// ── Regime View ─────────────────────────────────────────────────────────────── + +function RegimeCard({ pattern, allPatterns }: { pattern: Pattern; allPatterns: Pattern[] }) { + const [open, setOpen] = useState(false) + const isCounter = !!pattern.counter_of + const counterOf = isCounter ? allPatterns.find(p => p.id === pattern.counter_of) : null + const instances = Array.isArray(pattern.historical_instances) ? pattern.historical_instances : [] + const hitRate = pattern.backtest_runs_count + ? Math.round(((pattern.backtest_hits ?? 0) / pattern.backtest_runs_count) * 100) + : null + + return ( +
+ + {open && instances.length > 0 && ( +
+
Historical instances
+
+ {instances.map((inst: any, i: number) => ( +
+ {inst.date ?? '—'} + {inst.event_name ?? inst.event ?? inst.theme ?? '—'} + {inst.hit !== undefined && ( + + {inst.hit_type === 'full' || inst.hit === true ? '✓' : inst.hit_type === 'partial' ? '~' : '✗'} + + )} +
+ ))} +
+
+ )} +
+ ) +} + +function RegimeView({ patterns }: { patterns: Pattern[] }) { + const groups = useMemo(() => { + const byRegime: Record = {} + const untagged: Pattern[] = [] + for (const p of patterns) { + if (p.source !== 'backtested' && p.source !== 'user') continue + if (p.regime_tag) { + ;(byRegime[p.regime_tag] ??= []).push(p) + } else { + untagged.push(p) + } + } + return { byRegime, untagged } + }, [patterns]) + + const regimes = Object.entries(groups.byRegime).sort((a, b) => b[1].length - a[1].length) + + if (regimes.length === 0 && groups.untagged.length === 0) { + return ( +
+ +

No saved patterns yet.

+

Use "Find Matching" in Pattern Lab to classify and save patterns into regimes.

+
+ ) + } + + return ( +
+ {regimes.map(([tag, pats]) => ( +
+
+ + #{tag} + {pats.length} pattern{pats.length > 1 ? 's' : ''} +
+
+ {pats.map(p => )} +
+
+ ))} + {groups.untagged.length > 0 && ( +
+
+ + Untagged + {groups.untagged.length} pattern{groups.untagged.length > 1 ? 's' : ''} +
+
+ {groups.untagged.map(p => )} +
+
+ )} +
+ ) +} + // ── Page root ───────────────────────────────────────────────────────────────── -type ViewMode = 'tree' | 'instrument' +type ViewMode = 'tree' | 'instrument' | 'regime' export default function PatternExplorer() { const [view, setView] = useState('tree') @@ -810,6 +942,18 @@ export default function PatternExplorer() { Instrument + @@ -823,6 +967,7 @@ export default function PatternExplorer() { <> {view === 'tree' && } {view === 'instrument' && } + {view === 'regime' && } )} diff --git a/frontend/src/pages/PatternLab.tsx b/frontend/src/pages/PatternLab.tsx index 5b979df..5f9784b 100644 --- a/frontend/src/pages/PatternLab.tsx +++ b/frontend/src/pages/PatternLab.tsx @@ -3,12 +3,14 @@ import { useRunPatternLab, useEvaluatePatternLab, useSaveLabPattern, usePatternLabRuns, useDeleteLabRun, useInstrumentScan, useEvaluateInstrumentScan, + useFindMatchingPattern, MatchResult, validateTicker, } from '../hooks/useApi' import { FlaskConical, Play, CheckCircle2, XCircle, MinusCircle, Trash2, Save, RefreshCw, Search, CalendarDays, TrendingUp, TrendingDown, Zap, BarChart2, ScanLine, DollarSign, + GitMerge, Layers, Plus, } from 'lucide-react' import clsx from 'clsx' import { INSTRUMENTS, INSTRUMENT_CATEGORIES } from '../constants/instruments' @@ -255,6 +257,7 @@ export default function PatternLab() { const { mutate: deleteRun } = useDeleteLabRun() const { mutateAsync: runInstScan, isPending: scanning } = useInstrumentScan() const { mutateAsync: evaluateInst, isPending: evalInst } = useEvaluateInstrumentScan() + const { mutateAsync: findMatching } = useFindMatchingPattern() const runs: any[] = runsData ?? [] @@ -283,9 +286,11 @@ export default function PatternLab() { const [instSavedIdx, setInstSavedIdx] = useState>(new Set()) // ── Current run state (events mode) - const [activeRun, setActiveRun] = useState(null) - const [savedIdx, setSavedIdx] = useState>(new Set()) - const [toast, setToast] = useState(null) + const [activeRun, setActiveRun] = useState(null) + const [savedIdx, setSavedIdx] = useState>(new Set()) + const [toast, setToast] = useState(null) + const [matchResults, setMatchResults] = useState>({}) + const [matchLoading, setMatchLoading] = useState>({}) const filteredPresets = useMemo(() => PRESETS.filter(p => { if (yearFilter && p.year !== yearFilter) return false @@ -338,7 +343,28 @@ export default function PatternLab() { } } - const handleSave = async (idx: number) => { + const matchKey = (idx: number) => `${activeRun?.run_id}-${idx}` + + const handleFindMatching = async (idx: number) => { + if (!activeRun?.run_id) return + const key = matchKey(idx) + setMatchLoading(prev => ({ ...prev, [key]: true })) + try { + const result = await findMatching({ run_id: activeRun.run_id, pattern_index: idx }) + setMatchResults(prev => ({ ...prev, [key]: result })) + } catch (e: any) { + showToast(`Find matching failed: ${e?.response?.data?.detail ?? e?.message}`) + } finally { + setMatchLoading(prev => ({ ...prev, [key]: false })) + } + } + + const handleSave = async ( + idx: number, + action: 'new' | 'instance' | 'counter' = 'new', + target_id?: string, + regime_tag?: string, + ) => { if (!activeRun?.run_id) return const pat = activeRun.ai_result?.patterns?.[idx] if (!pat) return @@ -349,9 +375,13 @@ export default function PatternLab() { name: pat.name, category: pat.category, signal_direction: pat.signal_direction, + action, + target_id, + regime_tag, }) setSavedIdx(prev => new Set([...prev, idx])) - showToast(`"${pat.name}" saved to Pattern Library`) + const actionLabel = action === 'instance' ? 'merged as instance' : action === 'counter' ? 'saved as counter-scenario' : 'saved' + showToast(`"${pat.name}" ${actionLabel}`) } catch (e: any) { showToast(`Save failed: ${e?.response?.data?.detail ?? e?.message}`) } @@ -853,18 +883,65 @@ export default function PatternLab() { {out && } - {/* Save button */} -
+ {/* Save / Find Matching buttons */} +
{isSaved ? ( Saved - ) : ( - - )} + ) : (() => { + const key = matchKey(idx) + const mr = matchResults[key] + const loading = matchLoading[key] + if (mr) { + const isInstance = mr.recommendation === 'merge_as_instance' + const isCounter = mr.recommendation === 'counter_scenario' + const badgeColor = isInstance ? 'text-emerald-400 border-emerald-600' : isCounter ? 'text-orange-400 border-orange-600' : 'text-slate-400 border-slate-600' + return ( +
+
+ {isInstance ? <>Match: {mr.match_name} ({mr.confidence}%) + : isCounter ? <>Counter of: {mr.match_name} ({mr.confidence}%) + : <>New pattern} +
+ {mr.suggested_regime_tag && ( +
#{mr.suggested_regime_tag}
+ )} +
+ {isInstance && mr.match_id && ( + + )} + {isCounter && mr.match_id && ( + + )} + +
+
+ ) + } + return ( +
+ + +
+ ) + })()}