$npx -y skills add agiprolabs/claude-trading-skills --skill portfolio-analyticsPortfolio-level performance measurement including return metrics, risk metrics, risk-adjusted ratios, rolling analysis, and HTML reports
| 1 | # Portfolio Analytics |
| 2 | |
| 3 | Compute portfolio-level performance metrics from equity curves and trade logs. Covers return metrics, risk metrics, risk-adjusted ratios, drawdown analysis, rolling windows, benchmark comparison, trade-level statistics, and automated HTML report generation via quantstats. |
| 4 | |
| 5 | ## When to Use This Skill |
| 6 | |
| 7 | - After backtesting a strategy (e.g., from `vectorbt` or `strategy-framework`) |
| 8 | - Comparing multiple strategies or parameter sets side-by-side |
| 9 | - Generating investor-ready performance reports |
| 10 | - Evaluating live trading performance against benchmarks |
| 11 | - Assessing risk-adjusted returns for portfolio allocation decisions |
| 12 | |
| 13 | ## Prerequisites |
| 14 | |
| 15 | ```bash |
| 16 | uv pip install pandas numpy quantstats |
| 17 | ``` |
| 18 | |
| 19 | ## Input Format |
| 20 | |
| 21 | All analytics start from an **equity curve** — a time-indexed Series of portfolio values: |
| 22 | |
| 23 | ```python |
| 24 | import pandas as pd |
| 25 | import numpy as np |
| 26 | |
| 27 | # From a backtest |
| 28 | equity = pd.Series( |
| 29 | [10000, 10150, 10080, 10320, 10510, 10440, 10680], |
| 30 | index=pd.date_range("2025-01-01", periods=7, freq="D"), |
| 31 | name="strategy_equity" |
| 32 | ) |
| 33 | |
| 34 | # Convert to returns |
| 35 | returns = equity.pct_change().dropna() |
| 36 | ``` |
| 37 | |
| 38 | ## Return Metrics |
| 39 | |
| 40 | ### Total Return |
| 41 | |
| 42 | ```python |
| 43 | total_return = (equity.iloc[-1] / equity.iloc[0]) - 1 |
| 44 | ``` |
| 45 | |
| 46 | ### CAGR (Compound Annual Growth Rate) |
| 47 | |
| 48 | ```python |
| 49 | days = (equity.index[-1] - equity.index[0]).days |
| 50 | cagr = (equity.iloc[-1] / equity.iloc[0]) ** (365.25 / days) - 1 |
| 51 | ``` |
| 52 | |
| 53 | ### Daily Mean Return |
| 54 | |
| 55 | ```python |
| 56 | daily_mean = returns.mean() |
| 57 | annualized_mean = daily_mean * 252 # trading days |
| 58 | ``` |
| 59 | |
| 60 | ### Cumulative Returns |
| 61 | |
| 62 | ```python |
| 63 | cumulative = (1 + returns).cumprod() - 1 |
| 64 | ``` |
| 65 | |
| 66 | ## Risk Metrics |
| 67 | |
| 68 | ### Annualized Volatility |
| 69 | |
| 70 | ```python |
| 71 | daily_vol = returns.std() |
| 72 | annual_vol = daily_vol * np.sqrt(252) |
| 73 | ``` |
| 74 | |
| 75 | ### Value at Risk (VaR) |
| 76 | |
| 77 | Historical VaR at a given confidence level: |
| 78 | |
| 79 | ```python |
| 80 | def historical_var(returns: pd.Series, confidence: float = 0.95) -> float: |
| 81 | """Compute historical VaR. |
| 82 | |
| 83 | Args: |
| 84 | returns: Daily return series. |
| 85 | confidence: Confidence level (e.g., 0.95 for 95%). |
| 86 | |
| 87 | Returns: |
| 88 | VaR as a positive number representing potential loss. |
| 89 | """ |
| 90 | return -np.percentile(returns, (1 - confidence) * 100) |
| 91 | ``` |
| 92 | |
| 93 | ### Conditional VaR (CVaR / Expected Shortfall) |
| 94 | |
| 95 | ```python |
| 96 | def historical_cvar(returns: pd.Series, confidence: float = 0.95) -> float: |
| 97 | """Mean of returns below the VaR threshold.""" |
| 98 | var = historical_var(returns, confidence) |
| 99 | return -returns[returns <= -var].mean() |
| 100 | ``` |
| 101 | |
| 102 | ### Maximum Drawdown |
| 103 | |
| 104 | ```python |
| 105 | def max_drawdown(equity: pd.Series) -> float: |
| 106 | """Maximum peak-to-trough decline.""" |
| 107 | peak = equity.cummax() |
| 108 | drawdown = (equity - peak) / peak |
| 109 | return drawdown.min() # negative number |
| 110 | |
| 111 | def drawdown_series(equity: pd.Series) -> pd.Series: |
| 112 | """Full drawdown time series.""" |
| 113 | peak = equity.cummax() |
| 114 | return (equity - peak) / peak |
| 115 | ``` |
| 116 | |
| 117 | ### Time Underwater |
| 118 | |
| 119 | ```python |
| 120 | def time_underwater(equity: pd.Series) -> int: |
| 121 | """Longest consecutive period below previous peak (in days).""" |
| 122 | dd = drawdown_series(equity) |
| 123 | is_underwater = dd < 0 |
| 124 | groups = (~is_underwater).cumsum() |
| 125 | underwater_periods = is_underwater.groupby(groups).sum() |
| 126 | return int(underwater_periods.max()) if len(underwater_periods) > 0 else 0 |
| 127 | ``` |
| 128 | |
| 129 | ## Risk-Adjusted Ratios |
| 130 | |
| 131 | ### Sharpe Ratio |
| 132 | |
| 133 | ```python |
| 134 | def sharpe_ratio( |
| 135 | returns: pd.Series, |
| 136 | rf: float = 0.0, |
| 137 | periods_per_year: int = 252 |
| 138 | ) -> float: |
| 139 | """Annualized Sharpe ratio. |
| 140 | |
| 141 | Args: |
| 142 | returns: Period returns. |
| 143 | rf: Risk-free rate per period. |
| 144 | periods_per_year: Annualization factor. |
| 145 | |
| 146 | Returns: |
| 147 | Annualized Sharpe ratio. |
| 148 | """ |
| 149 | excess = returns - rf |
| 150 | if excess.std() == 0: |
| 151 | return 0.0 |
| 152 | return (excess.mean() / excess.std()) * np.sqrt(periods_per_year) |
| 153 | ``` |
| 154 | |
| 155 | ### Sortino Ratio |
| 156 | |
| 157 | ```python |
| 158 | def sortino_ratio( |
| 159 | returns: pd.Series, |
| 160 | rf: float = 0.0, |
| 161 | periods_per_year: int = 252 |
| 162 | ) -> float: |
| 163 | """Annualized Sortino ratio (penalizes only downside vol).""" |
| 164 | excess = returns - rf |
| 165 | downside = excess[excess < 0] |
| 166 | if len(downside) == 0 or downside.std() == 0: |
| 167 | return float("inf") if excess.mean() > 0 else 0.0 |
| 168 | return (excess.mean() / downside.std()) * np.sqrt(periods_per_year) |
| 169 | ``` |
| 170 | |
| 171 | ### Calmar Ratio |
| 172 | |
| 173 | ```python |
| 174 | def calmar_ratio(equity: pd.Series, periods_per_year: int = 252) -> float: |
| 175 | """CAGR divided by max drawdown (absolute value).""" |
| 176 | returns = equity.pct_change().dropna() |
| 177 | days = (equity.index[-1] - equity.index[0]).days |
| 178 | cagr = (equity.iloc[-1] / equity.iloc[0]) ** (365.25 / days) - 1 |
| 179 | mdd = abs(max_drawdown(equity)) |
| 180 | if mdd == 0: |
| 181 | return float("inf") if cagr > 0 else 0.0 |
| 182 | return cagr / mdd |
| 183 | ``` |
| 184 | |
| 185 | ### Omega Ratio |
| 186 | |
| 187 | ```python |
| 188 | def omega_ratio( |
| 189 | returns: pd.Series, |
| 190 | threshold: float = 0.0 |
| 191 | ) -> float: |
| 192 | """Ratio of probability-weighted gains to losses.""" |
| 193 | excess = |