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:
OpenSquared
2026-06-22 17:55:48 +02:00
parent a68a08d9af
commit cbf989502c
8 changed files with 1148 additions and 31 deletions

View File

@@ -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:

View 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 0100
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