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

@@ -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()