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>
This commit is contained in:
OpenSquared
2026-06-22 18:36:33 +02:00
parent 66f6607568
commit 5ac7b8a088
2 changed files with 247 additions and 79 deletions

View File

@@ -9,6 +9,7 @@ Workflow:
import json import json
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from math import log, sqrt, exp, erf
from typing import Optional from typing import Optional
import numpy as np import numpy as np
@@ -18,6 +19,140 @@ import yfinance as yf
_log = logging.getLogger(__name__) _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 ──────────────────────────────────────────────────────────── # ── Context builder ────────────────────────────────────────────────────────────
def _fetch_ticker(ticker: str, start: str, end: str) -> Optional[pd.DataFrame]: 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"}) outcomes.append({"pattern_name": pat.get("name"), "underlying": ticker, "error": "insufficient history"})
continue continue
entry_price = float(entry_sub["Close"].dropna().iloc[-1]) entry_price = float(entry_sub["Close"].dropna().iloc[-1])
exit_price = float(exit_sub["Close"].dropna().iloc[-1]) exit_price = float(exit_sub["Close"].dropna().iloc[-1])
actual_move = round((exit_price / entry_price - 1) * 100, 2) actual_move = round((exit_price / entry_price - 1) * 100, 2)
expected_dir = pat.get("expected_direction", "any") expected_dir = pat.get("expected_direction", "any")
expected_move = float(pat.get("expected_move_pct", 0)) 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": score = _score_outcome(actual_move, expected_move, expected_dir, strategy, ticker, horizon_days)
hit = actual_move >= threshold
elif expected_dir == "down":
hit = actual_move <= -threshold
else: # any / volatility
hit = abs(actual_move) >= threshold
outcomes.append({ outcomes.append({
"pattern_name": pat.get("name"), "pattern_name": pat.get("name"),
"underlying": ticker, "underlying": ticker,
"strategy": pat.get("strategy", ""), "strategy": strategy,
"signal_direction": pat.get("signal_direction", ""), "signal_direction": pat.get("signal_direction", ""),
"expected_direction": expected_dir, "expected_direction": expected_dir,
"expected_move_pct": expected_move, "expected_move_pct": expected_move,
@@ -234,8 +364,8 @@ def evaluate_outcomes(run: dict) -> list:
"exit_price": round(exit_price, 4), "exit_price": round(exit_price, 4),
"entry_date": analysis_date, "entry_date": analysis_date,
"exit_date": eval_dt.strftime("%Y-%m-%d"), "exit_date": eval_dt.strftime("%Y-%m-%d"),
"hit": hit,
"confidence": pat.get("confidence", 0), "confidence": pat.get("confidence", 0),
**score,
}) })
return outcomes return outcomes
@@ -395,25 +525,20 @@ def evaluate_instrument_outcomes(run: dict) -> list:
"analysis_date": pat_date, "error": "insufficient history"}) "analysis_date": pat_date, "error": "insufficient history"})
continue continue
entry_price = float(entry_sub["Close"].dropna().iloc[-1]) entry_price = float(entry_sub["Close"].dropna().iloc[-1])
exit_price = float(exit_sub["Close"].dropna().iloc[-1]) exit_price = float(exit_sub["Close"].dropna().iloc[-1])
actual_move = round((exit_price / entry_price - 1) * 100, 2) actual_move = round((exit_price / entry_price - 1) * 100, 2)
expected_dir = pat.get("expected_direction", "any") expected_dir = pat.get("expected_direction", "any")
expected_move = float(pat.get("expected_move_pct", 0)) 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": score = _score_outcome(actual_move, expected_move, expected_dir, strategy, ticker, pat_horizon)
hit = actual_move >= threshold
elif expected_dir == "down":
hit = actual_move <= -threshold
else:
hit = abs(actual_move) >= threshold
outcomes.append({ outcomes.append({
"pattern_name": pat.get("name"), "pattern_name": pat.get("name"),
"underlying": ticker, "underlying": ticker,
"strategy": pat.get("strategy", ""), "strategy": strategy,
"signal_direction": pat.get("signal_direction", ""), "signal_direction": pat.get("signal_direction", ""),
"expected_direction": expected_dir, "expected_direction": expected_dir,
"expected_move_pct": expected_move, "expected_move_pct": expected_move,
@@ -423,8 +548,8 @@ def evaluate_instrument_outcomes(run: dict) -> list:
"analysis_date": pat_date, "analysis_date": pat_date,
"entry_date": pat_date, "entry_date": pat_date,
"exit_date": eval_dt.strftime("%Y-%m-%d"), "exit_date": eval_dt.strftime("%Y-%m-%d"),
"hit": hit,
"confidence": pat.get("confidence", 0), "confidence": pat.get("confidence", 0),
**score,
}) })
return outcomes return outcomes

