Source code for trader.backtest.metrics

"""Backtest performance metrics (requirements §12).

Pure functions over numbers so they can be checked against hand-computed
values without constructing a simulation.
"""

from collections.abc import Sequence
from decimal import Decimal

__all__ = ["buy_and_hold", "max_drawdown_pct", "total_return_pct", "win_loss"]

_HUNDRED = Decimal(100)


[docs] def total_return_pct(starting_value: Decimal, ending_value: Decimal) -> Decimal: """Percentage change from start to end. Zero start yields zero.""" if starting_value == 0: return Decimal(0) return (ending_value - starting_value) / starting_value * _HUNDRED
[docs] def max_drawdown_pct(equity_curve: Sequence[Decimal]) -> Decimal: """Largest peak-to-trough decline, as a positive percentage. Measured against the running peak, not the starting value: a curve that ends higher than it began can still have suffered a severe drawdown. """ peak = Decimal(0) worst = Decimal(0) for value in equity_curve: peak = max(peak, value) if peak > 0: drawdown = (peak - value) / peak * _HUNDRED worst = max(worst, drawdown) return worst
[docs] def buy_and_hold( capital: Decimal, buy_price: Decimal, final_price: Decimal ) -> tuple[Decimal, Decimal]: """Ending value and return of putting `capital` into one symbol and holding. The do-nothing baseline every strategy result has to be read against: a strategy that returns 20% while the symbol itself returned 55% destroyed value while looking like a success. **Fractional shares, deliberately** — unlike the simulated strategy, which buys whole shares because a real order is for whole shares. Whole shares here would leave the remainder in cash and dampen the baseline by that fraction, which flatters the strategy for a reason that has nothing to do with the strategy: at `capital` 1000 and a 600 price, one share plus 400 idle cash tracks only 60% of the symbol's move. Worse, when the capital buys no shares at all the baseline would read exactly 0.00% — a number indistinguishable from a flat market, which is the failure `BacktestError`-on-a-short-window exists to prevent. Fractional shares make the baseline the symbol's own return, always, which is what "we could have just held it" means. Raises: ValueError: `buy_price` is not positive. Returning zero would be a confident number derived from unusable data. """ if buy_price <= 0: raise ValueError( f"a buy-and-hold baseline needs a positive buy price, got {buy_price}" ) ending_value = capital * final_price / buy_price return ending_value, (final_price - buy_price) / buy_price * _HUNDRED
[docs] def win_loss(pnls: Sequence[Decimal]) -> tuple[int, int]: """Count winning and losing trades. Break-even counts as neither.""" wins = sum(1 for p in pnls if p > 0) losses = sum(1 for p in pnls if p < 0) return wins, losses