diff --git a/backend/services/pattern_lab.py b/backend/services/pattern_lab.py index 6b15c77..8acd02c 100644 --- a/backend/services/pattern_lab.py +++ b/backend/services/pattern_lab.py @@ -9,6 +9,7 @@ Workflow: import json import logging from datetime import datetime, timedelta +from math import log, sqrt, exp, erf from typing import Optional import numpy as np @@ -18,6 +19,140 @@ 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]: @@ -207,25 +342,20 @@ def evaluate_outcomes(run: dict) -> list: 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) + 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% + strategy = pat.get("strategy", "") - if expected_dir == "up": - hit = actual_move >= threshold - elif expected_dir == "down": - hit = actual_move <= -threshold - else: # any / volatility - hit = abs(actual_move) >= threshold + score = _score_outcome(actual_move, expected_move, expected_dir, strategy, ticker, horizon_days) outcomes.append({ "pattern_name": pat.get("name"), "underlying": ticker, - "strategy": pat.get("strategy", ""), + "strategy": strategy, "signal_direction": pat.get("signal_direction", ""), "expected_direction": expected_dir, "expected_move_pct": expected_move, @@ -234,8 +364,8 @@ def evaluate_outcomes(run: dict) -> list: "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), + **score, }) return outcomes @@ -395,25 +525,20 @@ def evaluate_instrument_outcomes(run: dict) -> list: "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) + 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) + strategy = pat.get("strategy", "") - if expected_dir == "up": - hit = actual_move >= threshold - elif expected_dir == "down": - hit = actual_move <= -threshold - else: - hit = abs(actual_move) >= threshold + score = _score_outcome(actual_move, expected_move, expected_dir, strategy, ticker, pat_horizon) outcomes.append({ "pattern_name": pat.get("name"), "underlying": ticker, - "strategy": pat.get("strategy", ""), + "strategy": strategy, "signal_direction": pat.get("signal_direction", ""), "expected_direction": expected_dir, "expected_move_pct": expected_move, @@ -423,8 +548,8 @@ def evaluate_instrument_outcomes(run: dict) -> list: "analysis_date": pat_date, "entry_date": pat_date, "exit_date": eval_dt.strftime("%Y-%m-%d"), - "hit": hit, "confidence": pat.get("confidence", 0), + **score, }) return outcomes diff --git a/frontend/src/pages/PatternLab.tsx b/frontend/src/pages/PatternLab.tsx index ad225e2..445c609 100644 --- a/frontend/src/pages/PatternLab.tsx +++ b/frontend/src/pages/PatternLab.tsx @@ -5,9 +5,9 @@ import { useInstrumentScan, useEvaluateInstrumentScan, } from '../hooks/useApi' import { - FlaskConical, Play, CheckCircle2, XCircle, + FlaskConical, Play, CheckCircle2, XCircle, MinusCircle, Trash2, Save, RefreshCw, Search, CalendarDays, - TrendingUp, TrendingDown, Zap, BarChart2, ScanLine, + TrendingUp, TrendingDown, Zap, BarChart2, ScanLine, DollarSign, } from 'lucide-react' import clsx from 'clsx' import { INSTRUMENTS, INSTRUMENT_CATEGORIES } from '../constants/instruments' @@ -85,6 +85,62 @@ function MoveBadge({ move, dir }: { move: number; dir: string }) { ) } +function OutcomeRow({ out }: { out: any }) { + const hitType = out.hit_type ?? (out.hit ? 'full' : 'miss') + const borderCls = hitType === 'full' ? 'border-emerald-700/30' + : hitType === 'partial' ? 'border-amber-700/30' : 'border-red-700/30' + return ( +
+ {/* Hit type */} + {hitType === 'full' && ( + + FULL HIT + + )} + {hitType === 'partial' && ( + + PARTIAL + + )} + {hitType === 'miss' && ( + + MISS + + )} + + {/* Direction */} + + Dir: + {out.direction_correct ? '✓' : '✗'} + + {out.direction_correct && out.direction_ratio != null && ( + ({Math.round(out.direction_ratio * 100)}% of target) + )} + + + {/* Actual move */} + Actual: + + + vs {out.expected_direction === 'up' ? '+' : out.expected_direction === 'down' ? '-' : '±'}{out.expected_move_pct}% + + + {/* Options P&L estimate */} + {out.estimated_options_pnl_pct != null && ( + = 0 ? 'text-emerald-400' : 'text-red-400' + )}> + + {out.estimated_options_pnl_pct > 0 ? '+' : ''}{out.estimated_options_pnl_pct.toFixed(0)}% P&L + + )} + + {out.entry_date} → {out.exit_date} +
+ ) +} + // ── Main page ────────────────────────────────────────────────────────────────── // ── Shared instrument picker ─────────────────────────────────────────────────── @@ -310,18 +366,20 @@ export default function PatternLab() { 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 + const scoredOutcomes = outcomes.filter((o: any) => 'hit_type' in o || 'hit' in o) + const fullHits = outcomes.filter((o: any) => (o.hit_type ?? (o.hit ? 'full' : 'miss')) === 'full').length + const partialHits = outcomes.filter((o: any) => (o.hit_type ?? '') === 'partial').length + const hitRate = scoredOutcomes.length > 0 ? fullHits / scoredOutcomes.length : null // ── Instrument mode helpers const instPatterns: any[] = instRun?.ai_result?.patterns ?? [] const instOutcomes: any[] = instRun?.outcome ?? [] const instHasOut = instOutcomes.length > 0 const instOutcomeFor = (name: string) => instOutcomes.find((o: any) => o.pattern_name === name) - const instHitRate = instHasOut - ? instOutcomes.filter((o: any) => o.hit === true).length / instOutcomes.filter((o: any) => 'hit' in o).length - : null + const instScored = instOutcomes.filter((o: any) => 'hit_type' in o || 'hit' in o) + const instFull = instOutcomes.filter((o: any) => (o.hit_type ?? (o.hit ? 'full' : 'miss')) === 'full').length + const instPartial = instOutcomes.filter((o: any) => (o.hit_type ?? '') === 'partial').length + const instHitRate = instScored.length > 0 ? instFull / instScored.length : null return (
@@ -480,9 +538,15 @@ export default function PatternLab() {
{instHasOut && instHitRate !== null && ( - = 0.6 ? 'text-emerald-400' : instHitRate >= 0.4 ? 'text-yellow-400' : 'text-red-400')}> - {Math.round(instHitRate * 100)}% hit rate - +
+ = 0.6 ? 'text-emerald-400' : instHitRate >= 0.4 ? 'text-yellow-400' : 'text-red-400')}> + {Math.round(instHitRate * 100)}% full + + {instPartial > 0 && ( + +{instPartial} partial + )} + ({instFull}/{instScored.length}) +
)} {!instHasOut && (