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:
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user