""" 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" # Regime architecture action: Optional[str] = "new" # "new" | "instance" | "counter" target_id: Optional[str] = None # existing pattern id for instance/counter regime_tag: Optional[str] = None # short generic label (e.g. "Health Crisis") event_name: Optional[str] = None # human-readable event for historical_instance class FindMatchingRequest(BaseModel): run_id: str pattern_index: int class DiscoverRequest(BaseModel): query: str n: int = 6 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("/discover") async def discover_events(req: DiscoverRequest): """Use GPT-4o to generate historical/recent event suggestions matching the query.""" from openai import AsyncOpenAI key = get_config("openai_api_key") or "" if not key: raise HTTPException(400, "OpenAI API key not configured") client = AsyncOpenAI(api_key=key) prompt = f"""You are a financial markets research expert specialized in geopolitical and macro events. Query: "{req.query}" Identify {req.n} specific real events (2000-2025) that match this query and are relevant for options/derivatives trading analysis. Prioritize events with clear market impact and dateable start points. Return ONLY valid JSON: {{ "events": [ {{ "label": "Concise event name", "date": "YYYY-MM-DD", "category": "geopolitical|macro|volatility|commodities|fx_macro|credit", "horizon_days": 60, "assets": ["TICKER1", "TICKER2", "TICKER3"], "hint": "2-3 sentence market context for a trader: what happened, why it matters, key dynamics.", "confidence": 90 }} ] }} Rules: - Use valid Yahoo Finance tickers (SPY, GLD, USO, EURUSD=X, BTC-USD, etc.) - horizon_days: typical holding period to capture the event's full market impact - confidence: 0-100, how well-documented and dateable this event is - Diversify dates and instruments across the {req.n} results""" resp = await client.chat.completions.create( model="gpt-4o", messages=[{"role": "user", "content": prompt}], response_format={"type": "json_object"}, temperature=0.3, timeout=30, ) result = json.loads(resp.choices[0].message.content) events = result.get("events", []) # Sort by confidence desc events.sort(key=lambda e: e.get("confidence", 0), reverse=True) return {"events": events, "source": "ai", "query": req.query} @router.post("/find-matching") async def find_matching_pattern(req: FindMatchingRequest): """Ask GPT-4o-mini if this lab pattern matches / counter-scenario an existing library pattern.""" from services.database import get_custom_patterns from openai import AsyncOpenAI openai_key = get_config("openai_api_key") or "" if not openai_key: raise HTTPException(400, "OpenAI API key not configured") 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 for this pattern if evaluated 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 outcome = next((o for o in outcome_list if o.get("pattern_name") == pat.get("name")), None) # Build compact library (id, name, category, underlying, direction, description, regime_tag) library = get_custom_patterns() if not library: return { "recommendation": "new_pattern", "match_id": None, "match_name": None, "confidence": 100, "reasoning": "Library is empty — save as new pattern.", "suggested_regime_tag": pat.get("category", ""), } lib_compact = [] for p in library: trades = p.get("suggested_trades") or [] underlying = trades[0].get("underlying", "") if trades else "" lib_compact.append({ "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], }) outcome_str = "not evaluated" if outcome: ht = outcome.get("hit_type") or ("FULL HIT" if outcome.get("hit") else "MISS") outcome_str = f"{ht.upper()} — actual move {outcome.get('actual_move_pct',0):+.1f}%" prompt = f"""You are a quantitative macro pattern analyst. Determine if a new backtest pattern matches an existing library pattern. ## NEW PATTERN (from Pattern Lab backtest) Name: {pat.get('name')} Category: {pat.get('category')} Description: {pat.get('description')} Underlying: {pat.get('underlying')} Strategy: {pat.get('strategy')} Signal direction: {pat.get('signal_direction')} ({pat.get('expected_direction')}) Expected move: {pat.get('expected_direction','?')}{pat.get('expected_move_pct',0)}% Backtest outcome: {outcome_str} ## EXISTING LIBRARY PATTERNS {json.dumps(lib_compact, ensure_ascii=False)} ## DECISION RULES **merge_as_instance**: Same macro REGIME + same INSTRUMENT + same DIRECTION (both bullish, or both bearish, or both volatility/neutral). The new event is just another historical proof of the same thesis. **counter_scenario**: Same macro REGIME + same INSTRUMENT + OPPOSITE DIRECTION. Both are valid — they capture different scenarios within the same regime (e.g. pandemic bullish USD vs pandemic bearish USD). **new_pattern**: Different regime, different instrument, or no meaningful structural match. CRITICAL: Never merge opposite directions as a single pattern — use counter_scenario instead. Suggest a short generic regime_tag (2-5 words) capturing the macro theme, not the specific event (e.g. "Health Crisis", "Fed Dovish Pivot", "EM Currency Stress", "Geopolitical Energy Shock"). Return ONLY valid JSON: {{ "recommendation": "merge_as_instance" | "counter_scenario" | "new_pattern", "match_id": "existing pattern id or null", "match_name": "existing 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) try: resp = await client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": prompt}], temperature=0.1, response_format={"type": "json_object"}, timeout=30, ) return json.loads(resp.choices[0].message.content or "{}") except Exception as e: raise HTTPException(500, f"Matching failed: {e}") @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") action = req.action or "new" regime_tag = req.regime_tag or pat.get("category", "") new_instance = { "date": run["analysis_date"], "event_name": req.event_name or run["theme"], "horizon": run["horizon_days"], "run_id": req.run_id, "hit": hit, "hit_type": "full" if hit else "miss", "actual_move_pct": actual_move, } conn = get_conn() # ── ACTION: merge as historical instance of an existing pattern ──────────── if action == "instance" and req.target_id: existing = conn.execute("SELECT * FROM custom_patterns WHERE id=?", (req.target_id,)).fetchone() if not existing: conn.close() raise HTTPException(404, "Target pattern not found") instances = json.loads(existing["historical_instances"] or "[]") instances.append(new_instance) 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 historical_instances=?, backtest_hits=?, backtest_runs_count=?, regime_tag=COALESCE(regime_tag, ?), updated_at=datetime('now') WHERE id=?""", (json.dumps(instances), new_hits, new_runs, regime_tag, req.target_id)) conn.commit() conn.close() return {"saved": req.target_id, "action": "merged_instance", "backtest_hits": new_hits, "backtest_runs_count": new_runs} # ── ACTION: counter-scenario (new pattern linked to existing) ───────────── # Also tag the parent with regime_tag if not already set if action == "counter" and req.target_id: conn.execute("""UPDATE custom_patterns SET regime_tag=COALESCE(NULLIF(regime_tag,''), ?) WHERE id=?""", (regime_tag, req.target_id)) # ── ACTION: new or counter — check for exact name duplicate first ───────── name_match = conn.execute( "SELECT id, backtest_hits, backtest_runs_count FROM custom_patterns WHERE name=? AND source='backtested'", (pat_name,) ).fetchone() if name_match and action == "new": new_hits = (name_match["backtest_hits"] or 0) + (1 if hit else 0) new_runs = (name_match["backtest_runs_count"] or 0) + 1 conn.execute("""UPDATE custom_patterns SET backtest_hits=?, backtest_runs_count=?, regime_tag=COALESCE(NULLIF(regime_tag,''), ?), updated_at=datetime('now') WHERE id=?""", (new_hits, new_runs, regime_tag, name_match["id"])) conn.commit() conn.close() return {"saved": name_match["id"], "action": "updated", "backtest_hits": new_hits, "backtest_runs_count": new_runs} # ── Create new pattern ──────────────────────────────────────────────────── new_id = f"P_LAB_{uuid.uuid4().hex[:6].upper()}" 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, regime_tag, counter_of, is_active, taxonomy_path, updated_at ) VALUES (?,?,?,?,?,?,?,?,?,?,?,'backtested',?,?,?,?,?,?,1,'[]',datetime('now'))""", ( new_id, pat_name, pat.get("description", ""), json.dumps([]), json.dumps([]), json.dumps([new_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, regime_tag, req.target_id if action == "counter" else None, )) conn.commit() conn.close() return {"saved": new_id, "action": action, "backtest_hits": 1 if hit else 0, "backtest_runs_count": 1}