Skip to content

Risk

Covariance estimation and shrinkage, RiskModel, factor risk models, and risk contributions. See Risk Model.

risk

Portfolio risk: covariance estimation, correlation, and the RiskModel object.

Distinct from beacon.analysis.risk, which holds scalar single-series metrics (volatility, Sharpe, drawdown). This subpackage is about how assets move together: the matrix an optimiser inverts and a tracking-error calculation contracts against.

Needs only numpy, so it stays part of the core rather than sitting behind an extra.

RiskContributions dataclass

RiskContributions(
    volatility: float,
    marginal: dict[str, float] = dict(),
    contribution: dict[str, float] = dict(),
    covered_weight: float = 0.0,
    uncovered: tuple[str, ...] = (),
)

How a portfolio's volatility divides among its holdings.

Used for both total and active risk: the arithmetic is identical, only the weight vector differs.

Attributes:

Name Type Description
volatility float

Annualised volatility of the covered holdings, at the weights they are actually held. For an active decomposition this is the tracking error.

marginal dict[str, float]

Per name, the change in volatility per unit of extra weight.

contribution dict[str, float]

Per name, its share of volatility. Sums to it exactly. Can be negative for active risk, where an underweight that hedges an overweight genuinely reduces tracking error.

covered_weight float

Fraction the estimate speaks for. 1.0 when the model covers everything. For active risk this is a share of gross active weight, since active weights sum to roughly zero.

uncovered tuple[str, ...]

Names the covariance has no row for, so a reader can see which are missing rather than only how much weight is.

is_complete property

is_complete: bool

Whether the model covered the whole index.

ActiveRiskDecomposition dataclass

ActiveRiskDecomposition(
    total_variance: float,
    factor_variance: float,
    specific_variance: float,
    exposures: Series,
    factor_contributions: Series,
)

Squared tracking error split into common and specific risk.

Attributes:

Name Type Description
total_variance float

aᵀΣa, the active variance under this factor model.

factor_variance float

xᵀFx, the part explained by common factor exposures.

specific_variance float

aᵀDa, the part from asset-specific residuals.

exposures Series

Active factor exposures x, one per factor.

factor_contributions Series

Each factor's share of factor_variance, computed as exposure times marginal contribution, which sums to it exactly. Individual entries may be negative: a factor position that hedges another genuinely reduces risk, and hiding that behind an absolute value would misreport what the portfolio is doing.

tracking_error property

tracking_error: float

Annualised tracking error, the square root of the total.

factor_share property

factor_share: float

Fraction of active variance coming from common factors.

Returns 0.0 for a portfolio with no active risk at all, where the question has no answer rather than an answer of zero.

residual property

residual: float

Total minus the two parts. Zero, up to float noise, by construction.

reconciles

reconciles(tolerance: float = 1e-12) -> bool

Whether the two parts account for the total.

to_frame

to_frame() -> pd.DataFrame

Per-factor exposures and contributions, largest contribution first.

FactorRiskModel dataclass

FactorRiskModel(
    exposures: DataFrame,
    factor_covariance: DataFrame,
    specific_variance: Series,
    factor_returns: DataFrame,
    r_squared: float = 0.0,
    periods_per_year: int = PERIODS_PER_YEAR,
)

A fitted factor model: exposures, factor covariance, specific variance.

Attributes:

Name Type Description
exposures DataFrame

The n×k matrix B, assets on the index and factors on the columns.

factor_covariance DataFrame

The k×k matrix F, annualised.

specific_variance Series

The diagonal of D, annualised, one per asset.

factor_returns DataFrame

The fitted factor returns per period, for inspection.

r_squared float

Fraction of return variance the factors explain. Read it against a floor of roughly k/n rather than against zero: fitting k factors to an n-asset cross-section explains about that much by construction, because k free parameters will always fit something. Three factors plus a market term over twelve assets floors at about 0.33, so only a figure well above that is evidence of structure.

periods_per_year int

The annualisation factor applied.

asset_ids property

asset_ids: list[str]

Assets covered, in exposure-matrix order.

factor_names property

factor_names: list[str]

Factors, in matrix order.

covariance

covariance() -> pd.DataFrame

The implied asset covariance, B F Bᵀ + D.

Usable anywhere a RiskModel's covariance is, and by construction it is the matrix the active-risk decomposition reconciles against.

portfolio_exposures

portfolio_exposures(weights: dict[str, float]) -> pd.Series

Factor exposures of a portfolio: Bᵀw.

Parameters:

Name Type Description Default
weights dict[str, float]

Mapping of asset id to weight. Assets absent from it count as zero.

required

Returns:

Type Description
Series

pd.Series: One exposure per factor.

active_exposures

active_exposures(
    weights: dict[str, float], benchmark: dict[str, float]
) -> pd.Series

Factor exposures of the active position.

Parameters:

Name Type Description Default
weights dict[str, float]

Held weights.

required
benchmark dict[str, float]

Target weights.

required

Returns:

Type Description
Series

pd.Series: One active exposure per factor. Zero across the board

Series

means the portfolio takes no factor bets, whatever its holdings

Series

look like.

decompose_active_risk

decompose_active_risk(
    weights: dict[str, float], benchmark: dict[str, float]
) -> ActiveRiskDecomposition

Split squared tracking error into factor and specific risk.

Parameters:

Name Type Description Default
weights dict[str, float]

Held weights.

required
benchmark dict[str, float]

Target weights.

required

Returns:

Name Type Description
ActiveRiskDecomposition ActiveRiskDecomposition

The two parts, which sum to the total by

ActiveRiskDecomposition

construction, plus per-factor contributions.

RiskDiagnostics dataclass

RiskDiagnostics(
    observations: int,
    assets: int,
    target: str,
    intensity: float,
    average_correlation: float,
    condition_number: float,
    smallest_eigenvalue: float,
    positive_semi_definite: bool,
    repaired: bool = False,
)

