"""Core risk and performance metrics.
Provides the standard risk-adjusted return ratios used across portfolio
management, fund evaluation, and strategy research. Each metric
quantifies a different aspect of the risk-return trade-off.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from wraquant.core._coerce import coerce_array, coerce_series
[docs]
def sharpe_ratio(
returns: pd.Series,
risk_free: float = 0.0,
periods_per_year: int = 252,
) -> float:
"""Annualized Sharpe ratio.
The Sharpe ratio measures excess return per unit of total risk
(standard deviation). It is the most widely cited risk-adjusted
performance measure in finance.
When to use:
Use Sharpe when you want a single number summarising risk-adjusted
performance. Compare strategies on the same asset class (Sharpe
is less meaningful across asset classes with different return
distributions).
Mathematical formulation:
SR = (mean(r - r_f) / std(r - r_f)) * sqrt(N)
where r is the return series, r_f is the per-period risk-free
rate, and N is periods_per_year.
How to interpret:
- SR < 0: strategy loses money on a risk-adjusted basis.
- 0 < SR < 0.5: poor; barely compensating for risk.
- 0.5 < SR < 1.0: acceptable for long-only strategies.
- 1.0 < SR < 2.0: good; typical of well-designed quant strategies.
- SR > 2.0: excellent; verify this is not overfitting.
- SR > 3.0: suspicious; likely backtest artifact or very short
sample.
Parameters:
returns: Simple return series (e.g., daily percentage changes
divided by 100).
risk_free: Annual risk-free rate (e.g., 0.05 for 5%).
periods_per_year: Trading periods per year (252 for daily,
12 for monthly, 52 for weekly).
Returns:
Annualized Sharpe ratio as a float.
Example:
>>> import pandas as pd, numpy as np
>>> np.random.seed(42)
>>> daily_returns = pd.Series(np.random.normal(0.0005, 0.01, 252))
>>> sr = sharpe_ratio(daily_returns, risk_free=0.04)
>>> isinstance(sr, float)
True
Caveats:
- Assumes returns are IID and normally distributed; both
assumptions are violated in practice.
- Penalises upside volatility equally with downside volatility;
use ``sortino_ratio`` if you only care about downside risk.
- Annualisation via sqrt(N) is only exact for IID returns.
See Also:
sortino_ratio: Uses downside deviation only.
information_ratio: Measures alpha relative to a benchmark.
wraquant.backtest.tearsheet.comprehensive_tearsheet: Full report including Sharpe.
wraquant.stats.descriptive.rolling_sharpe: Time-varying Sharpe ratio.
References:
- Sharpe (1966), "Mutual Fund Performance"
- Bailey & Lopez de Prado (2012), "The Sharpe Ratio Efficient
Frontier"
"""
# Auto-detect periods_per_year from ReturnSeries metadata
if hasattr(returns, "periods_per_year"):
periods_per_year = returns.periods_per_year
returns = coerce_series(returns, "returns")
excess = returns - risk_free / periods_per_year
mean_excess = excess.mean()
std = excess.std()
if std == 0:
return 0.0
return float(mean_excess / std * np.sqrt(periods_per_year))
[docs]
def sortino_ratio(
returns: pd.Series,
risk_free: float = 0.0,
periods_per_year: int = 252,
) -> float:
"""Annualized Sortino ratio (downside risk only).
The Sortino ratio replaces total standard deviation with *downside
deviation* -- the standard deviation of negative excess returns
only. This avoids penalising strategies for upside volatility,
making it more appropriate for asymmetric return distributions
(which most equity strategies exhibit).
When to use:
Prefer Sortino over Sharpe when returns are skewed or when you
only care about downside risk. Particularly useful for options
strategies, trend-following, and any strategy with convex payoffs.
Mathematical formulation:
Sortino = (mean(r - r_f) / DD) * sqrt(N)
where DD = sqrt(mean(min(r - r_f, 0)^2)) is the downside
deviation (computed as the second lower partial moment).
How to interpret:
Values follow a similar scale to Sharpe, but Sortino is
typically higher because the denominator (downside deviation)
is smaller than total standard deviation. A Sortino of 2.0 is
roughly equivalent to a Sharpe of 1.5 for normally distributed
returns.
Parameters:
returns: Simple return series.
risk_free: Annual risk-free rate.
periods_per_year: Trading periods per year.
Returns:
Annualized Sortino ratio. Returns ``inf`` when there are no
negative excess returns and the mean is positive, and ``0.0``
when the mean is non-positive.
Example:
>>> import pandas as pd, numpy as np
>>> np.random.seed(42)
>>> daily_returns = pd.Series(np.random.normal(0.0005, 0.01, 252))
>>> sr = sortino_ratio(daily_returns, risk_free=0.04)
>>> sr > 0
True
See Also:
sharpe_ratio: Uses total standard deviation.
max_drawdown: Peak-to-trough loss measure.
References:
- Sortino & van der Meer (1991), "Downside Risk"
- Sortino & Satchell (2001), "Managing Downside Risk in
Financial Markets"
"""
# Auto-detect periods_per_year from ReturnSeries metadata
if hasattr(returns, "periods_per_year"):
periods_per_year = returns.periods_per_year
returns = coerce_series(returns, "returns")
excess = returns - risk_free / periods_per_year
downside = excess[excess < 0]
if len(downside) == 0:
return float("inf") if excess.mean() > 0 else 0.0
downside_std = np.sqrt((downside**2).mean())
if downside_std == 0:
return 0.0
return float(excess.mean() / downside_std * np.sqrt(periods_per_year))
[docs]
def max_drawdown(prices: pd.Series) -> float:
"""Maximum drawdown from a price series.
Maximum drawdown is the largest peak-to-trough decline in the price
series. It measures the worst historical loss an investor would have
experienced if they bought at the peak and sold at the trough.
When to use:
Use max drawdown as a "worst case" risk measure. It is more
intuitive than VaR for communicating tail risk to non-technical
stakeholders. Often used as a hard constraint in portfolio
optimisation (e.g., "max drawdown must stay below 15%").
Mathematical formulation:
MDD = min_t ( (P_t - max_{s<=t} P_s) / max_{s<=t} P_s )
The result is a negative number (or zero if the price series
only goes up).
How to interpret:
- MDD = -0.10 means the worst peak-to-trough loss was 10%.
- MDD = -0.50 means the strategy lost half its value at worst.
- For S&P 500 since 1928, the worst MDD was about -0.86
(1929-1932). Post-2000, the worst was about -0.57 (GFC).
- A strategy with a Sharpe of 1.0 and max drawdown of -0.30
has a Calmar ratio of about 0.33.
Parameters:
prices: Price or equity curve series (not returns). Must be
positive values.
Returns:
Maximum drawdown as a negative float (e.g., -0.25 for a 25%
drawdown). Returns 0.0 if the series is monotonically
increasing.
Example:
>>> import pandas as pd
>>> prices = pd.Series([100, 110, 105, 95, 108, 102])
>>> mdd = max_drawdown(prices)
>>> round(mdd, 4)
-0.1364
See Also:
sharpe_ratio: Risk-adjusted return measure.
sortino_ratio: Downside-only risk-adjusted return.
wraquant.backtest.tearsheet.comprehensive_tearsheet: Full report with drawdowns.
wraquant.stats.descriptive.rolling_drawdown: Time-varying drawdown.
References:
- Magdon-Ismail & Atiya (2004), "Maximum Drawdown"
"""
prices = coerce_series(prices, "prices")
cummax = prices.cummax()
drawdown = (prices - cummax) / cummax
return float(drawdown.min())
[docs]
def hit_ratio(returns: pd.Series) -> float:
"""Fraction of positive return periods (win rate).
The hit ratio measures how often the strategy produces a positive
return. It is the simplest measure of consistency and is often used
alongside payoff ratio (average win / average loss) to characterise
a strategy's profile.
When to use:
Use hit ratio for quick strategy diagnostics. A trend-following
strategy typically has a low hit ratio (30-45%) but large
average wins. A mean-reversion strategy typically has a high
hit ratio (55-70%) but smaller average wins. Neither is
inherently better -- what matters is the product of hit ratio
and payoff ratio.
How to interpret:
- 0.50 = coin flip; no directional edge.
- 0.55 = statistically meaningful edge on daily data.
- 0.60+ = strong edge; verify you are not overfitting.
- < 0.40 does not mean a bad strategy if the avg win >> avg loss.
Parameters:
returns: Simple return series.
Returns:
Hit ratio as a float between 0 and 1.
Example:
>>> import pandas as pd
>>> returns = pd.Series([0.01, -0.005, 0.008, -0.003, 0.012])
>>> hit_ratio(returns)
0.6
See Also:
sharpe_ratio: Risk-adjusted return measure.
information_ratio: Alpha per unit of tracking error.
"""
returns = coerce_series(returns, "returns")
clean = returns.dropna()
if len(clean) == 0:
return 0.0
return float((clean > 0).sum() / len(clean))
[docs]
def treynor_ratio(
returns: pd.Series,
benchmark: pd.Series,
risk_free: float = 0.0,
periods_per_year: int = 252,
) -> float:
"""Treynor ratio: excess return per unit of systematic (beta) risk.
The Treynor ratio is similar to the Sharpe ratio but uses beta
(systematic risk) rather than total standard deviation in the
denominator. It is the appropriate performance measure for
well-diversified portfolios where specific risk has been
diversified away.
When to use:
Use Treynor for comparing portfolios that are *part of* a larger
diversified portfolio (so only systematic risk matters). If the
portfolio is the investor's entire wealth, use Sharpe instead.
Mathematical formulation:
Treynor = (R_p - R_f) / beta_p
where R_p is annualized portfolio return, R_f is the risk-free
rate, and beta_p is the portfolio's beta vs the benchmark.
Parameters:
returns: Portfolio return series.
benchmark: Benchmark return series (same frequency and index).
risk_free: Annual risk-free rate.
periods_per_year: Trading periods per year.
Returns:
Treynor ratio as a float. Higher is better. Negative indicates
the portfolio underperformed the risk-free rate.
Example:
>>> import pandas as pd, numpy as np
>>> np.random.seed(42)
>>> market = pd.Series(np.random.normal(0.0005, 0.01, 252))
>>> portfolio = 1.2 * market + np.random.normal(0.0001, 0.005, 252)
>>> tr = treynor_ratio(portfolio, market, risk_free=0.04)
>>> isinstance(tr, float)
True
See Also:
sharpe_ratio: Total risk-adjusted return.
jensens_alpha: Excess return above CAPM prediction.
References:
- Treynor (1965), "How to Rate Management of Investment Funds"
"""
returns = coerce_series(returns, "returns")
benchmark = coerce_series(benchmark, "benchmark")
excess_port = returns - risk_free / periods_per_year
excess_bench = benchmark - risk_free / periods_per_year
aligned = pd.concat(
[excess_port.rename("p"), excess_bench.rename("b")], axis=1
).dropna()
cov_pb = np.cov(aligned["p"].values, aligned["b"].values, ddof=1)
beta = cov_pb[0, 1] / cov_pb[1, 1] if cov_pb[1, 1] != 0 else 0.0
if beta == 0:
return 0.0
ann_return = float(aligned["p"].mean() * periods_per_year)
return ann_return / beta
[docs]
def m_squared(
returns: pd.Series,
benchmark: pd.Series,
risk_free: float = 0.0,
periods_per_year: int = 252,
) -> float:
"""M-squared (Modigliani-Modigliani) risk-adjusted performance.
M-squared leverages or deleverages the portfolio to match the
benchmark's volatility, then measures the excess return at that
risk level. The result is in return units (basis points / percent),
making it easier to interpret than the dimensionless Sharpe ratio.
When to use:
Use M-squared when you want to compare two portfolios with
different risk levels on a common scale. M-squared answers:
"if both portfolios had the same risk as the benchmark, which
would earn more?"
Mathematical formulation:
M^2 = SR_p * sigma_b + R_f
Parameters:
returns: Portfolio return series.
benchmark: Benchmark return series.
risk_free: Annual risk-free rate.
periods_per_year: Trading periods per year.
Returns:
M-squared as an annualized return (float). Positive means the
portfolio outperforms the benchmark on a risk-adjusted basis.
Example:
>>> import pandas as pd, numpy as np
>>> np.random.seed(42)
>>> portfolio = pd.Series(np.random.normal(0.0006, 0.01, 252))
>>> benchmark = pd.Series(np.random.normal(0.0004, 0.009, 252))
>>> m2 = m_squared(portfolio, benchmark, risk_free=0.04)
>>> isinstance(m2, float)
True
See Also:
sharpe_ratio: Dimensionless risk-adjusted return.
References:
- Modigliani & Modigliani (1997), "Risk-Adjusted Performance"
"""
returns = coerce_series(returns, "returns")
benchmark = coerce_series(benchmark, "benchmark")
excess_bench = benchmark - risk_free / periods_per_year
sr_port = sharpe_ratio(returns, risk_free, periods_per_year)
bench_vol = float(excess_bench.std() * np.sqrt(periods_per_year))
return sr_port * bench_vol + risk_free
[docs]
def jensens_alpha(
returns: pd.Series,
benchmark: pd.Series,
risk_free: float = 0.0,
periods_per_year: int = 252,
) -> float:
"""Jensen's alpha: excess return above CAPM-predicted return.
Jensen's alpha measures the average return of the portfolio in
excess of what the Capital Asset Pricing Model (CAPM) predicts
given the portfolio's beta. A positive alpha indicates that the
manager generated return beyond what the market risk exposure
would explain.
Mathematical formulation:
alpha = R_p - [R_f + beta * (R_m - R_f)]
where R_p is the portfolio return, R_m is the benchmark return,
and beta is the portfolio's beta to the benchmark.
Parameters:
returns: Portfolio return series.
benchmark: Benchmark return series.
risk_free: Annual risk-free rate.
periods_per_year: Trading periods per year.
Returns:
Annualized Jensen's alpha as a float.
Example:
>>> import pandas as pd, numpy as np
>>> np.random.seed(42)
>>> market = pd.Series(np.random.normal(0.0005, 0.01, 252))
>>> stock = 0.0002 + 1.0 * market + np.random.normal(0, 0.005, 252)
>>> alpha = jensens_alpha(stock, market, risk_free=0.04)
>>> isinstance(alpha, float)
True
See Also:
treynor_ratio: Risk-adjusted return using beta.
appraisal_ratio: Alpha per unit of residual risk.
References:
- Jensen (1968), "The Performance of Mutual Funds in the Period
1945-1964"
"""
returns = coerce_series(returns, "returns")
benchmark = coerce_series(benchmark, "benchmark")
rf_per_period = risk_free / periods_per_year
excess_port = returns - rf_per_period
excess_bench = benchmark - rf_per_period
aligned = pd.concat(
[excess_port.rename("p"), excess_bench.rename("b")], axis=1
).dropna()
cov_pb = np.cov(aligned["p"].values, aligned["b"].values, ddof=1)
beta = cov_pb[0, 1] / cov_pb[1, 1] if cov_pb[1, 1] != 0 else 0.0
# Alpha per period
alpha_per_period = aligned["p"].mean() - beta * aligned["b"].mean()
return float(alpha_per_period * periods_per_year)
[docs]
def appraisal_ratio(
returns: pd.Series,
benchmark: pd.Series,
risk_free: float = 0.0,
periods_per_year: int = 252,
) -> float:
"""Appraisal ratio: Jensen's alpha per unit of residual risk.
The appraisal ratio (also called the Treynor-Black appraisal ratio)
measures the manager's alpha relative to the risk taken to achieve
it (residual/idiosyncratic volatility). A high appraisal ratio
means the manager generates alpha efficiently.
Mathematical formulation:
AR = alpha / sigma_epsilon
where alpha is Jensen's alpha and sigma_epsilon is the standard
deviation of the regression residuals (annualized).
Parameters:
returns: Portfolio return series.
benchmark: Benchmark return series.
risk_free: Annual risk-free rate.
periods_per_year: Trading periods per year.
Returns:
Appraisal ratio as a float. Higher is better.
Example:
>>> import pandas as pd, numpy as np
>>> np.random.seed(42)
>>> market = pd.Series(np.random.normal(0.0005, 0.01, 252))
>>> stock = 0.0003 + 1.0 * market + np.random.normal(0, 0.005, 252)
>>> ar = appraisal_ratio(stock, market, risk_free=0.04)
>>> isinstance(ar, float)
True
See Also:
jensens_alpha: The numerator of the appraisal ratio.
information_ratio: Alpha per unit of tracking error.
References:
- Treynor & Black (1973), "How to Use Security Analysis to
Improve Portfolio Selection"
"""
returns = coerce_series(returns, "returns")
benchmark = coerce_series(benchmark, "benchmark")
rf_per_period = risk_free / periods_per_year
excess_port = returns - rf_per_period
excess_bench = benchmark - rf_per_period
aligned = pd.concat(
[excess_port.rename("p"), excess_bench.rename("b")], axis=1
).dropna()
p_vals = aligned["p"].values
b_vals = aligned["b"].values
# OLS regression
cov_pb = np.cov(p_vals, b_vals, ddof=1)
beta = cov_pb[0, 1] / cov_pb[1, 1] if cov_pb[1, 1] != 0 else 0.0
alpha_per_period = np.mean(p_vals) - beta * np.mean(b_vals)
# Residuals
residuals = p_vals - (alpha_per_period + beta * b_vals)
residual_vol = float(np.std(residuals, ddof=1) * np.sqrt(periods_per_year))
alpha_annual = float(alpha_per_period * periods_per_year)
if residual_vol == 0:
return 0.0
return alpha_annual / residual_vol
[docs]
def capture_ratios(
returns: pd.Series,
benchmark: pd.Series,
) -> dict[str, float]:
"""Up-capture and down-capture ratios.
Capture ratios measure how much of the benchmark's up and down
movements the portfolio captures. An ideal portfolio has high
up-capture (>100%) and low down-capture (<100%).
When to use:
Use capture ratios for:
- Evaluating defensive vs aggressive positioning: a portfolio
with 90% up-capture and 70% down-capture is defensively
positioned and will outperform in bear markets.
- Manager selection: compare capture ratios across funds.
- Style analysis: growth funds typically have high up-capture
and high down-capture; value funds often have lower both.
Mathematical formulation:
Up-capture = (mean(r_p | r_b > 0) / mean(r_b | r_b > 0)) * 100
Down-capture = (mean(r_p | r_b < 0) / mean(r_b | r_b < 0)) * 100
Capture ratio = up-capture / down-capture
Parameters:
returns: Portfolio return series.
benchmark: Benchmark return series (same frequency and index).
Returns:
Dictionary containing:
- **up_capture** (*float*) -- Percentage of benchmark's up
movements captured (>100 = amplified).
- **down_capture** (*float*) -- Percentage of benchmark's down
movements captured (<100 = dampened losses).
- **capture_ratio** (*float*) -- up_capture / down_capture.
Values > 1 indicate the portfolio adds value through
asymmetric participation.
Example:
>>> import pandas as pd, numpy as np
>>> np.random.seed(42)
>>> benchmark = pd.Series(np.random.normal(0.0005, 0.01, 252))
>>> portfolio = 0.8 * benchmark + np.random.normal(0.0001, 0.005, 252)
>>> caps = capture_ratios(portfolio, benchmark)
>>> caps["up_capture"] > 0
True
See Also:
treynor_ratio: Systematic risk-adjusted return.
"""
returns = coerce_series(returns, "returns")
benchmark = coerce_series(benchmark, "benchmark")
aligned = pd.concat([returns.rename("p"), benchmark.rename("b")], axis=1).dropna()
up_mask = aligned["b"] > 0
down_mask = aligned["b"] < 0
up_bench_mean = aligned["b"][up_mask].mean()
up_port_mean = aligned["p"][up_mask].mean()
down_bench_mean = aligned["b"][down_mask].mean()
down_port_mean = aligned["p"][down_mask].mean()
up_capture = (
float(up_port_mean / up_bench_mean * 100) if up_bench_mean != 0 else 0.0
)
down_capture = (
float(down_port_mean / down_bench_mean * 100) if down_bench_mean != 0 else 0.0
)
cap_ratio = up_capture / down_capture if down_capture != 0 else float("inf")
return {
"up_capture": up_capture,
"down_capture": down_capture,
"capture_ratio": cap_ratio,
}