feat: Find Similar + Merge in Pattern Library

Backend (patterns.py):
- POST /api/patterns/find-similar — GPT-4o-mini compares a library pattern
  against all others; returns merge_as_instance | counter_scenario | new_pattern
- POST /api/patterns/merge — full transactional merge: remaps pattern_id in
  pattern_score_history, trade_entry_prices, ai_reasoning_traces, ai_call_logs,
  skipped_trades; unions historical_instances (dedup); sums backtest counters;
  deletes the discarded pattern

Frontend (PatternExplorer.tsx + useApi.ts):
- ScanSearch button on each non-builtin card triggers find-similar
- Inline result panel: duplicate → merge CTA with confirmation + destructive warning
  counter_scenario → apply regime_tag CTA; new_pattern → green "unique" badge
- useFindSimilarPattern + useMergePatterns hooks invalidate all-patterns on success

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-23 12:13:33 +02:00
parent 8d257adf3d
commit a630cdc708
3 changed files with 372 additions and 13 deletions

View File

@@ -363,6 +363,33 @@ export const useTogglePattern = () => {
})
}
export interface SimilarityResult {
recommendation: 'merge_as_instance' | 'counter_scenario' | 'new_pattern'
match_id: string | null
match_name: string | null
confidence: number
reasoning: string
suggested_regime_tag: string
}
export const useFindSimilarPattern = () =>
useMutation({
mutationFn: (pattern_id: string) =>
api.post('/patterns/find-similar', { pattern_id }).then(r => r.data as SimilarityResult),
})
export const useMergePatterns = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: { keep_id: string; discard_id: string }) =>
api.post('/patterns/merge', body).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['all-patterns'] })
qc.invalidateQueries({ queryKey: ['last-scores'] })
},
})
}
// ── Auto-Cycle ───────────────────────────────────────────────────────────────
export const useCycleStatus = () =>

View File

