From a630cdc70830a368c1a3ac4d697686e942c07949 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Tue, 23 Jun 2026 12:13:33 +0200 Subject: [PATCH] feat: Find Similar + Merge in Pattern Library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/routers/patterns.py | 214 ++++++++++++++++++++++++- frontend/src/hooks/useApi.ts | 27 ++++ frontend/src/pages/PatternExplorer.tsx | 144 +++++++++++++++-- 3 files changed, 372 insertions(+), 13 deletions(-) diff --git a/backend/routers/patterns.py b/backend/routers/patterns.py index 0c32a86..25b284d 100644 --- a/backend/routers/patterns.py +++ b/backend/routers/patterns.py @@ -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() diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 384c1b5..cc9b029 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -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 = () => diff --git a/frontend/src/pages/PatternExplorer.tsx b/frontend/src/pages/PatternExplorer.tsx index b1eb5c4..79bb26e 100644 --- a/frontend/src/pages/PatternExplorer.tsx +++ b/frontend/src/pages/PatternExplorer.tsx @@ -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(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({ )} {isEditable && !editing && !confirmDel && ( - + <> + + + )} {isEditable && editing && ( <> @@ -351,6 +372,105 @@ function PatternCard({ )} )} + + {/* Similarity result panel */} + {simResult && ( +
+
+
+ {simResult.recommendation === 'merge_as_instance' && ( + + Duplicate détecté + + )} + {simResult.recommendation === 'counter_scenario' && ( + + Counter-scenario + + )} + {simResult.recommendation === 'new_pattern' && ( + + Pattern unique + + )} + confiance {simResult.confidence}% +
+ +
+ + {simResult.match_name && ( +
+ Match : {simResult.match_name} + {simResult.suggested_regime_tag && ( + #{simResult.suggested_regime_tag} + )} +
+ )} + +

{simResult.reasoning}

+ + {/* Actions */} + {simResult.recommendation === 'merge_as_instance' && simResult.match_id && ( + confirmMerge ? ( +
+
+ + + "{pattern.name}" sera supprimé. Ses instances historiques, scores et trades seront fusionnés dans "{simResult.match_name}". Cette action est irréversible. + +
+
+ + +
+
+ ) : ( + + ) + )} + + {simResult.recommendation === 'counter_scenario' && simResult.match_id && ( + + )} +
+ )} ) }