- Remove all built-in patterns (no proof of legitimacy); seed_builtin_patterns is now a no-op
- DB: add backtest_lab_runs table + backtest_hits/runs_count columns on patterns
- services/pattern_lab.py: build_historical_context (yfinance + RSI/MA200),
run_ai_backtest (GPT-4o as historical analyst), evaluate_outcomes (actual moves at T+horizon)
- routers/pattern_lab.py: POST /run, POST /evaluate/{id}, GET /runs, DELETE /runs/{id},
POST /save-pattern (promotes hit pattern to library with reliability counters)
- PatternLab.tsx: 34 preset events 2015-2025 (macro/geo/credit/fx/commodities/volatility/tech),
3-panel layout — preset selector + wizard + run history, market data table,
AI pattern cards with hit/miss outcome display, Save to Library button
- useApi.ts: usePatternLabRuns, useRunPatternLab, useEvaluatePatternLab, useSaveLabPattern, useDeleteLabRun
- Sidebar + App.tsx: /pattern-lab route + FlaskConical nav link
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
252 lines
8.6 KiB
Python
252 lines
8.6 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"
|
|
|
|
|
|
# ── 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("/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("/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}
|