@@ -4,7 +4,7 @@ import clsx from 'clsx'
import {
TreePine, Search, ChevronRight, ChevronDown,
TrendingUp, TrendingDown, Zap, BookOpen, ExternalLink, RefreshCw,
Layers, GitMerge, Tag, Trash2, Pencil, Check, X,
Layers, GitMerge, Tag, Trash2, Pencil, Check, X, ScanSearch, AlertTriangle, ArrowRightLeft,
} from 'lucide-react'
import {
useAllPatterns,
@@ -12,7 +12,10 @@ import {
useLastScores,
useDeletePattern,
usePatchPattern,
useFindSimilarPattern,
useMergePatterns,
validateTicker,
type SimilarityResult,
} from '../hooks/useApi'
import { INSTRUMENTS, INSTRUMENT_CATEGORIES } from '../constants/instruments'
@@ -141,17 +144,21 @@ function PatternCard({
pattern: Pattern
highlightTrades?: string[]
}) {
const [expanded, setExpanded] = useState(false)
const [confirmDel, setConfirmDel] = useState(false)
const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(pattern.name)
const [editDesc, setEditDesc] = useState(pattern.description)
const [editCat, setEditCat] = useState(pattern.category ?? '')
const [editDir, setEditDir] = useState(pattern.signal_direction ?? '')
const [editRegime, setEditRegime] = useState(pattern.regime_tag ?? '')
const [expanded, setExpanded] = useState(false)
const [confirmDel, setConfirmDel] = useState(false)
const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(pattern.name)
const [editDesc, setEditDesc] = useState(pattern.description)
const [editCat, setEditCat] = useState(pattern.category ?? '')
const [editDir, setEditDir] = useState(pattern.signal_direction ?? '')
const [editRegime, setEditRegime] = useState(pattern.regime_tag ?? '')
const [simResult, setSimResult] = useState<SimilarityResult | null>(null)
const [confirmMerge, setConfirmMerge] = useState(false)
const { mutate: deletePattern, isPending: deleting } = useDeletePattern()
const { mutate: patchPattern, isPending: saving } = usePatchPattern()
const { mutate: findSimilar, isPending: scanning } = useFindSimilarPattern()
const { mutate: mergePatterns, isPending: merging } = useMergePatterns()
const trades = pattern.suggested_trades ?? []
const instances = pattern.historical_instances ?? []
@@ -203,9 +210,23 @@ function PatternCard({
</span>
)}
{isEditable && !editing && !confirmDel && (
<button onClick={startEdit} className="text-slate-700 hover:text-blue-400 transition-colors p-0.5 rounded" title="Edit">
<Pencil className="w-3.5 h-3.5" />
</button>
<>
<button
onClick={() => {
setSimResult(null)
setConfirmMerge(false)
findSimilar(pattern.id, { onSuccess: setSimResult })
}}
disabled={scanning}
className="text-slate-700 hover:text-violet-400 transition-colors p-0.5 rounded"
title="Find Similar / Deduplicate"
>
{scanning ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <ScanSearch className="w-3.5 h-3.5" />}
</button>
<button onClick={startEdit} className="text-slate-700 hover:text-blue-400 transition-colors p-0.5 rounded" title="Edit">
<Pencil className="w-3.5 h-3.5" />
</button>
</>
)}
{isEditable && editing && (
<>
@@ -351,6 +372,105 @@ function PatternCard({
)}
</div>
)}
{/* Similarity result panel */}
{simResult && (
<div className={clsx(
'border-t pt-3 mt-1 space-y-2',
simResult.recommendation === 'merge_as_instance' ? 'border-violet-700/40' :
simResult.recommendation === 'counter_scenario' ? 'border-amber-700/40' :
'border-emerald-700/40',
)}>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
{simResult.recommendation === 'merge_as_instance' && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-0.5 rounded bg-violet-900/40 border border-violet-700/50 text-violet-300 uppercase tracking-wider">
<GitMerge className="w-3 h-3" /> Duplicate détecté
</span>
)}
{simResult.recommendation === 'counter_scenario' && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-0.5 rounded bg-amber-900/40 border border-amber-700/50 text-amber-300 uppercase tracking-wider">
<ArrowRightLeft className="w-3 h-3" /> Counter-scenario
</span>
)}
{simResult.recommendation === 'new_pattern' && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-0.5 rounded bg-emerald-900/40 border border-emerald-700/50 text-emerald-300 uppercase tracking-wider">
<Check className="w-3 h-3" /> Pattern unique
</span>
)}
<span className="text-[10px] text-slate-500">confiance {simResult.confidence}%</span>
</div>
<button onClick={() => { setSimResult(null); setConfirmMerge(false) }}
className="text-slate-600 hover:text-slate-400 p-0.5">
<X className="w-3.5 h-3.5" />
</button>
</div>
{simResult.match_name && (
<div className="text-xs text-slate-400">
Match : <span className="text-white font-medium">{simResult.match_name}</span>
{simResult.suggested_regime_tag && (
<span className="ml-2 text-violet-400 font-mono">#{simResult.suggested_regime_tag}</span>
)}
</div>
)}
<p className="text-xs text-slate-400 leading-relaxed">{simResult.reasoning}</p>
{/* Actions */}
{simResult.recommendation === 'merge_as_instance' && simResult.match_id && (
confirmMerge ? (
<div className="bg-red-900/20 border border-red-700/40 rounded p-2.5 space-y-2">
<div className="flex items-start gap-2 text-xs text-red-300">
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-0.5" />
<span>
<strong>"{pattern.name}"</strong> sera supprimé. Ses instances historiques, scores et trades seront fusionnés dans <strong>"{simResult.match_name}"</strong>. Cette action est irréversible.
</span>
</div>
<div className="flex gap-2">
<button
onClick={() => mergePatterns(
{ keep_id: simResult.match_id!, discard_id: pattern.id },
{ onSuccess: () => { setSimResult(null); setConfirmMerge(false) } }
)}
disabled={merging}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-violet-700/50 hover:bg-violet-600/60 border border-violet-600/50 text-violet-200 transition-colors disabled:opacity-50"
>
<GitMerge className="w-3 h-3" />
{merging ? 'Fusion…' : 'Confirmer la fusion'}
</button>
<button onClick={() => setConfirmMerge(false)}
className="px-3 py-1.5 text-xs rounded border border-slate-700 text-slate-500 hover:text-slate-300 transition-colors">
Annuler
</button>
</div>
</div>
) : (
<button
onClick={() => setConfirmMerge(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-violet-900/30 hover:bg-violet-800/40 border border-violet-700/50 text-violet-300 transition-colors"
>
<GitMerge className="w-3 h-3" />
Fusionner dans "{simResult.match_name}"
</button>
)
)}
{simResult.recommendation === 'counter_scenario' && simResult.match_id && (
<button
onClick={() => patchPattern(
{ id: pattern.id, regime_tag: simResult.suggested_regime_tag || pattern.regime_tag },
{ onSuccess: () => setSimResult(null) }
)}
disabled={saving}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-amber-900/30 hover:bg-amber-800/40 border border-amber-700/50 text-amber-300 transition-colors"
>
<Tag className="w-3 h-3" />
Appliquer le regime tag "{simResult.suggested_regime_tag}"
</button>
)}
</div>
)}
</div>
)
}