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 |
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. |
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
¶
Annualised tracking error, the square root of the total.
factor_share
property
¶
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
¶
Total minus the two parts. Zero, up to float noise, by construction.
reconciles ¶
Whether the two parts account for the total.
to_frame ¶
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. |
covariance ¶
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 ¶
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 ¶
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. |
with_data ¶
Bind a DataFetcher for asset-level queries. Returns self.
volatilities ¶
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 ¶
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
|
|
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 ¶
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. |
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
|
|
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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. |
heuristic_intensity ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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: |
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 ¶
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 |
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. |
as_weights ¶
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
|
|
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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. |
is_positive_semi_definite ¶
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 ¶
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 ¶
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 ¶
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
¶
Annualised tracking error, the square root of the total.
factor_share
property
¶
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
¶
Total minus the two parts. Zero, up to float noise, by construction.
reconciles ¶
Whether the two parts account for the total.
to_frame ¶
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. |
covariance ¶
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 ¶
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 ¶
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 ¶
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: |
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. |
with_data ¶
Bind a DataFetcher for asset-level queries. Returns self.
volatilities ¶
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 ¶
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
|
|
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 ¶
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. |
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. |