feat: Pattern Lab — historical backtest engine for pattern discovery
- 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>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from routers import market_data, geopolitical, options, backtest, ai, portfolio, config, patterns, journal, cycle as cycle_router, profiles as profiles_router, reasoning as reasoning_router, knowledge as knowledge_router, options_vol as options_vol_router, analytics as analytics_router, risk as risk_router
|
||||
from routers import pattern_lab as pattern_lab_router
|
||||
from routers import logs as logs_router
|
||||
from routers import var as var_router
|
||||
from routers import reports as reports_router
|
||||
@@ -117,6 +118,7 @@ app.include_router(logs_router.router)
|
||||
app.include_router(var_router.router)
|
||||
app.include_router(reports_router.router)
|
||||
app.include_router(institutional_router.router)
|
||||
app.include_router(pattern_lab_router.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
251
backend/routers/pattern_lab.py
Normal file
251
backend/routers/pattern_lab.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
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}
|
||||
@@ -83,12 +83,39 @@ def init_db():
|
||||
"ALTER TABLE custom_patterns ADD COLUMN signal_direction TEXT",
|
||||
# Pattern Explorer — taxonomy tree path
|
||||
"ALTER TABLE custom_patterns ADD COLUMN taxonomy_path TEXT DEFAULT '[]'",
|
||||
# Pattern Lab — backtest reliability tracking
|
||||
"ALTER TABLE custom_patterns ADD COLUMN backtest_hits INTEGER DEFAULT 0",
|
||||
"ALTER TABLE custom_patterns ADD COLUMN backtest_runs_count INTEGER DEFAULT 0",
|
||||
# Remove all built-in patterns (no proof of legitimacy)
|
||||
"DELETE FROM custom_patterns WHERE source = 'builtin'",
|
||||
]:
|
||||
try:
|
||||
c.execute(_sql)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Pattern Lab — historical backtest runs
|
||||
c.execute("""CREATE TABLE IF NOT EXISTS backtest_lab_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
preset_id TEXT,
|
||||
theme TEXT NOT NULL,
|
||||
analysis_date TEXT NOT NULL,
|
||||
horizon_days INTEGER NOT NULL,
|
||||
assets TEXT NOT NULL,
|
||||
theme_hint TEXT,
|
||||
context_snapshot TEXT,
|
||||
ai_result TEXT,
|
||||
outcome TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
error_msg TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
evaluated_at TEXT
|
||||
)""")
|
||||
try:
|
||||
c.execute("CREATE INDEX IF NOT EXISTS idx_blr_date ON backtest_lab_runs(analysis_date DESC)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Phase 4.2 — Régime clusters (K-Means sur gauges macro)
|
||||
c.execute("""CREATE TABLE IF NOT EXISTS regime_clusters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -885,37 +912,8 @@ def update_position_notes(pos_id: str, notes: str):
|
||||
# ── Custom Patterns ────────────────────────────────────────────────────────────
|
||||
|
||||
def seed_builtin_patterns(builtin_patterns: List[Dict[str, Any]]):
|
||||
"""Seed built-in patterns into DB (idempotent). Always updates taxonomy_path."""
|
||||
conn = get_conn()
|
||||
existing = {r[0] for r in conn.execute("SELECT id FROM custom_patterns").fetchall()}
|
||||
for p in builtin_patterns:
|
||||
taxonomy_json = json.dumps(p.get("taxonomy_path", []))
|
||||
if p["id"] not in existing:
|
||||
conn.execute("""INSERT INTO custom_patterns (
|
||||
id, name, description, triggers, keywords, historical_instances,
|
||||
suggested_trades, asset_class, expected_move_pct, probability,
|
||||
horizon_days, source, is_active, taxonomy_path, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,'builtin',1,?,datetime('now'))""", (
|
||||
p["id"],
|
||||
p.get("name", ""),
|
||||
p.get("description", ""),
|
||||
json.dumps(p.get("triggers", [])),
|
||||
json.dumps(p.get("keywords", [])),
|
||||
json.dumps(p.get("historical_instances", [])),
|
||||
json.dumps(p.get("suggested_trades", [])),
|
||||
p.get("asset_class", "indices"),
|
||||
p.get("expected_move_pct", 0),
|
||||
p.get("probability", 0.5),
|
||||
p.get("horizon_days", 30),
|
||||
taxonomy_json,
|
||||
))
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE custom_patterns SET taxonomy_path=? WHERE id=? AND source='builtin'",
|
||||
(taxonomy_json, p["id"])
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
"""No-op: built-in patterns removed in favour of Pattern Lab backtested patterns."""
|
||||
pass
|
||||
|
||||
|
||||
def save_custom_pattern(pattern: Dict[str, Any]) -> str:
|
||||
|
||||
241
backend/services/pattern_lab.py
Normal file
241
backend/services/pattern_lab.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
Pattern Lab — Historical backtest engine for pattern discovery.
|
||||
|
||||
Workflow:
|
||||
1. build_historical_context(date, tickers) — pull prices + technicals via yfinance
|
||||
2. run_ai_backtest(context, hint, horizon) — GPT-4o acts as analyst on that past date
|
||||
3. evaluate_outcomes(run) — fetch actual prices at T+horizon, score each idea
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import yfinance as yf
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Context builder ────────────────────────────────────────────────────────────
|
||||
|
||||
def _fetch_ticker(ticker: str, start: str, end: str) -> Optional[pd.DataFrame]:
|
||||
try:
|
||||
df = yf.download(ticker, start=start, end=end, progress=False, auto_adjust=True)
|
||||
if isinstance(df.columns, pd.MultiIndex):
|
||||
df.columns = df.columns.get_level_values(0)
|
||||
return df if not df.empty else None
|
||||
except Exception as e:
|
||||
_log.warning(f"[PatternLab] yfinance error for {ticker}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _rsi(closes: pd.Series, period: int = 14) -> Optional[float]:
|
||||
if len(closes) < period + 1:
|
||||
return None
|
||||
delta = closes.diff()
|
||||
gain = delta.clip(lower=0).rolling(period).mean()
|
||||
loss = (-delta.clip(upper=0)).rolling(period).mean()
|
||||
rs = gain / loss
|
||||
val = 100 - (100 / (1 + rs.iloc[-1]))
|
||||
return round(float(val), 1) if not np.isnan(val) else None
|
||||
|
||||
|
||||
def build_historical_context(analysis_date: str, tickers: list) -> dict:
|
||||
dt = datetime.strptime(analysis_date, "%Y-%m-%d")
|
||||
hist_start = (dt - timedelta(days=400)).strftime("%Y-%m-%d")
|
||||
hist_end = (dt + timedelta(days=3)).strftime("%Y-%m-%d")
|
||||
|
||||
context: dict = {"analysis_date": analysis_date, "assets": {}}
|
||||
|
||||
all_tickers = list(tickers)
|
||||
if "^VIX" not in all_tickers:
|
||||
all_tickers.append("^VIX")
|
||||
|
||||
for ticker in all_tickers:
|
||||
df = _fetch_ticker(ticker, hist_start, hist_end)
|
||||
if df is None or "Close" not in df.columns:
|
||||
context["assets"][ticker] = {"error": "no data"}
|
||||
continue
|
||||
|
||||
df.index = pd.to_datetime(df.index).tz_localize(None)
|
||||
sub = df[df.index.normalize() <= dt].copy()
|
||||
if sub.empty:
|
||||
context["assets"][ticker] = {"error": "no data before date"}
|
||||
continue
|
||||
|
||||
closes = sub["Close"].dropna()
|
||||
price = float(closes.iloc[-1])
|
||||
|
||||
def ret(days: int) -> Optional[float]:
|
||||
past = sub[sub.index.normalize() <= dt - timedelta(days=days)]
|
||||
if past.empty:
|
||||
return None
|
||||
p0 = float(past["Close"].dropna().iloc[-1])
|
||||
return round((price / p0 - 1) * 100, 2) if p0 else None
|
||||
|
||||
ma50 = round(float(closes.tail(50).mean()), 2) if len(closes) >= 50 else None
|
||||
ma200 = round(float(closes.tail(200).mean()), 2) if len(closes) >= 200 else None
|
||||
|
||||
context["assets"][ticker] = {
|
||||
"price": round(price, 4),
|
||||
"ret_1w": ret(5),
|
||||
"ret_1m": ret(21),
|
||||
"ret_3m": ret(63),
|
||||
"ret_ytd": ret(int((dt - datetime(dt.year, 1, 1)).days)),
|
||||
"rsi_14": _rsi(closes.tail(60)),
|
||||
"ma50": ma50,
|
||||
"ma200": ma200,
|
||||
"pct_from_ma200": round((price / ma200 - 1) * 100, 2) if ma200 else None,
|
||||
}
|
||||
|
||||
return context
|
||||
|
||||
|
||||
# ── AI analysis ────────────────────────────────────────────────────────────────
|
||||
|
||||
async def run_ai_backtest(context: dict, theme_hint: str, horizon_days: int, openai_key: str) -> dict:
|
||||
from openai import AsyncOpenAI
|
||||
client = AsyncOpenAI(api_key=openai_key)
|
||||
|
||||
analysis_date = context["analysis_date"]
|
||||
assets = context.get("assets", {})
|
||||
|
||||
lines = []
|
||||
for ticker, d in assets.items():
|
||||
if "error" in d:
|
||||
continue
|
||||
line = f" {ticker}: {d.get('price', '?')}"
|
||||
if d.get("ret_1w") is not None: line += f" 1W:{d['ret_1w']:+.1f}%"
|
||||
if d.get("ret_1m") is not None: line += f" 1M:{d['ret_1m']:+.1f}%"
|
||||
if d.get("ret_3m") is not None: line += f" 3M:{d['ret_3m']:+.1f}%"
|
||||
if d.get("rsi_14") is not None: line += f" RSI:{d['rsi_14']:.0f}"
|
||||
if d.get("pct_from_ma200") is not None: line += f" vsMA200:{d['pct_from_ma200']:+.1f}%"
|
||||
lines.append(line)
|
||||
|
||||
market_block = "\n".join(lines) if lines else "No market data available."
|
||||
|
||||
prompt = f"""You are a senior macro/options trader. Today is {analysis_date}.
|
||||
You have full knowledge of what was happening in markets and geopolitics on this specific date.
|
||||
|
||||
## REAL MARKET DATA — {analysis_date}
|
||||
{market_block}
|
||||
|
||||
## CONTEXT / THEME
|
||||
{theme_hint}
|
||||
|
||||
## INSTRUCTIONS
|
||||
Using the market data above AND your knowledge of the historical context on {analysis_date}:
|
||||
- Identify 2 to 4 high-conviction trade patterns that were present at this time.
|
||||
- For each pattern, recommend the best options/futures strategy.
|
||||
- The investment horizon is {horizon_days} days from {analysis_date}.
|
||||
|
||||
For each pattern you MUST specify:
|
||||
- The primary underlying ticker (must be a real, liquid, yfinance-compatible symbol)
|
||||
- The expected percentage move in the UNDERLYING over {horizon_days} days
|
||||
- The expected direction: "up", "down", or "any" (for volatility plays)
|
||||
- A confidence score 0–100
|
||||
|
||||
Return ONLY this valid JSON (no markdown, no explanation):
|
||||
{{
|
||||
"patterns": [
|
||||
{{
|
||||
"name": "Short descriptive pattern name",
|
||||
"description": "What you observed and why this matters",
|
||||
"category": "géopolitique|macro_monétaire|technique|commodités_supply|risk_off|flux_saisonnier|géo_économique|crédit_stress",
|
||||
"signal_direction": "bullish|bearish|volatility|neutral",
|
||||
"underlying": "TICKER",
|
||||
"strategy": "e.g. long put, long call, straddle, call spread",
|
||||
"expected_move_pct": 12.5,
|
||||
"expected_direction": "up|down|any",
|
||||
"horizon_days": {horizon_days},
|
||||
"confidence": 72,
|
||||
"rationale": "Concise explanation of the trade thesis at this historical moment"
|
||||
}}
|
||||
]
|
||||
}}"""
|
||||
|
||||
try:
|
||||
resp = await client.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.2,
|
||||
response_format={"type": "json_object"},
|
||||
timeout=60,
|
||||
)
|
||||
raw = resp.choices[0].message.content or "{}"
|
||||
return json.loads(raw)
|
||||
except Exception as e:
|
||||
_log.error(f"[PatternLab] AI backtest failed: {e}")
|
||||
return {"patterns": [], "error": str(e)}
|
||||
|
||||
|
||||
# ── Outcome evaluator ──────────────────────────────────────────────────────────
|
||||
|
||||
def evaluate_outcomes(run: dict) -> list:
|
||||
analysis_date = run["analysis_date"]
|
||||
horizon_days = int(run["horizon_days"])
|
||||
ai_result = json.loads(run.get("ai_result") or "{}")
|
||||
patterns = ai_result.get("patterns", [])
|
||||
|
||||
dt = datetime.strptime(analysis_date, "%Y-%m-%d")
|
||||
eval_dt = dt + timedelta(days=horizon_days)
|
||||
# Don't evaluate if eval date is in the future
|
||||
if eval_dt.date() >= datetime.utcnow().date():
|
||||
return [{"error": f"Evaluation date {eval_dt.date()} is in the future"}]
|
||||
|
||||
fetch_start = (dt - timedelta(days=5)).strftime("%Y-%m-%d")
|
||||
fetch_end = (eval_dt + timedelta(days=5)).strftime("%Y-%m-%d")
|
||||
|
||||
outcomes = []
|
||||
for pat in patterns:
|
||||
ticker = pat.get("underlying", "")
|
||||
if not ticker:
|
||||
continue
|
||||
|
||||
df = _fetch_ticker(ticker, fetch_start, fetch_end)
|
||||
if df is None or "Close" not in df.columns:
|
||||
outcomes.append({"pattern_name": pat.get("name"), "underlying": ticker, "error": "no data"})
|
||||
continue
|
||||
|
||||
df.index = pd.to_datetime(df.index).tz_localize(None)
|
||||
|
||||
entry_sub = df[df.index.normalize() <= dt]
|
||||
exit_sub = df[df.index.normalize() <= eval_dt]
|
||||
if entry_sub.empty or exit_sub.empty:
|
||||
outcomes.append({"pattern_name": pat.get("name"), "underlying": ticker, "error": "insufficient history"})
|
||||
continue
|
||||
|
||||
entry_price = float(entry_sub["Close"].dropna().iloc[-1])
|
||||
exit_price = float(exit_sub["Close"].dropna().iloc[-1])
|
||||
actual_move = round((exit_price / entry_price - 1) * 100, 2)
|
||||
|
||||
expected_dir = pat.get("expected_direction", "any")
|
||||
expected_move = float(pat.get("expected_move_pct", 0))
|
||||
threshold = max(expected_move * 0.5, 3.0) # at least 50% of expected, min 3%
|
||||
|
||||
if expected_dir == "up":
|
||||
hit = actual_move >= threshold
|
||||
elif expected_dir == "down":
|
||||
hit = actual_move <= -threshold
|
||||
else: # any / volatility
|
||||
hit = abs(actual_move) >= threshold
|
||||
|
||||
outcomes.append({
|
||||
"pattern_name": pat.get("name"),
|
||||
"underlying": ticker,
|
||||
"strategy": pat.get("strategy", ""),
|
||||
"signal_direction": pat.get("signal_direction", ""),
|
||||
"expected_direction": expected_dir,
|
||||
"expected_move_pct": expected_move,
|
||||
"actual_move_pct": actual_move,
|
||||
"entry_price": round(entry_price, 4),
|
||||
"exit_price": round(exit_price, 4),
|
||||
"entry_date": analysis_date,
|
||||
"exit_date": eval_dt.strftime("%Y-%m-%d"),
|
||||
"hit": hit,
|
||||
"confidence": pat.get("confidence", 0),
|
||||
})
|
||||
|
||||
return outcomes
|
||||
Reference in New Issue
Block a user