Coverage for src/pyhrp/covariance.py: 100%
31 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-09-01 05:23 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-09-01 05:23 +0000
1"""Covariance and correlation estimation from returns.
3This module isolates the second-moment estimators used by the HRP allocation
4entry points:
5- compute_returns: Simple returns from a DataFrame of prices
6- compute_cov: Covariance matrix from a DataFrame of returns
7- compute_corr: Correlation matrix from a DataFrame of returns
8- check_finite_matrix: Guard that rejects matrices containing NaN/null entries
9"""
11from __future__ import annotations
13import warnings
15import numpy as np
16import polars as pl
18__all__ = ["check_finite_matrix", "compute_corr", "compute_cov", "compute_returns"]
21def _warn_on_missing_returns(returns: pl.DataFrame) -> None:
22 """Emit a warning when nulls or NaNs survive into the returns frame.
24 Args:
25 returns (pl.DataFrame): Returns frame straight out of ``pct_change``
26 """
27 null_counts = returns.null_count().row(0, named=True)
28 nan_counts = {c: int(n.is_nan().sum()) for c, n in ((col, returns[col]) for col in returns.columns)}
29 affected = {
30 asset: count + nan_counts[asset] for asset, count in null_counts.items() if count + nan_counts[asset] > 0
31 }
32 if affected:
33 detail = ", ".join(f"{a} ({n} rows)" for a, n in sorted(affected.items()))
34 warnings.warn(
35 f"Missing prices detected for: {detail}. "
36 "They are filled with zero returns, which biases covariance estimates. "
37 "Clean the data upstream or drop affected rows/assets.",
38 stacklevel=3,
39 )
42def compute_returns(prices: pl.DataFrame) -> pl.DataFrame:
43 r"""Compute simple returns from prices.
45 Drops leading all-null rows produced by pct_change and fills remaining
46 nulls/NaNs (e.g. from missing prices) with zero returns.
48 Warning:
49 Filling with zero is a dirty helper, not a data-cleaning strategy: a
50 suspended asset or an asset listed mid-sample contributes fabricated
51 zero returns that bias variance down and correlations toward zero.
52 A :class:`UserWarning` is emitted whenever this path fires. Callers
53 working with messy universes should clean the data first and compose
54 the pipeline manually::
56 from pyhrp import build_tree, risk_parity
57 from pyhrp import compute_corr, compute_cov
59 returns = prices.select(pl.all().pct_change()).drop_nulls()
60 root = risk_parity(root=build_tree(compute_corr(returns)).root,
61 cov=compute_cov(returns))
63 Args:
64 prices (pl.DataFrame): Asset price time series (columns are assets, rows are dates)
66 Returns:
67 pl.DataFrame: Simple returns, one row shorter than ``prices``
69 Examples:
70 >>> import polars as pl
71 >>> from pyhrp.covariance import compute_returns
72 >>> prices = pl.DataFrame({"A": [100.0, 110.0, 99.0]})
73 >>> compute_returns(prices)['A'].to_list()
74 [0.1, -0.1]
75 """
76 raw = prices.select(pl.all().pct_change()).filter(pl.any_horizontal(pl.all().is_not_null()))
77 _warn_on_missing_returns(raw)
78 return raw.fill_null(0.0).fill_nan(0.0)
81def check_finite_matrix(matrix: pl.DataFrame, name: str = "matrix") -> pl.DataFrame:
82 """Raise if a matrix contains NaN or null entries.
84 A covariance matrix with non-finite entries makes every downstream weight
85 meaningless; this guard fails loudly instead of propagating garbage.
87 Args:
88 matrix (pl.DataFrame): Square matrix (columns are assets)
89 name (str): Human-readable name used in the error message
91 Returns:
92 pl.DataFrame: The input matrix, unchanged
94 Raises:
95 ValueError: If any entry of ``matrix`` is NaN or null.
96 """
97 bad = {
98 col: int(count) + int(matrix[col].is_nan().sum())
99 for col, count in matrix.null_count().row(0, named=True).items()
100 if count > 0 or bool(matrix[col].is_nan().any())
101 }
102 if bad:
103 detail = ", ".join(f"{c} ({n})" for c, n in sorted(bad.items()))
104 msg = f"{name} contains NaN/null entries in column(s): {detail}"
105 raise ValueError(msg)
106 return matrix
109def compute_cov(df: pl.DataFrame) -> pl.DataFrame:
110 """Compute covariance matrix from a DataFrame of returns."""
111 cols = df.columns
112 cov = np.atleast_2d(np.cov(df.to_numpy().T))
113 return pl.DataFrame(dict(zip(cols, cov, strict=True)))
116def compute_corr(df: pl.DataFrame) -> pl.DataFrame:
117 """Compute correlation matrix from a DataFrame of returns."""
118 cols = df.columns
119 corr = np.atleast_2d(np.corrcoef(df.to_numpy().T))
120 return pl.DataFrame(dict(zip(cols, corr, strict=True)))