feat: regime system — Find Matching + By Regime view

- Pattern Lab: "Find Matching" button per pattern uses GPT-4o-mini to classify against library (merge_as_instance / counter_scenario / new_pattern); shows match badge + confidence + suggested #regime_tag; conditional action buttons (Merge / Save counter / Save new)
- save_pattern_from_run handles action='instance' (appends to historical_instances + updates stats), action='counter' (new pattern with counter_of link, tags parent regime_tag), action='new' (unchanged + regime_tag support)
- useApi.ts: extended useSaveLabPattern type + new useFindMatchingPattern mutation + MatchResult export type
- DB migrations: regime_tag + counter_of columns on custom_patterns
- PatternExplorer: new "By Regime" view groups saved patterns by regime_tag; RegimeCard shows historical instances, hit rate, counter-of link (orange); untagged group at bottom

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-22 20:52:11 +02:00
parent 198341b0c2
commit a435c11246
5 changed files with 438 additions and 47 deletions

View File

@@ -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<Set<number>>(new Set())
// ── Current run state (events mode)
const [activeRun, setActiveRun] = useState<any | null>(null)
const [savedIdx, setSavedIdx] = useState<Set<number>>(new Set())
const [toast, setToast] = useState<string | null>(null)
const [activeRun, setActiveRun] = useState<any | null>(null)
const [savedIdx, setSavedIdx] = useState<Set<number>>(new Set())
const [toast, setToast] = useState<string | null>(null)
const [matchResults, setMatchResults] = useState<Record<string, MatchResult>>({})
const [matchLoading, setMatchLoading] = useState<Record<string, boolean>>({})
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 && <OutcomeRow out={out} />}
</div>
{/* Save button */}
<div className="flex-shrink-0">
{/* Save / Find Matching buttons */}
<div className="flex-shrink-0 flex flex-col items-end gap-2">
{isSaved ? (
<span className="flex items-center gap-1 text-emerald-400 text-xs">
<CheckCircle2 className="w-3.5 h-3.5" /> Saved
</span>
) : (
<button onClick={() => handleSave(idx)}
className="flex items-center gap-1.5 border border-slate-600 hover:border-violet-500 hover:text-violet-300 text-slate-400 px-2.5 py-1.5 rounded text-xs transition-colors">
<Save className="w-3 h-3" /> Save pattern
</button>
)}
) : (() => {
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 (
<div className="flex flex-col items-end gap-1.5">
<div className={`text-[10px] border rounded px-1.5 py-0.5 ${badgeColor}`}>
{isInstance ? <><GitMerge className="w-3 h-3 inline mr-1" />Match: {mr.match_name} ({mr.confidence}%)</>
: isCounter ? <><Layers className="w-3 h-3 inline mr-1" />Counter of: {mr.match_name} ({mr.confidence}%)</>
: <><Plus className="w-3 h-3 inline mr-1" />New pattern</>}
</div>
{mr.suggested_regime_tag && (
<div className="text-[10px] text-violet-400 font-mono">#{mr.suggested_regime_tag}</div>
)}
<div className="flex gap-1.5">
{isInstance && mr.match_id && (
<button onClick={() => handleSave(idx, 'instance', mr.match_id, mr.suggested_regime_tag)}
className="flex items-center gap-1 border border-emerald-600 hover:border-emerald-400 text-emerald-400 px-2 py-1 rounded text-[10px] transition-colors">
<GitMerge className="w-3 h-3" /> Merge
</button>
)}
{isCounter && mr.match_id && (
<button onClick={() => handleSave(idx, 'counter', mr.match_id, mr.suggested_regime_tag)}
className="flex items-center gap-1 border border-orange-600 hover:border-orange-400 text-orange-400 px-2 py-1 rounded text-[10px] transition-colors">
<Layers className="w-3 h-3" /> Save counter
</button>
)}
<button onClick={() => handleSave(idx, 'new', undefined, mr.suggested_regime_tag)}
className="flex items-center gap-1 border border-slate-600 hover:border-slate-400 text-slate-400 px-2 py-1 rounded text-[10px] transition-colors">
<Save className="w-3 h-3" /> Save new
</button>
</div>
</div>
)
}
return (
<div className="flex gap-1.5">
<button onClick={() => handleFindMatching(idx)} disabled={loading}
className="flex items-center gap-1.5 border border-violet-700 hover:border-violet-400 text-violet-400 px-2.5 py-1.5 rounded text-xs transition-colors disabled:opacity-50">
{loading ? <RefreshCw className="w-3 h-3 animate-spin" /> : <GitMerge className="w-3 h-3" />}
Find matching
</button>
<button onClick={() => handleSave(idx)}
className="flex items-center gap-1.5 border border-slate-600 hover:border-violet-500 hover:text-violet-300 text-slate-400 px-2.5 py-1.5 rounded text-xs transition-colors">
<Save className="w-3 h-3" /> Save
</button>
</div>
)
})()}
</div>
</div>
</div>