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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
Reference in New Issue
Block a user