Source code for trader.performance.benchmark

"""Account ROI vs. a market benchmark (requirements §12.1, GitLab issue #19).

**Not the same feature as the backtest's buy-and-hold baseline** (MR !41,
`trader.backtest.metrics.buy_and_hold` + `trader.backtest.engine`). That one
answers "did this strategy beat holding the SAME symbol it traded", computed
from simulated bars over a backtest window. This module answers a different
question: "did the whole real ACCOUNT, over its real `account_snapshots`
history (requirements §10), beat a market INDEX" — QQQ/VOO by default,
configurable via `config/reporting.yaml`. It is a portfolio-level report over
live data, not a per-symbol simulation.

It deliberately REUSES `total_return_pct` and `buy_and_hold` from
`trader.backtest.metrics` rather than reimplementing them: both are pure
functions over two Decimal numbers with no backtest-specific assumption baked
in (no slippage, no whole-share constraint), so there is nothing here to
duplicate — only to call.
"""

from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from decimal import ROUND_HALF_UP, Decimal

from trader.backtest.metrics import buy_and_hold, total_return_pct
from trader.domain import Bar
from trader.errors import PerformanceError
from trader.persistence.models import AccountSnapshot

__all__ = [
    "AccountReturn",
    "BenchmarkReturn",
    "account_return",
    "benchmark_return",
    "excess_return_pct",
]

_PCT_PRECISION = Decimal("0.01")

#: A return needs two points — one cannot show a change. Named rather than a
#: bare `2` at each comparison site, so `ruff`'s magic-value check does not
#: flag either as an unexplained literal.
_MIN_POINTS_FOR_A_RETURN = 2


def _pct(value: Decimal) -> Decimal:
    """Round a computed return to 2 places for display.

    Applied once, at the point a percentage is produced from a division — the
    `Decimal` division itself is never rounded away from full precision before
    this, and this function is never handed a raw price or balance.
    """
    return value.quantize(_PCT_PRECISION, rounding=ROUND_HALF_UP)


[docs] @dataclass(frozen=True, slots=True) class AccountReturn: """The account's real return between the earliest and latest snapshot in a window.""" start_at: datetime end_at: datetime starting_value: Decimal ending_value: Decimal return_pct: Decimal #: How many snapshots the window actually spanned — surfaced so a report #: built from exactly 2 sparse snapshots can be told apart from one built #: from a dense history, even though both produce one first/last pair. snapshot_count: int
[docs] @dataclass(frozen=True, slots=True) class BenchmarkReturn: """A buy-and-hold return for one ticker over the SAME window as an `AccountReturn`.""" ticker: str start_at: datetime end_at: datetime starting_price: Decimal ending_price: Decimal #: What `starting_value` (an `AccountReturn`'s) would be worth today had it #: bought this ticker instead, fractional shares — see `buy_and_hold`'s own #: docstring for why fractional, not whole, shares are used here too. ending_value: Decimal return_pct: Decimal
[docs] def account_return(snapshots: Sequence[AccountSnapshot]) -> AccountReturn: """The account's return from its earliest to its latest snapshot in `snapshots`. Ordered by `(captured_at, id)` before taking first/last — the same tie-break `SnapshotRepository` itself uses — so a caller that already queried in order is not silently trusted, and one that did not is corrected rather than producing a return over the wrong pair. Raises: PerformanceError: fewer than 2 snapshots. A single point cannot show a return, and reporting 0.00% would read as "flat" rather than "unmeasurable" — the same reasoning `run_backtest` uses for a too-short window. """ if len(snapshots) < _MIN_POINTS_FOR_A_RETURN: raise PerformanceError( "Need at least 2 account snapshots to compute a return " f"(got {len(snapshots)}). Run 'trader account' at least twice, " "spanning the period you want measured." ) ordered = sorted(snapshots, key=lambda s: (s.captured_at, s.id)) first, last = ordered[0], ordered[-1] return AccountReturn( start_at=first.captured_at, end_at=last.captured_at, starting_value=first.portfolio_value, ending_value=last.portfolio_value, return_pct=_pct(total_return_pct(first.portfolio_value, last.portfolio_value)), snapshot_count=len(ordered), )
[docs] def benchmark_return( ticker: str, bars: Sequence[Bar], capital: Decimal ) -> BenchmarkReturn: """The buy-and-hold return `ticker` would have produced over `bars`. Marked close-to-close, not open-to-open or with slippage applied: this is a REPORT of what a lump-sum buy-and-hold would have returned over the account's snapshot window, not an executable order, so there is no fill price to model. Raises: PerformanceError: fewer than 2 bars. Same reasoning as `account_return` — a single bar cannot show a return. """ if len(bars) < _MIN_POINTS_FOR_A_RETURN: raise PerformanceError( f"Need at least 2 bars for {ticker} to compute a buy-and-hold " f"return, got {len(bars)}. Widen the date range." ) ordered = sorted(bars, key=lambda b: b.timestamp) first, last = ordered[0], ordered[-1] ending_value, pct = buy_and_hold(capital, first.close, last.close) return BenchmarkReturn( ticker=ticker, start_at=first.timestamp, end_at=last.timestamp, starting_price=first.close, ending_price=last.close, ending_value=ending_value, return_pct=_pct(pct), )
[docs] def excess_return_pct(account: AccountReturn, benchmark: BenchmarkReturn) -> Decimal: """How much the account beat (or lagged) `benchmark`. Negative is a real answer. Both operands are already display-rounded `Decimal`s (via `_pct`), so this subtraction loses no precision worth quantizing again — unlike `BacktestResult.excess_return_pct` (MR !41), which is a `@property` on one object because both figures live together there; here the two figures come from two different functions, so this is a plain function instead. """ return account.return_pct - benchmark.return_pct