feat: shadow book + shadow-vs-manual performance comparison

The manual paper book only contains trades taken by hand, inside a 20
minute window, on days someone was available. The backtest that validated
this strategy auto-takes the top-ranked qualified setups up to capacity
every session. The forward record was therefore measuring strategy plus
discretion plus availability -- and degrading silently on busy days.

The shadow book closes that gap: it mirrors the backtest's selection rule
(top strategy_rank qualified, up to capacity, 1% fixed-fractional risk)
and shares the manual book's exit policy, so the only difference between
the two books is which setups get taken. Selection ordering reuses the
strategy_rank the scanner already stores rather than recomputing it, so
the two cannot drift apart. It runs as a near-close pipeline step right
after the scan, marking entries at the same prices a human would see.

Gate-reset re-entry state is now scoped per book -- the books diverge as
soon as their entries differ, and each must see only its own stops.

Performance view rewritten around the comparison:
  - three series (shadow, manual, SPY) from a new endpoint
  - SPY changes from a per-trade cost-basis counterfactual to plain
    buy-and-hold %, since one line has to serve two books
  - headline stats are R-multiples, not currency: the books size
    differently, so only R compares across them
  - configurable start date, because the strategy has been revised
    repeatedly and pre-cutover trades ran under rules that no longer
    exist

Migration 024 also repairs the numeric weekday crons written by 023,
rewriting only rows still holding the broken form so hand-corrected
settings survive. Its literals are inlined because bound parameters
render as NULL under 'alembic upgrade --sql'.

The shadow book is opt-in and writes nothing until enabled. Verify its
first selections match a backtest of that day's cross-section before
trusting any point on the curve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 23:44:41 +02:00
co-authored by Claude Fable 5
parent 29715ef3d1
commit ba2df8b9fd
17 changed files with 1334 additions and 40 deletions
+173 -1
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import bisect
import logging
from datetime import date, datetime, timezone
from sqlalchemy import and_, func, select
@@ -20,7 +21,9 @@ from app.services.outcome_service import (
Bar,
evaluate_setup_against_bars,
)
from app.services.trade_policy import get_reentry_gate_locks
from app.services.trade_policy import MANUAL_BOOK, SHADOW_BOOK, get_reentry_gate_locks
logger = logging.getLogger(__name__)
# Exit policy for OPEN paper trades (auto-close). Production defaults to the
# July 2026 promoted strategy: initial stop + 3x ATR trailing stop, with a max
@@ -690,6 +693,66 @@ def build_equity_curve(
return out
KEY_PERFORMANCE_START = "performance_start_date"
async def get_performance_start(db: AsyncSession) -> date | None:
"""Date the performance view starts from, or None for 'all history'.
The strategy has been revised repeatedly, so early trades were taken under
rules that no longer exist. Pinning a start date keeps the comparison inside
one regime instead of averaging across configurations that were replaced.
"""
raw = await settings_store.get_value(db, KEY_PERFORMANCE_START, "")
if not raw or not str(raw).strip():
return None
try:
return date.fromisoformat(str(raw).strip())
except ValueError:
logger.warning("invalid %s: %r", KEY_PERFORMANCE_START, raw)
return None
def trade_r_multiple(trade, mark: float | None) -> float | None:
"""Result in R — profit measured in units of the trade's own initial risk.
R is the only sizing-independent yardstick available here: the shadow book
sizes at a fixed 1% of equity while manual trades were sized by hand, so
currency P&L cannot compare them. Open trades are marked to ``mark``.
"""
risk_per_share = abs(trade.entry_price - trade.stop_loss)
if risk_per_share <= 0:
return None
exit_price = trade.close_price if trade.status == "closed" else mark
if exit_price is None:
return None
per_share = (
exit_price - trade.entry_price
if trade.direction == "long"
else trade.entry_price - exit_price
)
return per_share / risk_per_share
def book_stats(trades: list, marks: dict[int, float]) -> dict:
"""Sizing-independent summary of one book: counts, win rate, R-multiples."""
rs = [
r
for r in (trade_r_multiple(t, marks.get(t.ticker_id)) for t in trades)
if r is not None
]
closed = [t for t in trades if t.status == "closed"]
wins = [r for r in rs if r > 0]
return {
"trades": len(trades),
"closed": len(closed),
"open": len(trades) - len(closed),
"win_rate": round(100.0 * len(wins) / len(rs), 1) if rs else None,
"total_r": round(sum(rs), 2) if rs else 0.0,
"avg_r": round(sum(rs) / len(rs), 3) if rs else None,
}
async def equity_curve(db: AsyncSession, user_id: int) -> list[dict]:
"""Equity-curve series for a user's paper book (empty without benchmark data)."""
trades = (
@@ -714,3 +777,112 @@ async def equity_curve(db: AsyncSession, user_id: int) -> list[dict]:
for tid, day, close in rows.all():
ticker_closes.setdefault(tid, {})[day] = float(close)
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."""
sorted_dates = {tid: sorted(c) for tid, c in ticker_closes.items()}
out: list[float] = []
for d in days:
total = 0.0
for t in trades:
if t.opened_at.date() > d:
continue
closed_on = (
t.closed_at.date()
if (t.status == "closed" and t.closed_at is not None)
else None
)
if closed_on is not None and closed_on <= d and t.close_price is not None:
ref = float(t.close_price)
else:
ref = _value_on_or_before(
sorted_dates.get(t.ticker_id) or [],
ticker_closes.get(t.ticker_id) or {},
d,
)
if ref is None:
continue
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))
return out
async def performance_summary(db: AsyncSession) -> 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.
"""
start = await get_performance_start(db)
stmt = select(PaperTrade)
if start is not None:
stmt = stmt.where(func.date(PaperTrade.opened_at) >= start)
trades = list((await db.execute(stmt)).scalars().all())
benchmark_closes = await benchmark_service.load_benchmark_closes(db)
empty = {
"start_date": start.isoformat() if start else None,
"series": [],
"stats": {},
}
if not trades or not benchmark_closes:
return empty
first = min(t.opened_at.date() for t in trades)
if start is not None:
first = max(first, start)
days = [d for d in sorted(benchmark_closes) if d >= first]
if not days:
return empty
ticker_ids = {t.ticker_id for t in trades}
rows = await db.execute(
select(OHLCVRecord.ticker_id, OHLCVRecord.date, OHLCVRecord.close).where(
OHLCVRecord.ticker_id.in_(ticker_ids), OHLCVRecord.date >= first
)
)
ticker_closes: dict[int, dict[date, float]] = {}
for tid, day, close in rows.all():
ticker_closes.setdefault(tid, {})[day] = float(close)
books = {
MANUAL_BOOK: [t for t in trades if (t.book or MANUAL_BOOK) == MANUAL_BOOK],
SHADOW_BOOK: [t for t in trades if t.book == SHADOW_BOOK],
}
pnl = {
name: _cumulative_pnl(book_trades, ticker_closes, days)
for name, book_trades in books.items()
}
bench_dates = sorted(benchmark_closes)
spy0 = _value_on_or_before(bench_dates, benchmark_closes, days[0])
spy_pct = [
round(100.0 * (benchmark_closes[d] / spy0 - 1.0), 2) if spy0 else 0.0
for d in days
]
# 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
}
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["spy"] = {"pct": spy_pct[-1] if spy_pct else 0.0}
series = [
{
"date": d.isoformat(),
"manual_pnl": pnl[MANUAL_BOOK][i],
"shadow_pnl": pnl[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}