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 __future__ import annotations
from dataclasses import dataclass
from typing import Any from typing import Any
import numpy as np import numpy as np
import pandas as pd
from numpy.typing import NDArray 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]]: def _validate_inputs(weights: NDArray[Any], cov: NDArray[Any]) -> tuple[NDArray[Any], NDArray[Any]]:
"""Normalize a portfolio vector and its covariance matrix.""" """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_i · (Σw)_i; 与 RC 的关系 RC_i = CV_i / w'Σw。"""
w, cov = _validate_inputs(weights, cov) w, cov = _validate_inputs(weights, cov)
return w * (cov @ w) # type: ignore[no-any-return] 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",
),
)