Source code for fynance.portfolio.covariance

#!/usr/bin/env python3
# coding: utf-8

r""" Conditioned covariance estimators for portfolio allocation.

Estimators of the asset return covariance matrix, from the raw sample
estimator to shrinkage, exponentially-weighted and denoising variants
that are better conditioned for use in Markowitz-style optimizers
(minimum variance, ERC, MDP, ...). Every estimator takes a ``(T, N)``
array of returns (rows = time, columns = assets) and returns a
symmetric ``(N, N)`` covariance matrix, so they are interchangeable as
the ``cov=`` callable consumed by the allocators.

Main entry points
-----------------
- :func:`sample_cov` — plain sample covariance (``numpy.cov`` wrapper).
- :func:`ledoit_wolf` — closed-form linear shrinkage (identity,
  constant-correlation or diagonal target).
- :func:`ewma_cov` — RiskMetrics-style exponentially weighted
  covariance.
- :func:`factor_cov` — low-rank + diagonal (statistical factor model)
  covariance.
- :func:`denoise_cov` — Marchenko-Pastur eigenvalue clipping on the
  correlation matrix.

"""

from __future__ import annotations

# Third-party packages
import numpy as np
from numba import njit
from numpy.typing import NDArray

__all__ = ['sample_cov', 'ledoit_wolf', 'ewma_cov', 'factor_cov', 'denoise_cov']


# =========================================================================== #
#                                helpers                                      #
# =========================================================================== #


def _validate_returns(X: NDArray) -> NDArray:
    """ Cast to float64, reshape 1-D input to a single column, check finiteness.

    Parameters
    ----------
    X : array_like
        Returns panel, 1-D (single asset) or 2-D (``T, N``).

    Returns
    -------
    np.ndarray
        Validated ``(T, N)`` float64 array.

    """
    X = np.asarray(X, dtype=np.float64)
    if X.ndim == 1:
        X = X.reshape(-1, 1)
    elif X.ndim != 2:
        raise ValueError(f"X must be 1-D or 2-D, got ndim={X.ndim}.")

    if not np.all(np.isfinite(X)):
        raise ValueError("X contains non-finite values (NaN or inf).")

    return X


def _symmetrize(M: NDArray) -> NDArray:
    """ Force exact symmetry of a square matrix: ``(M + M.T) / 2``. """
    M = np.asarray(M, dtype=np.float64)

    return (M + M.T) / 2.0


# =========================================================================== #
#                                sample covariance                            #
# =========================================================================== #