What the estimation did, and how trustworthy the result is.

Attributes:

Name Type Description
observations int

Periods used, after dropping incomplete rows.

assets int

Size of the cross-section.

target str

Which structured target was shrunk toward.

intensity float

Weight placed on that target, in [0, 1]. 0 means the estimate is the raw sample covariance.

average_correlation float

Mean off-diagonal correlation of the result.

condition_number float

Largest eigenvalue over smallest. Large values mean the matrix is near-singular and its inverse amplifies noise.

smallest_eigenvalue float

The most negative (or least positive) eigenvalue, which is what the PSD flag turns on.

positive_semi_definite bool

Whether every eigenvalue is non-negative to within tolerance. Truthful, not asserted: a caller inverting this matrix needs to know.

repaired bool

Whether eigenvalue clipping was applied to make it PSD.

RiskModel dataclass

RiskModel(
    covariance: DataFrame,
    correlation: DataFrame,
    diagnostics: RiskDiagnostics,
    periods_per_year: int = PERIODS_PER_YEAR,
    _data_fetcher: DataFetcher | None = None,
)

A covariance and correlation estimate for a set of assets.

Attributes:

Name Type Description
covariance DataFrame

Annualised covariance, indexed and columned by asset id.

correlation DataFrame

Correlation derived from covariance, unit diagonal.

diagnostics RiskDiagnostics

How the estimate was produced and how well conditioned it is.

periods_per_year int

The annualisation factor applied.

asset_ids property

asset_ids: list[str]

Assets covered, in matrix order.

with_data

with_data(data_fetcher: DataFetcher) -> RiskModel

Bind a DataFetcher for asset-level queries. Returns self.

volatilities

volatilities() -> pd.Series

Annualised volatility of each asset.

Returns:

Type Description
Series

pd.Series: Standard deviations, the square root of the covariance

Series

diagonal, indexed by asset id.

portfolio_variance

portfolio_variance(weights: dict[str, float]) -> float

Annualised variance of a weighted portfolio.

Parameters:

Name Type Description Default
weights dict[str, float]

Mapping of asset id to weight. Missing assets count as zero.

required

Returns:

Name Type Description
float float

wᵀ Σ w. Clamped at zero: a PSD covariance cannot

float

produce a negative variance, so any negative value is float noise

float

on a near-zero result and returning it would be nonsense.

portfolio_volatility

portfolio_volatility(weights: dict[str, float]) -> float

Annualised volatility of a weighted portfolio.

Parameters:

Name Type Description Default
weights dict[str, float]

Mapping of asset id to weight.

required

Returns:

Name Type Description
float float

Square root of the portfolio variance.

tracking_error

tracking_error(
    portfolio_weights: dict[str, float],
    benchmark_weights: dict[str, float],
) -> float

Annualised tracking error of a portfolio against a benchmark.

The volatility of the active position (the weight differences), which is the quantity an index-tracking mandate is measured on.

Parameters:

Name Type Description Default
portfolio_weights dict[str, float]

Held weights.

required
benchmark_weights dict[str, float]

Target weights.

required

Returns:

Name Type Description
float float

Annualised tracking error.

eigenvalues

eigenvalues() -> Matrix

Ascending eigenvalues of the covariance matrix.

active_risk_contributions

active_risk_contributions(
    weights: Mapping[str, float] | Series,
    benchmark: Mapping[str, float] | Series,
    covariance: DataFrame,
) -> RiskContributions

Decompose tracking error across active positions.

The same arithmetic as :func:risk_contributions, on active weights w - b instead of w. The reported volatility is then the annualised tracking error against that benchmark, and contributions sum to it exactly.

Contributions here can be negative, and that is the point. An active weight is signed, so an underweight in something correlated with what the portfolio is overweight genuinely reduces tracking error: it hedges. Taking an absolute value would hide the position doing the most useful thing in the book.

Parameters:

Name Type Description Default
weights Mapping[str, float] | Series

The portfolio's holdings.

required
benchmark Mapping[str, float] | Series

What it is measured against.

required
covariance DataFrame

Annualised covariance over the union of both.

required

Returns:

Name Type Description
RiskContributions RiskContributions

volatility is the tracking error; covered_weight

RiskContributions

is the share of gross active weight the model covers, since active

RiskContributions

weights sum to roughly zero and a plain sum would say nothing.

active_weights

active_weights(
    weights: Mapping[str, float] | Series,
    benchmark: Mapping[str, float] | Series,
) -> dict[str, float]

Holdings minus benchmark, over the union of both.

A name held and not in the benchmark is an overweight; one in the benchmark and not held is an underweight of its full benchmark weight. Taking the union rather than the intersection is what makes the second case visible: an omitted constituent is usually the largest active position a portfolio has, and intersecting would silently drop it.

risk_contributions

risk_contributions(
    weights: Mapping[str, float] | Series,
    covariance: DataFrame,
) -> RiskContributions

Decompose portfolio volatility across its holdings.

Parameters:

Name Type Description Default
weights Mapping[str, float] | Series

Holdings, identifier to weight, as a mapping or a Series. Need not sum to one: they will not when part of the index is uncovered, and renormalising would restate the portfolio.

required
covariance DataFrame

Annualised covariance, indexed and columned by identifier.

required

Returns:

Name Type Description
RiskContributions RiskContributions

The decomposition, with contributions summing to the

RiskContributions

reported volatility exactly.

annualise

annualise(
    covariance: Matrix,
    periods_per_year: int = PERIODS_PER_YEAR,
) -> Matrix

Scale a per-period covariance to an annual one.

Covariance scales linearly with the horizon, so this is a multiplication. Volatilities, being square roots, scale with its square root.

Parameters:

Name Type Description Default
covariance Matrix

Per-period covariance.

required
periods_per_year int

Periods in a year; 252 for daily data.

PERIODS_PER_YEAR

Returns:

Name Type Description
Matrix Matrix

