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 import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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 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 logs as logs_router
|
||||||
from routers import var as var_router
|
from routers import var as var_router
|
||||||
from routers import reports as reports_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(var_router.router)
|
||||||
app.include_router(reports_router.router)
|
app.include_router(reports_router.router)
|
||||||
app.include_router(institutional_router.router)
|
app.include_router(institutional_router.router)
|
||||||
|
app.include_router(pattern_lab_router.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@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",
|
"ALTER TABLE custom_patterns ADD COLUMN signal_direction TEXT",
|
||||||
# Pattern Explorer — taxonomy tree path
|
# Pattern Explorer — taxonomy tree path
|
||||||
"ALTER TABLE custom_patterns ADD COLUMN taxonomy_path TEXT DEFAULT '[]'",
|
"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:
|
try:
|
||||||
c.execute(_sql)
|
c.execute(_sql)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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)
|
# Phase 4.2 — Régime clusters (K-Means sur gauges macro)
|
||||||
c.execute("""CREATE TABLE IF NOT EXISTS regime_clusters (
|
c.execute("""CREATE TABLE IF NOT EXISTS regime_clusters (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -885,37 +912,8 @@ def update_position_notes(pos_id: str, notes: str):
|
|||||||
# ── Custom Patterns ────────────────────────────────────────────────────────────
|
# ── Custom Patterns ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def seed_builtin_patterns(builtin_patterns: List[Dict[str, Any]]):
|
def seed_builtin_patterns(builtin_patterns: List[Dict[str, Any]]):
|
||||||
"""Seed built-in patterns into DB (idempotent). Always updates taxonomy_path."""
|
"""No-op: built-in patterns removed in favour of Pattern Lab backtested patterns."""
|
||||||
conn = get_conn()
|
pass
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
def save_custom_pattern(pattern: Dict[str, Any]) -> str:
|
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
|
||||||
@@ -10,6 +10,7 @@ import CalendarPage from './pages/CalendarPage'
|
|||||||
import Portfolio from './pages/Portfolio'
|
import Portfolio from './pages/Portfolio'
|
||||||
import PatternEditor from './pages/PatternEditor'
|
import PatternEditor from './pages/PatternEditor'
|
||||||
import PatternExplorer from './pages/PatternExplorer'
|
import PatternExplorer from './pages/PatternExplorer'
|
||||||
|
import PatternLab from './pages/PatternLab'
|
||||||
import JournalDeBord from './pages/JournalDeBord'
|
import JournalDeBord from './pages/JournalDeBord'
|
||||||
import RapportIA from './pages/RapportIA'
|
import RapportIA from './pages/RapportIA'
|
||||||
import SuperContexte from './pages/SuperContexte'
|
import SuperContexte from './pages/SuperContexte'
|
||||||
@@ -43,6 +44,7 @@ export default function App() {
|
|||||||
<Route path="/options" element={<OptionsLab />} />
|
<Route path="/options" element={<OptionsLab />} />
|
||||||
<Route path="/patterns" element={<PatternExplorer />} />
|
<Route path="/patterns" element={<PatternExplorer />} />
|
||||||
<Route path="/patterns/edit" element={<PatternEditor />} />
|
<Route path="/patterns/edit" element={<PatternEditor />} />
|
||||||
|
<Route path="/pattern-lab" element={<PatternLab />} />
|
||||||
<Route path="/portfolio" element={<Portfolio />} />
|
<Route path="/portfolio" element={<Portfolio />} />
|
||||||
<Route path="/backtest" element={<Backtest />} />
|
<Route path="/backtest" element={<Backtest />} />
|
||||||
<Route path="/calendar" element={<CalendarPage />} />
|
<Route path="/calendar" element={<CalendarPage />} />
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const nav = [
|
|||||||
{ to: '/macro', icon: Activity, label: 'Macro Regime' },
|
{ to: '/macro', icon: Activity, label: 'Macro Regime' },
|
||||||
{ to: '/options', icon: TrendingUp, label: 'Options Lab' },
|
{ to: '/options', icon: TrendingUp, label: 'Options Lab' },
|
||||||
{ to: '/patterns', icon: Zap, label: 'Patterns' },
|
{ to: '/patterns', icon: Zap, label: 'Patterns' },
|
||||||
|
{ to: '/pattern-lab', icon: FlaskConical, label: 'Pattern Lab' },
|
||||||
{ to: '/portfolio', icon: DollarSign, label: 'Portfolio' },
|
{ to: '/portfolio', icon: DollarSign, label: 'Portfolio' },
|
||||||
{ to: '/journal', icon: BookOpen, label: 'Trading Journal' },
|
{ to: '/journal', icon: BookOpen, label: 'Trading Journal' },
|
||||||
{ to: '/rapport', icon: FileBarChart, label: 'Cycle Report' },
|
{ to: '/rapport', icon: FileBarChart, label: 'Cycle Report' },
|
||||||
|
|||||||
@@ -958,3 +958,52 @@ export const useRefreshInstitutionalReports = () =>
|
|||||||
mutationFn: (report_type?: string) =>
|
mutationFn: (report_type?: string) =>
|
||||||
api.post('/institutional/refresh', null, { params: report_type ? { report_type } : {} }).then(r => r.data),
|
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'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
573
frontend/src/pages/PatternLab.tsx
Normal file
573
frontend/src/pages/PatternLab.tsx
Normal file
@@ -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<string, { label: string; color: string }> = {
|
||||||
|
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 <span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${c.color} text-white`}>{c.label}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function MoveBadge({ move, dir }: { move: number; dir: string }) {
|
||||||
|
const ok = dir === 'up' ? move > 0 : dir === 'down' ? move < 0 : Math.abs(move) >= 3
|
||||||
|
return (
|
||||||
|
<span className={clsx('text-xs font-mono font-semibold', ok ? 'text-emerald-400' : 'text-red-400')}>
|
||||||
|
{move > 0 ? '+' : ''}{move.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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<number | null>(null)
|
||||||
|
const [catFilter, setCatFilter] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// ── Active experiment
|
||||||
|
const [selected, setSelected] = useState<Preset | null>(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<any | null>(null)
|
||||||
|
const [savedIdx, setSavedIdx] = useState<Set<number>>(new Set())
|
||||||
|
const [toast, setToast] = useState<string | null>(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 (
|
||||||
|
<div className="flex h-screen bg-dark-900 text-slate-200 overflow-hidden">
|
||||||
|
{/* ── Left sidebar: preset selector ──────────────────────────────────── */}
|
||||||
|
<div className="w-72 flex-shrink-0 border-r border-slate-700/40 flex flex-col overflow-hidden">
|
||||||
|
<div className="p-3 border-b border-slate-700/40">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<FlaskConical className="w-4 h-4 text-violet-400" />
|
||||||
|
<span className="font-semibold text-sm text-slate-100">Pattern Lab</span>
|
||||||
|
<span className="ml-auto text-[10px] bg-slate-700 rounded px-1.5 py-0.5 text-slate-400">{PRESETS.length} events</span>
|
||||||
|
</div>
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="w-3.5 h-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||||
|
<input
|
||||||
|
value={search} onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Year pills */}
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{YEARS.map(y => (
|
||||||
|
<button key={y} onClick={() => setYearFilter(yearFilter === y ? null : y)}
|
||||||
|
className={clsx('text-[10px] px-1.5 py-0.5 rounded border transition-colors', {
|
||||||
|
'bg-violet-700 border-violet-600 text-white': yearFilter === y,
|
||||||
|
'border-slate-700 text-slate-500 hover:text-slate-300': yearFilter !== y,
|
||||||
|
})}>{y}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Category pills */}
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{Object.entries(CATEGORIES).map(([key, { label }]) => (
|
||||||
|
<button key={key} onClick={() => setCatFilter(catFilter === key ? null : key)}
|
||||||
|
className={clsx('text-[10px] px-1.5 py-0.5 rounded transition-colors', {
|
||||||
|
'bg-slate-600 text-white': catFilter === key,
|
||||||
|
'text-slate-500 hover:text-slate-300': catFilter !== key,
|
||||||
|
})}>{label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preset list */}
|
||||||
|
<div className="flex-1 overflow-y-auto py-1">
|
||||||
|
{filteredPresets.map(p => (
|
||||||
|
<button key={p.id} onClick={() => handleSelectPreset(p)}
|
||||||
|
className={clsx('w-full text-left px-3 py-2 border-b border-slate-700/20 hover:bg-dark-700/60 transition-colors', {
|
||||||
|
'bg-violet-900/30 border-l-2 border-l-violet-500': selected?.id === p.id,
|
||||||
|
})}>
|
||||||
|
<div className="flex items-center gap-1.5 mb-0.5">
|
||||||
|
<span className="text-[10px] text-slate-500 font-mono">{p.year}</span>
|
||||||
|
<CategoryBadge cat={p.category} />
|
||||||
|
<span className="text-[10px] text-slate-600 ml-auto">{p.horizon_days}d</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-200 leading-tight">{p.label}</div>
|
||||||
|
<div className="text-[10px] text-slate-500 mt-0.5">{p.date}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{filteredPresets.length === 0 && (
|
||||||
|
<div className="p-4 text-center text-xs text-slate-600">No events match filters</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Run history count */}
|
||||||
|
<div className="p-2 border-t border-slate-700/40 text-[10px] text-slate-600">
|
||||||
|
{runs.length} past run{runs.length !== 1 ? 's' : ''} in history
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Main content ────────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{!selected ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-center">
|
||||||
|
<div>
|
||||||
|
<FlaskConical className="w-12 h-12 text-slate-700 mx-auto mb-3" />
|
||||||
|
<p className="text-slate-500 text-sm">Select a historical event on the left</p>
|
||||||
|
<p className="text-slate-600 text-xs mt-1">The AI will analyse what it would have done at that date using real market data</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-5 space-y-5">
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<CalendarDays className="w-4 h-4 text-violet-400" />
|
||||||
|
<h1 className="text-lg font-bold text-slate-100">{selected.label}</h1>
|
||||||
|
<CategoryBadge cat={selected.category} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 leading-relaxed max-w-2xl">{selected.hint}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Setup card ── */}
|
||||||
|
<div className="bg-dark-800 border border-slate-700/40 rounded-lg p-4">
|
||||||
|
<div className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-3">Experiment Setup</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-slate-500 mb-1 block">Analysis Date</label>
|
||||||
|
<input type="date" value={editDate} onChange={e => 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" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-slate-500 mb-1 block">Horizon (days)</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="range" min={14} max={365} value={editHorizon}
|
||||||
|
onChange={e => setEditHorizon(Number(e.target.value))}
|
||||||
|
className="flex-1 accent-violet-600" />
|
||||||
|
<span className="text-xs font-mono text-violet-300 w-8 text-right">{editHorizon}d</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-slate-500 mb-1 block">Assets (comma-separated)</label>
|
||||||
|
<input value={editAssets} onChange={e => 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" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<label className="text-[10px] text-slate-500 mb-1 block">Context hint for AI</label>
|
||||||
|
<textarea value={editHint} onChange={e => setEditHint(e.target.value)} rows={2}
|
||||||
|
className="w-full px-2 py-1.5 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-300 focus:outline-none focus:border-violet-500 resize-none" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center gap-3">
|
||||||
|
<button onClick={handleRun} disabled={running || !editDate}
|
||||||
|
className="flex items-center gap-2 bg-violet-700 hover:bg-violet-600 disabled:opacity-50 text-white px-4 py-2 rounded text-xs font-semibold transition-colors">
|
||||||
|
{running
|
||||||
|
? <><RefreshCw className="w-3.5 h-3.5 animate-spin" /> Building context + running AI…</>
|
||||||
|
: <><Play className="w-3.5 h-3.5" /> Run Backtest</>}
|
||||||
|
</button>
|
||||||
|
{running && <span className="text-xs text-slate-500">Fetching {editDate} market data + GPT-4o analysis…</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Context snapshot ── */}
|
||||||
|
{activeRun?.context?.assets && (
|
||||||
|
<div className="bg-dark-800 border border-slate-700/40 rounded-lg p-4">
|
||||||
|
<div className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-3">
|
||||||
|
Market Data — {activeRun.context.analysis_date}
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-[10px] text-slate-600 uppercase tracking-wide border-b border-slate-700/40">
|
||||||
|
<th className="text-left py-1 pr-3">Ticker</th>
|
||||||
|
<th className="text-right pr-3">Price</th>
|
||||||
|
<th className="text-right pr-3">1W</th>
|
||||||
|
<th className="text-right pr-3">1M</th>
|
||||||
|
<th className="text-right pr-3">3M</th>
|
||||||
|
<th className="text-right pr-3">RSI</th>
|
||||||
|
<th className="text-right">vs MA200</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(activeRun.context.assets).map(([ticker, d]: [string, any]) => (
|
||||||
|
<tr key={ticker} className="border-b border-slate-700/20">
|
||||||
|
<td className="py-1 pr-3 font-mono text-slate-200">{ticker}</td>
|
||||||
|
<td className="text-right pr-3 font-mono">{d.price ?? '—'}</td>
|
||||||
|
<td className="text-right pr-3">
|
||||||
|
{d.ret_1w != null ? (
|
||||||
|
<span className={d.ret_1w >= 0 ? 'text-emerald-400' : 'text-red-400'}>
|
||||||
|
{d.ret_1w > 0 ? '+' : ''}{d.ret_1w.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="text-right pr-3">
|
||||||
|
{d.ret_1m != null ? (
|
||||||
|
<span className={d.ret_1m >= 0 ? 'text-emerald-400' : 'text-red-400'}>
|
||||||
|
{d.ret_1m > 0 ? '+' : ''}{d.ret_1m.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="text-right pr-3">
|
||||||
|
{d.ret_3m != null ? (
|
||||||
|
<span className={d.ret_3m >= 0 ? 'text-emerald-400' : 'text-red-400'}>
|
||||||
|
{d.ret_3m > 0 ? '+' : ''}{d.ret_3m.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="text-right pr-3">
|
||||||
|
{d.rsi_14 != null ? (
|
||||||
|
<span className={clsx({
|
||||||
|
'text-red-400': d.rsi_14 > 70,
|
||||||
|
'text-emerald-400': d.rsi_14 < 30,
|
||||||
|
'text-slate-300': d.rsi_14 >= 30 && d.rsi_14 <= 70,
|
||||||
|
})}>{d.rsi_14.toFixed(0)}</span>
|
||||||
|
) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="text-right">
|
||||||
|
{d.pct_from_ma200 != null ? (
|
||||||
|
<span className={d.pct_from_ma200 >= 0 ? 'text-emerald-400' : 'text-red-400'}>
|
||||||
|
{d.pct_from_ma200 > 0 ? '+' : ''}{d.pct_from_ma200.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
) : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── AI pattern results ── */}
|
||||||
|
{aiPatterns.length > 0 && (
|
||||||
|
<div className="bg-dark-800 border border-slate-700/40 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="text-xs font-semibold text-slate-400 uppercase tracking-wide">
|
||||||
|
AI Pattern Analysis — {aiPatterns.length} pattern{aiPatterns.length !== 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
{!hasOutcomes && (
|
||||||
|
<button onClick={handleEvaluate} disabled={evaluating}
|
||||||
|
className="flex items-center gap-1.5 bg-teal-700 hover:bg-teal-600 disabled:opacity-50 text-white px-3 py-1.5 rounded text-xs font-semibold transition-colors">
|
||||||
|
{evaluating
|
||||||
|
? <><RefreshCw className="w-3 h-3 animate-spin" /> Fetching outcomes…</>
|
||||||
|
: <><BarChart2 className="w-3 h-3" /> Evaluate at T+{editHorizon}d</>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{hasOutcomes && hitRate !== null && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-slate-500">Hit rate:</span>
|
||||||
|
<span className={clsx('text-sm font-bold', hitRate >= 0.6 ? 'text-emerald-400' : hitRate >= 0.4 ? 'text-yellow-400' : 'text-red-400')}>
|
||||||
|
{Math.round(hitRate * 100)}%
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-600">({outcomes.filter((o: any) => o.hit).length}/{outcomes.filter((o: any) => 'hit' in o).length})</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{aiPatterns.map((pat: any, idx: number) => {
|
||||||
|
const out = outcomeForPattern(pat.name)
|
||||||
|
const isSaved = savedIdx.has(idx)
|
||||||
|
const dirIcon = pat.signal_direction === 'bullish' ? TrendingUp
|
||||||
|
: pat.signal_direction === 'bearish' ? TrendingDown
|
||||||
|
: Zap
|
||||||
|
const DirIcon = dirIcon
|
||||||
|
const dirColor = pat.signal_direction === 'bullish' ? 'text-emerald-400'
|
||||||
|
: pat.signal_direction === 'bearish' ? 'text-red-400' : 'text-violet-400'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={idx} className={clsx(
|
||||||
|
'border rounded-lg p-3 transition-colors',
|
||||||
|
out?.hit === true ? 'border-emerald-700/60 bg-emerald-900/10' :
|
||||||
|
out?.hit === false ? 'border-red-700/40 bg-red-900/10' :
|
||||||
|
'border-slate-700/40 bg-dark-700/30'
|
||||||
|
)}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<DirIcon className={`w-3.5 h-3.5 ${dirColor}`} />
|
||||||
|
<span className="text-sm font-semibold text-slate-100">{pat.name}</span>
|
||||||
|
<span className="text-[10px] bg-slate-700 rounded px-1.5 text-slate-400 font-mono">{pat.underlying}</span>
|
||||||
|
{pat.category && <CategoryBadge cat={pat.category} />}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mb-2">{pat.description}</p>
|
||||||
|
<div className="flex items-center gap-4 text-[10px] text-slate-500">
|
||||||
|
<span>Strategy: <span className="text-slate-300">{pat.strategy}</span></span>
|
||||||
|
<span>Expected: <span className={clsx('font-semibold', dirColor)}>
|
||||||
|
{pat.expected_direction === 'up' ? '+' : pat.expected_direction === 'down' ? '-' : '±'}{pat.expected_move_pct}%
|
||||||
|
</span></span>
|
||||||
|
<span>Horizon: <span className="text-slate-300">{pat.horizon_days}d</span></span>
|
||||||
|
<span>Confidence: <span className={clsx('font-semibold', pat.confidence >= 70 ? 'text-emerald-400' : pat.confidence >= 50 ? 'text-yellow-400' : 'text-slate-400')}>{pat.confidence}</span></span>
|
||||||
|
</div>
|
||||||
|
{pat.rationale && (
|
||||||
|
<p className="text-[10px] text-slate-500 mt-1.5 italic">{pat.rationale}</p>
|
||||||
|
)}
|
||||||
|
{/* Outcome row */}
|
||||||
|
{out && (
|
||||||
|
<div className={clsx(
|
||||||
|
'mt-2 pt-2 border-t flex items-center gap-4 text-[10px]',
|
||||||
|
out.hit ? 'border-emerald-700/30 text-emerald-300' : 'border-red-700/30 text-red-300'
|
||||||
|
)}>
|
||||||
|
{out.hit
|
||||||
|
? <CheckCircle2 className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
: <XCircle className="w-3.5 h-3.5 text-red-400" />}
|
||||||
|
<span className="font-semibold">{out.hit ? 'HIT' : 'MISS'}</span>
|
||||||
|
<span className="text-slate-400">Actual move:</span>
|
||||||
|
<MoveBadge move={out.actual_move_pct} dir={out.expected_direction} />
|
||||||
|
<span className="text-slate-500">{out.entry_date} → {out.exit_date}</span>
|
||||||
|
{out.entry_price && (
|
||||||
|
<span className="text-slate-500">
|
||||||
|
{out.entry_price} → {out.exit_price}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save button */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{isSaved ? (
|
||||||
|
<span className="flex items-center gap-1 text-emerald-400 text-xs">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5" /> Saved
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => handleSave(idx)}
|
||||||
|
className="flex items-center gap-1.5 border border-slate-600 hover:border-violet-500 hover:text-violet-300 text-slate-400 px-2.5 py-1.5 rounded text-xs transition-colors">
|
||||||
|
<Save className="w-3 h-3" /> Save pattern
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── History panel ───────────────────────────────────────────────────── */}
|
||||||
|
{runs.length > 0 && (
|
||||||
|
<div className="w-64 flex-shrink-0 border-l border-slate-700/40 overflow-y-auto">
|
||||||
|
<div className="p-3 border-b border-slate-700/40 text-xs font-semibold text-slate-400 uppercase tracking-wide">
|
||||||
|
Run History
|
||||||
|
</div>
|
||||||
|
{runs.map((run: any) => (
|
||||||
|
<div key={run.id}
|
||||||
|
className="px-3 py-2 border-b border-slate-700/20 hover:bg-dark-700/40 cursor-pointer group"
|
||||||
|
onClick={() => {
|
||||||
|
// Rehydrate run into active state
|
||||||
|
const preset = PRESETS.find(p => p.id === run.preset_id)
|
||||||
|
if (preset) setSelected(preset)
|
||||||
|
setEditDate(run.analysis_date)
|
||||||
|
setEditHorizon(run.horizon_days)
|
||||||
|
setEditAssets((run.assets ?? []).join(', '))
|
||||||
|
setEditHint(run.theme_hint ?? '')
|
||||||
|
setActiveRun({
|
||||||
|
run_id: run.id,
|
||||||
|
context: run.context_snapshot,
|
||||||
|
ai_result: run.ai_result,
|
||||||
|
outcome: run.outcome,
|
||||||
|
})
|
||||||
|
setSavedIdx(new Set())
|
||||||
|
}}>
|
||||||
|
<div className="flex items-center gap-1.5 mb-0.5">
|
||||||
|
<span className={clsx('w-1.5 h-1.5 rounded-full flex-shrink-0', {
|
||||||
|
'bg-emerald-500': run.status === 'evaluated',
|
||||||
|
'bg-teal-500': run.status === 'done',
|
||||||
|
'bg-yellow-500': run.status === 'running',
|
||||||
|
'bg-red-500': run.status === 'error',
|
||||||
|
'bg-slate-500': run.status === 'pending',
|
||||||
|
})} />
|
||||||
|
<span className="text-[10px] text-slate-400 font-mono">{run.analysis_date}</span>
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); deleteRun(run.id) }}
|
||||||
|
className="ml-auto opacity-0 group-hover:opacity-100 text-slate-600 hover:text-red-400 transition-all">
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-300 leading-tight truncate">{run.theme}</div>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className="text-[10px] text-slate-600">{run.horizon_days}d</span>
|
||||||
|
{run.status === 'evaluated' && run.outcome && (() => {
|
||||||
|
const outs: any[] = Array.isArray(run.outcome) ? run.outcome : []
|
||||||
|
const hits = outs.filter(o => o.hit === true).length
|
||||||
|
const total = outs.filter(o => 'hit' in o).length
|
||||||
|
return total > 0 ? (
|
||||||
|
<span className={clsx('text-[10px] font-semibold', hits / total >= 0.6 ? 'text-emerald-400' : 'text-red-400')}>
|
||||||
|
{Math.round(hits / total * 100)}% hit
|
||||||
|
</span>
|
||||||
|
) : null
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Toast ── */}
|
||||||
|
{toast && (
|
||||||
|
<div className="fixed bottom-5 left-1/2 -translate-x-1/2 bg-slate-800 border border-slate-600 rounded-lg px-4 py-2 text-sm text-slate-200 shadow-xl z-50">
|
||||||
|
{toast}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user