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