The annualised covariance.

Raises:

Type Description
CalculationError

If periods_per_year is not positive.

average_pairwise_correlation

average_pairwise_correlation(correlation: Matrix) -> float

Mean of the off-diagonal correlations.

Parameters:

Name Type Description Default
correlation Matrix

Correlation matrix.

required

Returns:

Name Type Description
float float

The average. 0.0 for a single asset, which has no pairs.

condition_number

condition_number(matrix: Matrix) -> float

Ratio of largest to smallest eigenvalue.

A large value means the matrix is near-singular, so its inverse (which any optimiser will want) amplifies estimation error. Shrinkage exists largely to bring this down.

Parameters:

Name Type Description Default
matrix Matrix

Symmetric matrix.

required

Returns:

Name Type Description
float float

The condition number, or infinity when the smallest

float

eigenvalue is zero or negative.

constant_correlation_target

constant_correlation_target(covariance: Matrix) -> Matrix

Shrinkage target keeping sample variances but one common correlation.

Each asset keeps its own estimated variance; every pair is assigned the average sample correlation. This retains the part of the sample estimate that is measured comparatively well (the individual variances) while replacing the part that is not, the O(n²) pairwise correlations.

Parameters:

Name Type Description Default
covariance Matrix

Sample covariance.

required

Returns:

Name Type Description
Matrix Matrix

The target. Positive semi-definite whenever covariance

Matrix

is, because the average correlation of a PSD correlation matrix

Matrix

cannot fall below -1/(n-1).

correlation_from_covariance

correlation_from_covariance(covariance: Matrix) -> Matrix

Derive the correlation matrix from a covariance matrix.

Parameters:

Name Type Description Default
covariance Matrix

Covariance matrix.

required

Returns:

Name Type Description
Matrix Matrix

Correlation matrix with an exact unit diagonal, clipped to

Matrix

[-1, 1]. An asset with zero (or negligibly small) variance yields zero

Matrix

correlation with every other asset rather than a division by zero,

Matrix

since it has no variation to correlate.

eigenvalues

eigenvalues(matrix: Matrix) -> Matrix

Ascending eigenvalues of a symmetric matrix.

heuristic_intensity

heuristic_intensity(
    observations: int, assets: int
) -> float

A transparent shrinkage intensity based on the panel's shape.

assets / (assets + observations): shrink hard when assets outnumber observations and the sample estimate is barely identified, and lightly when the history is long relative to the cross-section.

This is not the Ledoit-Wolf closed-form optimal intensity. That estimator minimises expected squared error under stated assumptions and needs careful derivation to implement correctly; guessing at it would produce a plausible number with no such guarantee. This rule is stated, monotone and testable, and any caller who has computed an optimal intensity elsewhere can pass it explicitly instead.

Parameters:

Name Type Description Default
observations int

Number of periods in the panel.

required
assets int

Number of assets.

required

Returns:

Name Type Description
float float

Intensity in (0, 1).

Raises:

Type Description
CalculationError

If either count is not positive.

is_positive_semi_definite

is_positive_semi_definite(
    matrix: Matrix, tolerance: float = PSD_TOLERANCE
) -> bool

Whether every eigenvalue is non-negative within tolerance.

The tolerance is relative to the largest eigenvalue, so the answer does not change when the matrix is rescaled: an annualised covariance and its daily counterpart must agree.

Parameters:

Name Type Description Default
matrix Matrix

Symmetric matrix to test.

required
tolerance float

Relative slack for eigenvalues that are zero in theory.

PSD_TOLERANCE

Returns:

Name Type Description
bool bool

True when the matrix is PSD to within tolerance.

nearest_positive_semi_definite

nearest_positive_semi_definite(
    matrix: Matrix, minimum_eigenvalue: float = 0.0
) -> Matrix

Repair a matrix by clipping its negative eigenvalues.

Decompose, floor the eigenvalues, rebuild. The result is the closest PSD matrix in the Frobenius sense.

Note that clipping changes the diagonal: the variances of the repaired matrix differ from the original by the total clipped mass. That is the honest cost of the repair, and it is why this is a fallback rather than something to apply routinely: shrinkage keeps the estimate PSD without it.

Parameters:

Name Type Description Default
matrix Matrix

Symmetric matrix to repair.

required
minimum_eigenvalue float

Floor applied to each eigenvalue. Zero yields the nearest PSD matrix; a small positive value yields a positive definite one, which is what an optimiser needing an inverse wants.

0.0

Returns:

Name Type Description
Matrix Matrix

The repaired matrix.

sample_covariance

sample_covariance(returns: Matrix) -> Matrix

Unbiased sample covariance of a returns panel.

Parameters:

Name Type Description Default
returns Matrix

Observations × assets array of period returns.

required

Returns:

Name Type Description
Matrix Matrix

Assets × assets covariance, symmetric and positive

Matrix

semi-definite by construction.

Raises:

Type Description
CalculationError

If there are fewer than two observations, which leaves the unbiased estimator undefined.

scaled_identity_target

scaled_identity_target(covariance: Matrix) -> Matrix

Shrinkage target assuming equal variances and zero correlation.

The average variance on the diagonal, zero elsewhere. Maximally structured: it discards every estimated relationship, which makes it a strong anchor when the panel is very short.

Parameters:

Name Type Description Default
covariance Matrix

Sample covariance.

required

Returns:

Name Type Description
Matrix Matrix

The target, positive definite whenever the average

Matrix

variance is positive.

shrink_covariance

shrink_covariance(
    sample: Matrix, target: Matrix, intensity: float
) -> Matrix

Blend a sample covariance toward a structured target.

(1 - intensity) * sample + intensity * target. Because this is a convex combination and both inputs are positive semi-definite, the result is too: shrinkage cannot introduce a negative-variance direction.

Parameters:

Name Type Description Default
sample Matrix

Sample covariance.

required
target Matrix

Structured target of the same shape.

