Files
OpenFin/backend/services/pattern_lab.py
OpenSquared 5ac7b8a088 feat: 3-tier outcome scoring + options P&L simulation in Pattern Lab
Backend (pattern_lab.py):
- Replace binary HIT/MISS with FULL / PARTIAL / MISS scoring
  FULL: right direction AND ≥ 50% of expected move
  PARTIAL: right direction AND ≥ 15% of expected move (was always MISS before)
  MISS: wrong direction or negligible move
- Add direction_correct, direction_ratio, hit_type fields to all outcomes
- Add Black-Scholes ATM options P&L simulation (_bs_price, _ncdf, _sigma_for)
  Normalised to S₀=K=100, per-asset-class vol heuristic (FX 8%, indices 16%, crypto 65%)
  Supports: long call/put, straddle, strangle, call spread, put spread
- estimated_options_pnl_pct shows what the strategy would have returned

Frontend (PatternLab.tsx):
- OutcomeRow component: FULL HIT (green) / PARTIAL (amber) / MISS (red)
- Shows direction tick/cross + ratio % of target achieved
- Shows estimated options P&L with DollarSign icon
- Hit rate header shows full hits + partial count separately
- Card border: emerald = full, amber = partial, red = miss

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 18:36:33 +02:00

556 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 math import log, sqrt, exp, erf
from typing import Optional
import numpy as np
import pandas as pd
import yfinance as yf
_log = logging.getLogger(__name__)
# ── Options P&L helpers ────────────────────────────────────────────────────────
def _ncdf(x: float) -> float:
return 0.5 * (1.0 + erf(x / sqrt(2.0)))
def _bs_price(S: float, K: float, T: float, r: float, sigma: float, opt_type: str) -> float:
"""Black-Scholes European option price. T in years. At expiry (T≤0) returns intrinsic."""
if T <= 0:
return max(S - K, 0.0) if opt_type == "call" else max(K - S, 0.0)
d1 = (log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * sqrt(T))
d2 = d1 - sigma * sqrt(T)
if opt_type == "call":
return S * _ncdf(d1) - K * exp(-r * T) * _ncdf(d2)
return K * exp(-r * T) * _ncdf(-d2) - S * _ncdf(-d1)
def _sigma_for(ticker: str) -> float:
"""Rough annualised vol estimate by asset class (ATM premium sizing)."""
t = ticker.upper()
if t.endswith("=X"): return 0.08 # FX pairs
if "VIX" in t: return 0.80 # volatility index
if t.startswith("^"): return 0.16 # equity indices
if t.endswith("-USD") or "-USD" in t: return 0.65 # crypto
if t.endswith("=F"): return 0.25 # commodity futures
return 0.20 # ETFs / default
def _options_pnl_pct(
strategy: str, ticker: str,
actual_move_pct: float, horizon_days: int,
expected_direction: str,
) -> Optional[float]:
"""
Estimate % P&L on the suggested options strategy assuming ATM entry,
full-horizon hold, and actual_move_pct underlying move by expiry.
Normalised to S₀ = K = 100.
"""
try:
sigma = _sigma_for(ticker)
T = max(horizon_days / 365.0, 1 / 365.0)
r = 0.03
S0 = 100.0
K = 100.0
S1 = S0 * (1.0 + actual_move_pct / 100.0)
strat = strategy.lower()
if "straddle" in strat:
cost = _bs_price(S0, K, T, r, sigma, "call") + _bs_price(S0, K, T, r, sigma, "put")
exit_v = _bs_price(S1, K, 0, r, sigma, "call") + _bs_price(S1, K, 0, r, sigma, "put")
elif "strangle" in strat:
Kc, Kp = K * 1.05, K * 0.95
cost = _bs_price(S0, Kc, T, r, sigma, "call") + _bs_price(S0, Kp, T, r, sigma, "put")
exit_v = _bs_price(S1, Kc, 0, r, sigma, "call") + _bs_price(S1, Kp, 0, r, sigma, "put")
elif "call spread" in strat or "bull call" in strat:
Kh = K * 1.10
cost = _bs_price(S0, K, T, r, sigma, "call") - _bs_price(S0, Kh, T, r, sigma, "call")
exit_v = _bs_price(S1, K, 0, r, sigma, "call") - _bs_price(S1, Kh, 0, r, sigma, "call")
elif "put spread" in strat or "bear put" in strat:
Kl = K * 0.90
cost = _bs_price(S0, K, T, r, sigma, "put") - _bs_price(S0, Kl, T, r, sigma, "put")
exit_v = _bs_price(S1, K, 0, r, sigma, "put") - _bs_price(S1, Kl, 0, r, sigma, "put")
elif "long call" in strat or ("call" in strat and "put" not in strat):
cost = _bs_price(S0, K, T, r, sigma, "call")
exit_v = _bs_price(S1, K, 0, r, sigma, "call")
elif "long put" in strat or ("put" in strat and "call" not in strat):
cost = _bs_price(S0, K, T, r, sigma, "put")
exit_v = _bs_price(S1, K, 0, r, sigma, "put")
else:
# Unknown strategy: fallback based on direction
if expected_direction == "down":
cost = _bs_price(S0, K, T, r, sigma, "put")
exit_v = _bs_price(S1, K, 0, r, sigma, "put")
elif expected_direction == "up":
cost = _bs_price(S0, K, T, r, sigma, "call")
exit_v = _bs_price(S1, K, 0, r, sigma, "call")
else:
cost = _bs_price(S0, K, T, r, sigma, "call") + _bs_price(S0, K, T, r, sigma, "put")
exit_v = _bs_price(S1, K, 0, r, sigma, "call") + _bs_price(S1, K, 0, r, sigma, "put")
if cost <= 0.01:
return None
return round((exit_v - cost) / cost * 100.0, 1)
except Exception:
return None
def _score_outcome(
actual_move: float,
expected_move: float,
expected_dir: str,
strategy: str,
ticker: str,
horizon_days: int,
) -> dict:
"""
3-tier scoring:
FULL — right direction AND ≥ 50% of expected magnitude
PARTIAL — right direction AND ≥ 15% of expected magnitude
MISS — wrong direction or negligible move
`hit` (bool) = True only for FULL (for backtest_hits counter compatibility).
"""
em = abs(float(expected_move))
if expected_dir == "up":
direction_correct = actual_move > 0
direction_ratio = (actual_move / em) if em > 0 else 0.0
elif expected_dir == "down":
direction_correct = actual_move < 0
direction_ratio = (-actual_move / em) if em > 0 else 0.0
else: # "any" / volatility
direction_correct = True
direction_ratio = (abs(actual_move) / em) if em > 0 else 0.0
direction_ratio = round(direction_ratio, 3)
if direction_correct and direction_ratio >= 0.50:
hit_type = "full"
elif direction_correct and direction_ratio >= 0.15:
hit_type = "partial"
else:
hit_type = "miss"
options_pnl = _options_pnl_pct(strategy, ticker, actual_move, horizon_days, expected_dir)
return {
"hit": hit_type == "full",
"hit_type": hit_type,
"direction_correct": direction_correct,
"direction_ratio": direction_ratio,
"estimated_options_pnl_pct": options_pnl,
}
# ── Context builder ────────────────────────────────────────────────────────────
def _fetch_ticker(ticker: str, start: str, end: str) -> Optional[pd.DataFrame]:
try:
df = yf.download(ticker, start=start, end=end, progress=False, auto_adjust=True)
if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.get_level_values(0)
return df if not df.empty else None
except Exception as e:
_log.warning(f"[PatternLab] yfinance error for {ticker}: {e}")
return None
def _rsi(closes: pd.Series, period: int = 14) -> Optional[float]:
if len(closes) < period + 1:
return None
delta = closes.diff()
gain = delta.clip(lower=0).rolling(period).mean()
loss = (-delta.clip(upper=0)).rolling(period).mean()
rs = gain / loss
val = 100 - (100 / (1 + rs.iloc[-1]))
return round(float(val), 1) if not np.isnan(val) else None
def build_historical_context(analysis_date: str, tickers: list) -> dict:
dt = datetime.strptime(analysis_date, "%Y-%m-%d")
hist_start = (dt - timedelta(days=400)).strftime("%Y-%m-%d")
hist_end = (dt + timedelta(days=3)).strftime("%Y-%m-%d")
context: dict = {"analysis_date": analysis_date, "assets": {}}
all_tickers = list(tickers)
if "^VIX" not in all_tickers:
all_tickers.append("^VIX")
for ticker in all_tickers:
df = _fetch_ticker(ticker, hist_start, hist_end)
if df is None or "Close" not in df.columns:
context["assets"][ticker] = {"error": "no data"}
continue
df.index = pd.to_datetime(df.index).tz_localize(None)
sub = df[df.index.normalize() <= dt].copy()
if sub.empty:
context["assets"][ticker] = {"error": "no data before date"}
continue
closes = sub["Close"].dropna()
price = float(closes.iloc[-1])
def ret(days: int) -> Optional[float]:
past = sub[sub.index.normalize() <= dt - timedelta(days=days)]
if past.empty:
return None
p0 = float(past["Close"].dropna().iloc[-1])
return round((price / p0 - 1) * 100, 2) if p0 else None
ma50 = round(float(closes.tail(50).mean()), 2) if len(closes) >= 50 else None
ma200 = round(float(closes.tail(200).mean()), 2) if len(closes) >= 200 else None
context["assets"][ticker] = {
"price": round(price, 4),
"ret_1w": ret(5),
"ret_1m": ret(21),
"ret_3m": ret(63),
"ret_ytd": ret(int((dt - datetime(dt.year, 1, 1)).days)),
"rsi_14": _rsi(closes.tail(60)),
"ma50": ma50,
"ma200": ma200,
"pct_from_ma200": round((price / ma200 - 1) * 100, 2) if ma200 else None,
}
return context
# ── AI analysis ────────────────────────────────────────────────────────────────
async def run_ai_backtest(context: dict, theme_hint: str, horizon_days: int, openai_key: str) -> dict:
from openai import AsyncOpenAI
client = AsyncOpenAI(api_key=openai_key)
analysis_date = context["analysis_date"]
assets = context.get("assets", {})
lines = []
for ticker, d in assets.items():
if "error" in d:
continue
line = f" {ticker}: {d.get('price', '?')}"
if d.get("ret_1w") is not None: line += f" 1W:{d['ret_1w']:+.1f}%"
if d.get("ret_1m") is not None: line += f" 1M:{d['ret_1m']:+.1f}%"
if d.get("ret_3m") is not None: line += f" 3M:{d['ret_3m']:+.1f}%"
if d.get("rsi_14") is not None: line += f" RSI:{d['rsi_14']:.0f}"
if d.get("pct_from_ma200") is not None: line += f" vsMA200:{d['pct_from_ma200']:+.1f}%"
lines.append(line)
market_block = "\n".join(lines) if lines else "No market data available."
prompt = f"""You are a senior macro/options trader. Today is {analysis_date}.
You have full knowledge of what was happening in markets and geopolitics on this specific date.
## REAL MARKET DATA — {analysis_date}
{market_block}
## CONTEXT / THEME
{theme_hint}
## INSTRUCTIONS
Using the market data above AND your knowledge of the historical context on {analysis_date}:
- Identify 2 to 4 high-conviction trade patterns that were present at this time.
- For each pattern, recommend the best options/futures strategy.
- The investment horizon is {horizon_days} days from {analysis_date}.
For each pattern you MUST specify:
- The primary underlying ticker (must be a real, liquid, yfinance-compatible symbol)
- The expected percentage move in the UNDERLYING over {horizon_days} days
- The expected direction: "up", "down", or "any" (for volatility plays)
- A confidence score 0100
Return ONLY this valid JSON (no markdown, no explanation):
{{
"patterns": [
{{
"name": "Short descriptive pattern name",
"description": "What you observed and why this matters",
"category": "géopolitique|macro_monétaire|technique|commodités_supply|risk_off|flux_saisonnier|géo_économique|crédit_stress",
"signal_direction": "bullish|bearish|volatility|neutral",
"underlying": "TICKER",
"strategy": "e.g. long put, long call, straddle, call spread",
"expected_move_pct": 12.5,
"expected_direction": "up|down|any",
"horizon_days": {horizon_days},
"confidence": 72,
"rationale": "Concise explanation of the trade thesis at this historical moment"
}}
]
}}"""
try:
resp = await client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
temperature=0.2,
response_format={"type": "json_object"},
timeout=60,
)
raw = resp.choices[0].message.content or "{}"
return json.loads(raw)
except Exception as e:
_log.error(f"[PatternLab] AI backtest failed: {e}")
return {"patterns": [], "error": str(e)}
# ── Outcome evaluator ──────────────────────────────────────────────────────────
def evaluate_outcomes(run: dict) -> list:
analysis_date = run["analysis_date"]
horizon_days = int(run["horizon_days"])
ai_result = json.loads(run.get("ai_result") or "{}")
patterns = ai_result.get("patterns", [])
dt = datetime.strptime(analysis_date, "%Y-%m-%d")
eval_dt = dt + timedelta(days=horizon_days)
# Don't evaluate if eval date is in the future
if eval_dt.date() >= datetime.utcnow().date():
return [{"error": f"Evaluation date {eval_dt.date()} is in the future"}]
fetch_start = (dt - timedelta(days=5)).strftime("%Y-%m-%d")
fetch_end = (eval_dt + timedelta(days=5)).strftime("%Y-%m-%d")
outcomes = []
for pat in patterns:
ticker = pat.get("underlying", "")
if not ticker:
continue
df = _fetch_ticker(ticker, fetch_start, fetch_end)
if df is None or "Close" not in df.columns:
outcomes.append({"pattern_name": pat.get("name"), "underlying": ticker, "error": "no data"})
continue
df.index = pd.to_datetime(df.index).tz_localize(None)
entry_sub = df[df.index.normalize() <= dt]
exit_sub = df[df.index.normalize() <= eval_dt]
if entry_sub.empty or exit_sub.empty:
outcomes.append({"pattern_name": pat.get("name"), "underlying": ticker, "error": "insufficient history"})
continue
entry_price = float(entry_sub["Close"].dropna().iloc[-1])
exit_price = float(exit_sub["Close"].dropna().iloc[-1])
actual_move = round((exit_price / entry_price - 1) * 100, 2)
expected_dir = pat.get("expected_direction", "any")
expected_move = float(pat.get("expected_move_pct", 0))
strategy = pat.get("strategy", "")
score = _score_outcome(actual_move, expected_move, expected_dir, strategy, ticker, horizon_days)
outcomes.append({
"pattern_name": pat.get("name"),
"underlying": ticker,
"strategy": 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"),
"confidence": pat.get("confidence", 0),
**score,
})
return outcomes
# ── Instrument Scan ────────────────────────────────────────────────────────────
async def run_instrument_scan(
ticker: str, instrument_name: str,
start_date: str, end_date: str,
horizon_days: int, openai_key: str,
) -> dict:
"""
Analyse a specific instrument over a historical period.
The AI identifies 4-6 key pattern instances (each with its own entry date)
that drove significant moves in the underlying.
"""
from openai import AsyncOpenAI
client = AsyncOpenAI(api_key=openai_key)
# Fetch full period price history
fetch_start = (datetime.strptime(start_date, "%Y-%m-%d") - timedelta(days=5)).strftime("%Y-%m-%d")
fetch_end = (datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=5)).strftime("%Y-%m-%d")
df = _fetch_ticker(ticker, fetch_start, fetch_end)
monthly_lines = []
weekly_spikes = []
if df is not None and "Close" in df.columns:
df.index = pd.to_datetime(df.index).tz_localize(None)
s_dt = pd.Timestamp(start_date)
e_dt = pd.Timestamp(end_date)
sub = df[(df.index >= s_dt) & (df.index <= e_dt)].copy()
if not sub.empty:
# Monthly returns
monthly = sub["Close"].resample("ME").last().dropna()
for i in range(1, len(monthly)):
ret = (monthly.iloc[i] / monthly.iloc[i - 1] - 1) * 100
monthly_lines.append(f" {monthly.index[i].strftime('%Y-%m')}: {ret:+.1f}% (price {monthly.iloc[i]:.2f})")
# Weekly moves > 3%
weekly = sub["Close"].resample("W").last().dropna()
for i in range(1, len(weekly)):
wret = (weekly.iloc[i] / weekly.iloc[i - 1] - 1) * 100
if abs(wret) >= 3.0:
weekly_spikes.append(
f" week of {weekly.index[i].strftime('%Y-%m-%d')}: {wret:+.1f}%"
)
monthly_block = "\n".join(monthly_lines) if monthly_lines else " (no monthly data)"
spikes_block = "\n".join(weekly_spikes[:20]) if weekly_spikes else " (none > 3%)"
prompt = f"""You are a senior macro/options trader with deep historical market knowledge.
Instrument: {instrument_name} [{ticker}]
Period: {start_date}{end_date}
## ACTUAL MONTHLY PRICE RETURNS
{monthly_block}
## SIGNIFICANT WEEKLY MOVES (>3%)
{spikes_block}
## YOUR TASK
Based on the actual price data above and your historical knowledge of this period:
1. Identify 4 to 6 DISTINCT pattern/event instances that drove the most significant moves in {instrument_name}
2. Each pattern must have a specific ENTRY DATE (the trading signal date) within the period
3. Each pattern must be unique — different catalysts on different dates
4. The trade horizon per pattern is {horizon_days} days
For each pattern instance you MUST specify:
- `analysis_date`: the exact signal/entry date (YYYY-MM-DD, must be within {start_date} to {end_date})
- `underlying`: use "{ticker}" as the underlying
- `expected_direction`: "up", "down", or "any" (for volatility)
- `expected_move_pct`: realistic % move over {horizon_days} days (consistent with what actually happened)
- `confidence`: 0-100
Return ONLY this valid JSON:
{{
"instrument": "{instrument_name}",
"ticker": "{ticker}",
"period": "{start_date} to {end_date}",
"patterns": [
{{
"name": "Pattern name",
"description": "What caused this move and why it was tradeable",
"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",
"analysis_date": "YYYY-MM-DD",
"underlying": "{ticker}",
"strategy": "long call|long put|straddle|call spread|put spread|...",
"expected_direction": "up|down|any",
"expected_move_pct": 5.0,
"horizon_days": {horizon_days},
"confidence": 75,
"rationale": "Why this was a high-conviction trade at this specific 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=90,
)
raw = resp.choices[0].message.content or "{}"
return json.loads(raw)
except Exception as e:
_log.error(f"[PatternLab] Instrument scan AI failed: {e}")
return {"patterns": [], "error": str(e)}
def evaluate_instrument_outcomes(run: dict) -> list:
"""
Like evaluate_outcomes but each pattern has its own analysis_date.
Used for instrument scans where the AI generates time-distributed pattern instances.
"""
ai_result = json.loads(run.get("ai_result") or "{}")
patterns = ai_result.get("patterns", [])
default_horizon = int(run["horizon_days"])
outcomes = []
for pat in patterns:
ticker = pat.get("underlying", "")
pat_date = pat.get("analysis_date") or run["analysis_date"]
pat_horizon = int(pat.get("horizon_days") or default_horizon)
if not ticker or not pat_date:
continue
try:
dt = datetime.strptime(pat_date, "%Y-%m-%d")
eval_dt = dt + timedelta(days=pat_horizon)
except ValueError:
continue
if eval_dt.date() >= datetime.utcnow().date():
outcomes.append({"pattern_name": pat.get("name"), "underlying": ticker,
"analysis_date": pat_date, "error": "eval date in the future"})
continue
fetch_start = (dt - timedelta(days=5)).strftime("%Y-%m-%d")
fetch_end = (eval_dt + timedelta(days=5)).strftime("%Y-%m-%d")
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,
"analysis_date": pat_date, "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,
"analysis_date": pat_date, "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))
strategy = pat.get("strategy", "")
score = _score_outcome(actual_move, expected_move, expected_dir, strategy, ticker, pat_horizon)
outcomes.append({
"pattern_name": pat.get("name"),
"underlying": ticker,
"strategy": 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),
"analysis_date": pat_date,
"entry_date": pat_date,
"exit_date": eval_dt.strftime("%Y-%m-%d"),
"confidence": pat.get("confidence", 0),
**score,
})
return outcomes