---
title: "4.1.2 Covarianzas: shrinkage, factores y denoising"
description: "Covarianza muestral, Ledoit-Wolf, modelos factoriales y denoising con Marchenko-Pastur, medidos frente a la covarianza verdadera publicada."
url: "https://pythonparatrading.com/curso-python-trading/nivel-4/estimacion-de-covarianzas-shrinkage-y-denoising/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-4/estimacion-de-covarianzas-shrinkage-y-denoising.png"
---

# 4.1.2 · Estimación de covarianzas: shrinkage, modelos factoriales y denoising

> Con 37 activos, una covarianza tiene 703 parámetros, y seis meses de datos dan 128 observaciones por activo. Esta lección compara cuatro familias de estimadores (muestral, Ledoit-Wolf, modelos factoriales y denoising por teoría de matrices aleatorias) contra la covarianza verdadera del universo sintético, y mide lo que importa a una cartera: cuánto riesgo tiene de verdad y cuánto cree tener.

- Curso: [Nivel 4: Carteras, riesgo, ejecución y trading en real](https://pythonparatrading.com/curso-python-trading/nivel-4/)
- Módulo: 4.1 · Teoría y optimización de carteras
- Duración: 70 min
- Requisitos: [4.1.1 · Markowitz: frontera eficiente y restricciones reales](https://pythonparatrading.com/curso-python-trading/nivel-4/markowitz-frontera-eficiente-y-restricciones/)

## Al terminar esta lección

- Explicar por qué la covarianza muestral se degrada cuando el número de activos se acerca al de observaciones y cómo se refleja en los autovalores.
- Implementar el estimador de Ledoit-Wolf, comprobarlo contra scikit-learn y elegir bien el objetivo del shrinkage (identidad, correlación, correlación constante).
- Construir una covarianza con un modelo factorial de factores observados y con componentes principales, y saber cuándo cada uno está bien especificado.
- Aplicar el denoising de Marchenko-Pastur y reconocer cuándo borra estructura real en lugar de ruido.
- Medir los estimadores con criterios de cartera: volatilidad real de la cartera de mínima varianza y cociente entre el riesgo prometido y el real.

## Demasiados parámetros para tan pocos datos

La lección 4.1.1 terminó con una regla: la varianza se estima con días; la media, con años. Esta lección se ocupa de la
parte que sí se puede estimar con días, la covarianza, y de por qué ni siquiera esa sale gratis. Con $N$ activos, la
matriz de covarianzas tiene $N(N+1)/2$ parámetros distintos. Con los 37 activos arriesgados del universo (todos menos
el monetario) son 703. Seis meses de datos diarios aportan 128 observaciones de cada activo: el número de datos crece
con $N \cdot T$, pero el de parámetros, con $N^2$.

El estimador de partida es la covarianza muestral,

$$
S = \frac{1}{T-1} \sum_{t=1}^{T} (x_t - \overline{x})(x_t - \overline{x})^\top ,
$$

insesgada elemento a elemento. El problema no está en cada elemento, sino en la matriz como un todo: cuando la razón
$q = N/T$ no es pequeña, sus autovalores se dispersan, los grandes salen mayores y los pequeños, menores de lo que son.
Un optimizador que busca la varianza mínima va directo a esas direcciones de varianza infravalorada.

Hay cuatro familias de respuestas, y esta lección las compara todas contra la verdad:

```mermaid
flowchart TB
  M[Muestral] --> L[Shrinkage]
  M --> F[Factores]
  M --> D[Denoising RMT]
  L --> L1[Ledoit-Wolf]
  F --> F1[Observados]
  F --> F2[PCA]
  D --> D1[Marchenko-Pastur]
```

## La ventana y la verdad

Los datos son los del universo sintético del nivel 4 (lección 4.1.1): 38 índices de 2006 a 2025 generados con un
modelo de diez factores publicado. La covarianza verdadera está en `universo4_cov_verdad.csv`, una por régimen. La
ventana de trabajo es el segundo semestre de 2024, entera en régimen de calma, así que la verdad con la que se compara
es la covarianza de la calma. El monetario queda fuera: es liquidez, y con una volatilidad del 0,2 % se comería
cualquier cartera de mínima varianza.

```python
import numpy as np
import pandas as pd

precios = pd.read_parquet("universo4_precios.parquet")
precios = precios.set_index("fecha")
fac = pd.read_parquet("universo4_factores_diarios.parquet")
fac = fac.set_index("fecha")
r = precios.pct_change()
r.iloc[0] = precios.iloc[0] / 100 - 1
exceso = r.sub(fac["rf"], axis=0).drop(columns="MONET")
activos = list(exceso.columns)

cov_v = pd.read_csv("universo4_cov_verdad.csv")
calma = cov_v[cov_v["regimen"] == "calma"].set_index("activo")
S_v = calma.loc[activos, activos].to_numpy()

ventana = exceso.loc["2024-07-01":"2024-12-31"]
print("regímenes en la ventana:",
      list(fac.loc[ventana.index, "regimen"].unique()))
X = ventana.to_numpy() * np.sqrt(252)     # escala anual
T, N = X.shape
print(f"N = {N} activos, T = {T} sesiones, N/T = {N / T:.2f}")
print("parámetros de la covarianza:", N * (N + 1) // 2)
```
```output
regímenes en la ventana: ['calma']
N = 37 activos, T = 128 sesiones, N/T = 0.29
parámetros de la covarianza: 703
```

Multiplicar los rendimientos diarios por $\sqrt{252}$ deja las covarianzas directamente en escala anual, la misma
que la verdad publicada. Los autovalores de la matriz de correlación enseñan dónde está el problema:

```python
def a_corr(S):
    d = np.sqrt(np.diag(S))
    return S / np.outer(d, d)

S_m = np.cov(X, rowvar=False)
l_v = np.linalg.eigvalsh(a_corr(S_v))[::-1]
l_m = np.linalg.eigvalsh(a_corr(S_m))[::-1]
print("mayores, verdad:", l_v[:4].round(2),
      " muestra:", l_m[:4].round(2))
print("menores, verdad:", l_v[-3:].round(4), " muestra:",
      l_m[-3:].round(4))
print(f"número de condición: verdad {np.linalg.cond(S_v):,.0f}, "
      f"muestra {np.linalg.cond(S_m):,.0f}")
```
```output
mayores, verdad: [18.21  7.27  2.78  1.13]  muestra: [17.08  7.6   2.9   1.16]
menores, verdad: [0.0202 0.013  0.0117]  muestra: [0.0139 0.0129 0.0064]
número de condición: verdad 32,579, muestra 31,703
```

El primer autovalor, el del mercado, absorbe la mitad de la traza (37 en una correlación de 37 activos). Los
autovalores pequeños son muy pequeños también en la verdad: el universo tiene versiones cubiertas y sin cubrir de las
mismas bolsas, cuatro tramos de bonos del Tesoro que cargan sobre el mismo factor de tipos, y sectores que son casi el
índice. Esas combinaciones tienen muy poco riesgo de verdad. En la muestra, el menor autovalor sale a la mitad del
verdadero (0,0064 frente a 0,0117): la muestra cree que existe una cartera con la mitad del riesgo que de verdad
tiene.

## Cómo medir un estimador

La distancia entre matrices (por ejemplo, la norma de Frobenius de la diferencia de correlaciones) es la medida más
citada y la menos útil: la dominan los elementos grandes y no dice nada de lo que le pasa a una cartera. Esta lección
usa dos medidas de cartera, con la cartera de **mínima varianza**, que solo depende de la covarianza:

- **Volatilidad real frente a la óptima**: los pesos se calculan con la estimación y su volatilidad se evalúa con la
  verdad, dividida por la de la cartera de mínima varianza verdadera. 1 es lo mejor posible.
- **Volatilidad prometida frente a la real**: la volatilidad que la estimación atribuye a sus propios pesos, dividida
  por la verdadera. Por debajo de 1, la cartera tiene más riesgo del que cree.

```python
uno = np.ones(N)
def min_var(S):
    x = np.linalg.solve(S, uno)
    return x / x.sum()

w_opt = min_var(S_v)
vol_opt = np.sqrt(w_opt @ S_v @ w_opt)

def evalua(S):
    w = min_var(S)
    real = np.sqrt(w @ S_v @ w)
    return {"err_corr": np.linalg.norm(a_corr(S) - a_corr(S_v))
            / np.linalg.norm(a_corr(S_v)),
            "vol_real/optima": real / vol_opt,
            "prometida/real": np.sqrt(w @ S @ w) / real}

print(f"vol. de la mínima varianza verdadera: {vol_opt:.2%}")
print({k: round(float(v), 3) for k, v in evalua(S_m).items()})
```
```output
vol. de la mínima varianza verdadera: 0.47%
{'err_corr': 0.173, 'vol_real/optima': 1.28, 'prometida/real': 0.657}
```

La cartera de mínima varianza verdadera tiene una volatilidad del 0,47 % (bonos cortos y combinaciones cubiertas). La
construida con la muestral tiene un 28 % más de volatilidad, y promete un 66 % de la que tiene: cree tener un tercio
menos de riesgo. Parte de esa diferencia es de escala (el semestre fue más tranquilo que la media de la calma); el
Monte Carlo de más abajo, sin ese efecto, da un 72 % con 126 sesiones. Es el sesgo de la lección 4.1.1 aplicado a la covarianza: el optimizador elige las direcciones con
el error más favorable.

## Shrinkage: Ledoit-Wolf

La idea del **shrinkage** es combinar la muestral, insesgada pero ruidosa, con un objetivo estructurado, sesgado pero
estable, $F$:

$$
\hat\Sigma = \delta F + (1 - \delta) S, \qquad 0 \le \delta \le 1 .
$$

Ledoit y Wolf (2004) eligieron como objetivo la identidad escalada, $F = m I$ con $m = \operatorname{tr}(S)/N$, y
derivaron la intensidad $\delta$ que minimiza el error cuadrático esperado. Su estimador de la intensidad compara la
dispersión de la muestral alrededor del objetivo con la varianza de la propia muestral:

$$
d^2 = \tfrac{1}{N}\lVert S - mI \rVert_F^2, \qquad
\overline{b}^2 = \tfrac{1}{N T^2} \sum_{t} \lVert x_t x_t^\top - S \rVert_F^2, \qquad
\delta = \frac{\min(\overline{b}^2, d^2)}{d^2} .
$$

Si la muestral es muy ruidosa ($\overline{b}^2$ grande frente a $d^2$), se encoge mucho; con muchos datos, poco. Es
corto de implementar y se puede contrastar con scikit-learn:

```python
from sklearn.covariance import LedoitWolf

def ledoit_wolf(X):
    Xc = X - X.mean(axis=0)
    T, N = Xc.shape
    S = Xc.T @ Xc / T
    m = np.trace(S) / N
    d2 = np.sum((S - m * np.eye(N)) ** 2) / N
    b2 = sum(np.sum((np.outer(x, x) - S) ** 2) for x in Xc)
    b2 = min(b2 / N / T**2, d2)
    delta = b2 / d2
    return delta * m * np.eye(N) + (1 - delta) * S, delta

S_lw, delta = ledoit_wolf(X)
skl = LedoitWolf().fit(X)
print(f"intensidad: propia {delta:.4f}, sklearn {skl.shrinkage_:.4f}")
print("misma matriz:", np.allclose(S_lw, skl.covariance_, atol=1e-12))
```
```output
intensidad: propia 0.0490, sklearn 0.0490
misma matriz: True
```

La implementación propia y la de scikit-learn coinciden (las dos usan la covarianza con divisor $T$, no $T-1$). La
intensidad es pequeña, un 4,9 %, porque 128 sesiones no son tan pocas para 37 activos. Y aun así el resultado es
desastroso, por el objetivo:

```python
def lw_correlacion(X):
    sd = X.std(axis=0)
    C, delta = ledoit_wolf((X - X.mean(axis=0)) / sd)
    return a_corr(C) * np.outer(sd, sd), delta

S_lwc, delta_c = lw_correlacion(X)

from pypfopt import risk_models
S_cc = risk_models.CovarianceShrinkage(
    pd.DataFrame(X), returns_data=True, frequency=1
).ledoit_wolf(shrinkage_target="constant_correlation").to_numpy()

tabla = pd.DataFrame({"muestral": evalua(S_m),
                      "LW covarianza": evalua(S_lw),
                      "LW correlación": evalua(S_lwc),
                      "LW corr. constante": evalua(S_cc)}).T
print(tabla.round(3))
vols = np.sqrt(np.diag(S_m))
print("vol. mínima, media y máxima:", vols.min().round(3),
      vols.mean().round(3), vols.max().round(3))
```
```output
                    err_corr  vol_real/optima  prometida/real
muestral               0.173            1.280           0.657
LW covarianza          0.226            2.758           1.333
LW correlación         0.190            1.238           1.077
LW corr. constante     0.205            1.222           1.150
vol. mínima, media y máxima: 0.014 0.112 0.224
```

Las volatilidades de los activos van del 1,4 % (bono de 1-3 años) al 22 % (energía). La identidad escalada empuja
todas las varianzas hacia su media: el bono corto, el activo clave de la cartera de mínima varianza, parece más
arriesgado de lo que es, y la cartera resultante tiene 2,76 veces la volatilidad óptima. El shrinkage no es un
estimador «mejor» en abstracto: es tan bueno como su objetivo.

Hay dos formas de arreglarlo. La primera, encoger la **correlación**: se tipifican los rendimientos, se aplica
Ledoit-Wolf a esa matriz (cuyo objetivo natural sí es la identidad) y se vuelven a poner las volatilidades de la
muestra. La segunda, el objetivo de **correlación constante** de Ledoit y Wolf (2004, «Honey, I Shrunk...»): todas las
correlaciones iguales a la media, cada varianza la suya. PyPortfolioOpt lo trae hecho; el argumento `frequency=1`
evita que multiplique por 252, porque los datos ya están en escala anual. Las dos dan alrededor de 1,23 veces la
volatilidad óptima, algo mejor que la muestral (1,28), y prometen un poco más de riesgo del que tienen (1,08 y 1,15):
se equivocan del lado prudente.

## Modelos factoriales

Un modelo factorial supone que los rendimientos dependen de pocos factores comunes y de un residuo propio de cada
activo, independiente del de los demás:

$$
x_t = B f_t + \varepsilon_t, \qquad \Sigma = B\, \Sigma_f\, B^\top + D ,
$$

con $D$ diagonal. En lugar de 703 parámetros hay $N \cdot k$ cargas, $k(k+1)/2$ covarianzas de los factores y $N$
varianzas residuales. Hay dos maneras de obtener los factores:

- **Observados**: series que se conocen (el mercado, los tipos, el dólar, los factores de Fama-French de la lección
  4.1.6). Las cargas salen de regresiones activo a activo.
- **Estadísticos**: los $k$ primeros componentes principales de la propia muestra. No hace falta saber qué son, pero
  hay que elegir $k$.

El universo trae en `universo4_factores_diarios.parquet` los rendimientos realizados de sus diez factores:

```python
F = fac.loc[ventana.index,
            [c for c in fac.columns if c.startswith("f_")]]
F = F.to_numpy() * np.sqrt(252)

def factorial(X, F):
    Fc = np.column_stack([np.ones(len(F)), F])
    coef, *_ = np.linalg.lstsq(Fc, X, rcond=None)
    B = coef[1:]
    resid = X - Fc @ coef
    D = np.diag(resid.var(axis=0, ddof=Fc.shape[1]))
    return B.T @ np.cov(F, rowvar=False) @ B + D

def pca(X, k):
    S = np.cov(X, rowvar=False)
    l, V = np.linalg.eigh(S)
    l, V = l[::-1][:k], V[:, ::-1][:, :k]
    Sk = V @ np.diag(l) @ V.T
    return Sk + np.diag(np.diag(S - Sk))

S_f = factorial(X, F)
tabla.loc["factores observados"] = evalua(S_f)
tabla.loc["PCA, 5 componentes"] = evalua(pca(X, 5))
print(tabla.round(3).iloc[[0, 2, 4, 5]])
```
```output
                     err_corr  vol_real/optima  prometida/real
muestral                0.173            1.280           0.657
LW correlación          0.190            1.238           1.077
factores observados     0.173            1.080           0.982
PCA, 5 componentes      0.184            1.267           1.113
```

El modelo de factores observados es el mejor de la lección: 1,08 veces la volatilidad óptima y un riesgo prometido
casi exacto (0,98). Hay que leerlo con cuidado: los datos se generaron con exactamente estos diez factores y residuos
independientes, así que el modelo está bien especificado por construcción. En mercados reales los factores son
aproximaciones y los residuos tienen correlaciones (sectoriales, de estilo) que el modelo ignora. El modelo con cinco
componentes principales lo ilustra: deja fuera factores débiles, como el dólar (que separa las versiones cubiertas de
las no cubiertas), su residuo deja de ser diagonal y la cartera queda en 1,27, como la muestral.

## Denoising con Marchenko-Pastur

La teoría de matrices aleatorias da una referencia para separar señal de ruido. Si los $N$ activos fueran
independientes y de varianza $\sigma^2$, los autovalores de su correlación muestral con $T$ observaciones no saldrían
todos iguales a 1: se repartirían, cuando $N$ y $T$ son grandes, según la distribución de Marchenko-Pastur, en el
intervalo

$$
\lambda_{\pm} = \sigma^2 \left(1 \pm \sqrt{N/T}\right)^2 .
$$

Laloux, Cizeau, Bouchaud y Potters (1999) observaron que casi todos los autovalores de la correlación de las acciones
del S&P 500 caían dentro de ese intervalo: indistinguibles del ruido. El **denoising** que populariza López de Prado
(2020) actúa en consecuencia: deja los autovalores por encima de $\lambda_+$, sustituye los demás por su media (para
conservar la traza) y reconstruye la correlación.

```mermaid
flowchart TB
  S[Covarianza] --> C[Correlación]
  C --> A[Autovalores]
  A --> R{"¿Mayor que λ+?"}
  R -- sí --> K[Se conserva]
  R -- no --> P[Se promedia]
  K --> N[Reconstruir]
  P --> N
  N --> V[Volatilidades]
```

```python
q = N / T
lam_mas, lam_menos = (1 + np.sqrt(q)) ** 2, (1 - np.sqrt(q)) ** 2
print(f"Marchenko-Pastur con σ² = 1: [{lam_menos:.3f}, {lam_mas:.3f}]")
print("autovalores por encima de λ+: muestra", (l_m > lam_mas).sum(),
      "| verdad", (l_v > lam_mas).sum())
print("autovalores verdaderos por debajo de λ-:",
      (l_v < lam_menos).sum(), "de", N)
```
```output
Marchenko-Pastur con σ² = 1: [0.214, 2.364]
autovalores por encima de λ+: muestra 3 | verdad 3
autovalores verdaderos por debajo de λ-: 21 de 37
```

*Fig. — Autovalores de la correlación de 37 activos, verdaderos y de seis meses de datos, con el intervalo de Marchenko-Pastur para ruido puro.* Autovalores ordenados de mayor a menor en escala logarítmica. Los tres primeros, verdaderos y muestrales, superan el borde superior de Marchenko-Pastur (2,36, destacado); los demás bajan hasta 0,01, y 21 de los 37 verdaderos quedan por debajo del borde inferior (0,214): estructura real de poco riesgo, no ruido. Los muestrales siguen a los verdaderos, con los más pequeños por debajo.

Con seis meses, el borde superior es 2,36 y solo tres autovalores de la muestra lo superan; en la verdad, también
tres. Hasta aquí, la receta funciona. Pero la verdad no es «tres factores y ruido blanco»: 21 de los 37 autovalores
verdaderos están por debajo del borde **inferior**. No son ruido: son las combinaciones de muy poco riesgo que tiene
el universo (cubierto frente a sin cubrir, tramos de bonos). Promediarlos con los demás les sube la varianza, que es
lo mismo que borrar esas coberturas:

```python
def denoise(X, ajusta=True):
    S = np.cov(X, rowvar=False)
    sd = np.sqrt(np.diag(S))
    l, V = np.linalg.eigh(a_corr(S))
    q = X.shape[1] / X.shape[0]
    s2 = 1.0
    for _ in range(50 if ajusta else 0):   # punto fijo para σ²
        s2 = l[l <= s2 * (1 + np.sqrt(q)) ** 2].mean()
    ruido = l <= s2 * (1 + np.sqrt(q)) ** 2
    l = l.copy()
    l[ruido] = l[ruido].mean()
    C = a_corr(V @ np.diag(l) @ V.T)
    return C * np.outer(sd, sd), int((~ruido).sum()), s2

S_dn, k1, _ = denoise(X, ajusta=False)
S_dn2, k2, s2 = denoise(X)
print(f"σ² = 1: conserva {k1}; σ² ajustada = {s2:.3f}: conserva {k2}")
tabla.loc["denoising σ² = 1"] = evalua(S_dn)
tabla.loc["denoising σ² ajustada"] = evalua(S_dn2)
print(tabla.round(3).iloc[[0, 2, 6, 7]])
```
```output
σ² = 1: conserva 3; σ² ajustada = 0.108: conserva 14
                       err_corr  vol_real/optima  prometida/real
muestral                  0.173            1.280           0.657
LW correlación            0.190            1.238           1.077
denoising σ² = 1          0.209            1.504           1.239
denoising σ² ajustada     0.172            1.291           1.010
```

Con $\sigma^2 = 1$, el denoising conserva tres autovalores, aplana los otros 34 y la cartera de mínima varianza
empeora hasta 1,50 veces la óptima, peor que la muestral. Ajustando $\sigma^2$ como hace López de Prado (aquí con un
punto fijo sencillo: la varianza del ruido es la media de los autovalores que quedan dentro del borde), el borde baja,
se conservan 14 autovalores y el resultado iguala a la muestral (1,29), con un riesgo prometido mucho más honesto
(1,01 frente a 0,66). Es una herramienta útil cuando la correlación verdadera se parece al supuesto (pocos factores
fuertes y un residuo parecido al ruido); cuando hay estructura en los autovalores pequeños, hay que comprobar antes de
aplicar.

## Muchas ventanas: Monte Carlo desde la verdad

Una sola ventana es una sola muestra. Para comparar con más datos, se simulan rendimientos con el modelo verdadero
(cargas de `universo4_verdad.csv`, covarianza de los factores en calma de `universo4_factores_verdad.csv` y
residuos independientes), con normales y sin GARCH para aislar el error de estimación, y se repite la comparación con
30 muestras por longitud de ventana:

```python
verdad = pd.read_csv("universo4_verdad.csv", index_col="activo")
fv = pd.read_csv("universo4_factores_verdad.csv")
fv = fv[fv["regimen"] == "calma"].set_index("factor")
nombres = list(fv.index)
B = verdad.loc[activos, [f"b_{k}" for k in nombres]].to_numpy()
vol_f = fv["vol_anual"].to_numpy()
S_fv = fv[[f"corr_{k}" for k in nombres]].to_numpy()
S_fv = S_fv * np.outer(vol_f, vol_f)
idio = verdad.loc[activos, "vol_idio"].to_numpy()
print("B·Σf·B' + D = calma:",
      np.allclose(B @ S_fv @ B.T + np.diag(idio**2), S_v))
```
```output
B·Σf·B' + D = calma: True
```

```python
rng = np.random.default_rng(7)
Lf = np.linalg.cholesky(S_fv)
estimadores = {
    "muestral": lambda X, F: np.cov(X, rowvar=False),
    "LW correlación": lambda X, F: lw_correlacion(X)[0],
    "factores": factorial,
    "denoising σ²=1": lambda X, F: denoise(X, ajusta=False)[0],
    "denoising ajustado": lambda X, F: denoise(X)[0],
}
filas = []
for T_ in (63, 126, 252, 504):
    for _ in range(30):
        Fs = rng.standard_normal((T_, len(nombres))) @ Lf.T
        Xs = Fs @ B.T + rng.standard_normal((T_, N)) * idio
        for nombre, est in estimadores.items():
            m = evalua(est(Xs, Fs))
            filas.append({"T": T_, "estimador": nombre, **m})
mc = pd.DataFrame(filas).groupby(["T", "estimador"]).mean()
print(mc["vol_real/optima"].unstack().round(2))
print(mc["prometida/real"].unstack().round(2))
```
```output
estimador  LW correlación  denoising ajustado  denoising σ²=1  factores  muestral
T
63                   1.26                1.27            1.96      1.09      1.55
126                  1.13                1.19            1.57      1.04      1.17
252                  1.08                1.16            1.46      1.02      1.08
504                  1.04                1.05            1.44      1.01      1.04
estimador  LW correlación  denoising ajustado  denoising σ²=1  factores  muestral
T
63                   1.10                0.90            1.02      0.91      0.43
126                  1.06                0.84            1.17      0.96      0.72
252                  1.05                0.76            1.27      0.98      0.87
504                  1.02                0.93            1.30      0.99      0.92
```

La figura de la cabecera resume la primera tabla. Con tres meses de datos (63 sesiones, $N/T = 0{,}59$), la cartera
de mínima varianza muestral tiene un 55 % más de volatilidad que la óptima y promete el 43 % de la que tiene. Con dos
años, la diferencia baja al 4 %. Ledoit-Wolf sobre la correlación gana sobre todo con ventanas cortas (1,26 frente a
1,55 a 63 sesiones) y converge con la muestral cuando hay datos: su intensidad cae sola. El modelo factorial
correctamente especificado es el mejor en todas las longitudes. Y el denoising con $\sigma^2 = 1$ es el peor con
ventanas largas: con 504 sesiones el borde se estrecha, pero sigue borrando las direcciones pequeñas que son reales,
así que su error no desaparece (1,44). El denoising ajustado, en cambio, promete
sistemáticamente menos riesgo del real (de 0,76 a 0,93).

<aside class="callout" data-kind="nota">

Las simulaciones son normales, sin GARCH ni cambios de régimen: el caso más favorable para la muestral. En los datos
del universo la volatilidad se agrupa (GARCH común): en los 28 semestres enteros en calma, la volatilidad conjunta de
los activos (la raíz de la traza de la covarianza) va del 75 % al 129 % de la de la verdad de la calma, y en el
segundo semestre de 2024 es el 80 %. Es un error de escala que se suma a todos los estimadores y que afecta sobre todo
al riesgo prometido.

</aside>

## Lo que ningún estimador ve

Todos los estimadores de esta lección estiman la covarianza del régimen que tienen delante. La ventana de 2024 es de
calma; la verdad publicada incluye también la covarianza de estrés. Basta evaluar las mismas carteras con ella:

```python
estres = cov_v[cov_v["regimen"] == "estres"].set_index("activo")
S_e = estres.loc[activos, activos].to_numpy()
for nombre, S in [("muestral", S_m), ("LW correlación", S_lwc),
                  ("factores", S_f)]:
    w = min_var(S)
    print(f"{nombre:15s} prometida {np.sqrt(w @ S @ w):.2%}  "
          f"calma {np.sqrt(w @ S_v @ w):.2%}  "
          f"estrés {np.sqrt(w @ S_e @ w):.2%}")
```
```output
muestral        prometida 0.39%  calma 0.60%  estrés 0.81%
LW correlación  prometida 0.62%  calma 0.58%  estrés 0.78%
factores        prometida 0.50%  calma 0.50%  estrés 0.70%
```

En estrés, la volatilidad de las tres carteras sube entre un 34 % y un 40 % sobre la de la calma, y en la muestral es
más del doble de lo prometido. Es un problema distinto del error de estimación: no se resuelve con más datos de la
calma, sino con escenarios de estrés y límites de riesgo (lecciones 4.2.4 y 4.2.5).

## Qué estimador usar

La figura reúne la ventana de la lección: la volatilidad real de la cartera de mínima varianza construida con cada
estimador, en veces la óptima.

*Fig. — Volatilidad real de la cartera de mínima varianza construida con cada estimador en el segundo semestre de 2024, en veces la óptima.* Barras horizontales. Muestral 1,28; Ledoit-Wolf sobre la covarianza 2,76 (destacada: el objetivo de identidad escalada destruye la estructura); Ledoit-Wolf sobre la correlación 1,24; correlación constante 1,22; factores observados 1,08; PCA con cinco componentes 1,27; denoising con σ² = 1 1,50; con σ² ajustada 1,29.

| Estimador | Cuándo funciona | Riesgo principal |
|---|---|---|
| Muestral | $T$ mucho mayor que $N$ (más de 10 veces) | Promete menos riesgo del real cuando $N/T$ crece |
| Ledoit-Wolf sobre la correlación | Por defecto, sobre todo con ventanas cortas | Poco, si el objetivo es razonable |
| Ledoit-Wolf sobre la covarianza | Activos con volatilidades parecidas | Destruye la estructura si no lo son |
| Factores observados | Cuando los factores explican la estructura | Mala especificación (factores que faltan) |
| PCA | Pocos factores fuertes | Elegir $k$; deja fuera los débiles |
| Denoising (Marchenko-Pastur) | Pocos autovalores fuertes y un resto parecido al ruido | Borra coberturas reales si hay autovalores muy pequeños |

Errores típicos:

- **Comparar estimadores con la norma de Frobenius.** En la ventana de la lección, el error de correlación va de 0,17
  a 0,23 entre los ocho estimadores y no ordena bien los resultados de cartera.
- **Datos no sincrónicos**: cierres de Tokio, Fráncfort y Nueva York el mismo día infravaloran las correlaciones
  entre regiones. Con datos diarios de mercados de distintas zonas horarias se usan rendimientos semanales o se
  corrigen los retardos.
- **Mezclar escalas**: diarios y anuales, simples y logarítmicos, o un activo en divisa local y otro en USD.
- **Ventanas sin pensar el régimen**: una ventana que mezcla calma y crisis estima una covarianza que no es ni la de
  una ni la de la otra.

## Ejercicios

1. Repite la tabla de estimadores con dos años de datos en calma (2023-01-01 a 2024-12-31). ¿Qué estimador gana
   ahora y cuánto se reduce la diferencia con la muestral?

**Solución.**

```python
v2 = exceso.loc["2023-01-01":"2024-12-31"]
X2 = v2.to_numpy() * np.sqrt(252)
F2 = fac.loc[v2.index,
             [c for c in fac.columns if c.startswith("f_")]]
F2 = F2.to_numpy() * np.sqrt(252)
print("T =", len(X2), "| regímenes:",
      list(fac.loc[v2.index, "regimen"].unique()))
t2 = pd.DataFrame({
    "muestral": evalua(np.cov(X2, rowvar=False)),
    "LW correlación": evalua(lw_correlacion(X2)[0]),
    "factores": evalua(factorial(X2, F2)),
    "denoising ajustado": evalua(denoise(X2)[0])}).T
print(t2.round(3))
```
```output
T = 502 | regímenes: ['calma']
                    err_corr  vol_real/optima  prometida/real
muestral               0.113            1.053           0.847
LW correlación         0.120            1.068           0.984
factores               0.113            1.025           0.913
denoising ajustado     0.113            1.058           0.861
```

Con 502 sesiones, la muestral queda a un 5 % de la óptima y los estimadores se juntan: Ledoit-Wolf apenas encoge y el
modelo factorial sigue siendo el mejor por estar bien especificado. El riesgo prometido sale por debajo del real en la
muestral, en el modelo factorial y en el denoising: en esos dos años la volatilidad conjunta fue un 14 % más baja que
la media de la calma, un error de escala que ningún estimador corrige. Ledoit-Wolf, que en el Monte Carlo promete algo más
riesgo del real (encoge las correlaciones y, con ellas, las coberturas), lo compensa aquí por casualidad (0,98).

2. Un gestor rebalancea cada seis meses a la cartera de mínima varianza. Calcula la rotación (la mitad de la suma de
   los cambios absolutos de peso) entre los semestres primero y segundo de 2024 con la muestral y con Ledoit-Wolf
   sobre la correlación.

**Solución.**

```python
def pesos_mv(desde, hasta, est):
    Xv = exceso.loc[desde:hasta].to_numpy() * np.sqrt(252)
    return min_var(est(Xv))

muestral = lambda Xv: np.cov(Xv, rowvar=False)
lwc = lambda Xv: lw_correlacion(Xv)[0]
for nombre, est in [("muestral", muestral), ("LW correlación", lwc)]:
    w1 = pesos_mv("2024-01-01", "2024-06-30", est)
    w2 = pesos_mv("2024-07-01", "2024-12-31", est)
    print(f"{nombre:15s} rotación {0.5 * np.abs(w2 - w1).sum():.0%}  "
          f"bruto {np.abs(w2).sum():.0%}")
```
```output
muestral        rotación 60%  bruto 196%
LW correlación  rotación 28%  bruto 160%
```

La cartera de mínima varianza sin restricciones es una cartera larga-corta apalancada, y sus pesos se mueven mucho de
un semestre a otro con cualquiera de los dos estimadores. Ledoit-Wolf reduce la rotación a menos de la mitad (del 60 %
al 28 %) y el apalancamiento bruto (del 196 % al 160 %), pero la cartera sigue siendo larga-corta y apalancada: el
shrinkage estabiliza la covarianza, no las decisiones de la cartera. Para eso hacen falta restricciones
(lección 4.1.1) y penalizar la rotación (lección 4.1.5).

3. Explica con tus palabras por qué un autovalor muy pequeño de la correlación verdadera, que corresponde a «versión
   cubierta menos versión sin cubrir de Europa», no es ruido, y qué tendría que hacer un procedimiento de denoising
   para respetarlo.

**Solución.**

Las dos versiones de Europa tienen la misma exposición a la bolsa europea y solo se diferencian en el dólar: su
diferencia es, casi exactamente, una posición en divisa, con mucha menos volatilidad que cada una por separado. Esa
combinación de poco riesgo existe por construcción del producto, no por azar de la muestra, y produce un autovalor
pequeño de la correlación. Marchenko-Pastur describe matrices de ruido puro, sin direcciones así; cuando la verdad
las tiene, el borde inferior no separa ruido de señal. Un procedimiento que las respete tiene que ajustar el nivel de
ruido (σ²) en lugar de suponer 1, o actuar solo sobre los autovalores intermedios, o partir de un modelo factorial
que incluya el factor que las genera (el dólar).

## Autoevaluación

1. Con seis meses de datos, la cartera de mínima varianza construida con la covarianza muestral promete una volatilidad que es el 66 % de la real. ¿Por qué se equivoca siempre en esa dirección?

   - A. Por un error de redondeo
   - B. Porque los datos tienen colas gruesas
   - C. Porque el optimizador busca las direcciones de menor varianza estimada, y esas son justamente las que la muestra infravalora: los autovalores pequeños de la muestral salen más pequeños que los verdaderos
   - D. Porque la covarianza muestral está sesgada hacia arriba

   *Respuesta C: Porque el optimizador busca las direcciones de menor varianza estimada, y esas son justamente las que la muestra infravalora: los autovalores pequeños de la muestral salen más pequeños que los verdaderos.* La muestral es insesgada elemento a elemento, pero sus autovalores no: los extremos se dispersan (los grandes salen mayores y los pequeños, menores). Minimizar la varianza estimada selecciona las direcciones con el error más favorable, y el riesgo prometido queda por debajo del real.

2. Ledoit-Wolf con el objetivo de la identidad escalada, aplicado a la covarianza de 37 activos con volatilidades del 1,4 % al 22 %, da una cartera de mínima varianza con 2,76 veces la volatilidad óptima. ¿Qué falla?

   - A. El objetivo: la identidad escalada empuja todas las varianzas hacia la varianza media, así que el bono corto parece mucho más arriesgado de lo que es y la cartera deja de usarlo
   - B. La fórmula de la intensidad
   - C. scikit-learn tiene un error
   - D. Que hacen falta más datos

   *Respuesta A: El objetivo: la identidad escalada empuja todas las varianzas hacia la varianza media, así que el bono corto parece mucho más arriesgado de lo que es y la cartera deja de usarlo.* El objetivo tiene que ser razonable para la estructura de los datos. Con volatilidades tan distintas, se encoge la correlación (con las varianzas de la muestra) o se usa el objetivo de correlación constante; los dos dan alrededor de 1,23.

3. En el universo del curso, 21 de los 37 autovalores de la correlación verdadera están por debajo del borde inferior de Marchenko-Pastur con seis meses de datos. ¿Qué implica para el denoising?

   - A. Que el denoising funciona mejor que nunca
   - B. Que hay que usar más componentes principales
   - C. Que la correlación verdadera está mal calculada
   - D. Que la estructura real no es «unos pocos factores más ruido blanco»: hay direcciones de muy poco riesgo que son reales (versiones cubiertas y sin cubrir, bonos de distinto plazo), y sustituir todos los autovalores pequeños por su media las borra

   *Respuesta D: Que la estructura real no es «unos pocos factores más ruido blanco»: hay direcciones de muy poco riesgo que son reales (versiones cubiertas y sin cubrir, bonos de distinto plazo), y sustituir todos los autovalores pequeños por su media las borra.* Marchenko-Pastur describe los autovalores de una matriz de ruido puro. Si la verdad tiene autovalores muy pequeños, como aquí, recortar todo lo que cae dentro del borde aplasta direcciones reales. Con σ² = 1 la cartera de mínima varianza empeora hasta 1,50 veces la óptima; ajustando σ², queda como la muestral.

4. El modelo de diez factores observados es el mejor estimador de la lección (1,08 veces la volatilidad óptima con seis meses). ¿Por qué hay que ser prudente al generalizarlo?

   - A. Porque los modelos factoriales siempre sobreajustan
   - B. Porque aquí los datos se generaron con exactamente esos diez factores: el modelo está bien especificado por construcción, cosa que en mercados reales no ocurre
   - C. Porque tiene más parámetros que la muestral
   - D. Porque no se puede invertir

   *Respuesta B: Porque aquí los datos se generaron con exactamente esos diez factores: el modelo está bien especificado por construcción, cosa que en mercados reales no ocurre.* Un modelo factorial gana cuando sus factores capturan la estructura de verdad. Con factores equivocados o incompletos (como PCA con cinco componentes, que deja fuera factores débiles), el residuo no es diagonal y el estimador pierde la ventaja: 1,27.

5. ¿Qué estimador anticipa la covarianza de una crisis a partir de seis meses en calma?

   - A. Ledoit-Wolf
   - B. El modelo factorial
   - C. Ninguno: todos estiman la covarianza de la calma; en estrés, la volatilidad real de las mismas carteras sube entre un 34 % y un 40 %
   - D. El denoising

   *Respuesta C: Ninguno: todos estiman la covarianza de la calma; en estrés, la volatilidad real de las mismas carteras sube entre un 34 % y un 40 %.* Los estimadores reducen el error de estimación dentro de un régimen. El cambio de régimen es otro problema: hay que tratarlo con escenarios de estrés y límites (lecciones 4.2.4 y 4.2.5).

## Ideas clave

- La covarianza muestral tiene N(N+1)/2 parámetros. Cuando N/T no es pequeño, sus autovalores extremos se dispersan y la cartera de mínima varianza promete menos riesgo del que tiene: el 66 % del real con 37 activos y 128 sesiones.
- Ledoit-Wolf combina la muestral con un objetivo estructurado con la intensidad que minimiza el error esperado. La implementación propia coincide con scikit-learn; el objetivo importa: con volatilidades heterogéneas, se encoge la correlación, no la covarianza.
- Un modelo factorial bien especificado es el mejor estimador (1,08 veces la volatilidad óptima con seis meses); con factores incompletos, como cinco componentes principales, pierde la ventaja.
- El denoising de Marchenko-Pastur supone que la estructura está en pocos autovalores grandes. Si la verdad tiene direcciones de muy poco riesgo, las borra: con σ² = 1 empeora la cartera; con σ² ajustada, iguala a la muestral.
- Los estimadores se juzgan por la cartera, no por la distancia entre matrices: el error de Frobenius apenas los distingue. Y ninguno anticipa la covarianza de una crisis.

## Recursos

### Research

- [Price Discovery & Arbitrage Price Theory](https://pythonparatrading.com/arbitrage-pricing-theory-and-price-discovery/) · Research-0006 · 6 min

### Documentación

- [scikit-learn: LedoitWolf](https://scikit-learn.org/stable/modules/generated/sklearn.covariance.LedoitWolf.html) · scikit-learn.org
- [PyPortfolioOpt: modelos de riesgo y shrinkage](https://pyportfolioopt.readthedocs.io/en/latest/RiskModels.html) · pyportfolioopt.readthedocs.io

### Libros

- [Ledoit, O. y Wolf, M. (2004): A well-conditioned estimator for large-dimensional covariance matrices. Journal of Multivariate Analysis 88(2), 365-411](https://doi.org/10.1016/S0047-259X(03)00096-4) · doi.org
- [Ledoit, O. y Wolf, M. (2004): Honey, I Shrunk the Sample Covariance Matrix. The Journal of Portfolio Management 30(4), 110-119](https://doi.org/10.3905/jpm.2004.110) · doi.org
- [Laloux, L., Cizeau, P., Bouchaud, J.-P. y Potters, M. (1999): Noise Dressing of Financial Correlation Matrices. Physical Review Letters 83(7), 1467-1470](https://doi.org/10.1103/PhysRevLett.83.1467) · doi.org
- [López de Prado, M. (2020): Machine Learning for Asset Managers. Cambridge University Press (capítulo 2, denoising)](https://doi.org/10.1017/9781108883658) · doi.org

### Datos

- [Datos del nivel 4: covarianza verdadera del universo por régimen (CSV)](https://pythonparatrading.com/curso/datos/nivel-4/universo4_cov_verdad.csv) · Descarga · universo4_cov_verdad.csv

Lección anterior: [4.1.1 · Markowitz: frontera eficiente y restricciones reales](https://pythonparatrading.com/curso-python-trading/nivel-4/markowitz-frontera-eficiente-y-restricciones/)

Lección siguiente: [4.1.3 · Black-Litterman: del equilibrio del mercado a las visiones](https://pythonparatrading.com/curso-python-trading/nivel-4/black-litterman/)
