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

@@ -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 (
<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 ──────────────────────────────────────────────────────────────────
// ── 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 (
<div className="flex h-screen bg-dark-900 text-slate-200 overflow-hidden">
@@ -480,9 +538,15 @@ export default function PatternLab() {
</div>
<div className="flex items-center gap-2">
{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')}>
{Math.round(instHitRate * 100)}% hit rate
</span>
<div className="flex items-center gap-2">
<span className={clsx('text-sm font-bold', instHitRate >= 0.6 ? 'text-emerald-400' : instHitRate >= 0.4 ? 'text-yellow-400' : 'text-red-400')}>
{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 && (
<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'
return (
<div key={idx} className={clsx('border rounded-lg p-3',
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')}>
(() => { const ht = out?.hit_type ?? (out?.hit ? 'full' : out ? 'miss' : null)
return ht === 'full' ? 'border-emerald-700/60 bg-emerald-900/10'
: 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-1">
<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>
</div>
{pat.rationale && <p className="text-[10px] text-slate-500 mt-1 italic">{pat.rationale}</p>}
{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>
)}
{out && <OutcomeRow out={out} />}
</div>
<div className="flex-shrink-0">
{isSaved
@@ -705,11 +760,14 @@ export default function PatternLab() {
)}
{hasOutcomes && hitRate !== null && (
<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')}>
{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>
{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>
@@ -728,9 +786,11 @@ export default function PatternLab() {
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'
(() => { const ht = out?.hit_type ?? (out?.hit ? 'full' : out ? 'miss' : null)
return ht === 'full' ? 'border-emerald-700/60 bg-emerald-900/10'
: 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-1">
@@ -753,25 +813,7 @@ export default function PatternLab() {
<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>
)}
{out && <OutcomeRow out={out} />}
</div>
{/* Save button */}
@@ -843,11 +885,12 @@ export default function PatternLab() {
<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
const scored = outs.filter(o => 'hit_type' in o || 'hit' in o)
const full = outs.filter(o => (o.hit_type ?? (o.hit ? 'full' : 'miss')) === 'full').length
const partial = outs.filter(o => (o.hit_type ?? '') === 'partial').length
return scored.length > 0 ? (
<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>
) : null
})()}