feat(dashboard): chart performance on one percent axis
Deploy / lint (push) Successful in 15s
Deploy / test (push) Successful in 1m57s
Deploy / deploy (push) Successful in 45s

The comparison plotted both books in dollars and SPY on a hidden rescaled
axis, stretched so its biggest move matched the books' biggest move. SPY's
height therefore said nothing about which was ahead, and the stat cells
showed R next to SPY's percent, which do not compare.

Everything is now percent of the shadow book's equity:
- shadow: actual P&L over equity at window start (start equity plus P&L
  realized before a pinned performance_start_date)
- your picks: re-sized at the shadow book's rule (risk_pct, 20% notional
  cap), so the gap between books is selection only
- SPY: buy & hold on the same axis

A strategy-vs-SPY verdict measures SPY from the shadow book's first trade,
so manual trades opening earlier no longer charge the strategy for SPY's
run-up before it existed. R stats stay underneath for book-vs-book reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
dennisthiessenandClaude Opus 5.5 committed 2026-10-09 09:26:24 +02:00
1 parent 9c48eb20c5
commit 4876f9ffdd
4 files changed
+405 -87

No files matched your search

+12
View File
@@ -38,6 +38,10 @@ export interface PerfPoint {
date: string;
manual_pnl: number;
shadow_pnl: number;
/** Percent of the shadow book's equity base — its actual result. */
shadow_pct: number;
/** Percent of equity as if sized by the shadow book's rule (hand sizing removed). */
manual_pct: number;
spy_pct: number;
}
@@ -49,10 +53,18 @@ export interface BookStats {
total_r: number;
avg_r: number | null;
pnl: number;
pct: number;
/** Shadow only: the book's first trade, and SPY's return from then — the fair yardstick. */
since?: string;
spy_pct?: number;
}
export interface PerformanceSummary {
start_date: string | null;
/** Shadow equity when the window opens — the base every % line is measured on. */
equity_base?: number;
/** Shadow book risk per trade, % of equity — so 1R ≈ this many percent. */
risk_pct?: number;
series: PerfPoint[];
stats: {
manual?: BookStats;
+155 -78
View File
@@ -1,11 +1,12 @@
import { useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getPerformance, type BookStats } from '../../api/paperTrades';
import { getPerformance, type BookStats, type PerfPoint } from '../../api/paperTrades';
import { Section } from '../ui/Section';
const W = 1040;
const H = 260;
const PAD = { top: 16, right: 92, bottom: 28, left: 60 };
const PAD = { top: 16, right: 120, bottom: 28, left: 60 };
const LABEL_GAP = 14;
const COLORS = {
shadow: 'var(--up)',
@@ -13,72 +14,102 @@ const COLORS = {
spy: 'var(--ink-3)',
} as const;
type SeriesKey = 'shadow_pct' | 'manual_pct' | 'spy_pct';
function money(v: number): string {
const sign = v > 0 ? '+' : v < 0 ? '−' : '';
const abs = Math.abs(v);
return `${sign}$${abs >= 10000 ? `${(abs / 1000).toFixed(1)}k` : abs.toFixed(0)}`;
}
function pct(v: number, digits = 1): string {
const sign = v > 0 ? '+' : v < 0 ? '−' : '';
return `${sign}${Math.abs(v).toFixed(digits)}%`;
}
function rMult(v: number): string {
return `${v > 0 ? '+' : ''}${v.toFixed(2)}R`;
}
/** Round tick steps to a clean 1/2/5 x 10^n ladder. */
function niceTicks(lo: number, hi: number, count = 4): number[] {
function niceTicks(lo: number, hi: number, count = 4): { ticks: number[]; step: number } {
const span = hi - lo || 1;
const raw = span / count;
const mag = 10 ** Math.floor(Math.log10(raw));
const step = [1, 2, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? raw;
const start = Math.ceil(lo / step) * step;
const out: number[] = [];
for (let v = start; v <= hi; v += step) out.push(v);
return out;
const ticks: number[] = [];
// Integer multiples of the step, so float drift never prints "+0.30000001%".
for (let k = Math.ceil(lo / step); k * step <= hi; k++) ticks.push(k * step);
return { ticks, step };
}
/** R-multiple stats read straight across; currency P&L does not, because the
* books size differently. Kept adjacent so the comparison is hard to misread. */
function StatCell({ label, stats, color }: { label: string; stats?: BookStats; color: string }) {
/** Headline in % (the chart's unit, comparable to SPY); R and $ underneath. */
function StatCell({
label,
note,
stats,
color,
}: {
label: string;
note: string;
stats?: BookStats;
color: string;
}) {
const head = (
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<i className="inline-block h-2 w-2 rounded-full" style={{ background: color }} />
{label}
</div>
);
if (!stats || stats.trades === 0) {
return (
<div className="min-w-[7rem]">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<i className="inline-block h-2 w-2 rounded-full" style={{ background: color }} />
{label}
</div>
{head}
<div className="num mt-1 text-sm text-gray-500">no trades yet</div>
</div>
);
}
return (
<div className="min-w-[7rem]">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<i className="inline-block h-2 w-2 rounded-full" style={{ background: color }} />
{label}
</div>
<div className="num mt-1 text-lg font-semibold text-gray-100">
{stats.total_r > 0 ? '+' : ''}
{stats.total_r.toFixed(2)}R
</div>
{head}
<div className="num mt-1 text-lg font-semibold text-gray-100">{pct(stats.pct)}</div>
<div className="num text-[11px] leading-relaxed text-gray-500">
{stats.trades} trades · {stats.win_rate ?? '—'}% win
{note}
<br />
avg {stats.avg_r === null ? '—' : `${stats.avg_r > 0 ? '+' : ''}${stats.avg_r.toFixed(2)}R`} · {money(stats.pnl)}
{rMult(stats.total_r)} · {stats.trades} trades · {stats.win_rate ?? '—'}% win
<br />
avg {stats.avg_r === null ? '—' : rMult(stats.avg_r)} · {money(stats.pnl)}
</div>
</div>
);
}
/** Shadow book (the strategy, traded automatically) vs the discretionary book
* vs SPY. Both books share an exit policy, so the only difference is which
* qualified setups get taken. */
* vs SPY, all in percent of the shadow book's equity. R is not a return and
* cannot sit on SPY's axis; percent of equity is the one unit all three share. */
export function PerfChart() {
const perf = useQuery({ queryKey: ['paper-trades', 'performance'], queryFn: getPerformance });
const [hover, setHover] = useState<number | null>(null);
const svgRef = useRef<SVGSVGElement>(null);
const data = perf.data?.series ?? [];
const data = useMemo(() => perf.data?.series ?? [], [perf.data]);
const stats = perf.data?.stats ?? {};
const startDate = perf.data?.start_date ?? null;
const equityBase = perf.data?.equity_base ?? null;
const riskPct = perf.data?.risk_pct ?? null;
// A book with no trades would draw a flat line on zero that reads as "0%".
const lines = useMemo(() => {
const out: { key: SeriesKey; color: string; label: string; dashed?: boolean }[] = [];
out.push({ key: 'spy_pct', color: COLORS.spy, label: 'SPY', dashed: true });
if ((stats.manual?.trades ?? 0) > 0) out.push({ key: 'manual_pct', color: COLORS.manual, label: 'yours' });
if ((stats.shadow?.trades ?? 0) > 0) out.push({ key: 'shadow_pct', color: COLORS.shadow, label: 'strategy' });
return out;
}, [stats.manual?.trades, stats.shadow?.trades]);
const geom = useMemo(() => {
if (data.length < 2) return null;
const values = data.flatMap((p) => [p.manual_pnl, p.shadow_pnl, 0]);
const values = data.flatMap((p) => [...lines.map((l) => p[l.key]), 0]);
const lo = Math.min(...values);
const hi = Math.max(...values);
const pad = (hi - lo) * 0.08 || 1;
@@ -88,19 +119,19 @@ export function PerfChart() {
const plotH = H - PAD.top - PAD.bottom;
const px = (i: number) => PAD.left + (i / (data.length - 1)) * plotW;
const py = (v: number) => PAD.top + plotH - ((v - yLo) / (yHi - yLo)) * plotH;
const line = (key: 'manual_pnl' | 'shadow_pnl') =>
data.map((p, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${py(p[key]).toFixed(1)}`).join(' ');
const path = (key: SeriesKey) =>
data.map((p: PerfPoint, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${py(p[key]).toFixed(1)}`).join(' ');
// SPY is a percentage reference, so it rides its own scale pinned to the
// same zero line — otherwise a flat book would squash it out of view.
const spyLo = Math.min(...data.map((p) => p.spy_pct), 0);
const spyHi = Math.max(...data.map((p) => p.spy_pct), 0);
const spySpan = Math.max(Math.abs(spyLo), Math.abs(spyHi)) || 1;
const bookSpan = Math.max(Math.abs(yLo), Math.abs(yHi)) || 1;
const spyY = (v: number) => py((v / spySpan) * bookSpan);
const spyLine = data
.map((p, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${spyY(p.spy_pct).toFixed(1)}`)
.join(' ');
// End-of-line labels, nudged apart so lines finishing close together stay legible.
const last = data[data.length - 1];
const ends = lines
.map((l) => ({ ...l, value: last[l.key], y: py(last[l.key]) + 4 }))
.sort((a, b) => a.y - b.y);
for (let k = 1; k < ends.length; k++) {
ends[k].y = Math.max(ends[k].y, ends[k - 1].y + LABEL_GAP);
}
const overflow = ends.length ? ends[ends.length - 1].y - (PAD.top + plotH + 4) : 0;
if (overflow > 0) ends.forEach((e) => (e.y -= overflow));
const xTicks: { i: number; label: string }[] = [];
let lastMonth = '';
@@ -115,12 +146,12 @@ export function PerfChart() {
const keep = Math.ceil(xTicks.length / 10);
for (let k = xTicks.length - 1; k >= 0; k--) if (k % keep !== 0) xTicks.splice(k, 1);
}
return { yLo, yHi, plotH, px, py, line, spyLine, spyY, yTicks: niceTicks(yLo, yHi), xTicks };
}, [data]);
return { plotH, px, py, path, ends, yTicks: niceTicks(yLo, yHi), xTicks };
}, [data, lines]);
if (!geom) {
return (
<Section title="Performance" hint="shadow book vs your picks vs SPY">
<Section title="Performance" hint="strategy vs your picks vs SPY">
<div className="glass p-5 text-sm text-gray-500">
No trades in the selected window yet
{startDate ? ` (since ${startDate})` : ''}. The shadow book starts recording once enabled.
@@ -128,7 +159,7 @@ export function PerfChart() {
</Section>
);
}
const { px, py, line, spyLine, spyY, yTicks, xTicks, plotH } = geom;
const { px, py, path, ends, yTicks, xTicks, plotH } = geom;
const onMove = (e: React.MouseEvent<SVGSVGElement>) => {
const rect = svgRef.current?.getBoundingClientRect();
@@ -138,38 +169,61 @@ export function PerfChart() {
setHover(Math.max(0, Math.min(data.length - 1, i)));
};
const last = data.length - 1;
const last = data[data.length - 1];
const hb = hover !== null ? data[hover] : null;
const fmtDate = (iso: string) =>
new Date(`${iso}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
// The question the card exists to answer: did the strategy beat sitting in SPY?
// SPY is measured from the strategy's first trade: if your picks started
// earlier, the full-window SPY figure would include days it didn't exist.
const hasShadow = (stats.shadow?.trades ?? 0) > 0;
const shadowPct = stats.shadow?.pct ?? 0;
const spyRef = stats.shadow?.spy_pct ?? stats.spy?.pct ?? 0;
const edge = hasShadow ? shadowPct - spyRef : null;
const since = stats.shadow?.since && stats.shadow.since !== data[0].date ? stats.shadow.since : null;
const base = equityBase !== null ? `$${Math.round(equityBase / 1000)}k` : 'the book';
const risk = riskPct !== null ? `${riskPct}%` : '1%';
return (
<Section
title="Performance"
hint={`shadow book vs your picks vs SPY${startDate ? ` · since ${startDate}` : ''}`}
hint={`strategy vs your picks vs SPY, % of equity${startDate ? ` · since ${startDate}` : ''}`}
>
<div className="glass p-5 pb-2">
<div className="mb-3 flex flex-wrap items-start justify-between gap-x-8 gap-y-3">
<div className="flex flex-wrap gap-x-8 gap-y-3">
<StatCell label="Shadow (strategy)" stats={stats.shadow} color={COLORS.shadow} />
<StatCell label="Your picks" stats={stats.manual} color={COLORS.manual} />
<StatCell label="Shadow (strategy)" note={`actual, on ${base}`} stats={stats.shadow} color={COLORS.shadow} />
<StatCell label="Your picks" note="at strategy sizing" stats={stats.manual} color={COLORS.manual} />
<div className="min-w-[7rem]">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<i className="inline-block h-2 w-2 rounded-full" style={{ background: COLORS.spy }} />
SPY
</div>
<div className="num mt-1 text-lg font-semibold text-gray-300">
{(stats.spy?.pct ?? 0) > 0 ? '+' : ''}
{(stats.spy?.pct ?? 0).toFixed(1)}%
</div>
<div className="num text-[11px] text-gray-500">buy &amp; hold</div>
<div className="num mt-1 text-lg font-semibold text-gray-300">{pct(stats.spy?.pct ?? 0)}</div>
<div className="num text-[11px] text-gray-500">buy &amp; hold, same window</div>
</div>
</div>
<p className="max-w-[22rem] text-[11px] leading-relaxed text-gray-500">
Books share the same exit, so the difference is <b className="text-gray-400">selection</b>.
Compare on <b className="text-gray-400">R</b>, not $ — sizing differs. Expect months of
noise before a gap means anything.
</p>
<div className="max-w-[24rem] text-[11px] leading-relaxed text-gray-500">
{edge !== null && (
<p className="num mb-1.5 text-xs text-gray-400">
Strategy vs SPY{since ? ` since ${fmtDate(since)}` : ''}:{' '}
<b style={{ color: edge >= 0 ? 'var(--up-text)' : 'var(--down-text)' }}>
{edge >= 0 ? '+' : '−'}
{Math.abs(edge).toFixed(1)} pts {edge >= 0 ? 'ahead' : 'behind'}
</b>{' '}
<span className="text-gray-500">
({pct(shadowPct)} vs {pct(spyRef)})
</span>
</p>
)}
<p>
Every line is <b className="text-gray-400">% of the strategy&apos;s {base} equity</b>, the
same unit as SPY. Your picks are re-sized to its {risk} risk per trade, so the gap between
the books is <b className="text-gray-400">selection</b>. 1R ≈ {risk} of equity (less when a
position hits the size cap). Expect months of noise before a gap means anything.
</p>
</div>
</div>
<svg
ref={svgRef}
@@ -178,15 +232,15 @@ export function PerfChart() {
onMouseMove={onMove}
onMouseLeave={() => setHover(null)}
role="img"
aria-label={`Shadow book ${money(data[last].shadow_pnl)}, your picks ${money(
data[last].manual_pnl,
)}, SPY ${(data[last].spy_pct ?? 0).toFixed(1)} percent`}
aria-label={`Strategy ${pct(last.shadow_pct)}, your picks ${pct(last.manual_pct)}, SPY ${pct(
last.spy_pct,
)}, as percent of equity`}
>
{yTicks.map((t) => (
{yTicks.ticks.map((t) => (
<g key={t}>
<line x1={PAD.left} x2={W - PAD.right} y1={py(t)} y2={py(t)} stroke="var(--grid)" strokeWidth="1" />
<text x={PAD.left - 8} y={py(t) + 3.5} textAnchor="end" className="num" fill="var(--ink-3)" fontSize="10">
{money(t)}
{pct(t, yTicks.step < 1 ? 1 : 0)}
</text>
</g>
))}
@@ -196,30 +250,53 @@ export function PerfChart() {
{label}
</text>
))}
<path d={spyLine} fill="none" stroke={COLORS.spy} strokeWidth="1.5" strokeDasharray="4 3" strokeLinejoin="round" />
<path d={line('manual_pnl')} fill="none" stroke={COLORS.manual} strokeWidth="2" strokeLinejoin="round" />
<path d={line('shadow_pnl')} fill="none" stroke={COLORS.shadow} strokeWidth="2" strokeLinejoin="round" />
{lines.map((l) => (
<path
key={l.key}
d={path(l.key)}
fill="none"
stroke={l.color}
strokeWidth={l.dashed ? 1.5 : 2}
strokeDasharray={l.dashed ? '4 3' : undefined}
strokeLinejoin="round"
/>
))}
{hover !== null && (
<g>
<line x1={px(hover)} x2={px(hover)} y1={PAD.top} y2={PAD.top + plotH} stroke="var(--ink-3)" strokeWidth="1" />
<circle cx={px(hover)} cy={py(data[hover].shadow_pnl)} r="4.5" fill={COLORS.shadow} stroke="var(--surface)" strokeWidth="2" />
<circle cx={px(hover)} cy={py(data[hover].manual_pnl)} r="4.5" fill={COLORS.manual} stroke="var(--surface)" strokeWidth="2" />
<circle cx={px(hover)} cy={spyY(data[hover].spy_pct)} r="4" fill={COLORS.spy} stroke="var(--surface)" strokeWidth="2" />
{lines.map((l) => (
<circle
key={l.key}
cx={px(hover)}
cy={py(data[hover][l.key])}
r={l.dashed ? 4 : 4.5}
fill={l.color}
stroke="var(--surface)"
strokeWidth="2"
/>
))}
</g>
)}
<text x={px(last) + 10} y={py(data[last].shadow_pnl) + 4} className="num" fill="var(--ink)" fontSize="11" fontWeight="600">
{money(data[last].shadow_pnl)}
</text>
<text x={px(last) + 10} y={py(data[last].manual_pnl) + 4} className="num" fill="var(--ink-2)" fontSize="11">
{money(data[last].manual_pnl)}
</text>
{ends.map((e) => (
<text
key={e.key}
x={px(data.length - 1) + 10}
y={e.y}
className="num"
fill={e.key === 'shadow_pct' ? 'var(--ink)' : e.key === 'manual_pct' ? 'var(--ink-2)' : 'var(--ink-3)'}
fontSize="11"
fontWeight={e.key === 'shadow_pct' ? 600 : undefined}
>
{pct(e.value)} {e.label}
</text>
))}
</svg>
<p className="num px-1 pb-1 pt-1.5 text-[11px] text-gray-500" aria-live="polite">
{hb ? (
<>
{fmtDate(hb.date)} — shadow <b className="text-gray-200">{money(hb.shadow_pnl)}</b> · yours{' '}
<b className="text-gray-200">{money(hb.manual_pnl)}</b> · SPY{' '}
<b className="text-gray-200">{hb.spy_pct.toFixed(1)}%</b>
{fmtDate(hb.date)} — strategy <b className="text-gray-200">{pct(hb.shadow_pct)}</b> · yours{' '}
<b className="text-gray-200">{pct(hb.manual_pct)}</b> · SPY{' '}
<b className="text-gray-200">{pct(hb.spy_pct)}</b>
</>
) : (
<>hover for daily values · realized + mark-to-market{startDate ? ` · window starts ${startDate}` : ''}</>