Files
signal-platform/app/services/paper_trade_service.py
T
dennisthiessenandClaude Opus 5.5 4876f9ffdd
Deploy / lint (push) Successful in 15s
Deploy / test (push) Successful in 1m57s
Deploy / deploy (push) Successful in 45s
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>
2026-10-09 09:26:24 +02:00

1055 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Paper-trading service: take, mark-to-market, and close simulated trades."""
from __future__ import annotations
import bisect
import logging
from datetime import date, datetime, timezone
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.exceptions import NotFoundError, ValidationError
from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade
from app.models.ticker import Ticker
from app.services import benchmark_service, settings_store
from app.services.outcome_service import (
OUTCOME_AMBIGUOUS,
OUTCOME_STOP_HIT,
OUTCOME_TARGET_HIT,
Bar,
evaluate_setup_against_bars,
)
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
# 30-trading-day hold. The older percent trail and target/stop modes remain
# selectable for comparison. Stored in SystemSetting so it's tunable and visible.
KEY_EXIT_MODE = "paper_exit_mode"
KEY_TRAILING_PCT = "paper_trailing_pct"
KEY_ATR_MULTIPLIER = "paper_atr_multiplier"
KEY_HOLD_DAYS = "paper_hold_days"
DEFAULT_EXIT_MODE = "atr_trailing"
DEFAULT_TRAILING_PCT = 12.0
DEFAULT_ATR_MULTIPLIER = 3.0
DEFAULT_HOLD_DAYS = 30
_VALID_EXIT_MODES = ("time", "trailing", "atr_trailing", "target")
async def get_exit_policy(db: AsyncSession) -> dict:
"""Active auto-exit policy:
{'mode': 'time'|'trailing'|'atr_trailing'|'target', ...}."""
mode = (await settings_store.get_value(db, KEY_EXIT_MODE, DEFAULT_EXIT_MODE)).strip().lower()
if mode not in _VALID_EXIT_MODES:
mode = DEFAULT_EXIT_MODE
raw = await settings_store.get_value(db, KEY_TRAILING_PCT, str(DEFAULT_TRAILING_PCT))
try:
pct = float(raw)
except (TypeError, ValueError):
pct = DEFAULT_TRAILING_PCT
pct = max(0.5, min(90.0, pct))
raw_atr = await settings_store.get_value(db, KEY_ATR_MULTIPLIER, str(DEFAULT_ATR_MULTIPLIER))
try:
atr_multiplier = float(raw_atr)
except (TypeError, ValueError):
atr_multiplier = DEFAULT_ATR_MULTIPLIER
atr_multiplier = max(0.5, min(10.0, atr_multiplier))
raw_days = await settings_store.get_value(db, KEY_HOLD_DAYS, str(DEFAULT_HOLD_DAYS))
try:
hold_days = int(float(raw_days))
except (TypeError, ValueError):
hold_days = DEFAULT_HOLD_DAYS
hold_days = max(2, min(250, hold_days))
return {
"mode": mode,
"trailing_pct": pct,
"atr_multiplier": atr_multiplier,
"hold_days": hold_days,
}
async def set_exit_policy(
db: AsyncSession,
*,
mode: str | None = None,
trailing_pct: float | None = None,
atr_multiplier: float | None = None,
hold_days: int | None = None,
) -> dict:
"""Persist the auto-exit policy (admin). Validates inputs."""
if mode is not None:
mode = mode.strip().lower()
if mode not in _VALID_EXIT_MODES:
raise ValidationError("mode must be 'time', 'trailing', 'atr_trailing' or 'target'")
await settings_store.upsert_setting(db, KEY_EXIT_MODE, mode)
if trailing_pct is not None:
if not 0.5 <= float(trailing_pct) <= 90.0:
raise ValidationError("trailing_pct must be between 0.5 and 90")
await settings_store.upsert_setting(db, KEY_TRAILING_PCT, str(float(trailing_pct)))
if atr_multiplier is not None:
if not 0.5 <= float(atr_multiplier) <= 10.0:
raise ValidationError("atr_multiplier must be between 0.5 and 10")
await settings_store.upsert_setting(db, KEY_ATR_MULTIPLIER, str(float(atr_multiplier)))
if hold_days is not None:
if not 2 <= int(hold_days) <= 250:
raise ValidationError("hold_days must be between 2 and 250")
await settings_store.upsert_setting(db, KEY_HOLD_DAYS, str(int(hold_days)))
await db.commit()
return await get_exit_policy(db)
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
normalised = symbol.strip().upper()
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
ticker = result.scalar_one_or_none()
if ticker is None:
raise NotFoundError(f"Ticker not found: {normalised}")
return ticker
async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, float]:
"""Latest stored close per ticker."""
if not ticker_ids:
return {}
latest = (
select(OHLCVRecord.ticker_id, func.max(OHLCVRecord.date).label("md"))
.where(OHLCVRecord.ticker_id.in_(ticker_ids))
.group_by(OHLCVRecord.ticker_id)
.subquery()
)
stmt = select(OHLCVRecord.ticker_id, OHLCVRecord.close).join(
latest,
and_(
OHLCVRecord.ticker_id == latest.c.ticker_id,
OHLCVRecord.date == latest.c.md,
),
)
result = await db.execute(stmt)
return {tid: float(close) for tid, close in result.all()}
async def _max_high_after(db: AsyncSession, ticker_id: int, since: date) -> float | None:
"""Highest high strictly after ``since`` — the running peak for a trailing stop."""
result = await db.execute(
select(func.max(OHLCVRecord.high)).where(
OHLCVRecord.ticker_id == ticker_id, OHLCVRecord.date > since
)
)
v = result.scalar()
return float(v) if v is not None else None
def _time_close(
direction: str, init_stop: float, hold_days: int, rows: list[tuple]
) -> tuple[float, date, str] | None:
"""Walk post-entry ``rows`` of (date, open, high, low, close); close at the
initial stop if hit (a gap through it fills at the open, matching the
backtest's fill model), else at the ``hold_days``-th bar's close ('time').
None while neither has happened."""
long = direction == "long"
for i, (d, open_, high, low, close) in enumerate(rows):
if (low <= init_stop) if long else (high >= init_stop):
fill = min(init_stop, open_) if long else max(init_stop, open_)
return float(fill), d, "stop"
if i + 1 >= hold_days:
return float(close), d, "time"
return None
def _trailing_close(
direction: str, entry: float, init_stop: float, trail_frac: float, bars: list[Bar]
) -> tuple[float, date, str] | None:
"""Walk post-entry bars; return (price, date, reason) when the trailing or initial
stop is hit, else None. The stop only ratchets up: max(init_stop, peak*(1-trail))
for a long. reason = 'trailing' once it's above the initial stop, else 'stop'."""
long = direction == "long"
peak = entry
for b in bars:
if long:
level = max(init_stop, peak * (1 - trail_frac))
if b.low <= level:
return level, b.date, ("trailing" if level > init_stop else "stop")
if b.high > peak:
peak = b.high
else:
level = min(init_stop, peak * (1 + trail_frac))
if b.high >= level:
return level, b.date, ("trailing" if level < init_stop else "stop")
if b.low < peak:
peak = b.low
return None
def _atr_series_from_rows(rows: list[tuple], period: int = 14) -> list[float | None]:
"""ATR at each index i, equal to ``compute_atr(rows[: i + 1])["atr"]`` but
computed in a single O(n) Wilder pass instead of re-smoothing the whole
prefix per bar. None where there are fewer than ``period + 1`` bars or the
rounded ATR is non-positive. ``period`` mirrors ``compute_atr``'s default;
keep them in sync.
Exactness: ``compute_atr`` keeps its running ATR unrounded through the
recurrence and rounds only at return, so storing ``round(running, 4)`` at
each index reproduces its per-prefix value bit-for-bit.
"""
n = len(rows)
out: list[float | None] = [None] * n
if n < period + 1:
return out
tr = [0.0] * n
for i in range(1, n):
high, low, prev_close = float(rows[i][2]), float(rows[i][3]), float(rows[i - 1][4])
tr[i] = max(high - low, abs(high - prev_close), abs(low - prev_close))
running = sum(tr[1 : period + 1]) / period
rounded = round(running, 4)
out[period] = rounded if rounded > 0 else None
for j in range(period + 1, n):
running = (running * (period - 1) + tr[j]) / period
rounded = round(running, 4)
out[j] = rounded if rounded > 0 else None
return out
def _atr_trailing_level(
direction: str,
entry: float,
init_stop: float,
atr_multiplier: float,
rows: list[tuple],
opened_on: date,
) -> float:
"""Current ATR trailing stop level after all available post-entry closes."""
long = direction == "long"
stop = float(init_stop)
anchor = float(entry)
atr_by_idx = _atr_series_from_rows(rows)
for idx, (d, _, _, _, close) in enumerate(rows):
if d <= opened_on:
continue
close = float(close)
atr = atr_by_idx[idx]
if long:
anchor = max(anchor, close)
if atr is not None:
next_stop = anchor - atr_multiplier * atr
if next_stop < close:
stop = max(stop, next_stop)
else:
anchor = min(anchor, close)
if atr is not None:
next_stop = anchor + atr_multiplier * atr
if next_stop > close:
stop = min(stop, next_stop)
return stop
def _atr_trailing_close(
direction: str,
entry: float,
init_stop: float,
atr_multiplier: float,
hold_days: int,
rows: list[tuple],
opened_on: date,
) -> tuple[float, date, str] | None:
"""Initial stop + ATR trailing stop + max hold, matching the portfolio sim.
Stop checks happen before the same day's trailing update, so a newly ratcheted
stop becomes active on the next bar. Gaps through the stop fill at the open.
"""
long = direction == "long"
stop = float(init_stop)
anchor = float(entry)
bars_held = 0
atr_by_idx = _atr_series_from_rows(rows)
for idx, (d, open_, high, low, close) in enumerate(rows):
if d <= opened_on:
continue
open_ = float(open_)
high = float(high)
low = float(low)
close = float(close)
bars_held += 1
if long:
if low <= stop:
reason = "trailing" if stop > init_stop + 1e-9 else "stop"
return min(stop, open_), d, reason
else:
if high >= stop:
reason = "trailing" if stop < init_stop - 1e-9 else "stop"
return max(stop, open_), d, reason
if bars_held >= hold_days:
return close, d, "time"
atr = atr_by_idx[idx]
if long:
anchor = max(anchor, close)
if atr is not None:
next_stop = anchor - atr_multiplier * atr
if next_stop < close:
stop = max(stop, next_stop)
else:
anchor = min(anchor, close)
if atr is not None:
next_stop = anchor + atr_multiplier * atr
if next_stop > close:
stop = min(stop, next_stop)
return None
async def create_trade(
db: AsyncSession,
user_id: int,
*,
symbol: str,
direction: str,
entry_price: float,
shares: float,
stop_loss: float,
target: float,
) -> PaperTrade:
direction = direction.strip().lower()
if direction not in ("long", "short"):
raise ValidationError("direction must be 'long' or 'short'")
if shares <= 0 or entry_price <= 0:
raise ValidationError("shares and entry_price must be positive")
ticker = await _get_ticker(db, symbol)
if ticker.id in await get_reentry_gate_locks(db):
raise ValidationError(
f"{ticker.symbol} requires a post-stop gate reset before re-entry"
)
trade = PaperTrade(
user_id=user_id,
ticker_id=ticker.id,
direction=direction,
entry_price=entry_price,
shares=shares,
stop_loss=stop_loss,
target=target,
status="open",
opened_at=datetime.now(timezone.utc),
# Near-close cutover era — Track Record must not mix with morning-scan
# fills or future broker-routed fills when comparing to backtests.
fill_mode="near_close",
)
db.add(trade)
await db.commit()
await db.refresh(trade)
return trade
def _to_dict(
trade: PaperTrade,
symbol: str,
current_price: float | None,
benchmark_closes: dict[date, float] | None = None,
trailing: tuple[float, float | None] | None = None,
holding_sessions: tuple[int, int] | None = None,
) -> dict:
# For open trades, mark to market; for closed, the realized exit price.
ref = current_price if trade.status == "open" else trade.close_price
# Alpha = trade return − benchmark (SPY) return over the same holding period.
benchmark_return = None
alpha_pct = None
alpha_usd = None
if ref is not None and trade.entry_price and benchmark_closes:
sign = 1.0 if trade.direction == "long" else -1.0
trade_return = (ref - trade.entry_price) / trade.entry_price * 100.0 * sign
as_of = (
trade.closed_at.date()
if trade.status == "closed" and trade.closed_at is not None
else date.today()
)
benchmark_return = benchmark_service.benchmark_return_pct(
benchmark_closes, trade.opened_at.date(), as_of
)
if benchmark_return is not None:
alpha_pct = trade_return - benchmark_return
alpha_usd = alpha_pct / 100.0 * trade.entry_price * trade.shares
return {
"id": trade.id,
"symbol": symbol,
"direction": trade.direction,
"entry_price": trade.entry_price,
"shares": trade.shares,
"stop_loss": trade.stop_loss,
"target": trade.target,
"status": trade.status,
"opened_at": trade.opened_at,
"close_price": trade.close_price,
"closed_at": trade.closed_at,
"current_price": ref,
"benchmark_return_pct": benchmark_return,
"alpha_pct": alpha_pct,
"alpha_usd": alpha_usd,
"close_reason": trade.close_reason,
"fill_mode": trade.fill_mode,
"trailing_stop": trailing[0] if trailing else None,
"trailing_distance_pct": trailing[1] if trailing else None,
"sessions_held": holding_sessions[0] if holding_sessions else None,
"sessions_remaining": holding_sessions[1] if holding_sessions else None,
}
async def list_trades(
db: AsyncSession,
user_id: int | None = None,
status: str | None = None,
book: str | None = MANUAL_BOOK,
) -> list[dict]:
"""Trades for the UI. Defaults to the discretionary book.
Shadow trades are attached to a user row for FK reasons only — they are not
that person's decisions. Listing them alongside manual trades would mix two
different books in one P&L and let the autonomous record be edited by hand.
Pass ``book=None`` to deliberately span both.
"""
stmt = (
select(PaperTrade, Ticker.symbol)
.join(Ticker, PaperTrade.ticker_id == Ticker.id)
)
if user_id is not None: # None → all users (single-user app; used by the digest)
stmt = stmt.where(PaperTrade.user_id == user_id)
if status is not None:
stmt = stmt.where(PaperTrade.status == status)
if book is not None:
stmt = stmt.where(PaperTrade.book == book)
stmt = stmt.order_by(PaperTrade.opened_at.desc())
rows = (await db.execute(stmt)).all()
open_ids = {t.ticker_id for t, _ in rows if t.status == "open"}
prices = await _latest_closes(db, open_ids)
# Benchmark closes for alpha — populated by the daily/benchmark job. Empty until
# that runs once, in which case alpha is simply left unset (a read path never
# makes a provider call).
benchmark_closes = await benchmark_service.load_benchmark_closes(db)
# Current trailing-stop level + distance for open trades (when a trailing
# policy is active).
policy = await get_exit_policy(db)
holding_sessions: dict[int, tuple[int, int]] = {}
if policy["mode"] in ("time", "atr_trailing"):
hold_days = int(policy["hold_days"])
open_trades = [trade for trade, _ in rows if trade.status == "open"]
if open_trades:
ticker_ids = {trade.ticker_id for trade in open_trades}
earliest_opened = min(trade.opened_at.date() for trade in open_trades)
session_rows = (
await db.execute(
select(OHLCVRecord.ticker_id, OHLCVRecord.date)
.where(
OHLCVRecord.ticker_id.in_(ticker_ids),
OHLCVRecord.date > earliest_opened,
)
.order_by(OHLCVRecord.ticker_id, OHLCVRecord.date)
)
).all()
dates_by_ticker: dict[int, list[date]] = {}
for ticker_id, session_date in session_rows:
dates_by_ticker.setdefault(int(ticker_id), []).append(session_date)
for trade in open_trades:
dates = dates_by_ticker.get(trade.ticker_id, [])
held = len(dates) - bisect.bisect_right(
dates, trade.opened_at.date()
)
# Do not clamp: a policy shortened below the current holding
# period must remain visible as overdue until the exit pass runs.
holding_sessions[trade.id] = (held, hold_days - held)
trailing_info: dict[int, tuple[float, float | None]] = {}
if policy["mode"] == "trailing":
trail_frac = policy["trailing_pct"] / 100.0
for t, _ in rows:
if t.status != "open":
continue
max_high = await _max_high_after(db, t.ticker_id, t.opened_at.date())
peak = max(t.entry_price, max_high) if max_high is not None else t.entry_price
long = t.direction == "long"
level = (
max(t.stop_loss, peak * (1 - trail_frac))
if long
else min(t.stop_loss, peak * (1 + trail_frac))
)
cur = prices.get(t.ticker_id)
dist = None
if cur:
dist = ((cur - level) / cur * 100.0) if long else ((level - cur) / cur * 100.0)
trailing_info[t.id] = (level, dist)
elif policy["mode"] == "atr_trailing":
atr_multiplier = float(policy["atr_multiplier"])
for t, _ in rows:
if t.status != "open":
continue
bars_result = await db.execute(
select(
OHLCVRecord.date, OHLCVRecord.open, OHLCVRecord.high,
OHLCVRecord.low, OHLCVRecord.close,
)
.where(OHLCVRecord.ticker_id == t.ticker_id)
.order_by(OHLCVRecord.date.asc())
)
level = _atr_trailing_level(
t.direction,
t.entry_price,
t.stop_loss,
atr_multiplier,
bars_result.all(),
t.opened_at.date(),
)
cur = prices.get(t.ticker_id)
dist = None
if cur:
long = t.direction == "long"
dist = ((cur - level) / cur * 100.0) if long else ((level - cur) / cur * 100.0)
trailing_info[t.id] = (level, dist)
return [
_to_dict(
t,
sym,
prices.get(t.ticker_id),
benchmark_closes,
trailing_info.get(t.id),
holding_sessions.get(t.id),
)
for t, sym in rows
]
async def close_trade(
db: AsyncSession,
user_id: int,
trade_id: int,
close_price: float | None = None,
) -> PaperTrade:
result = await db.execute(
select(PaperTrade).where(
PaperTrade.id == trade_id,
PaperTrade.user_id == user_id,
)
)
trade = result.scalar_one_or_none()
if trade is None:
raise NotFoundError(f"Paper trade not found: {trade_id}")
if trade.book == SHADOW_BOOK:
# The shadow book's value is that no human touched it. A hand-closed
# position would make its record something other than what the strategy
# would have done; it exits only via the automatic exit policy.
raise ValidationError(
"Shadow book trades are closed by the exit policy, not by hand"
)
if trade.status == "closed":
raise ValidationError("Trade is already closed")
if close_price is None:
prices = await _latest_closes(db, {trade.ticker_id})
close_price = prices.get(trade.ticker_id)
if close_price is None:
raise ValidationError("No current price available to close at; supply close_price")
trade.status = "closed"
trade.close_price = float(close_price)
trade.close_reason = "manual"
trade.closed_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(trade)
return trade
async def resolve_open_trades(db: AsyncSession) -> int:
"""Auto-close open trades per the active exit policy, from the daily bars.
Walks the bars after each trade's open. 'atr_trailing' closes at the initial
stop, a 3x-ATR-style trailing stop, or the hold_days-th close; 'time' closes
at the initial stop or the hold_days-th close; 'trailing' uses the legacy
percent trail; 'target' uses the setup's target or stop. Trades that have hit
nothing stay open. Returns the count closed.
"""
result = await db.execute(select(PaperTrade).where(PaperTrade.status == "open"))
open_trades = list(result.scalars().all())
if not open_trades:
return 0
policy = await get_exit_policy(db)
mode = policy["mode"]
trail_frac = policy["trailing_pct"] / 100.0
atr_multiplier = float(policy["atr_multiplier"])
hold_days = policy["hold_days"]
closed = 0
for trade in open_trades:
bars_result = await db.execute(
select(
OHLCVRecord.date, OHLCVRecord.open, OHLCVRecord.high,
OHLCVRecord.low, OHLCVRecord.close,
)
.where(
OHLCVRecord.ticker_id == trade.ticker_id,
OHLCVRecord.date > trade.opened_at.date(),
)
.order_by(OHLCVRecord.date.asc())
)
post_rows = bars_result.all()
bars = [Bar(date=d, high=h, low=lo) for d, _, h, lo, _ in post_rows]
if not bars:
continue
if mode == "time":
hit = _time_close(trade.direction, trade.stop_loss, hold_days, post_rows)
if hit is None:
continue # neither the stop nor the hold horizon reached yet
close_price, close_date, reason = hit
elif mode == "trailing":
hit = _trailing_close(trade.direction, trade.entry_price, trade.stop_loss, trail_frac, bars)
if hit is None:
continue # neither the trailing nor the initial stop reached yet
close_price, close_date, reason = hit
elif mode == "atr_trailing":
all_bars_result = await db.execute(
select(
OHLCVRecord.date, OHLCVRecord.open, OHLCVRecord.high,
OHLCVRecord.low, OHLCVRecord.close,
)
.where(OHLCVRecord.ticker_id == trade.ticker_id)
.order_by(OHLCVRecord.date.asc())
)
hit = _atr_trailing_close(
trade.direction,
trade.entry_price,
trade.stop_loss,
atr_multiplier,
hold_days,
all_bars_result.all(),
trade.opened_at.date(),
)
if hit is None:
continue
close_price, close_date, reason = hit
else:
# max_bars beyond the data so a still-open trade returns undecided (not "expired").
outcome, outcome_date = evaluate_setup_against_bars(
trade.direction, trade.stop_loss, trade.target, bars, max_bars=len(bars) + 1
)
if outcome == OUTCOME_TARGET_HIT:
close_price, close_date, reason = trade.target, outcome_date, "target"
elif outcome in (OUTCOME_STOP_HIT, OUTCOME_AMBIGUOUS):
close_price, close_date, reason = trade.stop_loss, outcome_date, "stop"
else:
continue
trade.status = "closed"
trade.close_price = float(close_price)
trade.close_reason = reason
trade.closed_at = datetime.combine(close_date, datetime.min.time(), tzinfo=timezone.utc)
closed += 1
if closed:
await db.commit()
return closed
# ---------------------------------------------------------------------------
# Equity curve — the paper book's cumulative P&L vs the same dollars in SPY.
def _value_on_or_before(
dates_sorted: list[date], closes: dict[date, float], target: date
) -> float | None:
"""Close on the nearest trading day at or before ``target`` (None if before history)."""
idx = bisect.bisect_right(dates_sorted, target) - 1
return closes[dates_sorted[idx]] if idx >= 0 else None
def build_equity_curve(
trades: list,
ticker_closes: dict[int, dict[date, float]],
benchmark_closes: dict[date, float],
) -> list[dict]:
"""Daily cumulative P&L of the paper book vs a benchmark counterfactual.
For every benchmark trading day since the first trade opened:
book_pnl = Σ realized P&L of trades closed by then
+ Σ mark-to-market P&L of trades still open (ticker close
on/before that day)
benchmark_pnl = Σ per trade: the SAME cost basis (entry x shares) riding
the benchmark over the SAME window (open → close/now).
Long-benchmark regardless of trade direction — the
question is "what if this money had just sat in SPY".
Pure function so the math is unit-testable; trades are duck-typed
(ticker_id, direction, entry_price, shares, status, opened_at, closed_at,
close_price). Trades opened before the stored benchmark history contribute
to book_pnl but not to benchmark_pnl (no baseline close to measure from).
"""
if not trades or not benchmark_closes:
return []
first = min(t.opened_at.date() for t in trades)
bench_dates = sorted(benchmark_closes)
days = [d for d in bench_dates if d >= first]
if not days:
return []
ticker_dates_sorted = {tid: sorted(c) for tid, c in ticker_closes.items()}
out: list[dict] = []
for d in days:
book = 0.0
bench = 0.0
any_priced = False
for t in trades:
opened = t.opened_at.date()
if opened > d:
continue
closed_on = (
t.closed_at.date()
if (t.status == "closed" and t.closed_at is not None)
else None
)
window_end = min(d, closed_on) if closed_on is not None else d
if closed_on is not None and closed_on <= d and t.close_price is not None:
ref = float(t.close_price)
else:
closes = ticker_closes.get(t.ticker_id) or {}
ref_val = _value_on_or_before(
ticker_dates_sorted.get(t.ticker_id) or [], closes, d
)
if ref_val is None:
continue
ref = ref_val
per_share = (
ref - t.entry_price if t.direction == "long" else t.entry_price - ref
)
book += per_share * t.shares
any_priced = True
s0 = _value_on_or_before(bench_dates, benchmark_closes, opened)
s1 = _value_on_or_before(bench_dates, benchmark_closes, window_end)
if s0 and s1:
bench += (t.entry_price * t.shares) * (s1 - s0) / s0
if any_priced:
out.append(
{
"date": d.isoformat(),
"book_pnl": round(book, 2),
"benchmark_pnl": round(bench, 2),
}
)
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 = (
(await db.execute(select(PaperTrade).where(PaperTrade.user_id == user_id)))
.scalars()
.all()
)
if not trades:
return []
benchmark_closes = await benchmark_service.load_benchmark_closes(db)
if not benchmark_closes:
return []
first = min(t.opened_at.date() for t in trades)
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)
return build_equity_curve(list(trades), ticker_closes, benchmark_closes)
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:
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 * 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.
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:
stmt = stmt.where(func.date(PaperTrade.opened_at) >= start)
if user_id is not None:
# "Your picks" must be *yours*. The shadow book is a single autonomous
# book with no owner, so it is never scoped to a user.
stmt = stmt.where(
(PaperTrade.book == SHADOW_BOOK) | (PaperTrade.user_id == user_id)
)
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
]
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
}
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,
"equity_base": round(equity_base, 2),
"risk_pct": config["risk_pct"],
"series": series,
"stats": stats,
}