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