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