- Backend: PATCH /api/patterns/custom/{id} — partial update, only provided fields changed
- useApi.ts: usePatchPattern mutation hook
- PatternCard: pencil icon (non-builtin only) → edit mode with inline inputs for name, description, direction (select), category, and #regime; ✓/✗ buttons to save or cancel; card border highlights blue while editing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
138 lines
4.3 KiB
Python
138 lines
4.3 KiB
Python
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
|
|
)
|
|
from services.geo_analyzer import PATTERN_TAXONOMY
|
|
|
|
router = APIRouter(prefix="/api/patterns", tags=["patterns"])
|
|
|
|
|
|
class PatternRequest(BaseModel):
|
|
id: Optional[str] = None
|
|
name: str
|
|
description: str
|
|
triggers: List[str]
|
|
keywords: List[str]
|
|
historical_instances: Optional[List[Dict[str, Any]]] = []
|
|
suggested_trades: Optional[List[Dict[str, Any]]] = []
|
|
asset_class: str
|
|
expected_move_pct: float
|
|
probability: float
|
|
horizon_days: int
|
|
ai_quality_score: Optional[int] = None
|
|
ai_evaluation: Optional[Dict[str, Any]] = None
|
|
source: Optional[str] = "custom"
|
|
counter_thesis: Optional[str] = None
|
|
invalidation_trigger: Optional[str] = None
|
|
invalidation_probability: Optional[float] = None
|
|
|
|
|
|
@router.get("/all")
|
|
def list_all():
|
|
"""Return all active patterns from DB (builtin + custom)."""
|
|
return get_custom_patterns()
|
|
|
|
|
|
@router.get("/builtin")
|
|
def list_builtin():
|
|
return [p for p in get_custom_patterns() if p.get("source") == "builtin"]
|
|
|
|
|
|
@router.get("/custom")
|
|
def list_custom():
|
|
return [p for p in get_custom_patterns() if p.get("source") != "builtin"]
|
|
|
|
|
|
@router.post("/custom")
|
|
def create_pattern(req: PatternRequest):
|
|
data = req.model_dump()
|
|
data["source"] = "custom"
|
|
pat_id = save_custom_pattern(data)
|
|
return {"id": pat_id, "status": "saved"}
|
|
|
|
|
|
@router.put("/custom/{pat_id}")
|
|
def update_pattern(pat_id: str, req: PatternRequest):
|
|
data = req.model_dump()
|
|
data["id"] = pat_id
|
|
save_custom_pattern(data)
|
|
return {"id": pat_id, "status": "updated"}
|
|
|
|
|
|
class PatternPatchRequest(BaseModel):
|
|
name: Optional[str] = None
|
|
description: Optional[str] = None
|
|
category: Optional[str] = None
|
|
signal_direction: Optional[str] = None
|
|
regime_tag: Optional[str] = None
|
|
|
|
|
|
@router.patch("/custom/{pat_id}")
|
|
def patch_pattern(pat_id: str, req: PatternPatchRequest):
|
|
"""Partial update — only provided fields are changed."""
|
|
from services.database import get_conn
|
|
conn = get_conn()
|
|
if not conn.execute("SELECT id FROM custom_patterns WHERE id=?", (pat_id,)).fetchone():
|
|
conn.close()
|
|
raise HTTPException(404, "Pattern not found")
|
|
updates = {k: v for k, v in req.model_dump().items() if v is not None}
|
|
if updates:
|
|
set_clause = ", ".join(f"{k}=?" for k in updates)
|
|
conn.execute(
|
|
f"UPDATE custom_patterns SET {set_clause}, updated_at=datetime('now') WHERE id=?",
|
|
list(updates.values()) + [pat_id],
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"id": pat_id, "status": "updated", "fields": list(updates.keys())}
|
|
|
|
|
|
@router.delete("/purge-all")
|
|
def purge_all_patterns():
|
|
"""Delete ALL custom/backtested patterns from the pattern library."""
|
|
from services.database import get_conn
|
|
conn = get_conn()
|
|
conn.execute("DELETE FROM custom_patterns WHERE source != 'builtin'")
|
|
n = conn.total_changes
|
|
conn.commit()
|
|
conn.close()
|
|
return {"deleted": n}
|
|
|
|
|
|
@router.delete("/custom/{pat_id}")
|
|
def delete_pattern(pat_id: str):
|
|
delete_custom_pattern(pat_id)
|
|
return {"status": "deleted"}
|
|
|
|
|
|
@router.put("/toggle/{pat_id}")
|
|
def toggle_pattern(pat_id: str):
|
|
"""Enable or disable a pattern (works for builtin and custom)."""
|
|
new_state = toggle_pattern_active(pat_id)
|
|
return {"id": pat_id, "is_active": new_state}
|
|
|
|
|
|
@router.get("/taxonomy")
|
|
def get_taxonomy():
|
|
"""Return the taxonomy tree + all patterns (frontend builds node→pattern mapping)."""
|
|
patterns = get_custom_patterns()
|
|
return {"tree": PATTERN_TAXONOMY, "patterns": patterns}
|
|
|
|
|
|
@router.get("/by-instrument")
|
|
def get_by_instrument(ticker: str = Query(..., description="Ticker / underlying to search")):
|
|
"""Return all patterns that have a suggested_trade matching the given ticker."""
|
|
ticker_lower = ticker.strip().lower()
|
|
result = []
|
|
for p in get_custom_patterns():
|
|
trades = p.get("suggested_trades") or []
|
|
matching = [
|
|
t for t in trades
|
|
if ticker_lower in (t.get("underlying") or "").lower()
|
|
]
|
|
if matching:
|
|
result.append({**p, "matching_trades": matching})
|
|
return result
|