QuantJourney Backtester

QuantJourney Backtester

Share product feedback

Thank you.

Your note is now in the QuantJourney inbox.

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.

Import 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

  • 01
    You have a return matrix and need quick benchmark-relative diagnostics before building a full report.
  • 02
    You are evaluating whether predicted returns, ranks or signal scores have any realized relationship to future returns.
  • 03
    You 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

function
excess_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

  • returns pd.DataFrame
    Strategy, portfolio, asset or factor return matrix. Rows are dates; columns are names being evaluated.
  • benchmark_returns pd.Series | pd.DataFrame
    Benchmark 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.
excess_returns_example.py Python
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

function
annualized_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

  • returns pd.DataFrame
    Period returns in decimal form, for example 0.01 for +1%.
  • days_per_year int
    Annualization 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.
annualized_volatility_example.py Python
vol = metrics.annualized_volatility(strategy_returns, days_per_year=252)
vol.sort_values(ascending=False)

active_return

function
active_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

  • returns pd.DataFrame
    Return matrix for strategies, assets or variants.
  • benchmark_returns pd.Series
    Benchmark 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_return_example.py Python
active = metrics.active_return(
    returns=strategy_returns,
    benchmark_returns=benchmark_returns["SPY"],
)

active.mul(252).sort_values(ascending=False)

information_coefficient

function
information_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

  • returns pd.DataFrame | pd.Series
    Realized returns, often forward returns shifted so they occur after the signal date.
  • predicted_returns pd.DataFrame | pd.Series
    Forecasts, 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.
information_coefficient_example.py Python
# 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

function
correlation_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

  • returns pd.DataFrame
    Return 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.
correlation_matrix_example.py Python
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

function
return_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

  • returns pd.Series
    Single 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.
return_persistence_example.py Python
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

function
market_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

  • returns pd.Series
    Single return series.
  • window int
    Rolling 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).
market_sensitivity_summary_example.py Python
sensitivity = metrics.market_sensitivity_summary(
    returns=strategy_returns["Momentum"],
    window=126,
)

print(sensitivity["avg_beta"], sensitivity["beta_vol"])

tail_dependence

function
tail_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

  • returns pd.Series
    Single return series.
  • quantile float
    Left-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_dependence_example.py Python
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().
metrics_timing_safety.py Python
# 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

  • 01
    Preserve index alignment behavior or update every caller and test that depends on it.
  • 02
    Preserve NaN policy intentionally; if it changes, add tests that show the new behavior.
  • 03
    Verify annualization assumptions for daily and non-daily inputs.
  • 04
    Add tests for both Series and DataFrame inputs when a function accepts both.
  • 05
    Test empty, all-NaN and zero-variance inputs.
  • 06
    Test mismatched benchmark indexes and missing benchmark dates.
  • 07
    Update 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.

metrics_workflow.py Python
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.

backtester/portfolio/calc/metrics.py Python
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.

metrics_information_coefficient.py Python
# 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.

metrics_benchmark_relative.py Python
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")