"""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, ) -> 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, } 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) 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)) 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_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, 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. """ 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 ] # 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}