Files
OpenFin/backend/routers/pattern_lab.py
OpenSquared 66f6607568 feat: Data Management tab — purge endpoints for all major data stores
Backend — new DELETE /purge-all endpoints:
  - /api/logs/purge-all          → truncate system_logs (immediate, no 30d wait)
  - /api/reports/purge-all       → cycle_reports + ai_reports
  - /api/portfolio/purge-all     → portfolio + trade_entry_prices
  - /api/analytics/purge-all     → pattern_score_history, regime_clusters,
                                    pattern_embeddings, cycle_runs,
                                    macro_regime_history, geo_alert_history
  - /api/var/purge-all           → var_snapshots + pnl_snapshots
  - /api/pattern-lab/purge-all   → backtest_lab_runs

Frontend — Config.tsx: new 'Data Management' tab
  - PurgeButton component with inline double-confirm (click Purge → confirm)
  - Shows table names affected + row count deleted
  - 6 purge actions: Logs, AI Reports, Portfolio, Analytics, VaR, Pattern Lab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 18:25:01 +02:00

347 lines
12 KiB
Python

"""
Pattern Lab router — historical backtest for pattern discovery.
Prefix: /api/pattern-lab
"""
import json
import uuid
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, HTTPException, BackgroundTasks
from pydantic import BaseModel
from services.database import get_conn, get_config, save_custom_pattern
router = APIRouter(prefix="/api/pattern-lab", tags=["pattern-lab"])
# ── Pydantic models ────────────────────────────────────────────────────────────
class RunRequest(BaseModel):
preset_id: Optional[str] = None
theme: str
analysis_date: str # YYYY-MM-DD
horizon_days: int = 90
assets: List[str]
theme_hint: str
class EvaluateRequest(BaseModel):
pass
class SavePatternRequest(BaseModel):
run_id: str
pattern_index: int
name: Optional[str] = None
category: Optional[str] = None
signal_direction: Optional[str] = None
asset_class: Optional[str] = "indices"
class InstrumentScanRequest(BaseModel):
ticker: str
instrument_name: str
start_date: str # YYYY-MM-DD
end_date: str # YYYY-MM-DD
horizon_days: int = 90
# ── Helpers ────────────────────────────────────────────────────────────────────
def _get_run(run_id: str) -> dict:
conn = get_conn()
row = conn.execute("SELECT * FROM backtest_lab_runs WHERE id=?", (run_id,)).fetchone()
conn.close()
if not row:
raise HTTPException(404, "Run not found")
return dict(row)
def _row_to_dict(row) -> dict:
d = dict(row)
for field in ("assets", "context_snapshot", "ai_result", "outcome"):
if d.get(field) and isinstance(d[field], str):
try:
d[field] = json.loads(d[field])
except Exception:
pass
return d
# ── Endpoints ──────────────────────────────────────────────────────────────────
@router.post("/run")
async def create_run(req: RunRequest):
"""Build historical context + run AI analysis. Returns run with AI patterns."""
from services.pattern_lab import build_historical_context, run_ai_backtest
openai_key = get_config("openai_api_key") or ""
if not openai_key:
raise HTTPException(400, "OpenAI API key not configured")
run_id = f"LAB_{uuid.uuid4().hex[:8].upper()}"
now = datetime.utcnow().isoformat()
# Persist run in pending state
conn = get_conn()
conn.execute("""INSERT INTO backtest_lab_runs
(id, preset_id, theme, analysis_date, horizon_days, assets, theme_hint, status, created_at)
VALUES (?,?,?,?,?,?,?,'running',?)""", (
run_id, req.preset_id, req.theme, req.analysis_date,
req.horizon_days, json.dumps(req.assets), req.theme_hint, now,
))
conn.commit()
conn.close()
try:
# Step 1: historical context
context = build_historical_context(req.analysis_date, req.assets)
# Step 2: AI analysis
ai_result = await run_ai_backtest(context, req.theme_hint, req.horizon_days, openai_key)
# Persist results
conn = get_conn()
conn.execute("""UPDATE backtest_lab_runs SET
context_snapshot=?, ai_result=?, status='done'
WHERE id=?""", (
json.dumps(context), json.dumps(ai_result), run_id,
))
conn.commit()
conn.close()
return {
"run_id": run_id,
"status": "done",
"context": context,
"ai_result": ai_result,
}
except Exception as e:
conn = get_conn()
conn.execute("UPDATE backtest_lab_runs SET status='error', error_msg=? WHERE id=?",
(str(e), run_id))
conn.commit()
conn.close()
raise HTTPException(500, f"Backtest failed: {e}")
@router.post("/evaluate/{run_id}")
def evaluate_run(run_id: str):
"""Fetch actual prices at T+horizon and score each AI pattern."""
from services.pattern_lab import evaluate_outcomes
run = _get_run(run_id)
if run["status"] not in ("done", "evaluated"):
raise HTTPException(400, f"Run status is '{run['status']}', must be 'done' first")
outcomes = evaluate_outcomes(run)
conn = get_conn()
conn.execute("""UPDATE backtest_lab_runs SET
outcome=?, status='evaluated', evaluated_at=datetime('now')
WHERE id=?""", (json.dumps(outcomes), run_id))
conn.commit()
conn.close()
return {"run_id": run_id, "outcomes": outcomes}
@router.get("/runs")
def list_runs():
conn = get_conn()
rows = conn.execute("""SELECT id, preset_id, theme, analysis_date, horizon_days,
assets, status, created_at, evaluated_at,
outcome, ai_result
FROM backtest_lab_runs ORDER BY created_at DESC LIMIT 100""").fetchall()
conn.close()
return [_row_to_dict(r) for r in rows]
@router.get("/runs/{run_id}")
def get_run(run_id: str):
return _row_to_dict(_get_run(run_id))
@router.delete("/purge-all")
def purge_all_runs():
"""Delete all Pattern Lab runs."""
conn = get_conn()
conn.execute("DELETE FROM backtest_lab_runs")
n = conn.total_changes
conn.commit()
conn.close()
return {"deleted": n}
@router.delete("/runs/{run_id}")
def delete_run(run_id: str):
conn = get_conn()
conn.execute("DELETE FROM backtest_lab_runs WHERE id=?", (run_id,))
conn.commit()
conn.close()
return {"deleted": run_id}
@router.post("/instrument-scan")
async def instrument_scan(req: InstrumentScanRequest):
"""Scan an instrument's historical price action to extract pattern instances."""
from services.pattern_lab import run_instrument_scan
openai_key = get_config("openai_api_key") or ""
if not openai_key:
raise HTTPException(400, "OpenAI API key not configured")
run_id = f"LAB_INS_{uuid.uuid4().hex[:8].upper()}"
now = datetime.utcnow().isoformat()
conn = get_conn()
conn.execute("""INSERT INTO backtest_lab_runs
(id, preset_id, theme, analysis_date, horizon_days, assets, theme_hint, status, created_at)
VALUES (?,?,?,?,?,?,?,'running',?)""", (
run_id,
"instrument_scan",
f"Instrument Scan: {req.instrument_name}",
req.start_date,
req.horizon_days,
json.dumps([req.ticker]),
json.dumps({"type": "instrument", "ticker": req.ticker,
"instrument_name": req.instrument_name,
"start_date": req.start_date, "end_date": req.end_date}),
now,
))
conn.commit()
conn.close()
try:
ai_result = await run_instrument_scan(
req.ticker, req.instrument_name,
req.start_date, req.end_date,
req.horizon_days, openai_key,
)
conn = get_conn()
conn.execute("""UPDATE backtest_lab_runs SET
ai_result=?, status='done'
WHERE id=?""", (json.dumps(ai_result), run_id))
conn.commit()
conn.close()
return {"run_id": run_id, "status": "done", "ai_result": ai_result}
except Exception as e:
conn = get_conn()
conn.execute("UPDATE backtest_lab_runs SET status='error', error_msg=? WHERE id=?",
(str(e), run_id))
conn.commit()
conn.close()
raise HTTPException(500, f"Instrument scan failed: {e}")
@router.post("/evaluate-instrument/{run_id}")
def evaluate_instrument_run(run_id: str):
"""Evaluate outcomes for an instrument scan (per-pattern dates)."""
from services.pattern_lab import evaluate_instrument_outcomes
run = _get_run(run_id)
if run["status"] not in ("done", "evaluated"):
raise HTTPException(400, f"Run status is '{run['status']}', must be 'done' first")
outcomes = evaluate_instrument_outcomes(run)
conn = get_conn()
conn.execute("""UPDATE backtest_lab_runs SET
outcome=?, status='evaluated', evaluated_at=datetime('now')
WHERE id=?""", (json.dumps(outcomes), run_id))
conn.commit()
conn.close()
return {"run_id": run_id, "outcomes": outcomes}
@router.post("/save-pattern")
def save_pattern_from_run(req: SavePatternRequest):
"""Promote one AI-suggested pattern (with its outcome) to the pattern library."""
run = _get_run(req.run_id)
ai_result = json.loads(run.get("ai_result") or "{}")
patterns = ai_result.get("patterns", [])
if req.pattern_index >= len(patterns):
raise HTTPException(400, "pattern_index out of range")
pat = patterns[req.pattern_index]
outcome_list: list = []
if run.get("outcome"):
try:
outcome_list = json.loads(run["outcome"]) if isinstance(run["outcome"], str) else run["outcome"]
except Exception:
pass
# Match outcome for this pattern
outcome = next((o for o in outcome_list if o.get("pattern_name") == pat.get("name")), None)
hit = bool(outcome.get("hit")) if outcome else None
actual_move = outcome.get("actual_move_pct") if outcome else None
pat_name = req.name or pat.get("name", "Unnamed Pattern")
# Check if same name already exists → update reliability counters
conn = get_conn()
existing = conn.execute(
"SELECT id, backtest_hits, backtest_runs_count FROM custom_patterns WHERE name=? AND source='backtested'",
(pat_name,)
).fetchone()
if existing:
new_hits = (existing["backtest_hits"] or 0) + (1 if hit else 0)
new_runs = (existing["backtest_runs_count"] or 0) + 1
conn.execute("""UPDATE custom_patterns
SET backtest_hits=?, backtest_runs_count=?, updated_at=datetime('now')
WHERE id=?""", (new_hits, new_runs, existing["id"]))
conn.commit()
conn.close()
return {"saved": existing["id"], "action": "updated", "backtest_hits": new_hits, "backtest_runs_count": new_runs}
# New pattern
new_id = f"P_LAB_{uuid.uuid4().hex[:6].upper()}"
historical_instance = {
"date": run["analysis_date"],
"horizon": run["horizon_days"],
"theme": run["theme"],
"hit": hit,
"actual_move_pct": actual_move,
}
conn.execute("""INSERT INTO custom_patterns (
id, name, description, triggers, keywords, historical_instances,
suggested_trades, asset_class, expected_move_pct, probability,
horizon_days, source, category, signal_direction,
backtest_hits, backtest_runs_count,
is_active, taxonomy_path, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,'backtested',?,?,?,?,1,'[]',datetime('now'))""", (
new_id,
pat_name,
pat.get("description", ""),
json.dumps([]),
json.dumps([]),
json.dumps([historical_instance]),
json.dumps([{
"underlying": pat.get("underlying", ""),
"strategy": pat.get("strategy", ""),
"expected_move_pct": pat.get("expected_move_pct", 0),
"asset_class": req.asset_class,
}]),
req.asset_class or "indices",
pat.get("expected_move_pct", 0),
pat.get("confidence", 50) / 100,
pat.get("horizon_days", run["horizon_days"]),
req.category or pat.get("category", ""),
req.signal_direction or pat.get("signal_direction", ""),
1 if hit else 0,
1,
))
conn.commit()
conn.close()
return {"saved": new_id, "action": "created", "backtest_hits": 1 if hit else 0, "backtest_runs_count": 1}