feat: add label-safe component risk decomposition

This commit is contained in:
ao gong
2026-08-21 22:17:05 +08:00
parent 35a52d781e
commit f7ad82534a
+104
View File
@@ -5,11 +5,48 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import numpy as np
import pandas as pd
from numpy.typing import NDArray
__all__ = [
"ComponentRiskResult",
"component_var",
"labeled_component_risk",
"marginal_risk_contribution",
"risk_contribution",
]
@dataclass(frozen=True, slots=True, eq=False)
class ComponentRiskResult:
"""Label-preserving Euler decomposition of portfolio volatility."""
portfolio_volatility: float
marginal: pd.Series
component: pd.Series
percentage: pd.Series
def grouped_component(self, groups: pd.Series) -> pd.Series:
"""Aggregate asset component risk by an explicitly aligned label series."""
if not isinstance(groups, pd.Series):
raise TypeError("groups must be a pandas Series")
if not groups.index.is_unique:
raise ValueError("groups must contain unique asset labels")
if not self.component.index.difference(groups.index).empty or not groups.index.difference(
self.component.index
).empty:
raise ValueError("groups and component risk must use the same asset labels")
aligned = groups.reindex(self.component.index)
if aligned.isna().any():
raise ValueError("groups must contain a non-missing label for every asset")
grouped = self.component.groupby(aligned, sort=True).sum()
grouped.name = "component_risk"
return grouped
def _validate_inputs(weights: NDArray[Any], cov: NDArray[Any]) -> tuple[NDArray[Any], NDArray[Any]]:
"""Normalize a portfolio vector and its covariance matrix."""
@@ -59,3 +96,70 @@ def component_var(weights: NDArray[Any], cov: NDArray[Any]) -> NDArray[Any]:
"""成分方差: w_i · (Σw)_i; 与 RC 的关系 RC_i = CV_i / w'Σw。"""
w, cov = _validate_inputs(weights, cov)
return w * (cov @ w) # type: ignore[no-any-return]
def labeled_component_risk(
weights: pd.Series,
covariance: pd.DataFrame,
) -> ComponentRiskResult:
"""Return a label-safe Euler decomposition that sums to portfolio volatility.
The covariance matrix may use a different asset order, but its row and
column label sets must exactly match ``weights``. Invalid or indefinite
covariance input is rejected instead of silently producing misleading risk
percentages.
"""
if not isinstance(weights, pd.Series):
raise TypeError("weights must be a pandas Series")
if not isinstance(covariance, pd.DataFrame):
raise TypeError("covariance must be a pandas DataFrame")
if weights.empty:
raise ValueError("weights must contain at least one asset")
if not weights.index.is_unique:
raise ValueError("weights must contain unique asset labels")
if not covariance.index.is_unique or not covariance.columns.is_unique:
raise ValueError("covariance must contain unique asset labels")
if not weights.index.difference(covariance.index).empty or not covariance.index.difference(
weights.index
).empty:
raise ValueError("weights and covariance must use the same asset labels")
if not weights.index.difference(covariance.columns).empty or not covariance.columns.difference(
weights.index
).empty:
raise ValueError("weights and covariance must use the same asset labels")
aligned_weights = weights.astype(float, copy=True)
aligned_covariance = covariance.reindex(
index=weights.index,
columns=weights.index,
).astype(float, copy=True)
weight_values = aligned_weights.to_numpy()
covariance_values = aligned_covariance.to_numpy()
if not np.isfinite(weight_values).all():
raise ValueError("weights must be finite")
if not np.isfinite(covariance_values).all():
raise ValueError("covariance must be finite")
if not np.allclose(covariance_values, covariance_values.T, rtol=1e-10, atol=1e-12):
raise ValueError("covariance must be symmetric")
eigenvalues = np.linalg.eigvalsh(covariance_values)
scale = max(1.0, float(np.max(np.abs(eigenvalues))))
if float(eigenvalues.min()) < -1e-10 * scale:
raise ValueError("covariance must be positive semidefinite")
portfolio_variance = float(weight_values @ covariance_values @ weight_values)
if portfolio_variance <= 0 or not np.isfinite(portfolio_variance):
raise ValueError("weights and covariance must produce positive portfolio variance")
portfolio_volatility = float(np.sqrt(portfolio_variance))
marginal_values = covariance_values @ weight_values / portfolio_volatility
component_values = weight_values * marginal_values
percentage_values = component_values / portfolio_volatility
return ComponentRiskResult(
portfolio_volatility=portfolio_volatility,
marginal=pd.Series(marginal_values, index=weights.index.copy(), name="marginal_risk"),
component=pd.Series(component_values, index=weights.index.copy(), name="component_risk"),
percentage=pd.Series(
percentage_values,
index=weights.index.copy(),
name="risk_contribution",
),
)