
Portfolio Analytics
- 226 installs
- 257 repo stars
- Updated June 24, 2026
- agiprolabs/claude-trading-skills
portfolio-analytics is a Claude Code skill that computes portfolio performance, risk, and risk-adjusted metrics from an equity curve and produces HTML reports via quantstats.
About
portfolio-analytics is a Claude Code skill that measures trading performance from an equity curve. It computes return metrics, risk metrics like VaR and maximum drawdown, and risk-adjusted ratios (Sharpe, Sortino, Calmar, Omega, Information), and generates HTML reports via quantstats. A developer uses it after a backtest to evaluate and compare strategies.
- Computes Sharpe, Sortino, Calmar, Omega, VaR/CVaR, and max drawdown from an equity curve
- Generates investor-ready HTML performance reports via quantstats
- Ships analyze_portfolio.py and compare_strategies.py with demo modes
Portfolio Analytics by the numbers
- 226 all-time installs (skills.sh)
- Ranked #430 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
portfolio-analytics capabilities & compatibility
Free; requires only the open-source pandas, numpy, and quantstats libraries, no API keys.
- Capabilities
- performance analysis · risk metrics · drawdown analysis · strategy comparison · report generation
- Use cases
- data analysis
- Pricing
- Free
What portfolio-analytics says it does
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.
npx skills add https://github.com/agiprolabs/claude-trading-skills --skill portfolio-analyticsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 226 |
|---|---|
| repo stars | ★ 257 |
| Last updated | June 24, 2026 |
| Repository | agiprolabs/claude-trading-skills ↗ |
What it does
Measure portfolio performance and risk from an equity curve and generate HTML reports.
Who is it for?
Evaluating and comparing backtested or live trading strategies with standard performance metrics.
Skip if: Generating trade signals or executing orders.
When should I use this skill?
You have an equity curve or trade log and need Sharpe, drawdown, and other performance metrics.
What you get
A full set of performance metrics and an HTML report to compare and rank strategies.
- Performance metric report
- Risk-adjusted ratios
- quantstats HTML report
By the numbers
- 5 risk-adjusted ratios (Sharpe, Sortino, Calmar, Omega, Information)
- 252 trading-day annualization factor
Files
Portfolio Analytics
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.
When to Use This Skill
- After backtesting a strategy (e.g., from
vectorbtorstrategy-framework) - Comparing multiple strategies or parameter sets side-by-side
- Generating investor-ready performance reports
- Evaluating live trading performance against benchmarks
- Assessing risk-adjusted returns for portfolio allocation decisions
Prerequisites
uv pip install pandas numpy quantstatsInput Format
All analytics start from an equity curve — a time-indexed Series of portfolio values:
import pandas as pd
import numpy as np
# From a backtest
equity = pd.Series(
[10000, 10150, 10080, 10320, 10510, 10440, 10680],
index=pd.date_range("2025-01-01", periods=7, freq="D"),
name="strategy_equity"
)
# Convert to returns
returns = equity.pct_change().dropna()Return Metrics
Total Return
total_return = (equity.iloc[-1] / equity.iloc[0]) - 1CAGR (Compound Annual Growth Rate)
days = (equity.index[-1] - equity.index[0]).days
cagr = (equity.iloc[-1] / equity.iloc[0]) ** (365.25 / days) - 1Daily Mean Return
daily_mean = returns.mean()
annualized_mean = daily_mean * 252 # trading daysCumulative Returns
cumulative = (1 + returns).cumprod() - 1Risk Metrics
Annualized Volatility
daily_vol = returns.std()
annual_vol = daily_vol * np.sqrt(252)Value at Risk (VaR)
Historical VaR at a given confidence level:
def historical_var(returns: pd.Series, confidence: float = 0.95) -> float:
"""Compute historical VaR.
Args:
returns: Daily return series.
confidence: Confidence level (e.g., 0.95 for 95%).
Returns:
VaR as a positive number representing potential loss.
"""
return -np.percentile(returns, (1 - confidence) * 100)Conditional VaR (CVaR / Expected Shortfall)
def historical_cvar(returns: pd.Series, confidence: float = 0.95) -> float:
"""Mean of returns below the VaR threshold."""
var = historical_var(returns, confidence)
return -returns[returns <= -var].mean()Maximum Drawdown
def max_drawdown(equity: pd.Series) -> float:
"""Maximum peak-to-trough decline."""
peak = equity.cummax()
drawdown = (equity - peak) / peak
return drawdown.min() # negative number
def drawdown_series(equity: pd.Series) -> pd.Series:
"""Full drawdown time series."""
peak = equity.cummax()
return (equity - peak) / peakTime Underwater
def time_underwater(equity: pd.Series) -> int:
"""Longest consecutive period below previous peak (in days)."""
dd = drawdown_series(equity)
is_underwater = dd < 0
groups = (~is_underwater).cumsum()
underwater_periods = is_underwater.groupby(groups).sum()
return int(underwater_periods.max()) if len(underwater_periods) > 0 else 0Risk-Adjusted Ratios
Sharpe Ratio
def sharpe_ratio(
returns: pd.Series,
rf: float = 0.0,
periods_per_year: int = 252
) -> float:
"""Annualized Sharpe ratio.
Args:
returns: Period returns.
rf: Risk-free rate per period.
periods_per_year: Annualization factor.
Returns:
Annualized Sharpe ratio.
"""
excess = returns - rf
if excess.std() == 0:
return 0.0
return (excess.mean() / excess.std()) * np.sqrt(periods_per_year)Sortino Ratio
def sortino_ratio(
returns: pd.Series,
rf: float = 0.0,
periods_per_year: int = 252
) -> float:
"""Annualized Sortino ratio (penalizes only downside vol)."""
excess = returns - rf
downside = excess[excess < 0]
if len(downside) == 0 or downside.std() == 0:
return float("inf") if excess.mean() > 0 else 0.0
return (excess.mean() / downside.std()) * np.sqrt(periods_per_year)Calmar Ratio
def calmar_ratio(equity: pd.Series, periods_per_year: int = 252) -> float:
"""CAGR divided by max drawdown (absolute value)."""
returns = equity.pct_change().dropna()
days = (equity.index[-1] - equity.index[0]).days
cagr = (equity.iloc[-1] / equity.iloc[0]) ** (365.25 / days) - 1
mdd = abs(max_drawdown(equity))
if mdd == 0:
return float("inf") if cagr > 0 else 0.0
return cagr / mddOmega Ratio
def omega_ratio(
returns: pd.Series,
threshold: float = 0.0
) -> float:
"""Ratio of probability-weighted gains to losses."""
excess = returns - threshold
gains = excess[excess > 0].sum()
losses = abs(excess[excess <= 0].sum())
if losses == 0:
return float("inf") if gains > 0 else 1.0
return gains / lossesInformation Ratio
def information_ratio(
returns: pd.Series,
benchmark_returns: pd.Series,
periods_per_year: int = 252
) -> float:
"""Excess return per unit of tracking error."""
active = returns - benchmark_returns
if active.std() == 0:
return 0.0
return (active.mean() / active.std()) * np.sqrt(periods_per_year)Rolling Analysis
Rolling Sharpe
def rolling_sharpe(
returns: pd.Series,
window: int = 63,
rf: float = 0.0,
periods_per_year: int = 252
) -> pd.Series:
"""Rolling annualized Sharpe ratio."""
excess = returns - rf
roll_mean = excess.rolling(window).mean()
roll_std = excess.rolling(window).std()
return (roll_mean / roll_std) * np.sqrt(periods_per_year)Rolling Max Drawdown
def rolling_max_drawdown(equity: pd.Series, window: int = 252) -> pd.Series:
"""Rolling max drawdown over a fixed window."""
result = pd.Series(index=equity.index, dtype=float)
for i in range(window, len(equity)):
window_eq = equity.iloc[i - window:i + 1]
peak = window_eq.cummax()
dd = (window_eq - peak) / peak
result.iloc[i] = dd.min()
return resultTrade-Level Analysis
When you have individual trade records:
def trade_statistics(pnl: pd.Series) -> dict:
"""Compute trade-level statistics from a series of trade PnL values.
Args:
pnl: Series where each value is the PnL of one trade.
Returns:
Dictionary of trade statistics.
"""
wins = pnl[pnl > 0]
losses = pnl[pnl < 0]
total = len(pnl)
win_rate = len(wins) / total if total > 0 else 0.0
avg_win = wins.mean() if len(wins) > 0 else 0.0
avg_loss = losses.mean() if len(losses) > 0 else 0.0
largest_win = wins.max() if len(wins) > 0 else 0.0
largest_loss = losses.min() if len(losses) > 0 else 0.0
gross_profit = wins.sum() if len(wins) > 0 else 0.0
gross_loss = abs(losses.sum()) if len(losses) > 0 else 0.0
profit_factor = gross_profit / gross_loss if gross_loss > 0 else float("inf")
expectancy = pnl.mean() if total > 0 else 0.0
return {
"total_trades": total,
"win_rate": win_rate,
"avg_win": avg_win,
"avg_loss": avg_loss,
"largest_win": largest_win,
"largest_loss": largest_loss,
"profit_factor": profit_factor,
"expectancy": expectancy,
"gross_profit": gross_profit,
"gross_loss": gross_loss,
}Monthly / Yearly Return Tables
def monthly_returns_table(returns: pd.Series) -> pd.DataFrame:
"""Pivot returns into a month-by-year table.
Returns:
DataFrame with years as rows, months (1-12) as columns,
and an Annual column.
"""
monthly = returns.resample("ME").apply(lambda x: (1 + x).prod() - 1)
table = monthly.groupby([monthly.index.year, monthly.index.month]).first()
table = table.unstack(level=1)
table.columns = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
]
# Annual column
annual = returns.resample("YE").apply(lambda x: (1 + x).prod() - 1)
table["Annual"] = annual.values[:len(table)]
return tableBenchmark Comparison
def benchmark_comparison(
strategy_returns: pd.Series,
benchmark_returns: pd.Series,
rf: float = 0.0
) -> dict:
"""Compare strategy to benchmark across key metrics."""
strat_eq = (1 + strategy_returns).cumprod()
bench_eq = (1 + benchmark_returns).cumprod()
return {
"strategy_total_return": strat_eq.iloc[-1] - 1,
"benchmark_total_return": bench_eq.iloc[-1] - 1,
"strategy_sharpe": sharpe_ratio(strategy_returns, rf),
"benchmark_sharpe": sharpe_ratio(benchmark_returns, rf),
"strategy_max_dd": max_drawdown(strat_eq),
"benchmark_max_dd": max_drawdown(bench_eq),
"information_ratio": information_ratio(strategy_returns, benchmark_returns),
"correlation": strategy_returns.corr(benchmark_returns),
"beta": (
strategy_returns.cov(benchmark_returns)
/ benchmark_returns.var()
),
"alpha": (
strategy_returns.mean()
- (strategy_returns.cov(benchmark_returns) / benchmark_returns.var())
* benchmark_returns.mean()
) * 252,
}Quantstats HTML Reports
Generate investor-ready HTML reports with one function call:
import quantstats as qs
# From returns Series
qs.reports.html(
returns,
benchmark=benchmark_returns, # optional
output="report.html",
title="My Strategy",
rf=0.0,
periods_per_year=252
)
# Individual metrics
print(f"Sharpe: {qs.stats.sharpe(returns):.2f}")
print(f"Sortino: {qs.stats.sortino(returns):.2f}")
print(f"Max DD: {qs.stats.max_drawdown(returns):.2%}")
print(f"Calmar: {qs.stats.calmar(returns):.2f}")
# Console tearsheet
qs.reports.full(returns)See references/quantstats_guide.md for full API reference and customization.
Integration with Vectorbt
import vectorbt as vbt
# After running a vectorbt backtest
portfolio = vbt.Portfolio.from_signals(close, entries, exits, init_cash=10000)
# Extract equity curve
equity = portfolio.value()
returns = portfolio.returns()
# Use quantstats
qs.reports.html(returns, output="backtest_report.html")Files
| File | Description |
|---|---|
references/metrics_guide.md | Formulas, derivations, annualization factors, interpretation benchmarks |
references/quantstats_guide.md | Quantstats library API, customization, integration patterns |
scripts/analyze_portfolio.py | Single portfolio analysis with all metrics, rolling stats, monthly table |
scripts/compare_strategies.py | Multi-strategy comparison with ranking by risk-adjusted metrics |
Related Skills
vectorbt— Backtesting engine that produces equity curves for analysisrisk-management— Portfolio-level risk guardrails and allocationposition-sizing— Optimal position sizing using portfolio metricskelly-criterion— Optimal growth rate sizing from win rate and payofftrading-visualization— Chart generation for equity curves and drawdowns
Portfolio Metrics — Formulas & Interpretation Guide
Notation
| Symbol | Meaning |
|---|---|
| R_t | Return in period t |
| R_f | Risk-free rate per period |
| N | Annualization factor (252 daily, 52 weekly, 12 monthly) |
| T | Total number of periods |
| P_t | Portfolio value at time t |
Return Metrics
Total Return
Total Return = (P_final / P_initial) - 1CAGR (Compound Annual Growth Rate)
CAGR = (P_final / P_initial) ^ (365.25 / days) - 1Where days is the calendar day count between first and last observation. CAGR smooths out volatility and shows the constant annual rate that would produce the same terminal value.
Annualized Mean Return
Annualized Mean = mean(R_t) * NThis is the arithmetic annualization. It overstates the geometric growth rate when returns are volatile. Use CAGR for the geometric rate.
Cumulative Return Series
Cumulative_t = product(1 + R_i for i in 1..t) - 1In pandas: (1 + returns).cumprod() - 1
Risk Metrics
Annualized Volatility
Vol_annual = std(R_t) * sqrt(N)Uses sample standard deviation. The sqrt(N) scaling assumes returns are IID — an approximation. For crypto and memecoin markets, volatility clustering makes this a rough estimate.
Value at Risk (VaR) — Historical Method
VaR(alpha) = -percentile(R_t, (1 - alpha) * 100)For 95% confidence: VaR = -percentile(returns, 5). This means "on 95% of days, the loss will not exceed VaR."
Interpretation: A daily VaR of 3% at 95% confidence means you expect to lose more than 3% on roughly 1 in 20 trading days.
Parametric VaR (assumes normal distribution):
VaR(alpha) = -(mean(R_t) + z_alpha * std(R_t))Where z_alpha is the z-score for the confidence level (1.645 for 95%, 2.326 for 99%).
Conditional VaR (CVaR / Expected Shortfall)
CVaR(alpha) = -mean(R_t where R_t <= -VaR(alpha))CVaR answers: "When we do breach VaR, how bad is the average loss?" It is always >= VaR and better captures tail risk.
Example: If VaR(95%) = 3% and the average loss on the worst 5% of days is 5.2%, then CVaR = 5.2%.
Maximum Drawdown
Drawdown_t = (P_t - max(P_1..P_t)) / max(P_1..P_t)
Max Drawdown = min(Drawdown_t for all t)Maximum drawdown measures the worst peak-to-trough decline. It is always negative (or zero). Report the absolute value when comparing.
Time Underwater
The longest consecutive period where the portfolio is below its previous peak. Measured in trading days or calendar days. Long underwater periods indicate difficulty recovering from drawdowns.
Risk-Adjusted Ratios
Sharpe Ratio
Sharpe = (mean(R_t) - R_f) / std(R_t) * sqrt(N)Derivation: The Sharpe ratio is the slope of the Capital Allocation Line — the excess return per unit of total risk. Annualization multiplies by sqrt(N) because mean scales linearly with N while standard deviation scales with sqrt(N).
Interpretation benchmarks:
| Sharpe | Rating |
|---|---|
| < 0 | Losing money |
| 0 - 0.5 | Poor |
| 0.5 - 1.0 | Acceptable |
| 1.0 - 2.0 | Good |
| 2.0 - 3.0 | Excellent |
| > 3.0 | Exceptional (verify — may indicate overfitting) |
Limitations: Assumes symmetric return distribution. Penalizes upside volatility equally with downside. Sensitive to measurement frequency.
Sortino Ratio
Sortino = (mean(R_t) - R_f) / std(R_t where R_t < R_f) * sqrt(N)Derivation: Replaces total standard deviation with downside deviation. Only penalizes returns below the threshold (usually the risk-free rate or zero). Better than Sharpe for strategies with positive skew (large winners, small losers).
Interpretation: Same scale as Sharpe but typically higher because downside deviation <= total deviation.
Calmar Ratio
Calmar = CAGR / |Max Drawdown|Interpretation: Return earned per unit of worst-case drawdown. A Calmar of 2.0 means the strategy earns twice its maximum historical drawdown per year.
| Calmar | Rating |
|---|---|
| < 0.5 | Poor |
| 0.5 - 1.0 | Acceptable |
| 1.0 - 3.0 | Good |
| > 3.0 | Excellent |
Omega Ratio
Omega(threshold) = integral[threshold to +inf] (1 - F(x)) dx
────────────────────────────────────────────
integral[-inf to threshold] F(x) dxIn practice (discrete):
Omega = sum(max(R_t - threshold, 0)) / sum(max(threshold - R_t, 0))Interpretation: The ratio of probability-weighted gains above a threshold to probability-weighted losses below it. Unlike Sharpe, Omega uses the entire return distribution, not just mean and variance. Omega > 1 means the strategy outperforms the threshold on a probability-weighted basis.
Information Ratio
IR = mean(R_strategy - R_benchmark) / std(R_strategy - R_benchmark) * sqrt(N)Derivation: The Sharpe ratio of the active (excess over benchmark) returns. Measures how consistently a strategy outperforms its benchmark.
| IR | Rating |
|---|---|
| < 0 | Underperforming benchmark |
| 0 - 0.5 | Moderate skill |
| 0.5 - 1.0 | Good skill |
| > 1.0 | Exceptional |
Annualization Factors
| Frequency | Periods per Year (N) | sqrt(N) |
|---|---|---|
| Daily (trading) | 252 | 15.875 |
| Daily (calendar) | 365 | 19.105 |
| Weekly | 52 | 7.211 |
| Monthly | 12 | 3.464 |
| Hourly (24/7 crypto) | 8760 | 93.59 |
| 5-minute (24/7 crypto) | 105120 | 324.22 |
Important for crypto: Traditional equities use 252 trading days. Crypto markets trade 24/7/365, so use 365 for daily or 8760 for hourly when analyzing crypto-native strategies. Solana memecoin strategies typically use 365-day annualization.
Trade-Level Metrics
Win Rate
Win Rate = count(trades where PnL > 0) / count(all trades)Win rate alone is uninformative. A 30% win rate with 5:1 reward-to-risk is excellent.
Profit Factor
Profit Factor = sum(winning trade PnL) / |sum(losing trade PnL)|| PF | Rating |
|---|---|
| < 1.0 | Losing |
| 1.0 - 1.5 | Marginal |
| 1.5 - 2.0 | Good |
| 2.0 - 3.0 | Excellent |
| > 3.0 | Exceptional (small sample?) |
Expectancy
Expectancy = (Win Rate * Avg Win) + ((1 - Win Rate) * Avg Loss)Where Avg Loss is negative. Expectancy is the expected PnL per trade. Positive expectancy is necessary (but not sufficient) for a viable strategy.
Payoff Ratio
Payoff Ratio = |Avg Win| / |Avg Loss|Combined with win rate, determines whether a strategy is viable:
- High win rate + low payoff = scalping
- Low win rate + high payoff = trend following
- Both high = very rare, verify with out-of-sample data
Common Pitfalls
1. Annualization errors: Using sqrt(252) on monthly data (should be sqrt(12)). 2. Survivorship bias: Only analyzing strategies that survived; ignoring blown-up variants. 3. Overfitting Sharpe: Optimizing for Sharpe on in-sample data inflates the metric. 4. Ignoring drawdown duration: A 20% drawdown lasting 2 days is very different from one lasting 6 months. 5. Comparing different frequencies: A daily Sharpe of 2.0 is not comparable to a monthly Sharpe of 2.0 unless both are annualized identically. 6. Small sample sizes: 30 trades is not enough to draw conclusions about win rate or profit factor. Aim for 100+ trades minimum.
Quantstats Library — Usage Guide
Installation
uv pip install quantstatsQuantstats depends on pandas, numpy, scipy, matplotlib, and seaborn. These install automatically.
Core Concepts
Quantstats operates on a pandas Series of returns (not prices). Index must be DatetimeIndex.
import quantstats as qs
import pandas as pd
# From an equity curve
equity = pd.Series([10000, 10200, 10150, 10400], index=pd.date_range("2025-01-01", periods=4))
returns = equity.pct_change().dropna()Reports
HTML Report
qs.reports.html(
returns,
benchmark=None, # Optional: benchmark returns Series
output="report.html", # File path for HTML output
title="Strategy Report", # Report title
rf=0.0, # Risk-free rate (annual)
periods_per_year=252, # Annualization factor
compounded=True, # Use geometric compounding
download_filename=None, # Custom download filename
)The HTML report includes:
- Cumulative returns chart (with benchmark if provided)
- Drawdown chart
- Monthly returns heatmap
- Return distribution histogram
- Rolling Sharpe and rolling volatility
- Key statistics table
- Worst drawdowns table
Console Full Report
qs.reports.full(returns, benchmark=benchmark_returns)Prints all metrics to stdout in a formatted table.
Metrics-Only Report
qs.reports.metrics(
returns,
benchmark=benchmark_returns,
mode="full", # "full", "basic", or a list of metric names
)Individual Statistics
All functions accept a returns Series and return a scalar.
Return Metrics
qs.stats.comp(returns) # Total compounded return
qs.stats.cagr(returns) # CAGR
qs.stats.expected_return(returns) # Mean return (annualized)
qs.stats.best(returns) # Best single period return
qs.stats.worst(returns) # Worst single period return
qs.stats.avg_return(returns) # Average period return
qs.stats.avg_win(returns) # Average winning return
qs.stats.avg_loss(returns) # Average losing return
qs.stats.win_rate(returns) # Fraction of positive periodsRisk Metrics
qs.stats.volatility(returns) # Annualized volatility
qs.stats.max_drawdown(returns) # Maximum drawdown (negative)
qs.stats.value_at_risk(returns) # Daily VaR at 95%
qs.stats.conditional_value_at_risk(returns) # CVaR at 95%
qs.stats.tail_ratio(returns) # Ratio of 95th to 5th percentile
qs.stats.common_sense_ratio(returns) # Profit factor * tail ratio
qs.stats.outlier_win_ratio(returns) # Ratio of max win to average win
qs.stats.outlier_loss_ratio(returns) # Ratio of max loss to average lossRisk-Adjusted Ratios
qs.stats.sharpe(returns, rf=0.0) # Annualized Sharpe ratio
qs.stats.sortino(returns, rf=0.0) # Annualized Sortino ratio
qs.stats.calmar(returns) # Calmar ratio
qs.stats.omega(returns) # Omega ratio (threshold=0)
qs.stats.information_ratio(returns, benchmark) # Information ratio
qs.stats.treynor_ratio(returns, benchmark) # Treynor ratio
qs.stats.gain_to_pain_ratio(returns) # Gain-to-pain ratio
qs.stats.risk_return_ratio(returns) # Return / volatilityDrawdown Analysis
qs.stats.max_drawdown(returns) # Worst drawdown
qs.stats.to_drawdown_series(returns) # Full drawdown time series
qs.stats.kelly_criterion(returns) # Kelly optimal fractionPlotting Functions
Quantstats includes matplotlib-based plotting:
# Cumulative returns
qs.plots.returns(returns, benchmark=benchmark_returns, savefig="returns.png")
# Drawdown periods
qs.plots.drawdown(returns, savefig="drawdown.png")
# Monthly returns heatmap
qs.plots.monthly_heatmap(returns, savefig="monthly.png")
# Distribution of returns
qs.plots.histogram(returns, savefig="histogram.png")
# Rolling Sharpe
qs.plots.rolling_sharpe(returns, savefig="rolling_sharpe.png")
# Rolling volatility
qs.plots.rolling_volatility(returns, savefig="rolling_vol.png")
# Yearly returns bar chart
qs.plots.yearly_returns(returns, savefig="yearly.png")
# All plots
qs.plots.snapshot(returns, savefig="snapshot.png")All plotting functions accept savefig to save to file instead of displaying.
Customization
Risk-Free Rate
The rf parameter is an annual rate. Quantstats converts it per-period internally.
# 5% annual risk-free rate
qs.stats.sharpe(returns, rf=0.05)Annualization
# Hourly crypto data
qs.stats.sharpe(returns, rf=0.0, periods=8760) # 24 * 365
# Weekly data
qs.stats.sharpe(returns, rf=0.0, periods=52)Compounding
# Arithmetic vs geometric returns
qs.stats.cagr(returns, compounded=True) # geometric (default)
qs.stats.cagr(returns, compounded=False) # arithmeticIntegration with Vectorbt
import vectorbt as vbt
import quantstats as qs
# Run backtest
portfolio = vbt.Portfolio.from_signals(close, entries, exits, init_cash=10000)
# Extract returns
returns = portfolio.returns()
# Generate HTML report
qs.reports.html(returns, output="backtest_report.html", title="VBT Backtest")
# Or access individual metrics
sharpe = qs.stats.sharpe(returns)
sortino = qs.stats.sortino(returns)
max_dd = qs.stats.max_drawdown(returns)Common Patterns
Strategy Comparison
strategies = {
"momentum": momentum_returns,
"mean_rev": mean_reversion_returns,
"hybrid": hybrid_returns,
}
for name, ret in strategies.items():
print(f"\n{'='*40}")
print(f"Strategy: {name}")
print(f" Sharpe: {qs.stats.sharpe(ret):.2f}")
print(f" Sortino: {qs.stats.sortino(ret):.2f}")
print(f" Max DD: {qs.stats.max_drawdown(ret):.2%}")
print(f" Calmar: {qs.stats.calmar(ret):.2f}")
print(f" CAGR: {qs.stats.cagr(ret):.2%}")Monthly Table from Quantstats
# Get monthly returns as DataFrame
monthly = qs.stats.monthly_returns(returns)
print(monthly.to_string(float_format=lambda x: f"{x:.2%}"))Export All Metrics
# Get all metrics as a DataFrame
all_metrics = qs.reports.metrics(returns, mode="full", display=False)
all_metrics.to_csv("strategy_metrics.csv")Limitations
- Quantstats assumes daily data by default. Always set
periods_per_yearfor other frequencies. - The HTML report renderer requires a graphical backend for matplotlib. On headless servers, set
matplotlib.use("Agg")before importing quantstats. - Benchmark data must be aligned (same dates) with strategy returns. Use
.reindex()or.align()if needed. - Large datasets (10+ years of daily data) may slow down the HTML report generation.
- Some metrics return
nanorinffor very short return series (under 20 observations).
Troubleshooting
"No display" error on servers:
import matplotlib
matplotlib.use("Agg")
import quantstats as qsBenchmark alignment:
strategy, benchmark = strategy_returns.align(benchmark_returns, join="inner")
qs.reports.html(strategy, benchmark=benchmark, output="report.html")Missing dates:
# Fill missing dates with 0 returns
returns = returns.asfreq("D", fill_value=0.0)#!/usr/bin/env python3
"""Comprehensive single-portfolio performance analysis.
Computes all major metrics from an equity curve: return metrics, risk metrics,
risk-adjusted ratios, drawdown analysis, rolling Sharpe, and monthly returns
table. Includes a --demo mode that generates a 1-year synthetic equity curve.
Usage:
python scripts/analyze_portfolio.py --demo
python scripts/analyze_portfolio.py --csv equity.csv --value-col portfolio_value
Dependencies:
uv pip install pandas numpy
Environment Variables:
None required.
"""
import argparse
import sys
from typing import Optional
import numpy as np
import pandas as pd
# ── Return Metrics ──────────────────────────────────────────────────
def total_return(equity: pd.Series) -> float:
"""Total return from start to end.
Args:
equity: Time-indexed portfolio value series.
Returns:
Total return as a decimal (e.g., 0.25 for 25%).
"""
return (equity.iloc[-1] / equity.iloc[0]) - 1
def cagr(equity: pd.Series) -> float:
"""Compound Annual Growth Rate.
Args:
equity: Time-indexed portfolio value series.
Returns:
Annualized compound growth rate.
"""
days = (equity.index[-1] - equity.index[0]).days
if days <= 0:
return 0.0
return (equity.iloc[-1] / equity.iloc[0]) ** (365.25 / days) - 1
def daily_mean_return(returns: pd.Series) -> float:
"""Average daily return.
Args:
returns: Daily return series.
Returns:
Mean daily return.
"""
return float(returns.mean())
def annualized_mean_return(
returns: pd.Series, periods_per_year: int = 252
) -> float:
"""Annualized arithmetic mean return.
Args:
returns: Period return series.
periods_per_year: Annualization factor.
Returns:
Annualized mean return.
"""
return float(returns.mean() * periods_per_year)
# ── Risk Metrics ────────────────────────────────────────────────────
def annualized_volatility(
returns: pd.Series, periods_per_year: int = 252
) -> float:
"""Annualized standard deviation of returns.
Args:
returns: Period return series.
periods_per_year: Annualization factor.
Returns:
Annualized volatility.
"""
return float(returns.std() * np.sqrt(periods_per_year))
def historical_var(returns: pd.Series, confidence: float = 0.95) -> float:
"""Historical Value at Risk.
Args:
returns: Period return series.
confidence: Confidence level (e.g., 0.95).
Returns:
VaR as a positive number representing potential loss.
"""
return float(-np.percentile(returns.dropna(), (1 - confidence) * 100))
def historical_cvar(returns: pd.Series, confidence: float = 0.95) -> float:
"""Conditional VaR (Expected Shortfall).
Args:
returns: Period return series.
confidence: Confidence level.
Returns:
CVaR as a positive number.
"""
var = historical_var(returns, confidence)
tail = returns[returns <= -var]
if len(tail) == 0:
return var
return float(-tail.mean())
def max_drawdown(equity: pd.Series) -> float:
"""Maximum peak-to-trough decline.
Args:
equity: Time-indexed portfolio value series.
Returns:
Max drawdown as a negative decimal (e.g., -0.15 for 15% decline).
"""
peak = equity.cummax()
dd = (equity - peak) / peak
return float(dd.min())
def drawdown_series(equity: pd.Series) -> pd.Series:
"""Full drawdown time series.
Args:
equity: Time-indexed portfolio value series.
Returns:
Series of drawdown values (negative or zero).
"""
peak = equity.cummax()
return (equity - peak) / peak
def max_time_underwater(equity: pd.Series) -> int:
"""Longest consecutive period below previous peak.
Args:
equity: Time-indexed portfolio value series.
Returns:
Number of periods spent in the longest drawdown.
"""
dd = drawdown_series(equity)
is_underwater = dd < 0
if not is_underwater.any():
return 0
groups = (~is_underwater).cumsum()
underwater_lengths = is_underwater.groupby(groups).sum()
return int(underwater_lengths.max())
# ── Risk-Adjusted Ratios ───────────────────────────────────────────
def sharpe_ratio(
returns: pd.Series, rf: float = 0.0, periods_per_year: int = 252
) -> float:
"""Annualized Sharpe ratio.
Args:
returns: Period return series.
rf: Risk-free rate per period.
periods_per_year: Annualization factor.
Returns:
Sharpe ratio.
"""
excess = returns - rf
if excess.std() == 0:
return 0.0
return float((excess.mean() / excess.std()) * np.sqrt(periods_per_year))
def sortino_ratio(
returns: pd.Series, rf: float = 0.0, periods_per_year: int = 252
) -> float:
"""Annualized Sortino ratio (downside deviation only).
Args:
returns: Period return series.
rf: Risk-free rate per period.
periods_per_year: Annualization factor.
Returns:
Sortino ratio.
"""
excess = returns - rf
downside = excess[excess < 0]
if len(downside) == 0 or downside.std() == 0:
return float("inf") if excess.mean() > 0 else 0.0
return float((excess.mean() / downside.std()) * np.sqrt(periods_per_year))
def calmar_ratio(equity: pd.Series, periods_per_year: int = 252) -> float:
"""Calmar ratio: CAGR / |max drawdown|.
Args:
equity: Time-indexed portfolio value series.
periods_per_year: Annualization factor (unused, CAGR uses calendar).
Returns:
Calmar ratio.
"""
annual_return = cagr(equity)
mdd = abs(max_drawdown(equity))
if mdd == 0:
return float("inf") if annual_return > 0 else 0.0
return annual_return / mdd
def omega_ratio(returns: pd.Series, threshold: float = 0.0) -> float:
"""Omega ratio: probability-weighted gains / losses.
Args:
returns: Period return series.
threshold: Threshold return (default 0).
Returns:
Omega ratio.
"""
excess = returns - threshold
gains = excess[excess > 0].sum()
losses = abs(excess[excess <= 0].sum())
if losses == 0:
return float("inf") if gains > 0 else 1.0
return float(gains / losses)
def information_ratio(
returns: pd.Series,
benchmark_returns: pd.Series,
periods_per_year: int = 252,
) -> float:
"""Information ratio: active return / tracking error.
Args:
returns: Strategy return series.
benchmark_returns: Benchmark return series (aligned).
periods_per_year: Annualization factor.
Returns:
Information ratio.
"""
active = returns - benchmark_returns
if active.std() == 0:
return 0.0
return float((active.mean() / active.std()) * np.sqrt(periods_per_year))
# ── Rolling Analysis ───────────────────────────────────────────────
def rolling_sharpe(
returns: pd.Series,
window: int = 63,
rf: float = 0.0,
periods_per_year: int = 252,
) -> pd.Series:
"""Rolling annualized Sharpe ratio.
Args:
returns: Period return series.
window: Rolling window size in periods.
rf: Risk-free rate per period.
periods_per_year: Annualization factor.
Returns:
Series of rolling Sharpe values.
"""
excess = returns - rf
roll_mean = excess.rolling(window).mean()
roll_std = excess.rolling(window).std()
return (roll_mean / roll_std) * np.sqrt(periods_per_year)
# ── Trade-Level Analysis ───────────────────────────────────────────
def trade_statistics(pnl: pd.Series) -> dict:
"""Compute trade-level statistics from PnL values.
Args:
pnl: Series where each element is a trade's PnL.
Returns:
Dictionary of trade-level metrics.
"""
wins = pnl[pnl > 0]
losses = pnl[pnl < 0]
total = len(pnl)
win_rate = len(wins) / total if total > 0 else 0.0
avg_win = float(wins.mean()) if len(wins) > 0 else 0.0
avg_loss = float(losses.mean()) if len(losses) > 0 else 0.0
largest_win = float(wins.max()) if len(wins) > 0 else 0.0
largest_loss = float(losses.min()) if len(losses) > 0 else 0.0
gross_profit = float(wins.sum()) if len(wins) > 0 else 0.0
gross_loss = float(abs(losses.sum())) if len(losses) > 0 else 0.0
profit_factor = gross_profit / gross_loss if gross_loss > 0 else float("inf")
expectancy = float(pnl.mean()) if total > 0 else 0.0
return {
"total_trades": total,
"win_rate": win_rate,
"avg_win": avg_win,
"avg_loss": avg_loss,
"largest_win": largest_win,
"largest_loss": largest_loss,
"profit_factor": profit_factor,
"expectancy": expectancy,
"gross_profit": gross_profit,
"gross_loss": gross_loss,
}
# ── Monthly Returns Table ──────────────────────────────────────────
def monthly_returns_table(returns: pd.Series) -> pd.DataFrame:
"""Create a month-by-year returns table.
Args:
returns: Daily return series with DatetimeIndex.
Returns:
DataFrame with years as rows, months as columns, plus Annual.
"""
monthly = returns.resample("ME").apply(lambda x: (1 + x).prod() - 1)
table_data: dict[int, dict[str, float]] = {}
month_names = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
]
for dt, val in monthly.items():
year = dt.year
month = dt.month
if year not in table_data:
table_data[year] = {}
table_data[year][month_names[month - 1]] = val
table = pd.DataFrame.from_dict(table_data, orient="index")
table = table.reindex(columns=month_names)
# Annual column
annual = returns.resample("YE").apply(lambda x: (1 + x).prod() - 1)
for dt, val in annual.items():
if dt.year in table.index:
table.loc[dt.year, "Annual"] = val
return table
# ── Demo Data Generation ───────────────────────────────────────────
def generate_demo_equity(
start_date: str = "2025-01-01",
periods: int = 252,
initial_capital: float = 10000.0,
annual_return: float = 0.25,
annual_vol: float = 0.30,
seed: int = 42,
) -> pd.Series:
"""Generate a synthetic equity curve for demonstration.
Args:
start_date: Start date string.
periods: Number of trading days.
initial_capital: Starting portfolio value.
annual_return: Target annualized return.
annual_vol: Target annualized volatility.
seed: Random seed for reproducibility.
Returns:
Time-indexed equity curve Series.
"""
rng = np.random.default_rng(seed)
daily_mu = annual_return / 252
daily_sigma = annual_vol / np.sqrt(252)
daily_returns = rng.normal(daily_mu, daily_sigma, periods)
prices = initial_capital * np.cumprod(1 + daily_returns)
prices = np.insert(prices, 0, initial_capital)
dates = pd.bdate_range(start=start_date, periods=len(prices))
return pd.Series(prices, index=dates, name="equity")
def generate_demo_trades(
n_trades: int = 100,
win_rate: float = 0.55,
avg_win_size: float = 150.0,
avg_loss_size: float = 100.0,
seed: int = 42,
) -> pd.Series:
"""Generate synthetic trade PnL data.
Args:
n_trades: Number of trades to generate.
win_rate: Fraction of winning trades.
avg_win_size: Average winning trade PnL.
avg_loss_size: Average losing trade PnL (as positive number).
seed: Random seed.
Returns:
Series of trade PnL values.
"""
rng = np.random.default_rng(seed)
pnl = []
for _ in range(n_trades):
if rng.random() < win_rate:
pnl.append(rng.exponential(avg_win_size))
else:
pnl.append(-rng.exponential(avg_loss_size))
return pd.Series(pnl, name="trade_pnl")
# ── Report Formatting ──────────────────────────────────────────────
def print_separator(title: str, width: int = 60) -> None:
"""Print a section separator."""
print(f"\n{'─' * width}")
print(f" {title}")
print(f"{'─' * width}")
def format_pct(value: float, decimals: int = 2) -> str:
"""Format a decimal as percentage string."""
return f"{value * 100:.{decimals}f}%"
def format_ratio(value: float, decimals: int = 2) -> str:
"""Format a ratio value."""
if value == float("inf"):
return "inf"
if value == float("-inf"):
return "-inf"
return f"{value:.{decimals}f}"
def print_full_report(equity: pd.Series, trade_pnl: Optional[pd.Series] = None) -> None:
"""Print a comprehensive portfolio performance report.
Args:
equity: Time-indexed portfolio value series.
trade_pnl: Optional series of individual trade PnL values.
"""
returns = equity.pct_change().dropna()
start = equity.index[0].strftime("%Y-%m-%d")
end = equity.index[-1].strftime("%Y-%m-%d")
days = (equity.index[-1] - equity.index[0]).days
print("=" * 60)
print(" PORTFOLIO PERFORMANCE REPORT")
print(f" Period: {start} to {end} ({days} calendar days)")
print(f" Data points: {len(equity)}")
print("=" * 60)
# ── Return Metrics
print_separator("RETURN METRICS")
print(f" Initial Capital: ${equity.iloc[0]:>12,.2f}")
print(f" Final Value: ${equity.iloc[-1]:>12,.2f}")
print(f" Total Return: {format_pct(total_return(equity)):>12}")
print(f" CAGR: {format_pct(cagr(equity)):>12}")
print(f" Daily Mean Return: {format_pct(daily_mean_return(returns), 4):>12}")
print(f" Ann. Mean Return: {format_pct(annualized_mean_return(returns)):>12}")
print(f" Best Day: {format_pct(float(returns.max())):>12}")
print(f" Worst Day: {format_pct(float(returns.min())):>12}")
# ── Risk Metrics
print_separator("RISK METRICS")
print(f" Ann. Volatility: {format_pct(annualized_volatility(returns)):>12}")
print(f" Daily VaR (95%): {format_pct(historical_var(returns, 0.95)):>12}")
print(f" Daily CVaR (95%): {format_pct(historical_cvar(returns, 0.95)):>12}")
print(f" Daily VaR (99%): {format_pct(historical_var(returns, 0.99)):>12}")
print(f" Daily CVaR (99%): {format_pct(historical_cvar(returns, 0.99)):>12}")
print(f" Max Drawdown: {format_pct(max_drawdown(equity)):>12}")
print(f" Max Time Underwater: {max_time_underwater(equity):>9} days")
# ── Drawdown Details
dd = drawdown_series(equity)
worst_dd_date = dd.idxmin()
# Find the peak before worst drawdown
peak_before = equity.loc[:worst_dd_date].idxmax()
print(f" Worst DD Peak Date: {peak_before.strftime('%Y-%m-%d'):>12}")
print(f" Worst DD Trough Date: {worst_dd_date.strftime('%Y-%m-%d'):>12}")
# ── Risk-Adjusted Ratios
print_separator("RISK-ADJUSTED RATIOS")
sr = sharpe_ratio(returns)
so = sortino_ratio(returns)
cr = calmar_ratio(equity)
om = omega_ratio(returns)
print(f" Sharpe Ratio: {format_ratio(sr):>12}")
print(f" Sortino Ratio: {format_ratio(so):>12}")
print(f" Calmar Ratio: {format_ratio(cr):>12}")
print(f" Omega Ratio: {format_ratio(om):>12}")
# Sharpe interpretation
if sr > 2.0:
interp = "Excellent"
elif sr > 1.0:
interp = "Good"
elif sr > 0.5:
interp = "Acceptable"
elif sr > 0:
interp = "Poor"
else:
interp = "Negative"
print(f" Sharpe Interpretation: {interp:>12}")
# ── Rolling Sharpe Summary
print_separator("ROLLING SHARPE (63-DAY)")
rs = rolling_sharpe(returns, window=63)
rs_clean = rs.dropna()
if len(rs_clean) > 0:
print(f" Current: {format_ratio(float(rs_clean.iloc[-1])):>12}")
print(f" Mean: {format_ratio(float(rs_clean.mean())):>12}")
print(f" Min: {format_ratio(float(rs_clean.min())):>12}")
print(f" Max: {format_ratio(float(rs_clean.max())):>12}")
print(f" Std Dev: {format_ratio(float(rs_clean.std())):>12}")
pct_positive = (rs_clean > 0).mean()
print(f" % Positive: {format_pct(float(pct_positive)):>12}")
else:
print(" Insufficient data for 63-day rolling window.")
# ── Return Distribution
print_separator("RETURN DISTRIBUTION")
print(f" Skewness: {float(returns.skew()):>12.3f}")
print(f" Kurtosis (excess): {float(returns.kurtosis()):>12.3f}")
pos_days = (returns > 0).sum()
neg_days = (returns < 0).sum()
zero_days = (returns == 0).sum()
print(f" Positive Days: {pos_days:>9} ({format_pct(pos_days / len(returns))})")
print(f" Negative Days: {neg_days:>9} ({format_pct(neg_days / len(returns))})")
print(f" Zero Days: {zero_days:>9}")
# ── Monthly Returns Table
print_separator("MONTHLY RETURNS")
mt = monthly_returns_table(returns)
if len(mt) > 0:
formatted = mt.map(
lambda x: f"{x * 100:6.2f}%" if pd.notna(x) else " N/A"
)
print(formatted.to_string())
# ── Trade-Level Statistics
if trade_pnl is not None:
print_separator("TRADE-LEVEL STATISTICS")
stats = trade_statistics(trade_pnl)
print(f" Total Trades: {stats['total_trades']:>12}")
print(f" Win Rate: {format_pct(stats['win_rate']):>12}")
print(f" Avg Win: ${stats['avg_win']:>11,.2f}")
print(f" Avg Loss: ${stats['avg_loss']:>11,.2f}")
print(f" Largest Win: ${stats['largest_win']:>11,.2f}")
print(f" Largest Loss: ${stats['largest_loss']:>11,.2f}")
print(f" Profit Factor: {format_ratio(stats['profit_factor']):>12}")
print(f" Expectancy: ${stats['expectancy']:>11,.2f}")
print(f" Gross Profit: ${stats['gross_profit']:>11,.2f}")
print(f" Gross Loss: ${stats['gross_loss']:>11,.2f}")
print(f" Net Profit: ${stats['gross_profit'] - stats['gross_loss']:>11,.2f}")
print(f"\n{'=' * 60}")
print(" Note: This is analytical output for informational purposes.")
print(" It does not constitute financial advice.")
print(f"{'=' * 60}\n")
# ── CSV Loading ─────────────────────────────────────────────────────
def load_equity_from_csv(
filepath: str,
value_col: str = "portfolio_value",
date_col: Optional[str] = None,
) -> pd.Series:
"""Load equity curve from CSV file.
Args:
filepath: Path to CSV file.
value_col: Column name for portfolio values.
date_col: Column name for dates (None = use index).
Returns:
Time-indexed equity Series.
Raises:
FileNotFoundError: If CSV file does not exist.
KeyError: If specified columns are not found.
"""
df = pd.read_csv(filepath)
if date_col and date_col in df.columns:
df[date_col] = pd.to_datetime(df[date_col])
df = df.set_index(date_col)
elif df.columns[0].lower() in ("date", "datetime", "timestamp", "time"):
date_name = df.columns[0]
df[date_name] = pd.to_datetime(df[date_name])
df = df.set_index(date_name)
else:
df.index = pd.to_datetime(df.index)
if value_col not in df.columns:
available = ", ".join(df.columns.tolist())
raise KeyError(
f"Column '{value_col}' not found. Available columns: {available}"
)
return df[value_col].sort_index()
# ── Main ────────────────────────────────────────────────────────────
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Portfolio performance analysis tool."
)
parser.add_argument(
"--demo",
action="store_true",
help="Run with synthetic demo data (1-year equity curve).",
)
parser.add_argument(
"--csv",
type=str,
default=None,
help="Path to CSV file with equity curve data.",
)
parser.add_argument(
"--value-col",
type=str,
default="portfolio_value",
help="Column name for portfolio values in CSV (default: portfolio_value).",
)
parser.add_argument(
"--date-col",
type=str,
default=None,
help="Column name for dates in CSV (default: auto-detect).",
)
return parser.parse_args()
def main() -> None:
"""Entry point."""
args = parse_args()
if args.demo:
print("Generating 1-year synthetic equity curve (seed=42)...")
print(" Annual return target: 25%, Annual vol target: 30%\n")
equity = generate_demo_equity(
start_date="2025-01-01",
periods=252,
initial_capital=10000.0,
annual_return=0.25,
annual_vol=0.30,
seed=42,
)
trade_pnl = generate_demo_trades(n_trades=100, seed=42)
print_full_report(equity, trade_pnl=trade_pnl)
elif args.csv:
try:
equity = load_equity_from_csv(
args.csv,
value_col=args.value_col,
date_col=args.date_col,
)
except FileNotFoundError:
print(f"Error: File not found: {args.csv}")
sys.exit(1)
except KeyError as e:
print(f"Error: {e}")
sys.exit(1)
print(f"Loaded {len(equity)} data points from {args.csv}\n")
print_full_report(equity)
else:
print("Usage:")
print(" python scripts/analyze_portfolio.py --demo")
print(" python scripts/analyze_portfolio.py --csv equity.csv --value-col portfolio_value")
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Multi-strategy performance comparison tool.
Computes metrics for multiple equity curves side-by-side, ranks strategies
by risk-adjusted metrics, and identifies the best performer. Includes a
--demo mode that generates three synthetic strategies with different profiles.
Usage:
python scripts/compare_strategies.py --demo
python scripts/compare_strategies.py --csv strat1.csv strat2.csv strat3.csv
Dependencies:
uv pip install pandas numpy
Environment Variables:
None required.
"""
import argparse
import sys
from typing import Optional
import numpy as np
import pandas as pd
# ── Metric Functions ────────────────────────────────────────────────
def total_return(equity: pd.Series) -> float:
"""Total return from start to end."""
return float((equity.iloc[-1] / equity.iloc[0]) - 1)
def cagr(equity: pd.Series) -> float:
"""Compound Annual Growth Rate."""
days = (equity.index[-1] - equity.index[0]).days
if days <= 0:
return 0.0
return float((equity.iloc[-1] / equity.iloc[0]) ** (365.25 / days) - 1)
def annualized_volatility(
returns: pd.Series, periods_per_year: int = 252
) -> float:
"""Annualized standard deviation."""
return float(returns.std() * np.sqrt(periods_per_year))
def max_drawdown(equity: pd.Series) -> float:
"""Maximum peak-to-trough decline (negative)."""
peak = equity.cummax()
dd = (equity - peak) / peak
return float(dd.min())
def sharpe_ratio(
returns: pd.Series, rf: float = 0.0, periods_per_year: int = 252
) -> float:
"""Annualized Sharpe ratio."""
excess = returns - rf
if excess.std() == 0:
return 0.0
return float((excess.mean() / excess.std()) * np.sqrt(periods_per_year))
def sortino_ratio(
returns: pd.Series, rf: float = 0.0, periods_per_year: int = 252
) -> float:
"""Annualized Sortino ratio."""
excess = returns - rf
downside = excess[excess < 0]
if len(downside) == 0 or downside.std() == 0:
return float("inf") if excess.mean() > 0 else 0.0
return float((excess.mean() / downside.std()) * np.sqrt(periods_per_year))
def calmar_ratio(equity: pd.Series) -> float:
"""CAGR / |max drawdown|."""
annual_return = cagr(equity)
mdd = abs(max_drawdown(equity))
if mdd == 0:
return float("inf") if annual_return > 0 else 0.0
return annual_return / mdd
def omega_ratio(returns: pd.Series, threshold: float = 0.0) -> float:
"""Probability-weighted gains / losses."""
excess = returns - threshold
gains = excess[excess > 0].sum()
losses = abs(excess[excess <= 0].sum())
if losses == 0:
return float("inf") if gains > 0 else 1.0
return float(gains / losses)
def historical_var(returns: pd.Series, confidence: float = 0.95) -> float:
"""Historical VaR as positive number."""
return float(-np.percentile(returns.dropna(), (1 - confidence) * 100))
def win_rate(returns: pd.Series) -> float:
"""Fraction of positive-return periods."""
if len(returns) == 0:
return 0.0
return float((returns > 0).sum() / len(returns))
# ── Strategy Metrics Computation ────────────────────────────────────
def compute_all_metrics(
name: str, equity: pd.Series, rf: float = 0.0
) -> dict:
"""Compute all metrics for a single strategy.
Args:
name: Strategy name for labeling.
equity: Time-indexed portfolio value series.
rf: Risk-free rate per period.
Returns:
Dictionary of metric name -> value.
"""
returns = equity.pct_change().dropna()
return {
"name": name,
"total_return": total_return(equity),
"cagr": cagr(equity),
"ann_volatility": annualized_volatility(returns),
"max_drawdown": max_drawdown(equity),
"sharpe": sharpe_ratio(returns, rf),
"sortino": sortino_ratio(returns, rf),
"calmar": calmar_ratio(equity),
"omega": omega_ratio(returns),
"var_95": historical_var(returns, 0.95),
"win_rate": win_rate(returns),
"final_value": float(equity.iloc[-1]),
"n_periods": len(returns),
}
# ── Demo Data ───────────────────────────────────────────────────────
def generate_strategy_equity(
start_date: str,
periods: int,
initial_capital: float,
annual_return: float,
annual_vol: float,
seed: int,
) -> pd.Series:
"""Generate synthetic equity curve.
Args:
start_date: Start date string.
periods: Number of trading days.
initial_capital: Starting value.
annual_return: Target annualized return.
annual_vol: Target annualized volatility.
seed: Random seed.
Returns:
Time-indexed equity Series.
"""
rng = np.random.default_rng(seed)
daily_mu = annual_return / 252
daily_sigma = annual_vol / np.sqrt(252)
daily_returns = rng.normal(daily_mu, daily_sigma, periods)
prices = initial_capital * np.cumprod(1 + daily_returns)
prices = np.insert(prices, 0, initial_capital)
dates = pd.bdate_range(start=start_date, periods=len(prices))
return pd.Series(prices, index=dates)
def generate_demo_strategies() -> dict[str, pd.Series]:
"""Generate three demo strategies with different risk/return profiles.
Returns:
Dictionary of strategy name -> equity Series.
"""
strategies = {
"Momentum": generate_strategy_equity(
start_date="2025-01-01",
periods=252,
initial_capital=10000.0,
annual_return=0.40,
annual_vol=0.45,
seed=42,
),
"Mean Reversion": generate_strategy_equity(
start_date="2025-01-01",
periods=252,
initial_capital=10000.0,
annual_return=0.20,
annual_vol=0.15,
seed=123,
),
"Trend Following": generate_strategy_equity(
start_date="2025-01-01",
periods=252,
initial_capital=10000.0,
annual_return=0.30,
annual_vol=0.35,
seed=456,
),
}
return strategies
# ── Comparison Report ───────────────────────────────────────────────
def format_val(value: float, fmt: str = "pct") -> str:
"""Format a value for display.
Args:
value: Numeric value to format.
fmt: Format type ('pct', 'ratio', 'dollar', 'int').
Returns:
Formatted string.
"""
if value == float("inf"):
return "inf"
if value == float("-inf"):
return "-inf"
if fmt == "pct":
return f"{value * 100:.2f}%"
elif fmt == "ratio":
return f"{value:.2f}"
elif fmt == "dollar":
return f"${value:,.2f}"
elif fmt == "int":
return f"{int(value)}"
return f"{value:.4f}"
def print_comparison_table(metrics_list: list[dict]) -> None:
"""Print side-by-side comparison of strategy metrics.
Args:
metrics_list: List of metric dictionaries from compute_all_metrics.
"""
col_width = 18
name_width = 22
# Header
header = f"{'Metric':<{name_width}}"
for m in metrics_list:
header += f"{m['name']:>{col_width}}"
print(header)
print("─" * (name_width + col_width * len(metrics_list)))
# Rows
rows = [
("Total Return", "total_return", "pct"),
("CAGR", "cagr", "pct"),
("Ann. Volatility", "ann_volatility", "pct"),
("Max Drawdown", "max_drawdown", "pct"),
("Sharpe Ratio", "sharpe", "ratio"),
("Sortino Ratio", "sortino", "ratio"),
("Calmar Ratio", "calmar", "ratio"),
("Omega Ratio", "omega", "ratio"),
("VaR (95%)", "var_95", "pct"),
("Win Rate", "win_rate", "pct"),
("Final Value", "final_value", "dollar"),
("Periods", "n_periods", "int"),
]
for label, key, fmt in rows:
row = f"{label:<{name_width}}"
for m in metrics_list:
row += f"{format_val(m[key], fmt):>{col_width}}"
print(row)
def print_rankings(metrics_list: list[dict]) -> None:
"""Print strategy rankings by key metrics.
Args:
metrics_list: List of metric dictionaries.
"""
ranking_metrics = [
("Sharpe Ratio", "sharpe", True),
("Sortino Ratio", "sortino", True),
("Calmar Ratio", "calmar", True),
("Total Return", "total_return", True),
("Max Drawdown", "max_drawdown", True), # higher (less negative) is better
("Ann. Volatility", "ann_volatility", False), # lower is better
]
print(f"\n{'Metric':<22}{'#1':<20}{'#2':<20}{'#3+':<20}")
print("─" * 82)
rank_scores: dict[str, float] = {m["name"]: 0.0 for m in metrics_list}
for label, key, higher_is_better in ranking_metrics:
sorted_strats = sorted(
metrics_list,
key=lambda x: x[key],
reverse=higher_is_better,
)
row = f"{label:<22}"
for i, s in enumerate(sorted_strats):
row += f"{s['name']:<20}"
# Award points: 3 for 1st, 2 for 2nd, 1 for 3rd, etc.
rank_scores[s["name"]] += len(sorted_strats) - i
print(row)
# Overall ranking
print(f"\n{'─' * 60}")
print(" OVERALL RANKING (by accumulated rank points)")
print(f"{'─' * 60}")
overall = sorted(rank_scores.items(), key=lambda x: x[1], reverse=True)
for rank, (name, score) in enumerate(overall, 1):
marker = " ← Best Risk-Adjusted" if rank == 1 else ""
print(f" #{rank} {name:<25} ({score:.0f} points){marker}")
def print_correlation_matrix(strategies: dict[str, pd.Series]) -> None:
"""Print return correlation matrix between strategies.
Args:
strategies: Dictionary of strategy name -> equity Series.
"""
returns_df = pd.DataFrame(
{name: eq.pct_change().dropna() for name, eq in strategies.items()}
)
# Align on common dates
returns_df = returns_df.dropna()
corr = returns_df.corr()
print(f"\n{'─' * 60}")
print(" RETURN CORRELATION MATRIX")
print(f"{'─' * 60}")
col_width = 18
name_width = 18
header = f"{'':<{name_width}}"
for name in corr.columns:
header += f"{name:>{col_width}}"
print(header)
for row_name in corr.index:
row = f"{row_name:<{name_width}}"
for col_name in corr.columns:
row += f"{corr.loc[row_name, col_name]:>{col_width}.3f}"
print(row)
def print_full_comparison(strategies: dict[str, pd.Series]) -> None:
"""Print complete multi-strategy comparison report.
Args:
strategies: Dictionary of strategy name -> equity Series.
"""
print("=" * 70)
print(" MULTI-STRATEGY COMPARISON REPORT")
print(f" Strategies: {', '.join(strategies.keys())}")
print("=" * 70)
# Compute metrics
metrics_list = []
for name, equity in strategies.items():
metrics = compute_all_metrics(name, equity)
metrics_list.append(metrics)
# Side-by-side metrics table
print(f"\n{'─' * 60}")
print(" PERFORMANCE METRICS")
print(f"{'─' * 60}")
print_comparison_table(metrics_list)
# Rankings
print(f"\n{'─' * 60}")
print(" STRATEGY RANKINGS")
print(f"{'─' * 60}")
print_rankings(metrics_list)
# Correlation
print_correlation_matrix(strategies)
# Diversification benefit
print(f"\n{'─' * 60}")
print(" EQUAL-WEIGHT PORTFOLIO")
print(f"{'─' * 60}")
returns_df = pd.DataFrame(
{name: eq.pct_change().dropna() for name, eq in strategies.items()}
)
returns_df = returns_df.dropna()
equal_weight_returns = returns_df.mean(axis=1)
eq_equity = (1 + equal_weight_returns).cumprod() * 10000
eq_metrics = compute_all_metrics("Equal Weight", eq_equity)
print(f" Total Return: {format_val(eq_metrics['total_return'], 'pct')}")
print(f" CAGR: {format_val(eq_metrics['cagr'], 'pct')}")
print(f" Sharpe: {format_val(eq_metrics['sharpe'], 'ratio')}")
print(f" Max Drawdown: {format_val(eq_metrics['max_drawdown'], 'pct')}")
print(f" Calmar: {format_val(eq_metrics['calmar'], 'ratio')}")
print(f" Ann. Volatility: {format_val(eq_metrics['ann_volatility'], 'pct')}")
# Check if equal-weight beats all individual strategies on Sharpe
individual_sharpes = [m["sharpe"] for m in metrics_list]
if eq_metrics["sharpe"] > max(individual_sharpes):
print("\n Equal-weight portfolio achieves HIGHER Sharpe than any")
print(" individual strategy — diversification benefit confirmed.")
else:
best_individual = max(metrics_list, key=lambda x: x["sharpe"])
print(f"\n Best individual strategy ({best_individual['name']}) has higher")
print(" Sharpe than equal-weight blend.")
print(f"\n{'=' * 70}")
print(" Note: This is analytical output for informational purposes.")
print(" It does not constitute financial advice.")
print(f"{'=' * 70}\n")
# ── CSV Loading ─────────────────────────────────────────────────────
def load_strategies_from_csv(
filepaths: list[str],
value_col: str = "portfolio_value",
date_col: Optional[str] = None,
) -> dict[str, pd.Series]:
"""Load multiple equity curves from CSV files.
Args:
filepaths: List of CSV file paths.
value_col: Column name for portfolio values.
date_col: Column name for dates.
Returns:
Dictionary of filename (without ext) -> equity Series.
"""
strategies = {}
for fp in filepaths:
df = pd.read_csv(fp)
name = fp.rsplit("/", 1)[-1].rsplit(".", 1)[0]
if date_col and date_col in df.columns:
df[date_col] = pd.to_datetime(df[date_col])
df = df.set_index(date_col)
elif df.columns[0].lower() in ("date", "datetime", "timestamp", "time"):
date_name = df.columns[0]
df[date_name] = pd.to_datetime(df[date_name])
df = df.set_index(date_name)
else:
df.index = pd.to_datetime(df.index)
if value_col not in df.columns:
available = ", ".join(df.columns.tolist())
print(f"Warning: '{value_col}' not found in {fp}. Available: {available}")
continue
strategies[name] = df[value_col].sort_index()
return strategies
# ── Main ────────────────────────────────────────────────────────────
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Compare multiple trading strategies."
)
parser.add_argument(
"--demo",
action="store_true",
help="Run with three synthetic demo strategies.",
)
parser.add_argument(
"--csv",
nargs="+",
type=str,
default=None,
help="Paths to CSV files (one per strategy).",
)
parser.add_argument(
"--value-col",
type=str,
default="portfolio_value",
help="Column name for portfolio values (default: portfolio_value).",
)
parser.add_argument(
"--date-col",
type=str,
default=None,
help="Column name for dates (default: auto-detect).",
)
return parser.parse_args()
def main() -> None:
"""Entry point."""
args = parse_args()
if args.demo:
print("Generating 3 demo strategies (1 year each)...")
print(" Momentum: 40% target return, 45% target vol")
print(" Mean Reversion: 20% target return, 15% target vol")
print(" Trend Following: 30% target return, 35% target vol\n")
strategies = generate_demo_strategies()
print_full_comparison(strategies)
elif args.csv:
if len(args.csv) < 2:
print("Error: Provide at least 2 CSV files for comparison.")
sys.exit(1)
try:
strategies = load_strategies_from_csv(
args.csv,
value_col=args.value_col,
date_col=args.date_col,
)
except Exception as e:
print(f"Error loading CSV files: {e}")
sys.exit(1)
if len(strategies) < 2:
print("Error: Need at least 2 valid strategies to compare.")
sys.exit(1)
print(f"Loaded {len(strategies)} strategies\n")
print_full_comparison(strategies)
else:
print("Usage:")
print(" python scripts/compare_strategies.py --demo")
print(" python scripts/compare_strategies.py --csv strat1.csv strat2.csv strat3.csv")
sys.exit(1)
if __name__ == "__main__":
main()
Related skills
FAQ
What input does it need?
A time-indexed equity curve (Series of portfolio values); it converts this to returns internally.
Which ratios does it compute?
Sharpe, Sortino, Calmar, Omega, and Information ratios, plus VaR, CVaR, max drawdown, and time underwater.