File size: 4,358 Bytes
0821f38 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 | """Performance statistics for wavelet strategy backtests.
Stats dataclass mirrors what an allocator would ask for:
CAGR, Sharpe, Sortino, Max DD, Calmar, hit rate, turnover, correlation.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
import numpy as np
import pandas as pd
@dataclass
class Stats:
name: str
years: float
total_return: float
cagr: float
volatility: float
sharpe: float
sortino: float
max_drawdown: float
calmar: float
hit_rate: float
annual_turnover: float
time_in_market: float
avg_gross_leverage: float
correlation_to_spy: Optional[float] = None
extra: dict = field(default_factory=dict)
def as_dict(self) -> dict:
return {
"Name": self.name,
"Years": f"{self.years:.1f}",
"Total Return": f"{self.total_return:.1%}",
"CAGR": f"{self.cagr:.2%}",
"Volatility": f"{self.volatility:.2%}",
"Sharpe": f"{self.sharpe:.3f}",
"Sortino": f"{self.sortino:.3f}",
"Max Drawdown": f"{self.max_drawdown:.2%}",
"Calmar": f"{self.calmar:.3f}",
"Hit Rate": f"{self.hit_rate:.1%}",
"Annual Turnover": f"{self.annual_turnover:.1f}×",
"Time in Market": f"{self.time_in_market:.1%}",
"Avg Gross Leverage": f"{self.avg_gross_leverage:.2f}×",
"Corr to SPY": f"{self.correlation_to_spy:.2f}" if self.correlation_to_spy is not None else "—",
}
def perf(
returns: pd.Series,
positions: pd.Series,
name: str = "Strategy",
benchmark: Optional[pd.Series] = None,
cost_bps: float = 1.0,
ann_factor: int = 252,
) -> Stats:
"""Compute full performance statistics from a daily returns series.
Args:
returns: daily P&L series (after costs), aligned with calendar
positions: daily position series (gross leverage proxy)
name: strategy label
benchmark: optional benchmark return series for correlation
cost_bps: round-trip cost in basis points (informational only; should
already be baked into `returns`)
ann_factor: trading days per year (default 252)
Returns:
Stats dataclass with all metrics.
"""
rets = returns.dropna()
if len(rets) == 0:
return Stats(
name=name, years=0, total_return=0, cagr=0, volatility=0,
sharpe=0, sortino=0, max_drawdown=0, calmar=0,
hit_rate=0, annual_turnover=0, time_in_market=0, avg_gross_leverage=0,
)
years = len(rets) / ann_factor
total_return = (1 + rets).prod() - 1
cagr = (1 + total_return) ** (1 / years) - 1 if years > 0 else 0.0
vol = rets.std() * np.sqrt(ann_factor)
sharpe = (rets.mean() * ann_factor) / vol if vol > 0 else 0.0
downside = rets[rets < 0]
downside_vol = downside.std() * np.sqrt(ann_factor) if len(downside) > 0 else 1e-9
sortino = (rets.mean() * ann_factor) / downside_vol if downside_vol > 0 else 0.0
cumulative = (1 + rets).cumprod()
rolling_max = cumulative.cummax()
drawdowns = cumulative / rolling_max - 1
max_dd = float(drawdowns.min())
calmar = cagr / abs(max_dd) if max_dd != 0 else 0.0
hit_rate = float((rets > 0).mean())
pos_aligned = positions.reindex(rets.index).fillna(0)
turnover = pos_aligned.diff().abs().sum() / years if years > 0 else 0.0
time_in_market = float((pos_aligned.abs() > 0.01).mean())
avg_gross_leverage = float(pos_aligned.abs().mean())
corr = None
if benchmark is not None:
bm = benchmark.reindex(rets.index).dropna()
common = rets.reindex(bm.index).dropna()
bm_aligned = bm.reindex(common.index).dropna()
common = common.reindex(bm_aligned.index)
if len(common) > 30:
corr = float(common.corr(bm_aligned))
return Stats(
name=name,
years=years,
total_return=total_return,
cagr=cagr,
volatility=vol,
sharpe=sharpe,
sortino=sortino,
max_drawdown=max_dd,
calmar=calmar,
hit_rate=hit_rate,
annual_turnover=turnover,
time_in_market=time_in_market,
avg_gross_leverage=avg_gross_leverage,
correlation_to_spy=corr,
)
|