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:
@@ -1,11 +1,15 @@
|
||||
import json
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Dict, Any
|
||||
from services.database import (
|
||||
save_custom_pattern, get_custom_patterns, delete_custom_pattern, toggle_pattern_active
|
||||
save_custom_pattern, get_custom_patterns, delete_custom_pattern, toggle_pattern_active,
|
||||
get_config,
|
||||
)
|
||||
from services.geo_analyzer import PATTERN_TAXONOMY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/patterns", tags=["patterns"])
|
||||
|
||||
|
||||
@@ -135,3 +139,211 @@ def get_by_instrument(ticker: str = Query(..., description="Ticker / underlying
|
||||
if matching:
|
||||
result.append({**p, "matching_trades": matching})
|
||||
return result
|
||||
|
||||
|
||||
# ── Find Similar ──────────────────────────────────────────────────────────────
|
||||
|
||||
class FindSimilarRequest(BaseModel):
|
||||
pattern_id: str
|
||||
|
||||
|
||||
@router.post("/find-similar")
|
||||
async def find_similar_pattern(req: FindSimilarRequest):
|
||||
"""Ask GPT-4o-mini if a library pattern duplicates or counter-scenarios another."""
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
openai_key = get_config("openai_api_key") or ""
|
||||
if not openai_key:
|
||||
raise HTTPException(400, "OpenAI API key not configured")
|
||||
|
||||
library = get_custom_patterns()
|
||||
source = next((p for p in library if p["id"] == req.pattern_id), None)
|
||||
if not source:
|
||||
raise HTTPException(404, "Pattern not found")
|
||||
|
||||
others = [p for p in library if p["id"] != req.pattern_id]
|
||||
if not others:
|
||||
return {
|
||||
"recommendation": "new_pattern", "match_id": None, "match_name": None,
|
||||
"confidence": 100, "reasoning": "No other patterns in library to compare.",
|
||||
"suggested_regime_tag": source.get("regime_tag") or source.get("category", ""),
|
||||
}
|
||||
|
||||
def _compact(p: dict) -> dict:
|
||||
trades = p.get("suggested_trades") or []
|
||||
underlying = trades[0].get("underlying", "") if trades else ""
|
||||
return {
|
||||
"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],
|
||||
}
|
||||
|
||||
trades_src = source.get("suggested_trades") or []
|
||||
underlying_src = trades_src[0].get("underlying", "") if trades_src else ""
|
||||
|
||||
prompt = f"""You are a quantitative macro pattern analyst. Determine if this library pattern duplicates or counter-scenarios another existing pattern.
|
||||
|
||||
## SOURCE PATTERN
|
||||
Name: {source.get('name')}
|
||||
Category: {source.get('category')}
|
||||
Description: {source.get('description')}
|
||||
Underlying: {underlying_src}
|
||||
Signal direction: {source.get('signal_direction')}
|
||||
Regime tag: {source.get('regime_tag') or 'none'}
|
||||
|
||||
## EXISTING LIBRARY PATTERNS (excluding source)
|
||||
{json.dumps([_compact(p) for p in others], ensure_ascii=False)}
|
||||
|
||||
## DECISION RULES
|
||||
**merge_as_instance**: Same macro REGIME + same INSTRUMENT + same DIRECTION. The source pattern is a redundant instance of an existing one — safe to merge.
|
||||
|
||||
**counter_scenario**: Same macro REGIME + same INSTRUMENT + OPPOSITE DIRECTION. Both are valid distinct patterns — do NOT merge, just mark the relationship.
|
||||
|
||||
**new_pattern**: Different regime, instrument, or no meaningful structural match. Keep as-is.
|
||||
|
||||
CRITICAL: Never merge opposite directions — use counter_scenario instead.
|
||||
Suggest a short generic regime_tag (2-5 words) capturing the macro theme.
|
||||
|
||||
Return ONLY valid JSON:
|
||||
{{
|
||||
"recommendation": "merge_as_instance" | "counter_scenario" | "new_pattern",
|
||||
"match_id": "matched pattern id or null",
|
||||
"match_name": "matched 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)
|
||||
resp = await client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.1,
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
raw = resp.choices[0].message.content or "{}"
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
raise HTTPException(500, f"AI returned non-JSON: {raw[:200]}")
|
||||
|
||||
|
||||
# ── Merge Patterns ────────────────────────────────────────────────────────────
|
||||
|
||||
class MergeRequest(BaseModel):
|
||||
keep_id: str
|
||||
discard_id: str
|
||||
|
||||
|
||||
@router.post("/merge")
|
||||
def merge_patterns(req: MergeRequest):
|
||||
"""Merge discard_id into keep_id.
|
||||
|
||||
- Migrates all child-table rows (scores, trades, traces, logs, embeddings)
|
||||
- Merges historical_instances (union, dedup by date+event)
|
||||
- Sums backtest_hits + backtest_runs_count
|
||||
- Deletes the discarded pattern
|
||||
"""
|
||||
from services.database import get_conn
|
||||
|
||||
if req.keep_id == req.discard_id:
|
||||
raise HTTPException(400, "keep_id and discard_id must be different")
|
||||
|
||||
conn = get_conn()
|
||||
try:
|
||||
keep_row = conn.execute("SELECT * FROM custom_patterns WHERE id=?", (req.keep_id,)).fetchone()
|
||||
discard_row = conn.execute("SELECT * FROM custom_patterns WHERE id=?", (req.discard_id,)).fetchone()
|
||||
if not keep_row:
|
||||
raise HTTPException(404, f"Pattern {req.keep_id} not found")
|
||||
if not discard_row:
|
||||
raise HTTPException(404, f"Pattern {req.discard_id} not found")
|
||||
|
||||
keep = dict(keep_row)
|
||||
discard = dict(discard_row)
|
||||
|
||||
# ── Merge historical_instances ────────────────────────────────────────
|
||||
def _load_instances(raw) -> list:
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
v = json.loads(raw)
|
||||
return v if isinstance(v, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
keep_inst = _load_instances(keep.get("historical_instances"))
|
||||
discard_inst = _load_instances(discard.get("historical_instances"))
|
||||
|
||||
seen = {(i.get("date", ""), i.get("event", "")) for i in keep_inst}
|
||||
for inst in discard_inst:
|
||||
key = (inst.get("date", ""), inst.get("event", ""))
|
||||
if key not in seen:
|
||||
keep_inst.append(inst)
|
||||
seen.add(key)
|
||||
keep_inst.sort(key=lambda x: x.get("date", ""), reverse=True)
|
||||
|
||||
# ── Merge numeric counters ────────────────────────────────────────────
|
||||
merged_hits = (keep.get("backtest_hits") or 0) + (discard.get("backtest_hits") or 0)
|
||||
merged_runs = (keep.get("backtest_runs_count") or 0) + (discard.get("backtest_runs_count") or 0)
|
||||
|
||||
# Prefer keep's regime_tag; fall back to discard's
|
||||
merged_regime = keep.get("regime_tag") or discard.get("regime_tag")
|
||||
|
||||
# ── Remap child tables ────────────────────────────────────────────────
|
||||
_CHILD_TABLES = [
|
||||
"pattern_score_history",
|
||||
"trade_entry_prices",
|
||||
"ai_reasoning_traces",
|
||||
"ai_call_logs",
|
||||
"skipped_trades",
|
||||
]
|
||||
for tbl in _CHILD_TABLES:
|
||||
try:
|
||||
conn.execute(
|
||||
f"UPDATE {tbl} SET pattern_id=? WHERE pattern_id=?",
|
||||
(req.keep_id, req.discard_id),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[merge] {tbl} remap failed (may not exist): {e}")
|
||||
|
||||
# pattern_embeddings: unique constraint on pattern_id — delete discard's embedding
|
||||
# (keep's embedding stays; it'll be regenerated on next cycle if needed)
|
||||
try:
|
||||
conn.execute("DELETE FROM pattern_embeddings WHERE pattern_id=?", (req.discard_id,))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Update keep pattern ───────────────────────────────────────────────
|
||||
conn.execute(
|
||||
"""UPDATE custom_patterns SET
|
||||
historical_instances=?,
|
||||
backtest_hits=?,
|
||||
backtest_runs_count=?,
|
||||
regime_tag=?,
|
||||
updated_at=datetime('now')
|
||||
WHERE id=?""",
|
||||
(
|
||||
json.dumps(keep_inst, ensure_ascii=False),
|
||||
merged_hits,
|
||||
merged_runs,
|
||||
merged_regime,
|
||||
req.keep_id,
|
||||
),
|
||||
)
|
||||
|
||||
# ── Delete discarded pattern ──────────────────────────────────────────
|
||||
conn.execute("DELETE FROM custom_patterns WHERE id=?", (req.discard_id,))
|
||||
conn.commit()
|
||||
|
||||
logger.info(f"[Merge] Kept {req.keep_id}, discarded {req.discard_id} — {len(keep_inst)} instances, {merged_hits}/{merged_runs} backtest hits")
|
||||
|
||||
updated = conn.execute("SELECT * FROM custom_patterns WHERE id=?", (req.keep_id,)).fetchone()
|
||||
return {"status": "merged", "kept_id": req.keep_id, "discarded_id": req.discard_id,
|
||||
"merged_instances": len(keep_inst), "pattern": dict(updated)}
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user