From 4876f9ffdd427504c9bbd21e63a4461a83649845 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Fri, 9 Oct 2026 09:26:24 +0200 Subject: [PATCH] feat(dashboard): chart performance on one percent axis 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 --- app/services/paper_trade_service.py | 122 ++++++++- frontend/src/api/paperTrades.ts | 12 + .../src/components/dashboard/PerfChart.tsx | 233 ++++++++++++------ tests/unit/test_performance_summary.py | 125 ++++++++++ 4 files changed, 405 insertions(+), 87 deletions(-) diff --git a/app/services/paper_trade_service.py b/app/services/paper_trade_service.py index 35133e3..d950630 100644 --- a/app/services/paper_trade_service.py +++ b/app/services/paper_trade_service.py @@ -835,8 +835,10 @@ async def equity_curve(db: AsyncSession, user_id: int) -> list[dict]: return build_equity_curve(list(trades), ticker_closes, benchmark_closes) -def _cumulative_pnl(trades: list, ticker_closes: dict, days: list[date]) -> list[float]: - """Cumulative realized + mark-to-market P&L of one book on each day.""" +def _cumulative_per_share( + trades: list, ticker_closes: dict, days: list[date], weight +) -> list[float]: + """Σ per-share P&L x ``weight(trade)`` on each day, realized + mark-to-market.""" sorted_dates = {tid: sorted(c) for tid, c in ticker_closes.items()} out: list[float] = [] for d in days: @@ -862,19 +864,89 @@ def _cumulative_pnl(trades: list, ticker_closes: dict, days: list[date]) -> list per_share = ( ref - t.entry_price if t.direction == "long" else t.entry_price - ref ) - total += per_share * t.shares - out.append(round(total, 2)) + total += per_share * weight(t) + out.append(total) return out +def _cumulative_pnl(trades: list, ticker_closes: dict, days: list[date]) -> list[float]: + """Cumulative realized + mark-to-market P&L of one book on each day.""" + return [ + round(v, 2) + for v in _cumulative_per_share(trades, ticker_closes, days, lambda t: t.shares) + ] + + +def strategy_sized_fraction(trade, risk_pct: float, notional_cap: float) -> float: + """Shares per unit of equity the shadow book's sizing rule would buy. + + ``risk_pct`` of equity at risk to the stop, capped at ``notional_cap`` of + equity in notional — the same rule as ``shadow_book_service.position_shares`` + minus the cash limit. Re-sizing a book this way leaves only *selection* to + differ, and expresses its result as a percent of equity like SPY's. + """ + risk_per_share = abs(trade.entry_price - trade.stop_loss) + if risk_per_share <= 0 or trade.entry_price <= 0: + return 0.0 + return min(risk_pct / 100.0 / risk_per_share, notional_cap / trade.entry_price) + + +def _cumulative_sized_pct( + trades: list, + ticker_closes: dict, + days: list[date], + risk_pct: float, + notional_cap: float, +) -> list[float]: + """Cumulative return of one book, in % of equity, at the shadow book's sizing.""" + series = _cumulative_per_share( + trades, + ticker_closes, + days, + lambda t: strategy_sized_fraction(t, risk_pct, notional_cap), + ) + return [round(100.0 * v, 2) for v in series] + + +async def _shadow_equity_base(db: AsyncSession, start_equity: float, first: date) -> float: + """Shadow equity when the window opens: start equity + P&L realized before it. + + Trades closed before a pinned ``performance_start_date`` are outside the + window, but they moved the equity the book sizes from — so the window's + percent return is measured against that equity, not the original stake. + """ + rows = await db.execute( + select(PaperTrade).where( + PaperTrade.book == SHADOW_BOOK, + PaperTrade.status == "closed", + PaperTrade.close_price.is_not(None), + func.date(PaperTrade.closed_at) < first, + ) + ) + realized = 0.0 + for t in rows.scalars(): + per_share = ( + t.close_price - t.entry_price + if t.direction == "long" + else t.entry_price - t.close_price + ) + realized += per_share * t.shares + return start_equity + realized + + async def performance_summary(db: AsyncSession, user_id: int | None = None) -> dict: """Shadow book vs discretionary book vs SPY, from the configured start date. - Currency P&L is reported per book but is *not* the comparison — the books - size differently, so the honest read is the R-multiple stats. SPY is a plain - buy-and-hold reference over the same window rather than a per-trade - counterfactual, so one line serves both books. + Everything is charted in one unit — percent of the shadow book's equity — + because SPY is a percent and R is not a return. The shadow line is its + actual P&L over its equity base; the manual book has no capital base, so it + is re-sized at the shadow book's rule (``strategy_sized_fraction``), which + also makes the gap between the two books pure selection. SPY is a plain + buy-and-hold reference over the same window. R-multiple stats stay as the + sizing-independent book-vs-book read. """ + from app.services import shadow_book_service + start = await get_performance_start(db) stmt = select(PaperTrade) if start is not None: @@ -929,6 +1001,19 @@ async def performance_summary(db: AsyncSession, user_id: int | None = None) -> d for d in days ] + config = await shadow_book_service.get_config(db) + equity_base = await _shadow_equity_base(db, config["start_equity"], days[0]) + pct = { + SHADOW_BOOK: [round(100.0 * v / equity_base, 2) for v in pnl[SHADOW_BOOK]], + MANUAL_BOOK: _cumulative_sized_pct( + books[MANUAL_BOOK], + ticker_closes, + days, + config["risk_pct"], + shadow_book_service.NOTIONAL_CAP, + ), + } + # Latest close per ticker, for marking open positions in the R stats. marks = { tid: closes[max(closes)] for tid, closes in ticker_closes.items() if closes @@ -936,15 +1021,34 @@ async def performance_summary(db: AsyncSession, user_id: int | None = None) -> d stats = {name: book_stats(bt, marks) for name, bt in books.items()} for name in books: stats[name]["pnl"] = pnl[name][-1] if pnl[name] else 0.0 + stats[name]["pct"] = pct[name][-1] if pct[name] else 0.0 stats["spy"] = {"pct": spy_pct[-1] if spy_pct else 0.0} + # The series opens at the first trade in *either* book. If manual trades came + # first, SPY's full-window return includes days the strategy did not exist, + # so strategy-vs-SPY is judged from the shadow book's own first trade. + if books[SHADOW_BOOK]: + since = max(min(t.opened_at.date() for t in books[SHADOW_BOOK]), days[0]) + s0 = _value_on_or_before(bench_dates, benchmark_closes, since) + s1 = benchmark_closes[days[-1]] + stats[SHADOW_BOOK]["since"] = since.isoformat() + stats[SHADOW_BOOK]["spy_pct"] = round(100.0 * (s1 / s0 - 1.0), 2) if s0 else 0.0 + series = [ { "date": d.isoformat(), "manual_pnl": pnl[MANUAL_BOOK][i], "shadow_pnl": pnl[SHADOW_BOOK][i], + "manual_pct": pct[MANUAL_BOOK][i], + "shadow_pct": pct[SHADOW_BOOK][i], "spy_pct": spy_pct[i], } for i, d in enumerate(days) ] - return {"start_date": start.isoformat() if start else None, "series": series, "stats": stats} + return { + "start_date": start.isoformat() if start else None, + "equity_base": round(equity_base, 2), + "risk_pct": config["risk_pct"], + "series": series, + "stats": stats, + } diff --git a/frontend/src/api/paperTrades.ts b/frontend/src/api/paperTrades.ts index e38748d..dd6ffc0 100644 --- a/frontend/src/api/paperTrades.ts +++ b/frontend/src/api/paperTrades.ts @@ -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; diff --git a/frontend/src/components/dashboard/PerfChart.tsx b/frontend/src/components/dashboard/PerfChart.tsx index 1862d8b..5241ee0 100644 --- a/frontend/src/components/dashboard/PerfChart.tsx +++ b/frontend/src/components/dashboard/PerfChart.tsx @@ -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 = ( +
+ + {label} +
+ ); if (!stats || stats.trades === 0) { return (
-
- - {label} -
+ {head}
no trades yet
); } return (
-
- - {label} -
-
- {stats.total_r > 0 ? '+' : ''} - {stats.total_r.toFixed(2)}R -
+ {head} +
{pct(stats.pct)}
- {stats.trades} trades · {stats.win_rate ?? '—'}% win + {note}
- 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 +
+ avg {stats.avg_r === null ? '—' : rMult(stats.avg_r)} · {money(stats.pnl)}
); } /** 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(null); const svgRef = useRef(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 ( -
+
No trades in the selected window yet {startDate ? ` (since ${startDate})` : ''}. The shadow book starts recording once enabled. @@ -128,7 +159,7 @@ export function PerfChart() {
); } - const { px, py, line, spyLine, spyY, yTicks, xTicks, plotH } = geom; + const { px, py, path, ends, yTicks, xTicks, plotH } = geom; const onMove = (e: React.MouseEvent) => { 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 (
- - + +
SPY
-
- {(stats.spy?.pct ?? 0) > 0 ? '+' : ''} - {(stats.spy?.pct ?? 0).toFixed(1)}% -
-
buy & hold
+
{pct(stats.spy?.pct ?? 0)}
+
buy & hold, same window
-

- Books share the same exit, so the difference is selection. - Compare on R, not $ — sizing differs. Expect months of - noise before a gap means anything. -

+
+ {edge !== null && ( +

+ Strategy vs SPY{since ? ` since ${fmtDate(since)}` : ''}:{' '} + = 0 ? 'var(--up-text)' : 'var(--down-text)' }}> + {edge >= 0 ? '+' : '−'} + {Math.abs(edge).toFixed(1)} pts {edge >= 0 ? 'ahead' : 'behind'} + {' '} + + ({pct(shadowPct)} vs {pct(spyRef)}) + +

+ )} +

+ Every line is % of the strategy's {base} equity, the + same unit as SPY. Your picks are re-sized to its {risk} risk per trade, so the gap between + the books is selection. 1R ≈ {risk} of equity (less when a + position hits the size cap). Expect months of noise before a gap means anything. +

+
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) => ( - {money(t)} + {pct(t, yTicks.step < 1 ? 1 : 0)} ))} @@ -196,30 +250,53 @@ export function PerfChart() { {label} ))} - - - + {lines.map((l) => ( + + ))} {hover !== null && ( - - - + {lines.map((l) => ( + + ))} )} - - {money(data[last].shadow_pnl)} - - - {money(data[last].manual_pnl)} - + {ends.map((e) => ( + + {pct(e.value)} {e.label} + + ))}

{hb ? ( <> - {fmtDate(hb.date)} — shadow {money(hb.shadow_pnl)} · yours{' '} - {money(hb.manual_pnl)} · SPY{' '} - {hb.spy_pct.toFixed(1)}% + {fmtDate(hb.date)} — strategy {pct(hb.shadow_pct)} · yours{' '} + {pct(hb.manual_pct)} · SPY{' '} + {pct(hb.spy_pct)} ) : ( <>hover for daily values · realized + mark-to-market{startDate ? ` · window starts ${startDate}` : ''} diff --git a/tests/unit/test_performance_summary.py b/tests/unit/test_performance_summary.py index 62cf0b1..c21ea07 100644 --- a/tests/unit/test_performance_summary.py +++ b/tests/unit/test_performance_summary.py @@ -68,6 +68,131 @@ class TestBookStats: assert stats["total_r"] == pytest.approx(4.0) +class TestStrategySizedPercent: + """Both books and SPY share one unit — percent of equity — on the chart.""" + + def test_one_r_is_risk_pct_of_equity(self): + # Entry 100, stop 90 → 10 of risk; 1% risk → 0.001 shares per $ equity + # (10% notional, under the 20% cap). Exit 120 = +2R = +2% of equity. + trade = _trade(book=MANUAL_BOOK, entry=100.0, stop=90.0, close=120.0) + assert pts.strategy_sized_fraction(trade, 1.0, 0.20) == pytest.approx(0.001) + days = [trade.closed_at.date()] + assert pts._cumulative_sized_pct([trade], {}, days, 1.0, 0.20) == [2.0] + + def test_notional_cap_shrinks_tight_stop_trades(self): + # Stop 1 away would need 100% of equity in notional for 1% risk; the + # 20% cap leaves 0.2% at risk, so +1R is only +0.2% of equity. + trade = _trade(book=MANUAL_BOOK, entry=100.0, stop=99.0, close=101.0) + days = [trade.closed_at.date()] + assert pts._cumulative_sized_pct([trade], {}, days, 1.0, 0.20) == [0.2] + + def test_hand_sized_shares_do_not_matter(self): + small = _trade(book=MANUAL_BOOK, entry=100.0, stop=90.0, close=120.0, shares=1) + large = _trade(book=MANUAL_BOOK, entry=100.0, stop=90.0, close=120.0, shares=5000) + days = [small.closed_at.date()] + assert pts._cumulative_sized_pct( + [small], {}, days, 1.0, 0.20 + ) == pts._cumulative_sized_pct([large], {}, days, 1.0, 0.20) + + +class TestPerformanceSummaryPercent: + @pytest.fixture + async def session(self): + from tests.conftest import _test_session_factory + + async with _test_session_factory() as session: + yield session + + @pytest.mark.asyncio + async def test_shadow_percent_is_measured_on_equity_at_window_start(self, session): + """A pinned start date moves the base: P&L realized before it is equity.""" + from app.models.benchmark_price import BenchmarkPrice + from app.models.ohlcv import OHLCVRecord + from app.models.paper_trade import PaperTrade + from app.models.ticker import Ticker + from app.models.user import User + + today = datetime.now(timezone.utc).date() + + def at(d): + return datetime(d.year, d.month, d.day, 20, tzinfo=timezone.utc) + + session.add(User(id=1, username="u", password_hash="x", role="user", has_access=True)) + ticker = Ticker(symbol="AAA") + session.add(ticker) + await session.flush() + session.add( + OHLCVRecord(ticker_id=ticker.id, date=today, open=110, high=110, low=110, + close=110, volume=1) + ) + for offset, close in ((10, 390.0), (3, 400.0), (0, 412.0)): + session.add(BenchmarkPrice(symbol="SPY", date=today - timedelta(days=offset), + close=close)) + common = dict(user_id=1, ticker_id=ticker.id, direction="long", entry_price=100.0, + stop_loss=90.0, target=150.0, fill_mode="near_close") + # Before the window: +$5,000 realized, so the window's base is $105k. + session.add(PaperTrade(**common, book=SHADOW_BOOK, shares=100, status="closed", + close_price=150.0, opened_at=at(today - timedelta(days=10)), + closed_at=at(today - timedelta(days=8)))) + # In the window: +$1,050 on 105 shares = +1.0% of $105k. + session.add(PaperTrade(**common, book=SHADOW_BOOK, shares=105, status="open", + opened_at=at(today - timedelta(days=3)))) + # Hand-sized manual trade, +1R → +1.0% at strategy sizing whatever its shares. + session.add(PaperTrade(**common, book=MANUAL_BOOK, shares=7, status="open", + opened_at=at(today - timedelta(days=3)))) + await session.commit() + await pts.settings_store.upsert_setting( + session, pts.KEY_PERFORMANCE_START, (today - timedelta(days=5)).isoformat() + ) + + summary = await pts.performance_summary(session, user_id=1) + + assert summary["equity_base"] == pytest.approx(105_000.0) + assert summary["risk_pct"] == pytest.approx(1.0) + last = summary["series"][-1] + assert last["shadow_pnl"] == pytest.approx(1050.0) + assert last["shadow_pct"] == pytest.approx(1.0) + assert last["manual_pct"] == pytest.approx(1.0) + assert last["spy_pct"] == pytest.approx(3.0) + assert summary["stats"]["shadow"]["pct"] == pytest.approx(1.0) + assert summary["stats"]["manual"]["pct"] == pytest.approx(1.0) + assert summary["stats"]["shadow"]["spy_pct"] == pytest.approx(3.0) + + @pytest.mark.asyncio + async def test_strategy_vs_spy_starts_at_the_first_shadow_trade(self, session): + """Manual trades opening first must not charge the strategy for SPY's run-up.""" + from app.models.benchmark_price import BenchmarkPrice + from app.models.paper_trade import PaperTrade + from app.models.ticker import Ticker + from app.models.user import User + + today = datetime.now(timezone.utc).date() + + def at(d): + return datetime(d.year, d.month, d.day, 20, tzinfo=timezone.utc) + + session.add(User(id=1, username="u", password_hash="x", role="user", has_access=True)) + ticker = Ticker(symbol="AAA") + session.add(ticker) + await session.flush() + for offset, close in ((6, 400.0), (2, 420.0), (0, 441.0)): + session.add(BenchmarkPrice(symbol="SPY", date=today - timedelta(days=offset), + close=close)) + common = dict(user_id=1, ticker_id=ticker.id, direction="long", entry_price=100.0, + stop_loss=90.0, target=150.0, fill_mode="near_close", shares=10, + status="open") + session.add(PaperTrade(**common, book=MANUAL_BOOK, opened_at=at(today - timedelta(days=6)))) + session.add(PaperTrade(**common, book=SHADOW_BOOK, opened_at=at(today - timedelta(days=2)))) + await session.commit() + + summary = await pts.performance_summary(session, user_id=1) + + # Full window 400 → 441 = +10.25%; the strategy's own window 420 → 441 = +5%. + assert summary["stats"]["spy"]["pct"] == pytest.approx(10.25) + assert summary["stats"]["shadow"]["spy_pct"] == pytest.approx(5.0) + assert summary["stats"]["shadow"]["since"] == (today - timedelta(days=2)).isoformat() + + class TestPerformanceStartDate: @pytest.fixture async def session(self):