required
intensity float

Weight on the target, in [0, 1]. 0 returns the sample unchanged; 1 returns the target.

required

Returns:

Name Type Description
Matrix Matrix

The shrunk covariance.

Raises:

Type Description
CalculationError

If intensity is outside [0, 1] or the shapes disagree.

fit_factor_model

fit_factor_model(
    returns: DataFrame,
    exposures: DataFrame,
    periods_per_year: int = PERIODS_PER_YEAR,
    include_market: bool = True,
) -> FactorRiskModel

Fit a cross-sectional factor model to a returns panel.

Each period's asset returns are regressed on the exposures, and the resulting coefficients are that period's factor returns. The factor covariance is their covariance over time; the specific variances are the residuals'.

Parameters:

Name Type Description Default
returns DataFrame

Period returns, dates on the index and assets on the columns. Rows with any missing value are dropped so every factor return is estimated over the same cross-section.

required
exposures DataFrame

Loadings, assets on the index and factors on the columns. Standardise them with :func:z_scores first unless they are already comparable.

required
periods_per_year int

Annualisation factor; 252 for daily returns.

PERIODS_PER_YEAR
include_market bool

Prepend an intercept column standing in for whatever moves every asset together. Without it the named factors must explain the market's own return as well as the differences between assets, and their fitted returns come out contaminated by it.

True

Returns:

Name Type Description
FactorRiskModel FactorRiskModel

The fitted model.

Raises:

Type Description
CalculationError

If the panel and the exposures do not cover the same assets, or if there are too few observations to estimate a factor covariance.

z_scores

z_scores(
    exposures: DataFrame,
    weights: dict[str, float] | None = None,
) -> pd.DataFrame

Standardise raw factor values across the universe.

Raw factor values arrive in whatever units they were measured in (a market cap in dollars, a book-to-price ratio, a twelve-month return) and cannot be compared or combined until they are on one scale. A z-score puts every factor in units of cross-sectional standard deviations, so an exposure of 1.0 means the same thing whichever factor it belongs to.

Parameters:

Name Type Description Default
exposures DataFrame

Raw values, assets on the index and factors on the columns.

required
weights dict[str, float] | None

Benchmark weights to centre on, so a portfolio holding the benchmark scores zero on every factor. None centres on the equally weighted mean, which makes exposures relative to the average asset rather than to the market.

None

Returns:

Type Description
DataFrame

pd.DataFrame: Standardised exposures, same shape. A factor with no

DataFrame

cross-sectional spread comes back as zeros: it cannot distinguish

DataFrame

between assets, so it carries no information, and dividing by its

DataFrame

spread would be dividing by noise.

Raises:

Type Description
CalculationError

If exposures is empty.

estimate_risk_model

estimate_risk_model(
    returns: DataFrame,
    target: str = CONSTANT_CORRELATION,
    intensity: float | None = None,
    periods_per_year: int = PERIODS_PER_YEAR,
    repair: bool = False,
) -> RiskModel

Estimate a shrunk covariance from a returns panel.

Parameters:

Name Type Description Default
returns DataFrame

DataFrame of period returns, dates on the index and assets on the columns. Rows with any missing value are dropped, so every covariance entry is estimated over the same periods. Pairwise deletion would give a matrix that need not be PSD at all.

required
target str

Structured target to shrink toward; one of TARGETS.

CONSTANT_CORRELATION
intensity float | None

Weight on the target in [0, 1]. None uses the heuristic from the panel's shape. Pass 0.0 for the raw sample covariance.

None
periods_per_year int

Annualisation factor; 252 for daily returns.

PERIODS_PER_YEAR
repair bool

Apply eigenvalue clipping if the result is not PSD. Off by default because shrinkage should make it unnecessary, and clipping silently shifts the variances.

False

Returns:

Name Type Description
RiskModel RiskModel

The estimate, annualised, with diagnostics.

Raises:

Type Description
CalculationError

If target is unknown, or the panel is too small.

contribution

Which holdings actually drive an index's risk.

A weights table says what the index owns. It does not say what the index is exposed to, and the two differ enough to matter: a name at 8% of a quiet utility might account for 3% of volatility, while a name at 4% of something volatile that moves with everything else accounts for 9%. A weights table without this column looks like a risk view and is not one.

The decomposition, and why it is exact

