backtester/portfolio/calc/metrics.py metrics.py:
Benchmark-relative and signal-quality helpers.
This file is the lightweight diagnostics layer around returns. Use it when you need benchmark-relative series, quick annualized volatility, signal quality checks, correlation structure, trend persistence or simple tail-behavior diagnostics without invoking the full performance engine.
from backtester.portfolio.calc import metrics Mental Model
- metrics.py operates on already-computed return series, benchmark series and optional signal scores.
- It does not run execution, portfolio accounting, walk-forward folds or report generation.
- Treat it as a small diagnostics layer: functions are stateless, composable and meant to sit between raw research notebooks and the heavier report pipeline.
When To Read This
- 01You have a return matrix and need quick benchmark-relative diagnostics before building a full report.
- 02You are evaluating whether predicted returns, ranks or signal scores have any realized relationship to future returns.
- 03You want small, composable functions that can be used in notebooks, strategy packets or report glue code.
File Anatomy
- Benchmark-relative helpers: excess_returns() keeps the full time series; active_return() collapses it into average excess return.
- Volatility helper: annualized_volatility() gives a quick column-wise risk scale, while deeper risk ratios live in risk.py.
- Signal-quality helper: information_coefficient() aligns realized and predicted returns, then computes correlation.
- Matrix structure helper: correlation_matrix() exposes co-movement across strategies, assets or factors.
- Single-series behavior helpers: return_persistence(), market_sensitivity_summary() and tail_dependence().
Module Boundary
This module does
- Benchmark-relative return transforms.
- Simple volatility scaling.
- Signal/return association checks.
- Correlation and persistence helpers.
- Small tail-behavior sketches for a single return series.
This module does not
- Simulate orders, fills, commissions or slippage.
- Compute full portfolio accounting or position state.
- Perform walk-forward validation or parameter promotion decisions.
- Generate PDF reports or own report section formatting.
- Replace risk.py, rolling_stats.py or portfolio_perf.py.
Data Contract
Inputs
- returns: per-period return series, not raw price levels. Usually a pd.DataFrame with dates as index and strategies/assets as columns; some functions accept a pd.Series.
- benchmark_returns: pd.Series or pd.DataFrame indexed by dates. It is reindexed to returns.index before subtraction.
- predicted_returns: pd.Series or pd.DataFrame containing forecasts, scores, ranks or expected returns aligned by date.
- Use one return convention per call. If inputs are log returns, keep benchmark and predicted-return comparisons in the same convention.
Outputs
- excess_returns() returns a DataFrame with the same index and columns as returns.
- annualized_volatility() and active_return() return Series indexed by return columns.
- information_coefficient(), return_persistence() return floats; sensitivity and tail helpers return small dictionaries.
Invariants
- All date-sensitive comparisons align by index before computing statistics; labels are part of the contract.
- The module assumes the caller has already chosen calendar, timezone and market-close conventions upstream.
- NaN handling is intentionally minimal: some functions rely on pandas behavior, while information_coefficient() converts NaNs to zero inside the correlation step.
- Zero-variance, empty or too-short inputs return NaN-like outputs rather than false precision.
- Annualization uses days_per_year=252 by default; change it explicitly for non-daily or non-US trading calendars.
- Return shape matters: Series functions are single-path diagnostics, while DataFrame functions operate column-wise or matrix-wise.
- Functions return raw numerical objects. Formatting, section labels and PDF rows belong to report/config layers.
Function Map
| Function | Input | Output | Main Caveat |
|---|---|---|---|
excess_returns | returns, benchmark_returns | aligned active return path | Index alignment matters; missing benchmark dates are not forward-filled here. |
active_return | returns, benchmark_returns | mean active return per column | Collapses the path and ignores drawdown timing. |
annualized_volatility | returns, days_per_year | annualized volatility per column | Assumes the input period scale matches days_per_year. |
information_coefficient | realized future returns, predicted returns or signal scores | correlation estimate | Timing and signal shift are critical to avoid look-ahead bias. |
correlation_matrix | return DataFrame | columns x columns correlation matrix | Missing data policy and regime dependence matter. |
return_persistence | single return Series | same-sign continuation ratio | Sign persistence is not predictive proof and ignores magnitude. |
market_sensitivity_summary | single return Series, window | avg_beta and beta_vol dictionary | Despite the key names, this is lag correlation, not external market beta. |
tail_dependence | single return Series, quantile | tail_beta and tail_correlation dictionary | Threshold and sample-size sensitive; not a full tail-risk model. |
Public API And Key Internals
excess_returns
functionexcess_returns(returns: pd.DataFrame, benchmark_returns: pd.DataFrame | pd.Series) -> pd.DataFrame Creates a benchmark-relative return series for every column in returns. This preserves the full path, so it can feed charts, active drawdown analysis, information ratio inputs or custom report rows.
Parameters
-
returnspd.DataFrameStrategy, portfolio, asset or factor return matrix. Rows are dates; columns are names being evaluated. -
benchmark_returnspd.Series | pd.DataFrameBenchmark return series or matrix. The function reindexes it to returns.index before subtraction.
Returns: pd.DataFrame with returns.index and returns.columns.
Use When
- You want the active return path, not just one active-return number.
- You need to inspect when a strategy added or lost value relative to SPY, a sector ETF or a custom benchmark.
How To Read It
- Positive values mean the strategy outperformed the benchmark on that date.
- The output is still a return series; compound it if you want cumulative active return.
Watch For
- The benchmark is reindexed, not forward-filled. Make sure missing benchmark dates are handled upstream.
- If benchmark_returns is a DataFrame, confirm the subtraction shape is what you intend.
from backtester.portfolio.calc import metrics
active_path = metrics.excess_returns(
returns=strategy_returns[["SMA_50_200", "Momentum"]],
benchmark_returns=benchmark_returns["SPY"],
)
active_cumulative = (1 + active_path).cumprod() - 1 annualized_volatility
functionannualized_volatility(returns: pd.DataFrame, *, days_per_year: int = 252) -> pd.Series Computes annualized standard deviation for each return column. This is the quick volatility helper; use risk.py for more complete risk reports.
Parameters
-
returnspd.DataFramePeriod returns in decimal form, for example 0.01 for +1%. -
days_per_yearintAnnualization scale. Use 252 for daily trading data unless your calendar is different.
Returns: pd.Series.
Use When
- You need a fast volatility scale for screening strategies or feature diagnostics.
- You are building a small table and do not need the full risk.py metric stack.
How To Read It
- A value of 0.20 means approximately 20% annualized volatility.
- The function uses sample standard deviation from pandas std().
Watch For
- This is not rolling volatility. For rolling paths use rolling_stats.rolling_volatility().
- Do not annualize already annualized inputs.
vol = metrics.annualized_volatility(strategy_returns, days_per_year=252)
vol.sort_values(ascending=False) active_return
functionactive_return(returns: pd.DataFrame, benchmark_returns: pd.Series) -> pd.Series Computes average return per column minus average benchmark return. Unlike excess_returns(), this collapses the path into one number per column.
Parameters
-
returnspd.DataFrameReturn matrix for strategies, assets or variants. -
benchmark_returnspd.SeriesBenchmark series. It is reindexed to returns.index before mean comparison.
Returns: pd.Series indexed by returns.columns.
Use When
- You need a quick average excess return number for ranking strategy variants.
- You do not need the timing/path of outperformance.
How To Read It
- The raw result is per-period average active return.
- Multiply by days_per_year if you want a rough annualized active-return estimate.
Watch For
- Average active return ignores path risk. Pair it with drawdown, volatility or information ratio.
- A strategy can have positive active return and still fail in specific crisis periods.
active = metrics.active_return(
returns=strategy_returns,
benchmark_returns=benchmark_returns["SPY"],
)
active.mul(252).sort_values(ascending=False) information_coefficient
functioninformation_coefficient(returns: pd.DataFrame | pd.Series, predicted_returns: pd.DataFrame | pd.Series) -> float Measures whether predictions move with realized returns after date alignment. In quant research this is usually used to test signal quality: higher positive IC means the signal tends to rank or forecast returns in the right direction.
Parameters
-
returnspd.DataFrame | pd.SeriesRealized returns, often forward returns shifted so they occur after the signal date. -
predicted_returnspd.DataFrame | pd.SeriesForecasts, expected returns, ranks or signal scores aligned to the same dates.
Returns: float correlation coefficient, or NaN when input is empty or has zero variance.
Use When
- You want a first-pass answer to: does this signal have predictive relationship to future returns?
- You are screening many signal variants before running a full backtest.
- You need a compact diagnostic for an optimization or research packet.
How To Read It
- Positive IC means higher predicted values generally correspond to higher realized returns.
- Negative IC can mean the signal is inverted, mistimed or dominated by a regime.
- Near-zero IC means the linear relationship is weak in the tested sample.
Watch For
- Always align signal timing to avoid look-ahead bias. Predictions at t should be compared to returns after t.
- The function flattens same-shape matrices. If shapes differ, it falls back to mean series by date.
- NaNs are converted to zero inside the correlation step; clean inputs upstream for production research.
Implementation Notes
- Same-shape DataFrames are flattened across dates and instruments.
- Different shapes are averaged across columns by date before correlation.
# Signal on date t should be compared with realized forward return after t.
forward_returns = prices.pct_change(21).shift(-21)
signal_scores = momentum_score.shift(1)
ic = metrics.information_coefficient(
returns=forward_returns,
predicted_returns=signal_scores,
)
print(f"21D information coefficient: {ic:.3f}") correlation_matrix
functioncorrelation_matrix(returns: pd.DataFrame) -> pd.DataFrame Returns the pairwise correlation matrix across return columns. Use this for strategy diversification, feature redundancy checks or portfolio construction diagnostics.
Parameters
-
returnspd.DataFrameReturn matrix. Each column is treated as a separate asset, strategy, factor or variant.
Returns: pd.DataFrame with returns.columns x returns.columns.
Use When
- You are checking whether strategy variants are truly different or just renamed versions of the same exposure.
- You need a fast input for diversification review before portfolio construction.
How To Read It
- Values near +1 move together; values near -1 move opposite; values near 0 are weakly linearly related.
- High strategy correlation can reduce the value of adding another strategy to the same book.
Watch For
- Correlation is sample-dependent and unstable over regimes. For rolling correlation use rolling_stats.
- Correlation does not capture nonlinear dependence or tail co-crashes.
corr = metrics.correlation_matrix(strategy_returns)
# Find pairs that may be redundant.
upper = corr.where(np.triu(np.ones(corr.shape), k=1).astype(bool))
high_corr_pairs = upper.stack().loc[lambda s: s.abs() > 0.85] return_persistence
functionreturn_persistence(returns: pd.Series) -> float Measures how often the sign of today return matches the sign of the previous return. It is a small diagnostic for streakiness, mean reversion or noisy return paths.
Parameters
-
returnspd.SeriesSingle return series. This function is not column-wise.
Returns: float in [0, 1], or NaN for empty/too-short inputs.
Use When
- You want to know whether a return series tends to continue in the same direction from one period to the next.
- You are comparing trend-following and mean-reversion behavior in quick diagnostics.
How To Read It
- Around 0.50 means sign continuation is close to random.
- Above 0.50 suggests more same-sign continuation; below 0.50 suggests more sign reversal.
Watch For
- This is sign-based and ignores magnitude.
- Autocorrelation, drawdown behavior and transaction costs are needed before trading on this signal.
persistence = metrics.return_persistence(strategy_returns["SMA_50_200"])
if persistence > 0.55:
print("Return signs show mild continuation.")
elif persistence < 0.45:
print("Return signs show mild reversal.") market_sensitivity_summary
functionmarket_sensitivity_summary(returns: pd.Series, window: int = 252) -> dict Computes a simple rolling correlation of a series with its own one-period lag, then summarizes the average and volatility of that rolling value. Despite the key names avg_beta and beta_vol, this is not benchmark beta; it is a compact persistence/sensitivity proxy.
Parameters
-
returnspd.SeriesSingle return series. -
windowintRolling window length used for correlation with the lagged series.
Returns: dict with avg_beta and beta_vol.
Use When
- You need a tiny summary of how stable the return series lag relationship is through time.
- You want a placeholder diagnostic before implementing richer rolling beta or autocorrelation analysis.
How To Read It
- avg_beta is the average rolling lag correlation.
- beta_vol is the standard deviation of that rolling lag correlation.
Watch For
- The function name is broader than the implementation. It does not compare against an external market benchmark.
- For true rolling market beta, use rolling_stats.rolling_beta(returns, benchmark, window).
sensitivity = metrics.market_sensitivity_summary(
returns=strategy_returns["Momentum"],
window=126,
)
print(sensitivity["avg_beta"], sensitivity["beta_vol"]) tail_dependence
functiontail_dependence(returns: pd.Series, quantile: float = 0.05) -> dict Looks at observations below a left-tail threshold and compares those tail observations with the full return series. It is a small stress-behavior diagnostic, not a full copula or systemic-risk model.
Parameters
-
returnspd.SeriesSingle return series. -
quantilefloatLeft-tail cutoff. 0.05 means the worst 5% observations.
Returns: dict with tail_beta and tail_correlation.
Use When
- You want a quick read on whether the left tail behaves differently from the rest of the series.
- You are building a stress section and need a small diagnostic next to VaR/CVaR or crisis tables.
How To Read It
- tail_beta is covariance between tail observations and matching full-series observations divided by full variance.
- tail_correlation is the correlation for those left-tail dates.
Watch For
- Small samples can make tail statistics unstable.
- Use crisis_analysis.py for named historical stress periods and risk.py for VaR/CVaR.
tail = metrics.tail_dependence(
returns=strategy_returns["RSI_MeanReversion"],
quantile=0.05,
)
print(f"Tail beta: {tail['tail_beta']:.2f}")
print(f"Tail correlation: {tail['tail_correlation']:.2f}") Timing And Look-Ahead Safety
Signals must be observable before the return interval they are tested against. This matters most for information_coefficient(), but the same discipline applies to every benchmark-relative or signal-quality diagnostic.
- For daily close-to-close returns, a signal computed using today close should normally be shifted before comparing to next-day or forward returns.
- A forecast at date t should be compared to returns after t, not returns that were already known when the signal was created.
- If the signal uses end-of-day prices, document whether it is tradable at the same close, next open or next close.
- Keep benchmark returns on the same timestamp convention as strategy returns before calling excess_returns() or active_return().
# Close-to-close example: today's close is not known before today's close.
close = prices["SPY"]
signal = close.pct_change(20).rank(pct=True)
# Evaluate the signal against future returns, not the return used to build it.
forward_returns = close.pct_change(1).shift(-1)
signal_observable = signal.shift(1)
ic = metrics.information_coefficient(
returns=forward_returns,
predicted_returns=signal_observable,
) Implementation Notes
- metrics.py is intentionally dependency-light: it only needs NumPy and pandas.
- The functions are designed as composable research helpers. They do not know about strategy objects, report engines or PDF layouts.
- Use this module for quick diagnostics; promote a calculation into risk.py or portfolio_perf.py only when it becomes part of the formal report contract.
Safe Edit Checklist
- 01Preserve index alignment behavior or update every caller and test that depends on it.
- 02Preserve NaN policy intentionally; if it changes, add tests that show the new behavior.
- 03Verify annualization assumptions for daily and non-daily inputs.
- 04Add tests for both Series and DataFrame inputs when a function accepts both.
- 05Test empty, all-NaN and zero-variance inputs.
- 06Test mismatched benchmark indexes and missing benchmark dates.
- 07Update examples when function semantics or timing assumptions change.
Code Walkthrough
Recommended workflow: quick diagnostics before full report generation
Use metrics.py early in research. It gives fast answers before you run the full backtester report stack.
import numpy as np
from backtester.portfolio.calc import metrics
# strategy_returns: DataFrame of daily strategy or variant returns
# benchmark_returns: Series of daily benchmark returns
# signal_scores: DataFrame of signal values known at date t
# forward_returns: DataFrame of realized future returns after date t
active_path = metrics.excess_returns(strategy_returns, benchmark_returns)
active_mean = metrics.active_return(strategy_returns, benchmark_returns)
vol = metrics.annualized_volatility(strategy_returns)
corr = metrics.correlation_matrix(strategy_returns)
ic = metrics.information_coefficient(
returns=forward_returns,
predicted_returns=signal_scores,
)
diagnostics = {
"active_mean_ann": active_mean * 252,
"annualized_vol": vol,
"information_coefficient": ic,
"max_pairwise_corr": corr.where(np.triu(np.ones(corr.shape), k=1).astype(bool)).max().max(),
} Source walkthrough: complete metrics.py API
The whole file is intentionally compact: one purpose per function, no strategy state, no report formatting.
def excess_returns(returns: pd.DataFrame, benchmark_returns: pd.DataFrame | pd.Series) -> pd.DataFrame:
bench = benchmark_returns.reindex(returns.index)
return returns.subtract(bench, axis=0)
def annualized_volatility(returns: pd.DataFrame, *, days_per_year: int = 252) -> pd.Series:
return returns.std() * np.sqrt(days_per_year)
def active_return(returns: pd.DataFrame, benchmark_returns: pd.Series) -> pd.Series:
bench = benchmark_returns.reindex(returns.index)
return returns.mean() - bench.mean()
def information_coefficient(
returns: pd.DataFrame | pd.Series,
predicted_returns: pd.DataFrame | pd.Series,
) -> float:
r_al, p_al = returns.align(predicted_returns, join="inner", axis=0)
r_df = r_al if isinstance(r_al, pd.DataFrame) else r_al.to_frame(name="ret")
p_df = p_al if isinstance(p_al, pd.DataFrame) else p_al.to_frame(name="pred")
if r_df.shape == p_df.shape:
r_vals = r_df.to_numpy().ravel()
p_vals = p_df.to_numpy().ravel()
else:
r_vals = r_df.mean(axis=1).to_numpy()
p_vals = p_df.mean(axis=1).to_numpy()
if np.std(r_vals) == 0 or np.std(p_vals) == 0:
return float("nan")
return float(np.corrcoef(r_vals, p_vals)[0, 1])
def correlation_matrix(returns: pd.DataFrame) -> pd.DataFrame:
return returns.corr()
def return_persistence(returns: pd.Series) -> float:
r = returns.dropna()
if len(r) < 2:
return float("nan")
return float((r.sign() == r.shift(1).sign()).mean()) Signal timing example: avoid look-ahead bias
The signal must be known before the future return it is evaluated against.
# Example: evaluate whether a 20-day momentum rank predicts the next 21 trading days.
momentum_score = prices.pct_change(20).rank(axis=1, pct=True)
forward_21d_returns = prices.pct_change(21).shift(-21)
# Shift the signal by one day so today's close is not used as if it were known earlier.
signal_known_at_trade_time = momentum_score.shift(1)
ic = metrics.information_coefficient(
returns=forward_21d_returns,
predicted_returns=signal_known_at_trade_time,
)
print(f"IC: {ic:.3f}") Benchmark-relative example: path first, scalar second
Use excess_returns() for the time series; use active_return() when you only need the average.
active_path = metrics.excess_returns(
returns=strategy_returns,
benchmark_returns=benchmark_returns["SPY"],
)
active_nav = (1 + active_path).cumprod()
average_active_return = metrics.active_return(
returns=strategy_returns,
benchmark_returns=benchmark_returns["SPY"],
)
summary = average_active_return.mul(252).rename("annualized_active_return")