"""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