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

@@ -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<MatchResult> =>
api.post('/pattern-lab/find-matching', body).then(r => r.data),
})
export const useDeleteLabRun = () => {
const qc = useQueryClient()
return useMutation({

View File

@@ -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 (
<div className={clsx(
'border rounded-lg overflow-hidden transition-colors',
isCounter ? 'border-orange-700/50 bg-orange-900/5' : 'border-slate-700/50 bg-dark-700/30'
)}>
<button className="w-full flex items-center gap-3 p-3 text-left hover:bg-slate-800/30 transition-colors"
onClick={() => setOpen(v => !v)}>
{open ? <ChevronDown className="w-3.5 h-3.5 text-slate-500 shrink-0" /> : <ChevronRight className="w-3.5 h-3.5 text-slate-500 shrink-0" />}
{isCounter
? <Layers className="w-4 h-4 text-orange-400 shrink-0" />
: <BookOpen className="w-4 h-4 text-violet-400 shrink-0" />}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-slate-100 truncate">{pattern.name}</span>
{isCounter && (
<span className="text-[10px] border border-orange-600 text-orange-400 rounded px-1.5 py-0.5 shrink-0">
counter of {counterOf?.name ?? pattern.counter_of}
</span>
)}
{pattern.signal_direction && (
<span className={clsx('text-[10px] rounded px-1.5 py-0.5 shrink-0',
pattern.signal_direction === 'up' ? 'bg-emerald-900/40 text-emerald-400' :
pattern.signal_direction === 'down' ? 'bg-red-900/40 text-red-400' : 'bg-slate-700 text-slate-400')}>
{pattern.signal_direction === 'up' ? '↑' : pattern.signal_direction === 'down' ? '↓' : '↕'} {pattern.signal_direction}
</span>
)}
{hitRate !== null && (
<span className={clsx('text-[10px] font-mono shrink-0',
hitRate >= 60 ? 'text-emerald-400' : hitRate >= 40 ? 'text-yellow-400' : 'text-red-400')}>
{hitRate}% ({pattern.backtest_hits}/{pattern.backtest_runs_count})
</span>
)}
</div>
<p className="text-[10px] text-slate-500 truncate mt-0.5">{pattern.description}</p>
</div>
<span className="text-[10px] text-slate-600 shrink-0 font-mono">{instances.length} events</span>
</button>
{open && instances.length > 0 && (
<div className="border-t border-slate-700/40 p-3 pt-2">
<div className="text-[10px] text-slate-500 mb-2 font-semibold uppercase tracking-wide">Historical instances</div>
<div className="flex flex-col gap-1.5">
{instances.map((inst: any, i: number) => (
<div key={i} className="flex items-center gap-3 text-[11px]">
<span className="text-slate-500 font-mono w-24 shrink-0">{inst.date ?? '—'}</span>
<span className="text-slate-300 flex-1 truncate">{inst.event_name ?? inst.event ?? inst.theme ?? '—'}</span>
{inst.hit !== undefined && (
<span className={clsx('shrink-0', inst.hit_type === 'full' || inst.hit === true ? 'text-emerald-400' : inst.hit_type === 'partial' ? 'text-amber-400' : 'text-red-400')}>
{inst.hit_type === 'full' || inst.hit === true ? '✓' : inst.hit_type === 'partial' ? '~' : '✗'}
</span>
)}
</div>
))}
</div>
</div>
)}
</div>
)
}
function RegimeView({ patterns }: { patterns: Pattern[] }) {
const groups = useMemo(() => {
const byRegime: Record<string, Pattern[]> = {}
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 (
<div className="flex flex-col items-center justify-center py-32 gap-3 text-slate-600">
<Tag className="w-8 h-8" />
<p className="text-sm">No saved patterns yet.</p>
<p className="text-xs">Use "Find Matching" in Pattern Lab to classify and save patterns into regimes.</p>
</div>
)
}
return (
<div className="flex flex-col gap-6">
{regimes.map(([tag, pats]) => (
<div key={tag} className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Tag className="w-3.5 h-3.5 text-violet-400" />
<span className="text-sm font-semibold text-violet-300">#{tag}</span>
<span className="text-xs text-slate-500">{pats.length} pattern{pats.length > 1 ? 's' : ''}</span>
</div>
<div className="flex flex-col gap-2 pl-4 border-l border-violet-900/40">
{pats.map(p => <RegimeCard key={p.id} pattern={p} allPatterns={patterns} />)}
</div>
</div>
))}
{groups.untagged.length > 0 && (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Tag className="w-3.5 h-3.5 text-slate-500" />
<span className="text-sm font-semibold text-slate-500">Untagged</span>
<span className="text-xs text-slate-600">{groups.untagged.length} pattern{groups.untagged.length > 1 ? 's' : ''}</span>
</div>
<div className="flex flex-col gap-2 pl-4 border-l border-slate-700/40">
{groups.untagged.map(p => <RegimeCard key={p.id} pattern={p} allPatterns={patterns} />)}
</div>
</div>
)}
</div>
)
}
// ── Page root ─────────────────────────────────────────────────────────────────
type ViewMode = 'tree' | 'instrument'
type ViewMode = 'tree' | 'instrument' | 'regime'
export default function PatternExplorer() {
const [view, setView] = useState<ViewMode>('tree')
@@ -810,6 +942,18 @@ export default function PatternExplorer() {
<Search className="w-3.5 h-3.5" />
Instrument
</button>
<button
className={clsx(
'flex items-center gap-1.5 px-3 py-1.5 rounded text-sm font-medium transition-colors',
view === 'regime'
? 'bg-violet-600/30 text-violet-300 border border-violet-500/30'
: 'text-slate-400 hover:text-slate-200'
)}
onClick={() => setView('regime')}
>
<Layers className="w-3.5 h-3.5" />
By Regime
</button>
</div>
</div>
@@ -823,6 +967,7 @@ export default function PatternExplorer() {
<>
{view === 'tree' && <TreeView patterns={patterns} />}
{view === 'instrument' && <InstrumentLens patterns={patterns} />}
{view === 'regime' && <RegimeView patterns={patterns} />}
</>
)}
</div>

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>