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 <noreply@anthropic.com>
This commit is contained in:
1 parent
9c48eb20c5
commit
4876f9ffdd
4 files changed
+405
-87
No files matched your search
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user