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