[docs] def sample_cov(X: NDArray, ddof: int = 1) -> NDArray: r""" Sample covariance matrix of a returns panel. Thin, validated wrapper around :func:`numpy.cov`: a 1-D input is treated as a single asset (returns ``[[var]]``); non-finite values raise. Parameters ---------- X : array_like Returns panel, shape ``(T,)`` or ``(T, N)``. ddof : int, optional Delta degrees of freedom (``ddof=1`` is the unbiased sample covariance, ``ddof=0`` the population/maximum-likelihood one). Default 1. Returns ------- np.ndarray Symmetric ``(N, N)`` covariance matrix. Examples -------- >>> import numpy as np >>> X = np.array([[1.0, 2.0], [2.0, 1.0], [3.0, 4.0]]) >>> sample_cov(X, ddof=1) array([[1. , 1. ], [1. , 2.33333333]]) """ X = _validate_returns(X) cov = np.atleast_2d(np.cov(X, rowvar=False, ddof=ddof)) return _symmetrize(cov)
# =========================================================================== # # Ledoit-Wolf shrinkage # # =========================================================================== # def _lw_shrinkage(X: NDArray, target: str) -> tuple[float, NDArray, NDArray]: """ Ledoit-Wolf shrinkage intensity, sample covariance and target matrix. Shared computation for :func:`ledoit_wolf` and :func:`_lw_intensity`. Implements the closed-form intensity of Ledoit & Wolf (2004a/2004b): ``kappa_hat = (pi_hat - rho_hat) / gamma_hat``, ``delta = clip(kappa_hat / T, 0, 1)``, where ``pi_hat`` is the (target-independent) sum of asymptotic variances of the sample covariance entries, ``rho_hat`` the asymptotic covariance between the target and the sample covariance, and ``gamma_hat = ||F - S||_F^2`` the (squared) misspecification of the target. """ if target not in ('identity', 'const_corr', 'diag'): raise ValueError( f"Unknown target {target!r}; expected 'identity', 'const_corr' " "or 'diag'." ) X = _validate_returns(X) T, N = X.shape Xc = X - X.mean(axis=0) S = Xc.T @ Xc / T if N == 1: return 0.0, S, S.copy() X2 = Xc ** 2 row_sq = X2.sum(axis=1) pi_hat = float(np.mean(row_sq ** 2) - np.sum(S ** 2)) pi_ii = np.mean(X2 ** 2, axis=0) - np.diag(S) ** 2 if target == 'identity': mu = np.trace(S) / N F = mu * np.eye(N) rho_hat = float((np.mean(row_sq ** 2) - (mu * N) ** 2) / N) elif target == 'diag': F = np.diag(np.diag(S)) rho_hat = float(np.sum(pi_ii)) else: # const_corr d = np.sqrt(np.diag(S)) denom = np.outer(d, d) safe_denom = np.where(denom > 0, denom, 1.0) corr = np.where(denom > 0, S / safe_denom, 0.0) r_bar = (np.sum(corr) - N) / (N * (N - 1)) F = r_bar * denom np.fill_diagonal(F, np.diag(S)) Z = X2 - np.diag(S)[None, :] G = Z * Xc theta1 = G.T @ Xc / T # theta1[i, j] = theta_hat_{ii,ij} safe_d = np.where(d > 0, d, 1.0) ratio = np.where( (d[:, None] > 0) & (d[None, :] > 0), d[None, :] / safe_d[:, None], 0.0, ) term = ratio * theta1 + ratio.T * theta1.T np.fill_diagonal(term, 0.0) rho_hat = float(np.sum(pi_ii) + 0.5 * r_bar * np.sum(term)) gamma_hat = float(np.sum((F - S) ** 2)) if gamma_hat <= 0.0: delta = 0.0 else: kappa_hat = (pi_hat - rho_hat) / gamma_hat delta = float(np.clip(kappa_hat / T, 0.0, 1.0)) return delta, S, F def _lw_intensity(X: NDArray, target: str = 'const_corr') -> float: """ Ledoit-Wolf shrinkage intensity (in ``[0, 1]``) for a returns panel. Exposed as a private helper so callers/tests can inspect the intensity without recomputing the shrunk matrix. See :func:`ledoit_wolf`. """ delta, _, _ = _lw_shrinkage(X, target) return delta
[docs] def ledoit_wolf(X: NDArray, target: str = 'const_corr') -> NDArray: r""" Ledoit-Wolf linear shrinkage covariance estimator. Closed-form convex combination :math:`(1 - \delta) S + \delta F` of the sample covariance :math:`S` and a low-variance target :math:`F`, with the shrinkage intensity :math:`\delta \in [0, 1]` chosen to minimize the asymptotic expected Frobenius loss. Well conditioned even when :math:`N` is comparable to or larger than :math:`T` (where the plain sample covariance is singular or ill-conditioned). Parameters ---------- X : array_like Returns panel, shape ``(T,)`` or ``(T, N)``. target : {'const_corr', 'identity', 'diag'}, optional Shrinkage target: - ``'const_corr'`` — constant-correlation matrix built from the mean off-diagonal correlation (Ledoit & Wolf, 2004b). - ``'identity'`` — mean-variance scaled identity (Ledoit & Wolf, 2004a). - ``'diag'`` — ``diag(S)``, i.e. shrink off-diagonal entries only. Default ``'const_corr'``. Returns ------- np.ndarray Symmetric ``(N, N)`` shrunk covariance matrix. References ---------- .. [1] O. Ledoit, M. Wolf, "A well-conditioned estimator for large-dimensional covariance matrices", Journal of Multivariate Analysis, 88(2), 2004, 365-411. .. [2] O. Ledoit, M. Wolf, "Honey, I shrunk the sample covariance matrix", The Journal of Portfolio Management, 30(4), 2004, 110-119. Examples -------- >>> import numpy as np >>> rng = np.random.default_rng(0) >>> X = rng.standard_normal((50, 4)) >>> S = ledoit_wolf(X) >>> S.shape (4, 4) >>> bool(np.allclose(S, S.T)) True """ delta, S, F = _lw_shrinkage(X, target) cov = delta * F + (1.0 - delta) * S return _symmetrize(cov)
# =========================================================================== # # exponentially weighted covariance # # =========================================================================== # @njit(cache=True) def _ewma_cov_kernel(Xc: NDArray, lam: float) -> NDArray: """ Accumulate lambda-weighted outer products of demeaned rows. ``Xc`` is already demeaned; weights ``lam ** (T - 1 - t)`` are normalized to sum to one inside the loop, no ``(T, N, N)`` temp. """ T, N = Xc.shape weights = np.empty(T, dtype=np.float64) wsum = 0.0 for t in range(T): wt = lam ** (T - 1 - t) weights[t] = wt wsum += wt cov = np.zeros((N, N), dtype=np.float64) for t in range(T): wn = weights[t] / wsum for i in range(N): wxi = wn * Xc[t, i] for j in range(i, N): cov[i, j] += wxi * Xc[t, j] for i in range(N): for j in range(i + 1, N): cov[j, i] = cov[i, j] return cov
[docs] def ewma_cov(X: NDArray, halflife: float = 63.0) -> NDArray: r""" RiskMetrics-style exponentially weighted covariance matrix. Weights recent observations more heavily: :math:`\lambda = 0.5^{1 / halflife}`, weight of observation :math:`t` (out of :math:`T`, most recent last) proportional to :math:`\lambda^{T - 1 - t}`, normalized to sum to one. Data is demeaned by the plain (equally-weighted) column mean before accumulating weighted outer products. Parameters ---------- X : array_like Returns panel, shape ``(T,)`` or ``(T, N)``, rows in chronological order (oldest first). halflife : float, optional Number of steps after which a past observation's weight is halved. Default 63.0 (~ one quarter of trading days). Returns ------- np.ndarray Symmetric ``(N, N)`` covariance matrix. References ---------- .. [1] J.P. Morgan/Reuters, "RiskMetrics -- Technical Document", 4th edition, 1996. Examples -------- >>> import numpy as np >>> rng = np.random.default_rng(0) >>> X = rng.standard_normal((100, 3)) >>> S = ewma_cov(X, halflife=20.0) >>> S.shape (3, 3) >>> bool(np.allclose(S, S.T)) True """ X = _validate_returns(X) if halflife <= 0: raise ValueError("halflife must be strictly positive.") Xc = X - X.mean(axis=0) lam = 0.5 ** (1.0 / halflife) cov = _ewma_cov_kernel(Xc, lam) return _symmetrize(cov)
# =========================================================================== # # factor covariance # # =========================================================================== #
[docs] def factor_cov(X: NDArray, n_factors: int = 3) -> NDArray: r""" Statistical factor-model covariance (low-rank + diagonal). Eigendecomposes the (population) sample covariance and keeps the ``k = min(n_factors, N)`` largest eigenpairs as a common (systematic) component :math:`B B^\top` with loadings :math:`B = V_k \text{diag}(\sqrt{l_k})`; the remainder of each asset's variance is kept as an idiosyncratic diagonal term. The result is positive semi-definite by construction and matches the sample covariance's diagonal (total variance) exactly. Parameters ---------- X : array_like Returns panel, shape ``(T,)`` or ``(T, N)``. n_factors : int, optional Number of factors to keep, clipped to ``N``. Default 3. Returns ------- np.ndarray Symmetric ``(N, N)`` covariance matrix, ``B @ B.T + diag(idio)``. Examples -------- >>> import numpy as np >>> rng = np.random.default_rng(0) >>> X = rng.standard_normal((100, 5)) >>> S = factor_cov(X, n_factors=2) >>> S.shape (5, 5) >>> bool(np.all(np.linalg.eigvalsh(S) >= -1e-10)) True """ if n_factors < 1: raise ValueError("n_factors must be a positive integer.") X = _validate_returns(X) T, N = X.shape S = sample_cov(X, ddof=0) k = min(n_factors, N) eigvals, eigvecs = np.linalg.eigh(S) order = np.argsort(eigvals)[::-1][:k] lk = np.clip(eigvals[order], 0.0, None) Vk = eigvecs[:, order] B = Vk * np.sqrt(lk)[None, :] common = B @ B.T eps = 1e-12 * np.trace(S) / N idio = np.clip(np.diag(S) - np.diag(common), eps, None) cov = common + np.diag(idio) return _symmetrize(cov)
# =========================================================================== # # Marchenko-Pastur denoising # # =========================================================================== #
[docs] def denoise_cov(sigma: NDArray, n_obs: int, method: str = 'clip') -> NDArray: r""" Marchenko-Pastur eigenvalue clipping on the correlation matrix. Converts ``sigma`` to a correlation matrix, replaces the "noise" eigenvalues (those at or below the Marchenko-Pastur upper edge :math:`\lambda_+ = (1 + \sqrt{q})^2`, :math:`q = N / n\_obs`) by their mean (trace-preserving), reconstructs, forces a unit diagonal and rescales back by the original volatilities. Parameters ---------- sigma : array_like Symmetric ``(N, N)`` covariance matrix to denoise. n_obs : int Number of observations the covariance was estimated on. method : {'clip'}, optional Denoising method. Only ``'clip'`` (constant-residual-eigenvalue) is implemented; the keyword is validated so the signature is stable for future methods. Default ``'clip'``. Returns ------- np.ndarray Symmetric ``(N, N)`` denoised covariance matrix, same diagonal and trace as ``sigma``. References ---------- .. [1] V.A. Marchenko, L.A. Pastur, "Distribution of eigenvalues for some sets of random matrices", Mat. Sb., 72(114), 1967, 507-536. .. [2] M. Lopez de Prado, "Machine Learning for Asset Managers", Cambridge University Press, 2020 (constant residual eigenvalue method). Examples -------- >>> import numpy as np >>> rng = np.random.default_rng(0) >>> X = rng.standard_normal((60, 10)) >>> S = sample_cov(X, ddof=0) >>> D = denoise_cov(S, n_obs=60) >>> D.shape (10, 10) >>> bool(np.allclose(np.diag(D), np.diag(S))) True """ if method != 'clip': raise ValueError(f"Unknown method {method!r}; only 'clip' is supported.") if n_obs <= 0: raise ValueError("n_obs must be strictly positive.") sigma = np.asarray(sigma, dtype=np.float64) if sigma.ndim != 2 or sigma.shape[0] != sigma.shape[1]: raise ValueError("sigma must be a square 2-D covariance matrix.") if not np.all(np.isfinite(sigma)): raise ValueError("sigma contains non-finite values (NaN or inf).") sigma = _symmetrize(sigma) N = sigma.shape[0] vols = np.sqrt(np.diag(sigma)) denom = np.outer(vols, vols) safe_denom = np.where(denom > 0, denom, 1.0) corr = np.where(denom > 0, sigma / safe_denom, 0.0) np.fill_diagonal(corr, 1.0) q = N / float(n_obs) lam_plus = (1.0 + np.sqrt(q)) ** 2 eigvals, eigvecs = np.linalg.eigh(corr) mask = eigvals <= lam_plus if np.any(mask): eigvals = np.where(mask, np.mean(eigvals[mask]), eigvals) denoised_corr = (eigvecs * eigvals) @ eigvecs.T np.fill_diagonal(denoised_corr, 1.0) cov = denoised_corr * denom return _symmetrize(cov)