View File

@@ -5,9 +5,9 @@ import {
useInstrumentScan, useEvaluateInstrumentScan, useInstrumentScan, useEvaluateInstrumentScan,
} from '../hooks/useApi' } from '../hooks/useApi'
import { import {
FlaskConical, Play, CheckCircle2, XCircle, FlaskConical, Play, CheckCircle2, XCircle, MinusCircle,
Trash2, Save, RefreshCw, Search, CalendarDays, Trash2, Save, RefreshCw, Search, CalendarDays,
TrendingUp, TrendingDown, Zap, BarChart2, ScanLine, TrendingUp, TrendingDown, Zap, BarChart2, ScanLine, DollarSign,
} from 'lucide-react' } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import { INSTRUMENTS, INSTRUMENT_CATEGORIES } from '../constants/instruments' 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 (
<div className={clsx('mt-2 pt-2 border-t flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px]', borderCls)}>
{/* Hit type */}
{hitType === 'full' && (
<span className="flex items-center gap-1 text-emerald-300 font-semibold">
<CheckCircle2 className="w-3.5 h-3.5" /> FULL HIT
</span>
)}
{hitType === 'partial' && (
<span className="flex items-center gap-1 text-amber-300 font-semibold">
<MinusCircle className="w-3.5 h-3.5" /> PARTIAL
</span>
)}
{hitType === 'miss' && (
<span className="flex items-center gap-1 text-red-300 font-semibold">
<XCircle className="w-3.5 h-3.5" /> MISS
</span>
)}
{/* Direction */}
<span className="text-slate-500">
Dir: <span className={out.direction_correct ? 'text-emerald-400 font-semibold' : 'text-red-400 font-semibold'}>
{out.direction_correct ? '✓' : '✗'}
</span>
{out.direction_correct && out.direction_ratio != null && (
<span className="text-slate-500 ml-0.5">({Math.round(out.direction_ratio * 100)}% of target)</span>
)}
</span>
{/* Actual move */}
<span className="text-slate-400">Actual:</span>
<MoveBadge move={out.actual_move_pct} dir={out.expected_direction} />
<span className="text-slate-600">
vs {out.expected_direction === 'up' ? '+' : out.expected_direction === 'down' ? '-' : '±'}{out.expected_move_pct}%
</span>
{/* Options P&L estimate */}
{out.estimated_options_pnl_pct != null && (
<span className={clsx(
'flex items-center gap-0.5 font-semibold font-mono',
out.estimated_options_pnl_pct >= 0 ? 'text-emerald-400' : 'text-red-400'
)}>
<DollarSign className="w-3 h-3" />
{out.estimated_options_pnl_pct > 0 ? '+' : ''}{out.estimated_options_pnl_pct.toFixed(0)}% P&L
</span>
)}
<span className="text-slate-600">{out.entry_date} {out.exit_date}</span>
</div>
)
}
// ── Main page ────────────────────────────────────────────────────────────────── // ── Main page ──────────────────────────────────────────────────────────────────
// ── Shared instrument picker ─────────────────────────────────────────────────── // ── Shared instrument picker ───────────────────────────────────────────────────
@@ -310,18 +366,20 @@ export default function PatternLab() {
const outcomeForPattern = (name: string) => const outcomeForPattern = (name: string) =>
outcomes.find((o: any) => o.pattern_name === name) outcomes.find((o: any) => o.pattern_name === name)
const hitRate = hasOutcomes const scoredOutcomes = outcomes.filter((o: any) => 'hit_type' in o || 'hit' in o)
? outcomes.filter((o: any) => o.hit === true).length / outcomes.filter((o: any) => 'hit' in o).length const fullHits = outcomes.filter((o: any) => (o.hit_type ?? (o.hit ? 'full' : 'miss')) === 'full').length
: null const partialHits = outcomes.filter((o: any) => (o.hit_type ?? '') === 'partial').length
const hitRate = scoredOutcomes.length > 0 ? fullHits / scoredOutcomes.length : null
// ── Instrument mode helpers // ── Instrument mode helpers
const instPatterns: any[] = instRun?.ai_result?.patterns ?? [] const instPatterns: any[] = instRun?.ai_result?.patterns ?? []
const instOutcomes: any[] = instRun?.outcome ?? [] const instOutcomes: any[] = instRun?.outcome ?? []
const instHasOut = instOutcomes.length > 0 const instHasOut = instOutcomes.length > 0
const instOutcomeFor = (name: string) => instOutcomes.find((o: any) => o.pattern_name === name) const instOutcomeFor = (name: string) => instOutcomes.find((o: any) => o.pattern_name === name)
const instHitRate = instHasOut const instScored = instOutcomes.filter((o: any) => 'hit_type' in o || 'hit' in o)
? instOutcomes.filter((o: any) => o.hit === true).length / instOutcomes.filter((o: any) => 'hit' in o).length const instFull = instOutcomes.filter((o: any) => (o.hit_type ?? (o.hit ? 'full' : 'miss')) === 'full').length
: null const instPartial = instOutcomes.filter((o: any) => (o.hit_type ?? '') === 'partial').length
const instHitRate = instScored.length > 0 ? instFull / instScored.length : null
return ( return (
<div className="flex h-screen bg-dark-900 text-slate-200 overflow-hidden"> <div className="flex h-screen bg-dark-900 text-slate-200 overflow-hidden">
@@ -480,9 +538,15 @@ export default function PatternLab() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{instHasOut && instHitRate !== null && ( {instHasOut && instHitRate !== null && (
<span className={clsx('text-sm font-bold', instHitRate >= 0.6 ? 'text-emerald-400' : instHitRate >= 0.4 ? 'text-yellow-400' : 'text-red-400')}> <div className="flex items-center gap-2">
{Math.round(instHitRate * 100)}% hit rate <span className={clsx('text-sm font-bold', instHitRate >= 0.6 ? 'text-emerald-400' : instHitRate >= 0.4 ? 'text-yellow-400' : 'text-red-400')}>
</span> {Math.round(instHitRate * 100)}% full
</span>
{instPartial > 0 && (
<span className="text-xs text-amber-400">+{instPartial} partial</span>
)}
<span className="text-xs text-slate-600">({instFull}/{instScored.length})</span>
</div>
)} )}
{!instHasOut && ( {!instHasOut && (
<button onClick={handleInstEvaluate} disabled={evalInst} <button onClick={handleInstEvaluate} disabled={evalInst}
@@ -502,9 +566,11 @@ export default function PatternLab() {
const dirCol = pat.signal_direction === 'bullish' ? 'text-emerald-400' : pat.signal_direction === 'bearish' ? 'text-red-400' : 'text-violet-400' const dirCol = pat.signal_direction === 'bullish' ? 'text-emerald-400' : pat.signal_direction === 'bearish' ? 'text-red-400' : 'text-violet-400'
return ( return (
<div key={idx} className={clsx('border rounded-lg p-3', <div key={idx} className={clsx('border rounded-lg p-3',
out?.hit === true ? 'border-emerald-700/60 bg-emerald-900/10' : (() => { const ht = out?.hit_type ?? (out?.hit ? 'full' : out ? 'miss' : null)
out?.hit === false ? 'border-red-700/40 bg-red-900/10' : return ht === 'full' ? 'border-emerald-700/60 bg-emerald-900/10'
'border-slate-700/40 bg-dark-700/30')}> : ht === 'partial' ? 'border-amber-700/40 bg-amber-900/10'
: ht === 'miss' ? '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 items-start gap-3">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-0.5"> <div className="flex items-center gap-2 mb-0.5">
@@ -523,18 +589,7 @@ export default function PatternLab() {
<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> <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> </div>
{pat.rationale && <p className="text-[10px] text-slate-500 mt-1 italic">{pat.rationale}</p>} {pat.rationale && <p className="text-[10px] text-slate-500 mt-1 italic">{pat.rationale}</p>}
{out && ( {out && <OutcomeRow out={out} />}
<div className={clsx('mt-2 pt-2 border-t flex items-center gap-3 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:</span>
<span className={clsx('font-mono font-semibold', out.actual_move_pct > 0 ? 'text-emerald-400' : 'text-red-400')}>
{out.actual_move_pct > 0 ? '+' : ''}{out.actual_move_pct?.toFixed(1)}%
</span>
<span className="text-slate-500">{out.entry_date} {out.exit_date}</span>
</div>
)}
</div> </div>
<div className="flex-shrink-0"> <div className="flex-shrink-0">
{isSaved {isSaved
@@ -705,11 +760,14 @@ export default function PatternLab() {
)} )}
{hasOutcomes && hitRate !== null && ( {hasOutcomes && hitRate !== null && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-slate-500">Hit rate:</span> <span className="text-xs text-slate-500">Full hits:</span>
<span className={clsx('text-sm font-bold', hitRate >= 0.6 ? 'text-emerald-400' : hitRate >= 0.4 ? 'text-yellow-400' : 'text-red-400')}> <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)}% {Math.round(hitRate * 100)}%
</span> </span>
<span className="text-xs text-slate-600">({outcomes.filter((o: any) => o.hit).length}/{outcomes.filter((o: any) => 'hit' in o).length})</span> {partialHits > 0 && (
<span className="text-xs text-amber-400">+{partialHits} partial</span>
)}
<span className="text-xs text-slate-600">({fullHits}/{scoredOutcomes.length})</span>
</div> </div>
)} )}
</div> </div>
@@ -728,9 +786,11 @@ export default function PatternLab() {
return ( return (
<div key={idx} className={clsx( <div key={idx} className={clsx(
'border rounded-lg p-3 transition-colors', 'border rounded-lg p-3 transition-colors',
out?.hit === true ? 'border-emerald-700/60 bg-emerald-900/10' : (() => { const ht = out?.hit_type ?? (out?.hit ? 'full' : out ? 'miss' : null)
out?.hit === false ? 'border-red-700/40 bg-red-900/10' : return ht === 'full' ? 'border-emerald-700/60 bg-emerald-900/10'
'border-slate-700/40 bg-dark-700/30' : ht === 'partial' ? 'border-amber-700/40 bg-amber-900/10'
: ht === 'miss' ? '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 items-start gap-3">
<div className="flex-1"> <div className="flex-1">
@@ -753,25 +813,7 @@ export default function PatternLab() {
<p className="text-[10px] text-slate-500 mt-1.5 italic">{pat.rationale}</p> <p className="text-[10px] text-slate-500 mt-1.5 italic">{pat.rationale}</p>
)} )}
{/* Outcome row */} {/* Outcome row */}
{out && ( {out && <OutcomeRow out={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> </div>
{/* Save button */} {/* Save button */}
@@ -843,11 +885,12 @@ export default function PatternLab() {
<span className="text-[10px] text-slate-600">{run.horizon_days}d</span> <span className="text-[10px] text-slate-600">{run.horizon_days}d</span>
{run.status === 'evaluated' && run.outcome && (() => { {run.status === 'evaluated' && run.outcome && (() => {
const outs: any[] = Array.isArray(run.outcome) ? run.outcome : [] const outs: any[] = Array.isArray(run.outcome) ? run.outcome : []
const hits = outs.filter(o => o.hit === true).length const scored = outs.filter(o => 'hit_type' in o || 'hit' in o)
const total = outs.filter(o => 'hit' in o).length const full = outs.filter(o => (o.hit_type ?? (o.hit ? 'full' : 'miss')) === 'full').length
return total > 0 ? ( const partial = outs.filter(o => (o.hit_type ?? '') === 'partial').length
<span className={clsx('text-[10px] font-semibold', hits / total >= 0.6 ? 'text-emerald-400' : 'text-red-400')}> return scored.length > 0 ? (
{Math.round(hits / total * 100)}% hit <span className={clsx('text-[10px] font-semibold', full / scored.length >= 0.6 ? 'text-emerald-400' : 'text-red-400')}>
{Math.round(full / scored.length * 100)}%{partial > 0 ? ` +${partial}p` : ''}
</span> </span>
) : null ) : null
})()} })()}