For weights w and an annualised covariance S, portfolio volatility is sigma = sqrt(w' S w). Differentiating gives each name's marginal contribution, meaning how much volatility changes per unit of additional weight:

marginal = (S w) / sigma

and its component contribution is its weight times that:

contribution[i] = w[i] x marginal[i]

These sum to sigma exactly, not approximately, because sigma is homogeneous of degree one in the weights and Euler's theorem applies. That is worth knowing because it makes the acceptance test a real one: if the parts do not add to the whole, something is wrong and there is no tolerance to hide behind.

Names the model does not cover

A constituent added last week has too little history to estimate against. Three ways to handle it, and only one of them is honest:

  • drop it and renormalise the rest: this claims the index holds more of the covered names than it does, and silently restates the portfolio
  • fail the whole request: one new name blanks the column for 499 others
  • compute over the covered names at their actual weights, and report what fraction of the index that was

The third is what happens here. The reported volatility is then genuinely the volatility of the covered part as held, the identity still holds exactly over that part, and covered_weight says how much of the index the figure speaks for. A number that describes 94% of an index and says so is more useful than one that describes 100% of a portfolio nobody holds.

RiskContributions dataclass

RiskContributions(
    volatility: float,
    marginal: dict[str, float] = dict(),
    contribution: dict[str, float] = dict(),
    covered_weight: float = 0.0,
    uncovered: tuple[str, ...] = (),
)

How a portfolio's volatility divides among its holdings.

Used for both total and active risk: the arithmetic is identical, only the weight vector differs.

Attributes:

Name Type Description
volatility float

Annualised volatility of the covered holdings, at the weights they are actually held. For an active decomposition this is the tracking error.

marginal dict[str, float]

Per name, the change in volatility per unit of extra weight.

contribution dict[str, float]

Per name, its share of volatility. Sums to it exactly. Can be negative for active risk, where an underweight that hedges an overweight genuinely reduces tracking error.

covered_weight float

Fraction the estimate speaks for. 1.0 when the model covers everything. For active risk this is a share of gross active weight, since active weights sum to roughly zero.

uncovered tuple[str, ...]

Names the covariance has no row for, so a reader can see which are missing rather than only how much weight is.

is_complete property
is_complete: bool

Whether the model covered the whole index.

as_weights

as_weights(
    weights: Mapping[str, float] | Series,
) -> dict[str, float]

Normalise a weight vector to a plain mapping.

Accepts a pandas Series (such as OptimisationResult.weights) or any mapping (such as an index weight snapshot), so either can be passed to the functions in this module.

Parameters:

Name Type Description Default
weights Mapping[str, float] | Series

Identifier to weight.

required

Returns:

Name Type Description
dict dict[str, float]

The same weights with string keys and float values.

risk_contributions

risk_contributions(
    weights: Mapping[str, float] | Series,
    covariance: DataFrame,
) -> RiskContributions

Decompose portfolio volatility across its holdings.

Parameters:

Name Type Description Default
weights Mapping[str, float] | Series

Holdings, identifier to weight, as a mapping or a Series. Need not sum to one: they will not when part of the index is uncovered, and renormalising would restate the portfolio.

required
covariance DataFrame

Annualised covariance, indexed and columned by identifier.

required

Returns:

Name Type Description
RiskContributions RiskContributions

The decomposition, with contributions summing to the

RiskContributions

reported volatility exactly.

active_weights

active_weights(
    weights: Mapping[str, float] | Series,
    benchmark: Mapping[str, float] | Series,
) -> dict[str, float]

Holdings minus benchmark, over the union of both.

A name held and not in the benchmark is an overweight; one in the benchmark and not held is an underweight of its full benchmark weight. Taking the union rather than the intersection is what makes the second case visible: an omitted constituent is usually the largest active position a portfolio has, and intersecting would silently drop it.

active_risk_contributions

active_risk_contributions(
    weights: Mapping[str, float] | Series,
    benchmark: Mapping[str, float] | Series,
    covariance: DataFrame,
) -> RiskContributions

Decompose tracking error across active positions.

The same arithmetic as :func:risk_contributions, on active weights w - b instead of w. The reported volatility is then the annualised tracking error against that benchmark, and contributions sum to it exactly.

Contributions here can be negative, and that is the point. An active weight is signed, so an underweight in something correlated with what the portfolio is overweight genuinely reduces tracking error: it hedges. Taking an absolute value would hide the position doing the most useful thing in the book.

Parameters:

Name Type Description Default
weights Mapping[str, float] | Series

The portfolio's holdings.

required
benchmark Mapping[str, float] | Series

What it is measured against.

required
covariance DataFrame

Annualised covariance over the union of both.

required

Returns:

Name Type Description
RiskContributions RiskContributions

volatility is the tracking error; covered_weight

RiskContributions

is the share of gross active weight the model covers, since active

RiskContributions

weights sum to roughly zero and a plain sum would say nothing.

covariance

Covariance estimation and the linear algebra it needs.

A sample covariance matrix estimated from a short history is a poor risk forecast: with fewer observations than assets it is singular, and even with more it overstates the dispersion of eigenvalues, so the minimum-variance direction it suggests is largely noise. Shrinking it toward a structured target trades a little bias for a large reduction in estimation error.

Everything here works on plain numpy arrays so the numerical layer stays separate from the pandas-shaped result object in model.py.

sample_covariance

sample_covariance(returns: Matrix) -> Matrix

Unbiased sample covariance of a returns panel.

Parameters:

Name Type Description Default
returns Matrix

Observations × assets array of period returns.

required

Returns:

Name Type Description
Matrix Matrix

Assets × assets covariance, symmetric and positive

Matrix

semi-definite by construction.

Raises:

Type Description
CalculationError

If there are fewer than two observations, which leaves the unbiased estimator undefined.

scaled_identity_target

scaled_identity_target(covariance: Matrix) -> Matrix

Shrinkage target assuming equal variances and zero correlation.

The average variance on the diagonal, zero elsewhere. Maximally structured: it discards every estimated relationship, which makes it a strong anchor when the panel is very short.

Parameters:

Name Type Description Default
covariance Matrix

Sample covariance.

required

Returns:

Name Type Description
Matrix Matrix

The target, positive definite whenever the average

Matrix

variance is positive.

constant_correlation_target

constant_correlation_target(covariance: Matrix) -> Matrix

Shrinkage target keeping sample variances but one common correlation.

Each asset keeps its own estimated variance; every pair is assigned the average sample correlation. This retains the part of the sample estimate that is measured comparatively well (the individual variances) while replacing the part that is not, the O(n²) pairwise correlations.

Parameters:

Name Type Description Default
covariance Matrix

Sample covariance.

required

Returns:

Name Type Description
Matrix Matrix

The target. Positive semi-definite whenever covariance

Matrix

is, because the average correlation of a PSD correlation matrix

Matrix

cannot fall below -1/(n-1).

heuristic_intensity

heuristic_intensity(
    observations: int, assets: int
) -> float

A transparent shrinkage intensity based on the panel's shape.

assets / (assets + observations): shrink hard when assets outnumber observations and the sample estimate is barely identified, and lightly when the history is long relative to the cross-section.

This is not the Ledoit-Wolf closed-form optimal intensity. That estimator minimises expected squared error under stated assumptions and needs careful derivation to implement correctly; guessing at it would produce a plausible number with no such guarantee. This rule is stated, monotone and testable, and any caller who has computed an optimal intensity elsewhere can pass it explicitly instead.

Parameters:

Name Type Description Default
observations int

Number of periods in the panel.

required
assets int

Number of assets.

required

Returns:

Name Type Description
float float

Intensity in (0, 1).

Raises:

Type Description
CalculationError

If either count is not positive.

shrink_covariance

shrink_covariance(
    sample: Matrix, target: Matrix, intensity: float
) -> Matrix

Blend a sample covariance toward a structured target.

(1 - intensity) * sample + intensity * target. Because this is a convex combination and both inputs are positive semi-definite, the result is too: shrinkage cannot introduce a negative-variance direction.

Parameters:

Name Type Description Default
sample Matrix

Sample covariance.

required
target Matrix

Structured target of the same shape.

required
intensity float

Weight on the target, in [0, 1]. 0 returns the sample unchanged; 1 returns the target.

required

Returns:

Name Type Description
Matrix Matrix

The shrunk covariance.

Raises:

Type Description
CalculationError

If intensity is outside [0, 1] or the shapes disagree.

correlation_from_covariance

correlation_from_covariance(covariance: Matrix) -> Matrix

Derive the correlation matrix from a covariance matrix.

Parameters:

Name Type Description Default
covariance Matrix

Covariance matrix.

required

Returns:

Name Type Description
Matrix Matrix

Correlation matrix with an exact unit diagonal, clipped to

Matrix

[-1, 1]. An asset with zero (or negligibly small) variance yields zero

Matrix

correlation with every other asset rather than a division by zero,

Matrix

since it has no variation to correlate.

average_pairwise_correlation

average_pairwise_correlation(correlation: Matrix) -> float

Mean of the off-diagonal correlations.

Parameters:

Name Type Description Default
correlation Matrix

Correlation matrix.

required

Returns:

Name Type Description
float float

The average. 0.0 for a single asset, which has no pairs.

eigenvalues

eigenvalues(matrix: Matrix) -> Matrix

Ascending eigenvalues of a symmetric matrix.

is_positive_semi_definite

is_positive_semi_definite(
    matrix: Matrix, tolerance: float = PSD_TOLERANCE
) -> bool

Whether every eigenvalue is non-negative within tolerance.

The tolerance is relative to the largest eigenvalue, so the answer does not change when the matrix is rescaled: an annualised covariance and its daily counterpart must agree.

Parameters:

Name Type Description Default
matrix Matrix

Symmetric matrix to test.

required
tolerance float

Relative slack for eigenvalues that are zero in theory.

PSD_TOLERANCE

Returns:

Name Type Description
bool bool

True when the matrix is PSD to within tolerance.

condition_number

condition_number(matrix: Matrix) -> float

Ratio of largest to smallest eigenvalue.

A large value means the matrix is near-singular, so its inverse (which any optimiser will want) amplifies estimation error. Shrinkage exists largely to bring this down.

Parameters:

Name Type Description Default
matrix Matrix

Symmetric matrix.

required

Returns:

Name Type Description
float float

The condition number, or infinity when the smallest

float

eigenvalue is zero or negative.

nearest_positive_semi_definite

nearest_positive_semi_definite(
    matrix: Matrix, minimum_eigenvalue: float = 0.0
) -> Matrix

Repair a matrix by clipping its negative eigenvalues.

Decompose, floor the eigenvalues, rebuild. The result is the closest PSD matrix in the Frobenius sense.

Note that clipping changes the diagonal: the variances of the repaired matrix differ from the original by the total clipped mass. That is the honest cost of the repair, and it is why this is a fallback rather than something to apply routinely: shrinkage keeps the estimate PSD without it.

Parameters:

Name Type Description Default
matrix Matrix

Symmetric matrix to repair.

required
minimum_eigenvalue float

Floor applied to each eigenvalue. Zero yields the nearest PSD matrix; a small positive value yields a positive definite one, which is what an optimiser needing an inverse wants.

0.0

Returns:

Name Type Description
Matrix Matrix

The repaired matrix.

annualise

annualise(
    covariance: Matrix,
    periods_per_year: int = PERIODS_PER_YEAR,
) -> Matrix

Scale a per-period covariance to an annual one.

Covariance scales linearly with the horizon, so this is a multiplication. Volatilities, being square roots, scale with its square root.

Parameters:

Name Type Description Default
covariance Matrix

Per-period covariance.

required
periods_per_year int

Periods in a year; 252 for daily data.

PERIODS_PER_YEAR

Returns:

Name Type Description
Matrix Matrix

The annualised covariance.

Raises:

Type Description
CalculationError

If periods_per_year is not positive.

factors

Factor risk models, and the decomposition of active risk they make possible.

Why a factor model, and not just a covariance

A sample covariance says how much risk a portfolio carries. It cannot say why, because it has no vocabulary for why: it is n² numbers with no structure. A factor model imposes one:

r = B f + ε

Each asset's return is a set of exposures B to a handful of common factors whose returns are f, plus a residual ε that is specific to that asset. The factors are the vocabulary: a portfolio is overweight momentum, or short size, and those are statements a person can act on.

That structure produces a covariance too:

Σ = B F Bᵀ + D

with F the factor covariance and D the diagonal of specific variances. The two terms are the whole point: common risk and idiosyncratic risk, cleanly separated.

The identity, and the condition it needs

For an active position a = w - b, with active factor exposures x = Bᵀa:

TE² = aᵀΣa = aᵀ(B F Bᵀ + D)a = xᵀ F x + aᵀ D a

so squared tracking error splits exactly into a factor part and a specific part. This is worth being precise about, because it is easy to state loosely and get wrong: the identity holds because Σ is defined as BFBᵀ + D, not because B and Σ happen to be lying around together. Take an arbitrary sample covariance and an arbitrary exposure matrix and there is a cross term, and the two pieces will not add up.

So the decomposition here reconciles to this model's tracking error, not to the one a sample-covariance model would give for the same portfolio. Comparing the two is informative (the gap is what the factors fail to explain), but they are two different numbers and the identity belongs to one of them.

Fitting

Factor returns are recovered by cross-sectional regression: for each period, the assets' returns are regressed on their exposures, and the coefficients are that period's factor returns. Solved by least squares rather than by inverting BᵀB, so collinear exposures degrade gracefully instead of raising.

ActiveRiskDecomposition dataclass

ActiveRiskDecomposition(
    total_variance: float,
    factor_variance: float,
    specific_variance: float,
    exposures: Series,
    factor_contributions: Series,
)

Squared tracking error split into common and specific risk.

Attributes:

Name Type Description
total_variance float

aᵀΣa, the active variance under this factor model.

factor_variance float

xᵀFx, the part explained by common factor exposures.

specific_variance float

aᵀDa, the part from asset-specific residuals.

exposures Series

Active factor exposures x, one per factor.

factor_contributions Series

Each factor's share of factor_variance, computed as exposure times marginal contribution, which sums to it exactly. Individual entries may be negative: a factor position that hedges another genuinely reduces risk, and hiding that behind an absolute value would misreport what the portfolio is doing.

tracking_error property
tracking_error: float

Annualised tracking error, the square root of the total.

factor_share property
factor_share: float

Fraction of active variance coming from common factors.

Returns 0.0 for a portfolio with no active risk at all, where the question has no answer rather than an answer of zero.

residual property
residual: float

Total minus the two parts. Zero, up to float noise, by construction.

reconciles
reconciles(tolerance: float = 1e-12) -> bool

Whether the two parts account for the total.

to_frame
to_frame() -> pd.DataFrame

Per-factor exposures and contributions, largest contribution first.

FactorRiskModel dataclass

FactorRiskModel(
    exposures: DataFrame,
    factor_covariance: DataFrame,
    specific_variance: Series,
    factor_returns: DataFrame,
    r_squared: float = 0.0,
    periods_per_year: int = PERIODS_PER_YEAR,
)

A fitted factor model: exposures, factor covariance, specific variance.

Attributes:

Name Type Description
exposures DataFrame

The n×k matrix B, assets on the index and factors on the columns.

factor_covariance DataFrame

The k×k matrix F, annualised.

specific_variance Series

The diagonal of D, annualised, one per asset.

factor_returns DataFrame

The fitted factor returns per period, for inspection.

r_squared float

Fraction of return variance the factors explain. Read it against a floor of roughly k/n rather than against zero: fitting k factors to an n-asset cross-section explains about that much by construction, because k free parameters will always fit something. Three factors plus a market term over twelve assets floors at about 0.33, so only a figure well above that is evidence of structure.

periods_per_year int

The annualisation factor applied.

asset_ids property
asset_ids: list[str]

Assets covered, in exposure-matrix order.

factor_names property
factor_names: list[str]

Factors, in matrix order.

covariance
covariance() -> pd.DataFrame

The implied asset covariance, B F Bᵀ + D.

Usable anywhere a RiskModel's covariance is, and by construction it is the matrix the active-risk decomposition reconciles against.

portfolio_exposures
portfolio_exposures(weights: dict[str, float]) -> pd.Series

Factor exposures of a portfolio: Bᵀw.

Parameters:

Name Type Description Default
weights dict[str, float]

Mapping of asset id to weight. Assets absent from it count as zero.

required

Returns:

Type Description
Series

pd.Series: One exposure per factor.

active_exposures
active_exposures(
    weights: dict[str, float], benchmark: dict[str, float]
) -> pd.Series

Factor exposures of the active position.

Parameters:

Name Type Description Default
weights dict[str, float]

Held weights.

required
benchmark dict[str, float]

Target weights.

required

Returns:

Type Description
Series

pd.Series: One active exposure per factor. Zero across the board

Series

means the portfolio takes no factor bets, whatever its holdings

Series

look like.

decompose_active_risk
decompose_active_risk(
    weights: dict[str, float], benchmark: dict[str, float]
) -> ActiveRiskDecomposition

Split squared tracking error into factor and specific risk.

Parameters:

Name Type Description Default
weights dict[str, float]

Held weights.

required
benchmark dict[str, float]

Target weights.

required

Returns:

Name Type Description
ActiveRiskDecomposition ActiveRiskDecomposition

The two parts, which sum to the total by

ActiveRiskDecomposition

construction, plus per-factor contributions.

z_scores

z_scores(
    exposures: DataFrame,
    weights: dict[str, float] | None = None,
) -> pd.DataFrame

Standardise raw factor values across the universe.

Raw factor values arrive in whatever units they were measured in (a market cap in dollars, a book-to-price ratio, a twelve-month return) and cannot be compared or combined until they are on one scale. A z-score puts every factor in units of cross-sectional standard deviations, so an exposure of 1.0 means the same thing whichever factor it belongs to.

Parameters:

Name Type Description Default
exposures DataFrame

Raw values, assets on the index and factors on the columns.

required
weights dict[str, float] | None

Benchmark weights to centre on, so a portfolio holding the benchmark scores zero on every factor. None centres on the equally weighted mean, which makes exposures relative to the average asset rather than to the market.

None

Returns:

Type Description
DataFrame

pd.DataFrame: Standardised exposures, same shape. A factor with no

DataFrame

cross-sectional spread comes back as zeros: it cannot distinguish

DataFrame

between assets, so it carries no information, and dividing by its

DataFrame

spread would be dividing by noise.

Raises:

Type Description
CalculationError

If exposures is empty.

fit_factor_model

fit_factor_model(
    returns: DataFrame,
    exposures: DataFrame,
    periods_per_year: int = PERIODS_PER_YEAR,
    include_market: bool = True,
) -> FactorRiskModel

Fit a cross-sectional factor model to a returns panel.

Each period's asset returns are regressed on the exposures, and the resulting coefficients are that period's factor returns. The factor covariance is their covariance over time; the specific variances are the residuals'.

Parameters:

Name Type Description Default
returns DataFrame

Period returns, dates on the index and assets on the columns. Rows with any missing value are dropped so every factor return is estimated over the same cross-section.

required
exposures DataFrame

Loadings, assets on the index and factors on the columns. Standardise them with :func:z_scores first unless they are already comparable.

required
periods_per_year int

Annualisation factor; 252 for daily returns.

PERIODS_PER_YEAR
include_market bool

Prepend an intercept column standing in for whatever moves every asset together. Without it the named factors must explain the market's own return as well as the differences between assets, and their fitted returns come out contaminated by it.

True

Returns:

Name Type Description
FactorRiskModel FactorRiskModel

The fitted model.

Raises:

Type Description
CalculationError

If the panel and the exposures do not cover the same assets, or if there are too few observations to estimate a factor covariance.

model

RiskModel: the output of a covariance estimation.

Follows the same shape as IndexResult and BacktestResult: a dataclass carrying pandas structures, with opt-in data binding via .with_data() and accessors that answer the questions callers actually have (what is this portfolio's volatility, how much tracking error does this active position carry) rather than making every caller do the matrix algebra.

RiskDiagnostics dataclass

RiskDiagnostics(
    observations: int,
    assets: int,
    target: str,
    intensity: float,
    average_correlation: float,
    condition_number: float,
    smallest_eigenvalue: float,
    positive_semi_definite: bool,
    repaired: bool = False,
)

What the estimation did, and how trustworthy the result is.

Attributes:

Name Type Description
observations int

Periods used, after dropping incomplete rows.

assets int

Size of the cross-section.

target str

Which structured target was shrunk toward.

intensity float

Weight placed on that target, in [0, 1]. 0 means the estimate is the raw sample covariance.

average_correlation float

Mean off-diagonal correlation of the result.

condition_number float

Largest eigenvalue over smallest. Large values mean the matrix is near-singular and its inverse amplifies noise.

smallest_eigenvalue float

The most negative (or least positive) eigenvalue, which is what the PSD flag turns on.

positive_semi_definite bool

Whether every eigenvalue is non-negative to within tolerance. Truthful, not asserted: a caller inverting this matrix needs to know.

repaired bool

Whether eigenvalue clipping was applied to make it PSD.

RiskModel dataclass

RiskModel(
    covariance: DataFrame,
    correlation: DataFrame,
    diagnostics: RiskDiagnostics,
    periods_per_year: int = PERIODS_PER_YEAR,
    _data_fetcher: DataFetcher | None = None,
)

A covariance and correlation estimate for a set of assets.

Attributes:

Name Type Description
covariance DataFrame

Annualised covariance, indexed and columned by asset id.

correlation DataFrame

Correlation derived from covariance, unit diagonal.

diagnostics RiskDiagnostics

How the estimate was produced and how well conditioned it is.

periods_per_year int

The annualisation factor applied.

asset_ids property
asset_ids: list[str]

Assets covered, in matrix order.

with_data
with_data(data_fetcher: DataFetcher) -> RiskModel

Bind a DataFetcher for asset-level queries. Returns self.

volatilities
volatilities() -> pd.Series

Annualised volatility of each asset.

Returns:

Type Description
Series

pd.Series: Standard deviations, the square root of the covariance

Series

diagonal, indexed by asset id.

portfolio_variance
portfolio_variance(weights: dict[str, float]) -> float

Annualised variance of a weighted portfolio.

Parameters:

Name Type Description Default
weights dict[str, float]

Mapping of asset id to weight. Missing assets count as zero.

required

Returns:

Name Type Description
float float

wᵀ Σ w. Clamped at zero: a PSD covariance cannot

float

produce a negative variance, so any negative value is float noise

float

on a near-zero result and returning it would be nonsense.

portfolio_volatility
portfolio_volatility(weights: dict[str, float]) -> float

Annualised volatility of a weighted portfolio.

Parameters:

Name Type Description Default
weights dict[str, float]

Mapping of asset id to weight.

required

Returns:

Name Type Description
float float

Square root of the portfolio variance.

tracking_error
tracking_error(
    portfolio_weights: dict[str, float],
    benchmark_weights: dict[str, float],
) -> float

Annualised tracking error of a portfolio against a benchmark.

The volatility of the active position (the weight differences), which is the quantity an index-tracking mandate is measured on.

Parameters:

Name Type Description Default
portfolio_weights dict[str, float]

Held weights.

required
benchmark_weights dict[str, float]

Target weights.

required

Returns:

Name Type Description
float float

Annualised tracking error.

eigenvalues
eigenvalues() -> Matrix

Ascending eigenvalues of the covariance matrix.

estimate_risk_model

estimate_risk_model(
    returns: DataFrame,
    target: str = CONSTANT_CORRELATION,
    intensity: float | None = None,
    periods_per_year: int = PERIODS_PER_YEAR,
    repair: bool = False,
) -> RiskModel

Estimate a shrunk covariance from a returns panel.

Parameters:

Name Type Description Default
returns DataFrame

DataFrame of period returns, dates on the index and assets on the columns. Rows with any missing value are dropped, so every covariance entry is estimated over the same periods. Pairwise deletion would give a matrix that need not be PSD at all.

required
target str

Structured target to shrink toward; one of TARGETS.

CONSTANT_CORRELATION
intensity float | None

Weight on the target in [0, 1]. None uses the heuristic from the panel's shape. Pass 0.0 for the raw sample covariance.

None
periods_per_year int

Annualisation factor; 252 for daily returns.

PERIODS_PER_YEAR
repair bool

Apply eigenvalue clipping if the result is not PSD. Off by default because shrinkage should make it unnecessary, and clipping silently shifts the variances.

False

Returns:

Name Type Description
RiskModel RiskModel

The estimate, annualised, with diagnostics.

Raises:

Type Description
CalculationError

If target is unknown, or the panel is too small.