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

+113 -9
View File
@@ -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,
}