diff --git a/backend/main.py b/backend/main.py index 9465a8a..5629268 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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("/") diff --git a/backend/routers/pattern_lab.py b/backend/routers/pattern_lab.py new file mode 100644 index 0000000..4ffa028 --- /dev/null +++ b/backend/routers/pattern_lab.py @@ -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} diff --git a/backend/services/database.py b/backend/services/database.py index 752e810..ee4f1ee 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -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: diff --git a/backend/services/pattern_lab.py b/backend/services/pattern_lab.py new file mode 100644 index 0000000..f3fc48d --- /dev/null +++ b/backend/services/pattern_lab.py @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fba672c..1e84d24 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import CalendarPage from './pages/CalendarPage' import Portfolio from './pages/Portfolio' import PatternEditor from './pages/PatternEditor' import PatternExplorer from './pages/PatternExplorer' +import PatternLab from './pages/PatternLab' import JournalDeBord from './pages/JournalDeBord' import RapportIA from './pages/RapportIA' import SuperContexte from './pages/SuperContexte' @@ -43,6 +44,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 6a93692..68acd3b 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -13,6 +13,7 @@ const nav = [ { to: '/macro', icon: Activity, label: 'Macro Regime' }, { to: '/options', icon: TrendingUp, label: 'Options Lab' }, { to: '/patterns', icon: Zap, label: 'Patterns' }, + { to: '/pattern-lab', icon: FlaskConical, label: 'Pattern Lab' }, { to: '/portfolio', icon: DollarSign, label: 'Portfolio' }, { to: '/journal', icon: BookOpen, label: 'Trading Journal' }, { to: '/rapport', icon: FileBarChart, label: 'Cycle Report' }, diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 3716f1f..7e00e78 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -958,3 +958,52 @@ export const useRefreshInstitutionalReports = () => mutationFn: (report_type?: string) => api.post('/institutional/refresh', null, { params: report_type ? { report_type } : {} }).then(r => r.data), }) + +// ── Pattern Lab ─────────────────────────────────────────────────────────────── +export const usePatternLabRuns = () => + useQuery({ + queryKey: ['pattern-lab-runs'], + queryFn: () => api.get('/pattern-lab/runs').then(r => r.data), + staleTime: 30_000, + }) + +export const useRunPatternLab = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: { + preset_id?: string; theme: string; analysis_date: string; + horizon_days: number; assets: string[]; theme_hint: string; + }) => api.post('/pattern-lab/run', body).then(r => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['pattern-lab-runs'] }), + }) +} + +export const useEvaluatePatternLab = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (run_id: string) => api.post(`/pattern-lab/evaluate/${run_id}`, {}).then(r => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['pattern-lab-runs'] }), + }) +} + +export const useSaveLabPattern = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: { + run_id: string; pattern_index: number; name?: string; + category?: string; signal_direction?: string; asset_class?: string; + }) => api.post('/pattern-lab/save-pattern', body).then(r => r.data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['all-patterns'] }) + qc.invalidateQueries({ queryKey: ['pattern-lab-runs'] }) + }, + }) +} + +export const useDeleteLabRun = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (run_id: string) => api.delete(`/pattern-lab/runs/${run_id}`).then(r => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['pattern-lab-runs'] }), + }) +} diff --git a/frontend/src/pages/PatternLab.tsx b/frontend/src/pages/PatternLab.tsx new file mode 100644 index 0000000..316efc6 --- /dev/null +++ b/frontend/src/pages/PatternLab.tsx @@ -0,0 +1,573 @@ +import { useState, useMemo } from 'react' +import { + useRunPatternLab, useEvaluatePatternLab, useSaveLabPattern, + usePatternLabRuns, useDeleteLabRun, +} from '../hooks/useApi' +import { + FlaskConical, Play, ChevronRight, CheckCircle2, XCircle, + Clock, Trash2, Save, RefreshCw, Search, CalendarDays, + TrendingUp, TrendingDown, Zap, BarChart2, AlertTriangle, +} from 'lucide-react' +import clsx from 'clsx' + +// ── Preset catalogue (2015-2025, 34 events) ──────────────────────────────────── + +interface Preset { + id: string; year: number; label: string; date: string + category: string; horizon_days: number; assets: string[]; hint: string +} + +const PRESETS: Preset[] = [ + { id: 'china-deval-2015', year: 2015, label: 'China CNY Devaluation', date: '2015-08-11', category: 'fx_macro', horizon_days: 60, assets: ['SPY','FXI','GLD','UUP','EEM'], hint: 'China surprises markets with a yuan devaluation shock. Global risk-off, EM currencies crash, commodities sell. Shanghai composite already down 30% from peak.' }, + { id: 'oil-crash-2016', year: 2016, label: 'Oil Crash to $26', date: '2016-01-20', category: 'commodities', horizon_days: 90, assets: ['USO','XLE','HYG','SPY','GLD'], hint: 'WTI crude at 12-year lows near $26. Energy sector destruction, high-yield credit stress from E&P distress. Fed on hold as global deflation spreads.' }, + { id: 'brexit-2016', year: 2016, label: 'Brexit Vote Shock', date: '2016-06-24', category: 'geopolitical', horizon_days: 60, assets: ['GBPUSD=X','EWU','SPY','GLD','TLT'], hint: 'UK votes Leave 52-48. GBP flash crash -10% overnight. European bank stocks collapse. Flight to safety — gilts, gold, USD. BoE emergency measures expected.' }, + { id: 'trump-elect-2016', year: 2016, label: 'Trump Election 2016', date: '2016-11-09', category: 'macro', horizon_days: 90, assets: ['SPY','TLT','GLD','UUP','XLF'], hint: 'Trump wins US presidential election. Reflation trade ignites: banks surge, bonds sell, USD rallies sharply, gold dumps, infrastructure/defense stocks soar.' }, + { id: 'volmageddon-2018', year: 2018, label: 'Volmageddon — XIV Collapse', date: '2018-02-05', category: 'volatility', horizon_days: 30, assets: ['SPY','TLT','GLD','QQQ'], hint: 'VIX spikes 115% intraday. Inverse-vol ETPs (XIV, SVXY) collapse -90%. Market liquidity evaporates. Fed hiking into froth. Flash crash with rapid recovery.' }, + { id: 'trade-war-2018', year: 2018, label: 'US-China Trade War Start', date: '2018-03-22', category: 'geopolitical', horizon_days: 90, assets: ['SPY','FXI','QQQ','UUP','EEM'], hint: 'Trump announces $60B tariffs on China under Section 301. Trade war declared. Tech and industrials sell. China threatens retaliation on US agriculture and autos.' }, + { id: 'turkey-em-2018', year: 2018, label: 'Turkey / EM Currency Crisis', date: '2018-08-10', category: 'fx_macro', horizon_days: 60, assets: ['TUR','EEM','UUP','GLD','EWZ'], hint: 'TRY crashes 20% in a day. Turkey in full currency crisis — Erdogan conflict with Fed. EM contagion to South Africa, Argentina, Brazil. USD wrecking ball.' }, + { id: 'q4-selloff-2018', year: 2018, label: 'Q4 2018 Fed Overtightening', date: '2018-10-10', category: 'macro', horizon_days: 90, assets: ['SPY','QQQ','HYG','TLT','GLD'], hint: "Powell 'long way from neutral' comment. Growth scare, credit spread widening. SPX enters 20% drawdown into Christmas. Fed eventually pivots Jan 2019." }, + { id: 'fed-pivot-2019', year: 2019, label: 'Fed Dovish Pivot', date: '2019-01-04', category: 'macro', horizon_days: 90, assets: ['SPY','TLT','GLD','EEM','UUP'], hint: 'Powell signals patience — hiking cycle pause. Risk assets explode higher. USD weakens. EM and gold rally. Goldilocks scenario emerges after Q4 2018 crash.' }, + { id: 'yield-inversion-2019', year: 2019, label: 'Yield Curve Inversion', date: '2019-03-22', category: 'macro', horizon_days: 120, assets: ['TLT','XLF','SPY','GLD','IEF'], hint: '3M-10Y US Treasury yield curve inverts for first time since 2007. Recession indicator flashing. Banks sell. Gold and long bonds rally as growth fears build.' }, + { id: 'aramco-attack-2019', year: 2019, label: 'Saudi Aramco Facility Attack', date: '2019-09-16', category: 'geopolitical', horizon_days: 30, assets: ['USO','GLD','SPY','XLE','UUP'], hint: 'Drone attacks on Abqaiq and Khurais take out 5% of global oil supply. WTI gaps +15% at open. Geopolitical risk premium spikes. Then fades fast.' }, + { id: 'repo-stress-2019', year: 2019, label: 'Repo Market Liquidity Stress', date: '2019-09-17', category: 'credit', horizon_days: 30, assets: ['TLT','SPY','GLD','UUP'], hint: 'Overnight repo rates spike to 10%, overwhelming the Fed funds target. Fed emergency repo operations. Banking system dollar liquidity stress. Precursor to QE4.' }, + { id: 'covid-crash-2020', year: 2020, label: 'COVID Pandemic Crash', date: '2020-02-24', category: 'macro', horizon_days: 90, assets: ['SPY','QQQ','GLD','TLT','USO'], hint: 'COVID spreads globally. Italy in lockdown. Markets price pandemic. SPX enters fastest bear market in history — 35% in 33 days. VIX touches 85. Fed emergency cut.' }, + { id: 'oil-negative-2020', year: 2020, label: 'Oil Goes Negative (-$37)', date: '2020-04-20', category: 'commodities', horizon_days: 60, assets: ['USO','XLE','SPY','UUP','HYG'], hint: 'WTI May futures settle at -$37.63. Physical storage full. Zero demand from lockdowns. Energy sector existential risk. OPEC+ emergency cut already announced.' }, + { id: 'covid-recovery-2020', year: 2020, label: 'COVID Recovery / QE Infinity', date: '2020-04-09', category: 'macro', horizon_days: 90, assets: ['SPY','QQQ','GLD','TLT','EEM'], hint: 'Fed announces unlimited QE + corporate bond buying. $2.2T CARES Act. Markets stage massive recovery. Tech leads. Gold rallies to ATH. Mega-cap dominance begins.' }, + { id: 'us-election-2020', year: 2020, label: 'Biden Election / Vaccine Rally', date: '2020-11-04', category: 'macro', horizon_days: 90, assets: ['SPY','QQQ','XLE','GLD','IWM'], hint: 'Biden wins, split Congress. Pfizer vaccine +90% efficacy announced same week. Massive rotation: value vs growth, energy vs tech. Small caps surge. Gold drops.' }, + { id: 'gme-squeeze-2021', year: 2021, label: 'GME Short Squeeze / WSB', date: '2021-01-27', category: 'credit', horizon_days: 30, assets: ['SPY','QQQ','IWM','HYG','TLT'], hint: 'WallStreetBets coordinates GME squeeze to $480. Robinhood halts buying. Hedge fund forced deleveraging. Systemic risk fear. Silver squeeze attempt follows.' }, + { id: 'reflation-2021', year: 2021, label: 'Reflation Trade / Bond Crash', date: '2021-02-25', category: 'macro', horizon_days: 60, assets: ['TLT','QQQ','XLE','XLF','GLD'], hint: '10Y yields spike to 1.6%. Bond massacre. Tech rotation as rates hurt valuations. Value/cyclicals surge. Energy, banks, materials outperform. Inflation trade.' }, + { id: 'taper-2021', year: 2021, label: 'Fed Tapering Announcement', date: '2021-11-03', category: 'macro', horizon_days: 90, assets: ['TLT','GLD','EEM','UUP','SPY'], hint: 'Fed officially announces QE tapering. Hawkish pivot accelerates. EM selloff. USD strengthens. Gold initially weak. Growth stocks start multi-month decline.' }, + { id: 'russia-ukraine-2022', year: 2022, label: 'Russia Invades Ukraine', date: '2022-02-24', category: 'geopolitical', horizon_days: 90, assets: ['USO','GLD','WEAT','SPY','EWG'], hint: 'Full-scale Russian invasion. Energy price shock — European gas 10x. Wheat/corn spike +30%. European recession fear. Germany energy vulnerability. NATO unity.' }, + { id: 'fed-rate-shock-2022', year: 2022, label: 'Fed Rate Shock — CPI 9.1%', date: '2022-06-13', category: 'macro', horizon_days: 90, assets: ['TLT','SPY','QQQ','GLD','UUP'], hint: 'CPI prints 9.1%. Fed goes 75bps (first time since 1994). Rate shock across all assets. Everything sold — bonds, stocks, gold, crypto. DXY to 114. Bear market.' }, + { id: 'energy-crisis-eu-2022', year: 2022, label: 'European Energy Crisis', date: '2022-08-26', category: 'commodities', horizon_days: 90, assets: ['EURUSD=X','EWG','SPY','GLD','TLT'], hint: 'EU nat gas prices at 10x historical average. EUR/USD breaks parity. Germany industry threatens shutdown. ECB caught between inflation and recession.' }, + { id: 'gilt-crisis-2022', year: 2022, label: 'UK Gilt Crisis / LDI Implosion', date: '2022-09-23', category: 'credit', horizon_days: 30, assets: ['GBPUSD=X','EWU','TLT','GLD','SPY'], hint: 'Truss mini-budget — unfunded £45B tax cuts. GBP crashes to 1.035. 30Y gilt yields +100bps in hours. LDI pension funds margin calls. BoE emergency QE.' }, + { id: 'ftx-2022', year: 2022, label: 'FTX Crypto Exchange Collapse', date: '2022-11-08', category: 'credit', horizon_days: 60, assets: ['BTC-USD','ETH-USD','SPY','GLD','QQQ'], hint: 'FTX halts withdrawals. $8B hole. Alameda Research insolvent. Crypto contagion: Genesis, BlockFi. Bitcoin -75% ytd. Regulatory crackdown imminent. Crypto winter.' }, + { id: 'china-zero-covid-2022', year: 2022, label: 'China Zero-COVID Exit', date: '2022-12-07', category: 'macro', horizon_days: 90, assets: ['FXI','EEM','USO','GLD','EWJ'], hint: 'China abruptly abandons zero-COVID after Urumqi protests. Reopening trade ignites. Commodities demand surge expected. But wave of infections causes volatility.' }, + { id: 'boj-yyc-2023', year: 2023, label: 'BoJ YCC Band Widening', date: '2023-01-18', category: 'fx_macro', horizon_days: 90, assets: ['USDJPY=X','EWJ','TLT','GLD','SPY'], hint: 'BoJ widens YCC band to ±50bps surprise. JPY surges 3%. JGB yields spike. Global carry trade unwind risk. BoJ forced into bond buying. Yields globally affected.' }, + { id: 'svb-2023', year: 2023, label: 'SVB Banking Crisis', date: '2023-03-10', category: 'credit', horizon_days: 30, assets: ['XLF','TLT','GLD','SPY','UUP'], hint: 'Silicon Valley Bank fails — largest US bank failure since 2008. Signature Bank closes. Contagion to Credit Suisse. Fed BTFP emergency facility. Deposit flight fear.' }, + { id: 'opec-cut-2023', year: 2023, label: 'OPEC+ Surprise Production Cut', date: '2023-04-02', category: 'commodities', horizon_days: 60, assets: ['USO','XLE','SPY','GLD','UUP'], hint: 'OPEC+ shocks with 1.66Mb/d voluntary cuts beyond existing deal. WTI +6% in a day. Energy stocks surge. Inflation re-acceleration fear. Saudi strategy shift.' }, + { id: 'debt-ceiling-2023', year: 2023, label: 'US Debt Ceiling X-Date', date: '2023-05-24', category: 'macro', horizon_days: 30, assets: ['TLT','SPY','GLD','UUP','BIL'], hint: 'Treasury X-date imminent. T-bill rates spike on short-end. Dollar stress. CDS on US sovereign widen. Last-minute Bipartisan deal expected but tail risk real.' }, + { id: 'ai-mania-2023', year: 2023, label: 'AI Mania — Nvidia Surge', date: '2023-05-25', category: 'tech', horizon_days: 90, assets: ['QQQ','SPY','SMH','TLT','GLD'], hint: 'Nvidia Q1 blowout — revenue guidance +50%. AI bubble narrative goes mainstream. Mag7 concentration soars. Breadth collapses. Tech leads while rest of market flat.' }, + { id: 'iran-israel-2024', year: 2024, label: 'Iran Direct Attack on Israel', date: '2024-04-14', category: 'geopolitical', horizon_days: 30, assets: ['GLD','USO','SPY','TLT','UUP'], hint: 'Iran launches 300+ drones/missiles at Israel in first-ever direct attack. Israel intercepts ~99%. Gold spikes to ATH. Oil +3%. Then rapid de-escalation. Premium collapses.' }, + { id: 'carry-unwind-2024', year: 2024, label: 'JPY Carry Trade Unwind', date: '2024-08-05', category: 'fx_macro', horizon_days: 30, assets: ['USDJPY=X','EWJ','SPY','GLD','TLT'], hint: 'BoJ surprise rate hike triggers unwinding of $4T yen carry trade. Nikkei -12% in one session. VIX spikes to 65. Global deleveraging. Rapid recovery within 2 weeks.' }, + { id: 'china-stimulus-2024', year: 2024, label: 'China Stimulus Bazooka', date: '2024-09-24', category: 'macro', horizon_days: 60, assets: ['FXI','EEM','KWEB','USO','GLD'], hint: 'PBOC surprises with rate cuts + reserve ratio cut + property support. Chinese equities +25% in 2 weeks. Commodity demand surge expected. Copper, iron ore, oil up.' }, + { id: 'trump-elect-2024', year: 2024, label: 'Trump Election 2024 / Red Sweep', date: '2024-11-06', category: 'macro', horizon_days: 90, assets: ['SPY','QQQ','TLT','UUP','BTC-USD'], hint: 'Trump wins presidency + Republican Congress. Reflation 2.0: tax cuts, deregulation, tariffs. USD surges. Crypto explodes. Europe and EM underperform. Bonds sell.' }, + { id: 'trump-tariff-2025', year: 2025, label: 'Trump Tariff Liberation Day', date: '2025-04-02', category: 'geopolitical', horizon_days: 90, assets: ['SPY','QQQ','GLD','UUP','EEM'], hint: 'Trump announces 145% tariffs on China + 10% universal tariff. Largest trade war since 1930. Recession fear. SPX -15% in 3 days. Gold ATH $3200. Treasury yields volatile.' }, +] + +const CATEGORIES: Record = { + macro: { label: 'Macro', color: 'bg-blue-600' }, + geopolitical:{ label: 'Geopolitical',color: 'bg-red-700' }, + commodities: { label: 'Commodities', color: 'bg-amber-600' }, + fx_macro: { label: 'FX/Macro', color: 'bg-violet-600' }, + credit: { label: 'Credit', color: 'bg-orange-600' }, + volatility: { label: 'Volatility', color: 'bg-cyan-600' }, + tech: { label: 'Tech', color: 'bg-emerald-600' }, +} + +const YEARS = [...new Set(PRESETS.map(p => p.year))].sort() + +// ── Small components ─────────────────────────────────────────────────────────── + +function CategoryBadge({ cat }: { cat: string }) { + const c = CATEGORIES[cat] ?? { label: cat, color: 'bg-slate-600' } + return {c.label} +} + +function MoveBadge({ move, dir }: { move: number; dir: string }) { + const ok = dir === 'up' ? move > 0 : dir === 'down' ? move < 0 : Math.abs(move) >= 3 + return ( + + {move > 0 ? '+' : ''}{move.toFixed(1)}% + + ) +} + +// ── Main page ────────────────────────────────────────────────────────────────── + +export default function PatternLab() { + const { data: runsData, isLoading: runsLoading } = usePatternLabRuns() + const { mutateAsync: runBacktest, isPending: running } = useRunPatternLab() + const { mutateAsync: evaluateRun, isPending: evaluating } = useEvaluatePatternLab() + const { mutateAsync: savePattern } = useSaveLabPattern() + const { mutate: deleteRun } = useDeleteLabRun() + + const runs: any[] = runsData ?? [] + + // ── Filters + const [search, setSearch] = useState('') + const [yearFilter, setYearFilter] = useState(null) + const [catFilter, setCatFilter] = useState(null) + + // ── Active experiment + const [selected, setSelected] = useState(null) + const [editDate, setEditDate] = useState('') + const [editHorizon, setEditHorizon] = useState(90) + const [editAssets, setEditAssets] = useState('') + const [editHint, setEditHint] = useState('') + + // ── Current run state + const [activeRun, setActiveRun] = useState(null) + const [savedIdx, setSavedIdx] = useState>(new Set()) + const [toast, setToast] = useState(null) + + const filteredPresets = useMemo(() => PRESETS.filter(p => { + if (yearFilter && p.year !== yearFilter) return false + if (catFilter && p.category !== catFilter) return false + if (search && !p.label.toLowerCase().includes(search.toLowerCase())) return false + return true + }), [search, yearFilter, catFilter]) + + const showToast = (msg: string) => { + setToast(msg) + setTimeout(() => setToast(null), 3000) + } + + const handleSelectPreset = (p: Preset) => { + setSelected(p) + setEditDate(p.date) + setEditHorizon(p.horizon_days) + setEditAssets(p.assets.join(', ')) + setEditHint(p.hint) + setActiveRun(null) + setSavedIdx(new Set()) + } + + const handleRun = async () => { + if (!selected && !editDate) return + const assets = editAssets.split(',').map(s => s.trim()).filter(Boolean) + try { + const result = await runBacktest({ + preset_id: selected?.id, + theme: selected?.label ?? 'Custom', + analysis_date: editDate, + horizon_days: editHorizon, + assets, + theme_hint: editHint, + }) + setActiveRun(result) + setSavedIdx(new Set()) + } catch (e: any) { + showToast(`Error: ${e?.response?.data?.detail ?? e?.message ?? 'unknown'}`) + } + } + + const handleEvaluate = async () => { + if (!activeRun?.run_id) return + try { + const result = await evaluateRun(activeRun.run_id) + setActiveRun((prev: any) => ({ ...prev, outcome: result.outcomes })) + } catch (e: any) { + showToast(`Error: ${e?.response?.data?.detail ?? e?.message ?? 'unknown'}`) + } + } + + const handleSave = async (idx: number) => { + if (!activeRun?.run_id) return + const pat = activeRun.ai_result?.patterns?.[idx] + if (!pat) return + try { + await savePattern({ + run_id: activeRun.run_id, + pattern_index: idx, + name: pat.name, + category: pat.category, + signal_direction: pat.signal_direction, + }) + setSavedIdx(prev => new Set([...prev, idx])) + showToast(`"${pat.name}" saved to Pattern Library`) + } catch (e: any) { + showToast(`Save failed: ${e?.response?.data?.detail ?? e?.message}`) + } + } + + const aiPatterns: any[] = activeRun?.ai_result?.patterns ?? [] + const outcomes: any[] = activeRun?.outcome ?? [] + const hasOutcomes = outcomes.length > 0 + + const outcomeForPattern = (name: string) => + outcomes.find((o: any) => o.pattern_name === name) + + const hitRate = hasOutcomes + ? outcomes.filter((o: any) => o.hit === true).length / outcomes.filter((o: any) => 'hit' in o).length + : null + + return ( +
+ {/* ── Left sidebar: preset selector ──────────────────────────────────── */} +
+
+
+ + Pattern Lab + {PRESETS.length} events +
+ {/* Search */} +
+ + setSearch(e.target.value)} + placeholder="Search events..." + className="w-full pl-7 pr-2 py-1.5 text-xs bg-dark-700 border border-slate-700/40 rounded text-slate-200 placeholder-slate-500 focus:outline-none focus:border-violet-600" + /> +
+ {/* Year pills */} +
+ {YEARS.map(y => ( + + ))} +
+ {/* Category pills */} +
+ {Object.entries(CATEGORIES).map(([key, { label }]) => ( + + ))} +
+
+ + {/* Preset list */} +
+ {filteredPresets.map(p => ( + + ))} + {filteredPresets.length === 0 && ( +
No events match filters
+ )} +
+ + {/* Run history count */} +
+ {runs.length} past run{runs.length !== 1 ? 's' : ''} in history +
+
+ + {/* ── Main content ────────────────────────────────────────────────────── */} +
+ {!selected ? ( +
+
+ +

Select a historical event on the left

+

The AI will analyse what it would have done at that date using real market data

+
+
+ ) : ( +
+ {/* ── Header ── */} +
+
+
+ +

{selected.label}

+ +
+

{selected.hint}

+
+
+ + {/* ── Setup card ── */} +
+
Experiment Setup
+
+
+ + setEditDate(e.target.value)} + className="w-full px-2 py-1.5 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-200 focus:outline-none focus:border-violet-500" /> +
+
+ +
+ setEditHorizon(Number(e.target.value))} + className="flex-1 accent-violet-600" /> + {editHorizon}d +
+
+
+ + setEditAssets(e.target.value)} + className="w-full px-2 py-1.5 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-200 focus:outline-none focus:border-violet-500 font-mono" /> +
+
+
+ +