---
title: "4.1.8 Riskfolio-Lib, PyPortfolioOpt, skfolio y bt"
description: "Los mismos problemas de cartera con Riskfolio-Lib, PyPortfolioOpt, skfolio y código propio: convenciones, valores por defecto, mantenimiento y licencias."
url: "https://pythonparatrading.com/curso-python-trading/nivel-4/riskfolio-pyportfolioopt-skfolio-y-bt/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-4/riskfolio-pyportfolioopt-skfolio-y-bt.png"
---

# 4.1.8 · Herramientas de carteras: Riskfolio-Lib, PyPortfolioOpt, skfolio y bt

> Tres librerías de optimización de carteras y una de backtesting resuelven los problemas del módulo con una línea cada uno. Esta lección les pone los mismos cinco problemas y los compara con implementaciones propias: coinciden cuando el problema es el mismo, y dejan de coincidir por convenciones que no se ven (media geométrica, escala de la covarianza, orden de las hojas de un árbol). Termina con lo que no sale en ningún resultado: quién mantiene cada librería, con qué licencia y qué arrastra al instalarla.

- 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: 60 min
- Requisitos: [2.4.6 · LEAN/QuantConnect y Zipline Reloaded; bt para asignación de activos](https://pythonparatrading.com/curso-python-trading/nivel-2/lean-zipline-reloaded-y-bt/), [0.7.3 · Álgebra lineal intuitiva: vectores y matrices como preparación para carteras](https://pythonparatrading.com/curso-python-trading/nivel-0/algebra-lineal-para-carteras/), [4.1.7 · Rebalanceo y carteras de estrategias](https://pythonparatrading.com/curso-python-trading/nivel-4/rebalanceo-y-carteras-de-estrategias/)

## Al terminar esta lección

- Resolver mínima varianza, máximo Sharpe, risk parity, HRP y mínimo CVaR con Riskfolio-Lib 7.4.0, PyPortfolioOpt 1.6.0 y skfolio 1.9.13, y comprobar cada resultado contra código propio.
- Identificar las convenciones que cambian los pesos: rendimientos o precios, unidades diarias o anuales, media aritmética o geométrica, tipo libre, enlace y orden de hojas en HRP.
- Evaluar asignaciones fuera de muestra con la validación walk-forward de skfolio y llevar los pesos a un backtest con bt.
- Valorar el mantenimiento y la licencia de cada librería con datos de PyPI y GitHub, incluidas las dependencias que llegan sin pedirlas.

## Cuatro herramientas y una referencia

Las lecciones del módulo construyen cada método desde cero: Markowitz (4.1.1), estimadores de covarianza (4.1.2),
Black-Litterman (4.1.3), risk parity y HRP (4.1.4), CVaR y costes (4.1.5). En la práctica casi nadie lo reescribe
todo: se usa una librería. Las tres de optimización más usadas en Python son:

- **PyPortfolioOpt** (`pypfopt`), la más veterana y sencilla: media-varianza, Black-Litterman, HRP, CVaR y CDaR, y
  una utilidad para pasar pesos a acciones enteras. Licencia MIT.
- **Riskfolio-Lib** (`riskfolio`), la más amplia en medidas de riesgo, sobre cvxpy: 26 medidas en el optimizador
  clásico, carteras jerárquicas (HRP, HERC, NCO), factores, informes en Excel. Licencia BSD de tres cláusulas.
- **skfolio**, la más reciente, construida con la API de scikit-learn: cada optimizador es un estimador con `fit` y
  `predict`, y se puede validar con las herramientas de selección de modelos. Licencia BSD de tres cláusulas.

**bt** no optimiza: es una librería de backtesting de carteras (lección 2.4.6), útil para llevar los pesos de
cualquiera de las anteriores a una simulación con rebalanceos y costes. La forma de trabajar de todas es parecida, y
lo que cambia es dónde vive cada paso:

```mermaid
flowchart TB
  R[Rendimientos] --> P[Estimar μ y Σ]
  P --> O[Optimizador]
  O --> W[Pesos]
  W --> B[Backtest con bt]
  W --> V[Validación fuera]
```

El método de la lección: los mismos cinco problemas en las tres librerías y en código propio (fórmula cerrada o
cvxpy, como en las lecciones anteriores), con los mismos datos. Si los pesos coinciden, la librería hace lo que se cree
que hace; si no, hay una convención que conviene conocer antes de usarla en real. Lo primero, qué versiones hay
instaladas y con qué licencia se declaran:

```python
import sys
import warnings
from importlib import metadata

# Riskfolio-Lib usa `*` de cvxpy para multiplicar matrices y cvxpy
# avisa (UserWarning) en cada problema; el aviso no cambia el
# resultado y se silencia solo ese mensaje
warnings.filterwarnings("ignore", message=r"(?s).*This use of ``\*``",
                        category=UserWarning)
import riskfolio as rp
import pypfopt
import skfolio

for p in ["riskfolio-lib", "pyportfolioopt", "skfolio", "cvxpy",
          "vectorbt"]:
    m = metadata.metadata(p)
    lic = m.get("License-Expression") or m.get("License") or ""
    lic = (lic.splitlines() or ["(vacío)"])[0][:30]
    print(f"{p:15s} {m['Version']:7s} {lic}")
print([r for r in metadata.requires("riskfolio-lib")
       if r.startswith("vectorbt")])
print("vectorbt importado:",
      any(k.startswith("vectorbt") for k in sys.modules))
```
```output
riskfolio-lib   7.4.0   BSD (3-clause)
pyportfolioopt  1.6.0   MIT License
skfolio         1.9.13  BSD 3-Clause License
cvxpy           1.9.3   Apache-2.0
vectorbt        1.2.0   (vacío)
['vectorbt>=0.28.0']
vectorbt importado: False
```

Las versiones son las del entorno del curso (`curso-code/uv.lock`): Riskfolio-Lib 7.4.0, PyPortfolioOpt 1.6.0, skfolio
1.9.13 y cvxpy 1.9.3, que es el motor de las tres. Las dos últimas líneas de la salida sorprenden: Riskfolio-Lib
declara `vectorbt>=0.28.0` como dependencia (vectorbt 1.2.0 está instalado por eso, no por decisión del curso), pero
después de importar Riskfolio-Lib, vectorbt no aparece entre los módulos cargados, y una búsqueda en el código
instalado de Riskfolio-Lib no encuentra ninguna referencia a él. Su metadato de licencia está vacío; se vuelve a él en
la sección de licencias.

## Los datos y las convenciones

Ocho activos del universo del nivel (bolsa de EE. UU., Europa y Japón cubiertas a USD, emergentes, Tesoro a 7-10
años, crédito con grado de inversión, alto rendimiento y oro), diez años de rendimientos diarios simples en exceso del
tipo libre. Los datos son sintéticos (README del nivel 4):

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

precios = pd.read_parquet("universo4_precios.parquet").set_index(
    "fecha")
fac = pd.read_parquet("universo4_factores_diarios.parquet").set_index(
    "fecha")
A = ["RV_EEUU", "RV_EUR_C", "RV_JAP_C", "RV_EMG", "RF_T710",
     "RF_CIG", "RF_CHY", "MP_ORO"]
X = precios[A].pct_change().sub(fac["rf"], axis=0).loc["2016":]
mu, S = X.mean(), X.cov()          # diarios, sin anualizar
print(X.shape, X.index[0].date(), X.index[-1].date())
pd.DataFrame({"media_anual_%": 252 * mu * 100,
              "vol_anual_%": np.sqrt(252 * np.diag(S)) * 100},
             index=A).round(2)
```
```output
(2514, 8) 2016-01-04 2025-12-31
          media_anual_%  vol_anual_%
RV_EEUU           15.01        18.90
RV_EUR_C          11.80        18.24
RV_JAP_C           8.49        18.03
RV_EMG            16.26        25.00
RF_T710           -2.40         5.85
RF_CIG            -0.20         5.48
RF_CHY             5.81         9.56
MP_ORO            11.84        14.40
```

2.514 sesiones de 2016 a 2025. En esta década la renta variable y el oro rindieron mucho (15,0 % anual en exceso la
bolsa de EE. UU., 16,3 % emergentes, 11,8 % el oro) y los bonos del Tesoro perdieron (−2,4 % anual en exceso): una
muestra con medias muy dispares, justo la clase de muestra que hace inestables los pesos de máximo Sharpe (4.1.1).

Antes de llamar a nada, las convenciones de entrada de cada librería, leídas en su documentación y comprobadas en las
firmas de las versiones instaladas:

| | PyPortfolioOpt 1.6.0 | Riskfolio-Lib 7.4.0 | skfolio 1.9.13 |
|---|---|---|---|
| Entrada | Precios por defecto (`returns_data=False`); rendimientos con `returns_data=True` | Rendimientos (`Portfolio(returns=...)`) | Rendimientos simples (`fit(X)`) |
| Unidades | Anualiza: `frequency=252` en medias y covarianzas | Las de los datos (diarias), sin anualizar | Las de los datos; las métricas del `Portfolio` resultante se anualizan con 252 |
| Media por defecto | Geométrica (`compounding=True`) | Aritmética (`method_mu="hist"`) | Aritmética (`EmpiricalPrior`) |
| Tipo libre | `max_sharpe(risk_free_rate=0.0)` | `rf=0`, en la unidad de los datos | `risk_free_rate=0.0` |
| Salida | `OrderedDict` de pesos | `DataFrame` con la columna `weights` | `weights_` (array) y `predict(X)`, que devuelve un `Portfolio` |
| Pesos por defecto | Entre 0 y 1 | Solo largos (`sht=False`), hasta 1 | Entre 0 y 1 (`min_weights`, `max_weights`) |

## Mínima varianza: la misma respuesta, si se escala

La cartera de mínima varianza con solo largos es un problema cuadrático pequeño. Como referencia, la fórmula cerrada
sin restricción de signo ($w = \Sigma^{-1}\mathbf{1} / \mathbf{1}'\Sigma^{-1}\mathbf{1}$, lección 0.7.3) y cvxpy
con la restricción, resuelto dos veces, con la covarianza diaria y con la anual:

```python
import cvxpy as cp

inv = np.linalg.inv(S)
cerrada = pd.Series(inv.sum(axis=1) / inv.sum(), A)

def mv_propia(S):
    w = cp.Variable(len(S))
    cp.Problem(cp.Minimize(cp.quad_form(w, S.to_numpy())),
               [cp.sum(w) == 1, w >= 0]).solve(solver="CLARABEL")
    return pd.Series(w.value, S.index)

propia_diaria = mv_propia(S)
propia = mv_propia(S * 252)       # el mismo problema, mejor escalado
pd.DataFrame({"cerrada": cerrada, "largos_diaria": propia_diaria,
              "largos_anual": propia}).round(5)
```
```output
          cerrada  largos_diaria  largos_anual
RV_EEUU  -0.05391        0.00008       0.00000
RV_EUR_C  0.02514        0.00065       0.00000
RV_JAP_C  0.04572        0.03002       0.03050
RV_EMG   -0.03133        0.00003       0.00000
RF_T710   1.30731        0.68115       0.68101
RF_CIG   -0.90739        0.00001       0.00000
RF_CHY    0.54828        0.22564       0.22604
MP_ORO    0.06618        0.06241       0.06245
```

La fórmula cerrada pone un 130,7 % en el bono del Tesoro y un −90,7 % en el crédito: dos activos muy correlados, uno
contra otro. Con solo largos quedan cuatro activos: 68,1 % de Tesoro, 22,6 % de alto rendimiento, 6,2 % de oro y
3,0 % de Japón. Lo instructivo es la columna del medio. Con la covarianza diaria (varianzas del orden de 1e-4 y una
función objetivo del orden de 1e-5), CLARABEL se para con pesos de 0,00065 donde deberían ser cero: sus tolerancias
son absolutas y, a esa escala, grandes. Con la covarianza anual, el mismo problema multiplicado por 252, la solución
sale limpia. Escalar los datos antes de llamar a un solucionador es una costumbre barata que evita diferencias de este
tipo. Ahora las tres librerías:

```python
from pypfopt import EfficientFrontier
from skfolio.optimization import MeanRisk

ef = EfficientFrontier(None, S * 252)          # anual por convención
w_ppo = pd.Series(ef.min_volatility())
port = rp.Portfolio(returns=X)
port.assets_stats(method_mu="hist", method_cov="hist")
w_rf = port.optimization(model="Classic", rm="MV", obj="MinRisk",
                         hist=True)["weights"]
w_sk = pd.Series(MeanRisk().fit(X).weights_, A)
mv = pd.DataFrame({"propia": propia, "pypfopt": w_ppo,
                   "riskfolio": w_rf, "skfolio": w_sk})
print(mv.round(4))
print("máxima diferencia con la propia:",
      mv.sub(propia, axis=0).abs().max().round(6).to_dict())
```
```output
          propia  pypfopt  riskfolio  skfolio
RV_EEUU   0.0000   0.0000     0.0000   0.0000
RV_EUR_C  0.0000   0.0000     0.0000   0.0000
RV_JAP_C  0.0305   0.0305     0.0305   0.0305
RV_EMG    0.0000   0.0000     0.0000   0.0000
RF_T710   0.6810   0.6810     0.6810   0.6810
RF_CIG    0.0000   0.0000     0.0000   0.0000
RF_CHY    0.2260   0.2260     0.2260   0.2260
MP_ORO    0.0625   0.0625     0.0624   0.0624
máxima diferencia con la propia: {'propia': 0.0, 'pypfopt': 1e-06, 'riskfolio': 3.3e-05, 'skfolio': 2.8e-05}
```

Las tres coinciden con la referencia a menos de 0,0033 puntos porcentuales de peso (PyPortfolioOpt, que anualiza, a
0,0001). Riskfolio-Lib y skfolio reciben los datos diarios, y la diferencia residual es del orden de la tolerancia de
sus solucionadores: no tiene importancia práctica. La figura de la cabecera resume estas diferencias para los cinco
problemas de la lección.

## Máximo Sharpe: qué media

El máximo Sharpe con solo largos se resuelve como un problema cuadrático con el cambio de variable de
Charnes-Cooper: minimizar $y'\Sigma y$ con $\mu'y = 1$ e $y \ge 0$, y normalizar $w = y / \mathbf{1}'y$. Con la media
muestral:

```python
from pypfopt import expected_returns
from skfolio.optimization import ObjectiveFunction

y = cp.Variable(len(A))                       # Charnes-Cooper
cp.Problem(cp.Minimize(cp.quad_form(y, S.to_numpy())),
           [mu.to_numpy() @ y == 1, y >= 0]).solve(solver="CLARABEL")
propia_s = pd.Series(y.value / y.value.sum(), A)

geo = expected_returns.mean_historical_return(X, returns_data=True)
ari = expected_returns.mean_historical_return(X, returns_data=True,
                                              compounding=False)
ms = pd.DataFrame({
    "propia": propia_s,
    "pypfopt_geo": EfficientFrontier(geo, S * 252).max_sharpe(),
    "pypfopt_ari": EfficientFrontier(ari, S * 252).max_sharpe(),
    "riskfolio": port.optimization(model="Classic", rm="MV",
                                   obj="Sharpe", rf=0,
                                   hist=True)["weights"],
    "skfolio": pd.Series(MeanRisk(
        objective_function=ObjectiveFunction.MAXIMIZE_RATIO
    ).fit(X).weights_, A)})
print((100 * pd.DataFrame({"geo": geo, "ari": ari})).round(2).T)
ms.loc[ms.max(axis=1) > 0.001].round(4)
```
```output
     RV_EEUU  RV_EUR_C  RV_JAP_C  RV_EMG  RF_T710  RF_CIG  RF_CHY  MP_ORO
geo    14.13     10.65      7.10   14.03    -2.54   -0.35    5.49   11.41
ari    15.01     11.80      8.49   16.26    -2.40   -0.20    5.81   11.84
         propia  pypfopt_geo  pypfopt_ari  riskfolio  skfolio
RV_EEUU  0.3189       0.3102       0.3189     0.3189   0.3190
RF_CHY   0.1648       0.1686       0.1648     0.1648   0.1646
MP_ORO   0.5163       0.5212       0.5163     0.5163   0.5164
```

La cartera de máximo Sharpe de esta muestra tiene solo tres activos: 31,9 % de bolsa de EE. UU., 16,5 % de alto
rendimiento y 51,6 % de oro, los tres con mejor relación entre rentabilidad y volatilidad en la década. Riskfolio-Lib,
skfolio y PyPortfolioOpt con la media aritmética dan los mismos pesos. PyPortfolioOpt con su media por defecto, la
**geométrica** (la tasa anual compuesta), da 31,0 %, 16,9 % y 52,1 %: la geométrica es menor que la aritmética en
aproximadamente $\sigma^2/2$, poco en los bonos (−2,54 % frente a −2,40 %) y mucho en los activos volátiles (14,03 %
frente a 16,26 % en emergentes). Ninguna de las dos es «la correcta»: la aritmética es la esperanza del rendimiento de
un periodo, la que corresponde a la media-varianza de un periodo; lo importante es saber cuál usa cada librería.

Una trampa más de la misma familia, y del mismo paquete. La documentación publicada de PyPortfolioOpt (Read the Docs,
que se titula «PyPortfolioOpt 1.5.4 documentation» a 22 de octubre de 2026) muestra `max_sharpe(risk_free_rate=0.02)`;
la versión instalada tiene otro valor por defecto:

```python
import inspect

print(inspect.signature(EfficientFrontier.max_sharpe))
print(inspect.signature(EfficientFrontier.portfolio_performance))
```
```output
(self, risk_free_rate=0.0)
(self, verbose=False, risk_free_rate=0.0)
```

En la 1.6.0, `risk_free_rate=0.0`. Con rendimientos en exceso (los de la lección) es lo correcto; con rendimientos
totales y la documentación en la mano, alguien esperaría un 2 % que ya no se aplica. La regla general: la firma de la
versión instalada (`inspect.signature`, o `help`) manda sobre la documentación y sobre lo que se recuerde.

## Risk parity: contribuciones iguales

La risk parity de contribuciones iguales (ERC, lección 4.1.4) tiene una formulación convexa: minimizar
$\tfrac12 y'\Sigma y - \tfrac1n\sum_i \ln y_i$ y normalizar. PyPortfolioOpt no la incluye (su única cartera «por
riesgo» es HRP); Riskfolio-Lib la tiene como `rp_optimization` y skfolio como `RiskBudgeting`:

```python
from skfolio.optimization import RiskBudgeting

z = cp.Variable(len(A))
cp.Problem(cp.Minimize(0.5 * cp.quad_form(z, S.to_numpy())
                       - cp.sum(cp.log(z)) / len(A))
           ).solve(solver="CLARABEL")
propia_rp = pd.Series(z.value / z.value.sum(), A)
rp_ = pd.DataFrame({
    "propia": propia_rp,
    "riskfolio": port.rp_optimization(model="Classic", rm="MV",
                                      hist=True)["weights"],
    "skfolio": pd.Series(RiskBudgeting().fit(X).weights_, A)})
contrib = propia_rp * (S @ propia_rp) / (propia_rp @ S @ propia_rp)
rp_["contribucion_%"] = 100 * contrib
rp_.round(4)
```
```output
          propia  riskfolio  skfolio  contribucion_%
RV_EEUU   0.0542     0.0542   0.0542         12.5008
RV_EUR_C  0.0580     0.0580   0.0579         12.5008
RV_JAP_C  0.0624     0.0624   0.0624         12.5007
RV_EMG    0.0430     0.0430   0.0430         12.5008
RF_T710   0.3306     0.3306   0.3306         12.4991
RF_CIG    0.2164     0.2164   0.2164         12.4989
RF_CHY    0.1148     0.1148   0.1148         12.4995
MP_ORO    0.1207     0.1207   0.1207         12.4995
```

Las tres coinciden y cada activo aporta un 12,50 % de la varianza (con diferencias en el cuarto decimal por la
tolerancia). El Tesoro recibe un 33,1 % del capital y emergentes un 4,3 %: el reparto es por riesgo, no por capital.

## HRP: tres librerías, tres respuestas

HRP (López de Prado, 2016) no resuelve un problema de optimización, sino que aplica un procedimiento, y cada paso
tiene variantes:

```mermaid
flowchart TB
  C[Correlaciones] --> D[Distancia]
  D --> T[Árbol de enlace]
  T --> O[Orden de hojas]
  O --> B[Bisección]
  B --> W[Pesos]
```

La distancia es $d_{ij} = \sqrt{(1-\rho_{ij})/2}$; el árbol se construye con un método de enlace (el artículo usa el
enlace simple, *single*); el orden de las hojas del árbol da una lista de activos en la que los parecidos quedan
juntos («cuasi-diagonalización»); la bisección parte la lista por la mitad una y otra vez y reparte el peso entre las
dos mitades en proporción inversa a su varianza (la de una cartera de varianza inversa dentro de cada mitad). La
implementación propia, en pocas líneas:

```python
from scipy.cluster.hierarchy import leaves_list, linkage
from scipy.spatial.distance import squareform

def var_grupo(S, g):
    s = S.loc[g, g]
    ivp = 1 / np.diag(s)
    ivp = ivp / ivp.sum()
    return ivp @ s @ ivp

def hrp(X, metodo="single", orden_optimo=False):
    C, S = X.corr(), X.cov()
    D = np.sqrt(((1 - C) / 2).clip(0, 1))
    Z = linkage(squareform(D.to_numpy(), checks=False), metodo,
                optimal_ordering=orden_optimo)
    orden = list(X.columns[leaves_list(Z)])      # cuasi-diagonal
    w = pd.Series(1.0, index=orden)
    grupos = [orden]
    while grupos:                                # bisección
        grupos = [g[a:b] for g in grupos if len(g) > 1
                  for a, b in ((0, len(g) // 2),
                               (len(g) // 2, len(g)))]
        for i in range(0, len(grupos), 2):
            g1, g2 = grupos[i], grupos[i + 1]
            v1, v2 = var_grupo(S, g1), var_grupo(S, g2)
            w[g1] *= v2 / (v1 + v2)
            w[g2] *= v1 / (v1 + v2)
    return w[X.columns], orden

w_hrp, orden = hrp(X)
print(orden)
w_hrp.round(4)
```
```output
['MP_ORO', 'RF_T710', 'RF_CIG', 'RF_CHY', 'RV_JAP_C', 'RV_EMG', 'RV_EEUU', 'RV_EUR_C']
RV_EEUU     0.0180
RV_EUR_C    0.0194
RV_JAP_C    0.0223
RV_EMG      0.0116
RF_T710     0.4001
RF_CIG      0.3480
RF_CHY      0.1146
MP_ORO      0.0661
dtype: float64
```

El orden de las hojas junta el oro con la renta fija y deja las cuatro bolsas al final. La primera bisección enfrenta
oro, Tesoro, crédito y alto rendimiento con las cuatro bolsas, y la mitad de renta fija, mucho menos volátil, se lleva
casi todo el peso: 40,0 % de Tesoro, 34,8 % de crédito y entre 1,2 % y 2,2 % en cada bolsa.

PyPortfolioOpt tiene HRP en `HRPOpt`. Con la versión instalada de SciPy, no funciona:

```python raises
from pypfopt import HRPOpt

HRPOpt(X).optimize()
```
```output
AttributeError: module 'scipy.cluster.hierarchy' has no attribute '_LINKAGE_METHODS'
```

`HRPOpt.optimize` comprueba el método de enlace contra `scipy.cluster.hierarchy._LINKAGE_METHODS`, un atributo privado
(el guion bajo lo dice) que SciPy 1.18 ya no expone en ese módulo. El fallo está documentado en la incidencia #754 del
repositorio de PyPortfolioOpt, abierta el 2 de agosto de 2026, con varias propuestas de corrección también abiertas;
la última versión publicada sigue siendo la 1.6.0 de febrero de 2026 (consulta del 22 de octubre de 2026). Para
comparar, la celda siguiente repone el atributo un momento y lo borra después: es un arreglo para esta comparación, no
algo que deba ir a producción, donde lo prudente es fijar la versión de SciPy o usar otra implementación.

```python
import scipy.cluster.hierarchy as sch
from skfolio.cluster import HierarchicalClustering, LinkageMethod
from skfolio.optimization import HierarchicalRiskParity
from skfolio.seriation import HierarchicalSeriation

# arreglo temporal SOLO para comparar (incidencia abierta #754)
sch._LINKAGE_METHODS = {"single": 0, "complete": 1, "average": 2,
                        "centroid": 3, "median": 4, "ward": 5,
                        "weighted": 6}
w_ppo_h = pd.Series(HRPOpt(X).optimize())
del sch._LINKAGE_METHODS

hc = rp.HCPortfolio(returns=X)
def rf_hrp(**kw):
    return hc.optimization(model="HRP", codependence="pearson",
                           rm="MV", **kw)["weights"]
def sk_hrp(metodo, orden_optimo=True):
    orden = HierarchicalSeriation(
        hierarchical_clustering_estimator=HierarchicalClustering(
            linkage_method=metodo), optimal_ordering=orden_optimo)
    return pd.Series(HierarchicalRiskParity(
        seriation_estimator=orden).fit(X).weights_, A)

h = pd.DataFrame({
    "propia": w_hrp,
    "pypfopt": w_ppo_h,
    "rf_def": rf_hrp(),
    "rf_sin_orden": rf_hrp(leaf_order=False),
    "sk_def": pd.Series(HierarchicalRiskParity().fit(X).weights_, A),
    "sk_single": sk_hrp(LinkageMethod.SINGLE),
    "sk_sin_orden": sk_hrp(LinkageMethod.SINGLE, False),
    "propia_ward": hrp(X, "ward", True)[0]}).loc[A]
print(h.round(4))
print(h.sub(h["propia"], axis=0).abs().max().round(4).to_dict())
```
```output
          propia  pypfopt  rf_def  rf_sin_orden  sk_def  sk_single  sk_sin_orden  propia_ward
RV_EEUU   0.0180   0.0180  0.0193        0.0180  0.0187     0.0193        0.0180       0.0187
RV_EUR_C  0.0194   0.0194  0.0208        0.0194  0.0207     0.0208        0.0194       0.0207
RV_JAP_C  0.0223   0.0223  0.0379        0.0223  0.0212     0.0379        0.0223       0.0212
RV_EMG    0.0116   0.0116  0.0232        0.0116  0.0107     0.0232        0.0116       0.0107
RF_T710   0.4001   0.4001  0.3184        0.4001  0.4001     0.3184        0.4001       0.4001
RF_CIG    0.3480   0.3480  0.3626        0.3480  0.3480     0.3626        0.3480       0.3480
RF_CHY    0.1146   0.1146  0.1586        0.1146  0.1146     0.1586        0.1146       0.1146
MP_ORO    0.0661   0.0661  0.0593        0.0661  0.0661     0.0593        0.0661       0.0661
{'propia': 0.0, 'pypfopt': 0.0, 'rf_def': 0.0817, 'rf_sin_orden': 0.0, 'sk_def': 0.0013, 'sk_single': 0.0817, 'sk_sin_orden': 0.0, 'propia_ward': 0.0013}
```

Tres resultados distintos con los mismos datos y el mismo método «HRP»:

- **PyPortfolioOpt** (con el arreglo) reproduce exactamente la implementación propia: enlace simple y el orden de las
  hojas que sale del árbol, como en el artículo.
- **Riskfolio-Lib**, por defecto, pone 31,8 % en el Tesoro en lugar de 40,0 %, y 15,9 % en alto rendimiento en lugar
  de 11,5 %: una diferencia de hasta 8,2 puntos. La causa es `leaf_order=True`: antes de la bisección reordena las
  hojas con la ordenación óptima de SciPy (`optimal_ordering`), que deja el árbol igual pero cambia qué activos quedan
  en cada mitad. Con `leaf_order=False` coincide con la propia.
- **skfolio**, por defecto, usa el enlace de **Ward** con ordenación óptima: 0,13 puntos de diferencia, porque aquí
  el árbol de Ward se parece al simple (la columna `propia_ward`, la implementación propia con Ward y ordenación
  óptima, da lo mismo que skfolio). Con enlace simple y ordenación óptima da lo mismo que Riskfolio-Lib por defecto;
  sin ordenación óptima, lo mismo que la propia. La forma antigua de elegir el enlace,
  `hierarchical_clustering_estimator=` directamente en `HierarchicalRiskParity`, avisa en la 1.9.13 de que se
  retirará en la versión 2.0; la celda usa la nueva, `seriation_estimator=HierarchicalSeriation(...)`.

Ninguna variante es un error. Lo que esto enseña es que «HRP» no es un número sino una familia de procedimientos, y
que un resultado de HRP sin el enlace, la distancia y el orden de las hojas no se puede reproducir.

## Mínimo CVaR: un programa lineal

El CVaR histórico al 95 % (la pérdida media del 5 % de peores días, lección 4.1.5) se minimiza con la formulación de
Rockafellar y Uryasev, un programa lineal con una variable auxiliar por día:

```python
from pypfopt import EfficientCVaR
from skfolio import RiskMeasure

T, n = X.shape
w, a, u = cp.Variable(n), cp.Variable(), cp.Variable(T)
cp.Problem(cp.Minimize(a + cp.sum(u) / (0.05 * T)),
           [u >= 0, u >= -X.to_numpy() @ w - a,
            cp.sum(w) == 1, w >= 0]).solve(solver="CLARABEL")
mc = pd.DataFrame({
    "propia": pd.Series(w.value, A),
    "pypfopt": pd.Series(EfficientCVaR(mu * 252, X,
                                       beta=0.95).min_cvar()),
    "riskfolio": port.optimization(model="Classic", rm="CVaR",
                                   obj="MinRisk", hist=True)["weights"],
    "skfolio": pd.Series(MeanRisk(risk_measure=RiskMeasure.CVAR,
                                  cvar_beta=0.95).fit(X).weights_, A)})
print(mc.round(4))
cvar = a.value + u.value.sum() / (0.05 * T)
print(f"CVaR diario al 95 %: {100 * cvar:.3f} %")
```
```output
          propia  pypfopt  riskfolio  skfolio
RV_EEUU   0.0000   0.0000     0.0000   0.0000
RV_EUR_C  0.0000   0.0000     0.0000   0.0000
RV_JAP_C  0.0425   0.0425     0.0425   0.0425
RV_EMG    0.0000   0.0000     0.0000   0.0000
RF_T710   0.6590   0.6590     0.6590   0.6590
RF_CIG    0.0000   0.0000     0.0000   0.0000
RF_CHY    0.1963   0.1963     0.1963   0.1963
MP_ORO    0.1022   0.1022     0.1022   0.1022
CVaR diario al 95 %: 0.705 %
```

Las cuatro coinciden: 65,9 % de Tesoro, 19,6 % de alto rendimiento, 10,2 % de oro y 4,3 % de Japón, con un CVaR
diario del 0,705 %. Los nombres de los parámetros, en cambio, no: `beta=0.95` en PyPortfolioOpt, `alpha=0.05` (por
defecto) en el `Portfolio` de Riskfolio-Lib y `cvar_beta=0.95` en skfolio, tres formas de decir lo mismo. Un detalle
de PyPortfolioOpt: si `EfficientCVaR` recibe `None` como rendimientos esperados, los pesos salen indexados por número
y no por activo; con una serie con nombres, salen con nombres.

## Lo que solo hace cada una

La amplitud de cada librería, contada con su propio código:

```python
import re
from skfolio import ExtraRiskMeasure

doc = rp.Portfolio.optimization.__doc__
medidas_rf = re.findall(r"- '(\w+)'",
                        doc[doc.find("rm : str"):doc.find("obj : str")])
print("Riskfolio-Lib, rm del optimizador clásico:", len(medidas_rf))
print("skfolio, RiskMeasure + ExtraRiskMeasure:",
      len(RiskMeasure), "+", len(ExtraRiskMeasure))
print("PyPortfolioOpt:", [c for c in dir(pypfopt)
                          if c.startswith("Efficient")])
```
```output
Riskfolio-Lib, rm del optimizador clásico: 26
skfolio, RiskMeasure + ExtraRiskMeasure: 19 + 7
PyPortfolioOpt: ['EfficientCDaR', 'EfficientCVaR', 'EfficientFrontier', 'EfficientSemivariance']
```

Riskfolio-Lib documenta 26 medidas de riesgo para su optimizador clásico (varianza, semivarianza, CVaR, EVaR,
drawdowns, Gini…); skfolio, 19 en `RiskMeasure` más 7 en `ExtraRiskMeasure` (para los métodos jerárquicos); y
PyPortfolioOpt, cuatro fronteras: varianza, semivarianza, CVaR y CDaR. Pero la diferencia más útil de skfolio no es el
número de medidas, sino que cada optimizador es un estimador de scikit-learn y se puede **validar fuera de muestra**
con las mismas herramientas que un modelo de aprendizaje automático. Con 20 años de datos, ventanas de dos años para
estimar y un mes para aplicar:

```python
from skfolio.model_selection import WalkForward, cross_val_predict
from skfolio.optimization import EqualWeighted, InverseVolatility

Xt = precios[A].pct_change().sub(fac["rf"], axis=0).dropna()
wf = WalkForward(train_size=504, test_size=21)
filas = {}
for nombre, modelo in [("equiponderada", EqualWeighted()),
                       ("inversa_vol", InverseVolatility()),
                       ("min_varianza", MeanRisk()),
                       ("hrp", HierarchicalRiskParity())]:
    p = cross_val_predict(modelo, Xt, cv=wf)
    filas[nombre] = {"sharpe": p.annualized_sharpe_ratio,
                     "media_%": 100 * p.annualized_mean,
                     "caida_max_%": 100 * p.max_drawdown}
pd.DataFrame(filas).T.round(2)
```
```output
               sharpe  media_%  caida_max_%
equiponderada    0.38     4.44        46.25
inversa_vol      0.43     3.19        24.82
min_varianza     0.27     1.33        23.68
hrp              0.39     2.00        22.29
```

`cross_val_predict` reestima cada modelo con los dos años anteriores a cada mes y devuelve una
`MultiPeriodPortfolio` con los rendimientos fuera de muestra encadenados. De 2008 a 2025, la equiponderada da un
Sharpe de 0,38 con una caída máxima del 46,3 %; la inversa a la volatilidad, 0,43 (24,8 %); HRP, 0,39 (22,3 %); y la
mínima varianza, 0,27 (23,7 %). Las carteras que miran el riesgo reducen la caída a la mitad; ninguna mejora mucho
el Sharpe de la equiponderada, que es lo habitual (lección 4.1.4). Las métricas de skfolio se anualizan con 252 y la
caída máxima sale en positivo: otra convención que conviene mirar antes de comparar con otra herramienta.

Las otras dos tienen sus especialidades. PyPortfolioOpt trae `DiscreteAllocation`, que convierte pesos en acciones
enteras con un capital dado, y un Black-Litterman muy directo (lección 4.1.3). Riskfolio-Lib trae carteras
jerárquicas además de HRP (HERC y NCO, lección 4.1.4), restricciones sobre factores y sobre la red de correlaciones e
informes en Excel. skfolio trae, además de la validación, priors (factores, Black-Litterman, entropía), estimadores
de covarianza y la posibilidad de encadenar todo en un `Pipeline` de scikit-learn.

## Mantenimiento y licencias

Una librería de carteras termina dentro de un proceso que se ejecuta cada día con dinero real. Antes de depender de
ella conviene mirar quién la mantiene, con qué ritmo y con qué licencia, en las fuentes primarias: la API de PyPI
(`https://pypi.org/pypi/<paquete>/json`) y el repositorio. Los datos, consultados el 22 de octubre de 2026:

| Paquete | Última versión (PyPI) | Primera versión | Licencia | Repositorio |
|---|---|---|---|---|
| `skfolio` | 1.9.13, 22-10-2026 | 0.0.1, 14-12-2023 | BSD-3-Clause | `skfolio/skfolio`, último push el 22-10-2026 |
| `riskfolio-lib` | 7.4.0, 04-10-2026 | 0.0.1, 03-03-2020 | BSD de tres cláusulas | `dcajasn/Riskfolio-Lib`, último push el 22-10-2026 |
| `pyportfolioopt` | 1.6.0, 26-02-2026 | 0.1.0rc1, 10-06-2018 | MIT | `PyPortfolio/PyPortfolioOpt`, último commit en la rama principal el 07-07-2026 |
| `bt` | 1.4.1, 10-10-2026 | 0.1.1, 26-06-2014 | MIT | `pmorissette/bt`, último push el 20-10-2026 |
| `cvxpy` | 1.9.3, 19-09-2026 | 0.1, 05-05-2014 | Apache-2.0 | `cvxpy/cvxpy`, último push el 21-10-2026 |
| `vectorbt` | 1.2.0, 19-10-2026 | 0.15.0, 06-12-2020 | Apache 2.0 con Commons Clause | `polakowo/vectorbt`, último push el 19-10-2026 |

*Fig. — Trimestres con al menos una versión publicada en PyPI de cvxpy, bt, PyPortfolioOpt, Riskfolio-Lib y skfolio, y su última versión (consulta del 22 de octubre de 2026).* Cinco filas con una marca por cada trimestre en que la librería publicó alguna versión en PyPI, de 2014 a 2026. cvxpy publica desde 2014 de forma continua (la última, 1.9.3, en septiembre de 2026). bt, desde 2014, con huecos largos y muchas versiones en 2026 (1.4.1, octubre de 2026). PyPortfolioOpt, desde 2018, con tres versiones desde 2023: mayo de 2023, diciembre de 2024 y la 1.6.0 de febrero de 2026 (destacada). Riskfolio-Lib, desde 2020, con versiones frecuentes (7.4.0, octubre de 2026). skfolio, desde diciembre de 2023, casi cada trimestre y a veces varias al día (1.9.13, octubre de 2026).

La figura dibuja los trimestres con alguna versión en PyPI. skfolio publica a un ritmo altísimo (117 versiones desde
diciembre de 2023, cinco de ellas entre el 21 y el 22 de octubre de 2026): mucha actividad, y también una API que
cambia (el aviso de la sección de HRP). Riskfolio-Lib publica cada pocos meses. PyPortfolioOpt ha publicado tres
versiones desde 2023; su repositorio pasó de `robertmartin8/PyPortfolioOpt` a la organización `PyPortfolio`, tiene
abierta la incidencia de HRP desde agosto sin versión que la corrija y su documentación publicada aún se titula 1.5.4.
bt, con huecos de años, ha publicado seis versiones entre septiembre y octubre de 2026. Ninguna de estas señales es
una sentencia; juntas dicen cuánto margen hay que dejar (versiones fijadas, pruebas propias como las de esta lección)
antes de actualizar.

Sobre licencias, las tres de optimización son permisivas (MIT o BSD), igual que cvxpy (Apache 2.0). La sorpresa está
en lo que llega sin pedirlo: Riskfolio-Lib 7.4.0 exige vectorbt, y vectorbt se distribuye con la licencia Apache 2.0
más la condición **Commons Clause**, que no concede el derecho a «vender» el software, definido como ofrecer a
terceros, a cambio de un pago, un producto o servicio cuyo valor venga total o sustancialmente de su funcionalidad
(fichero `LICENSE.md` de su repositorio). Para uso personal o para investigar no cambia nada; para quien construya un
servicio comercial sobre una imagen que incluya el entorno, es una cláusula que hay que leer con un jurista. Las
dependencias forman parte de lo que se distribuye aunque el código no las importe.

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

Las cifras de mantenimiento cambian cada semana. Para un proyecto, lo que vale es el procedimiento: consultar PyPI y
el repositorio en el momento de decidir, fijar versiones en el `uv.lock` (lección 0.2.2), y tener pruebas que
comparen la librería con una referencia propia, como las celdas de esta lección, para detectar un cambio de
comportamiento al actualizar.

</aside>

## bt: de los pesos al backtest

Las librerías de optimización dan pesos; para ver cómo se comportan con rebalanceos, costes y posiciones enteras hace
falta un backtest. bt (lección 2.4.6) acepta una tabla de pesos objetivo por fecha con `WeighTarget`. No está en el
entorno del curso (su código va sin ejecutar, como en 2.4.6 y 4.1.7); el de abajo usa los pesos fuera de muestra de
skfolio y está comprobado contra el código fuente de bt 1.4.1:

```python skip title="hrp_skfolio_bt.py"
import bt
from skfolio.model_selection import WalkForward, cross_val_predict
from skfolio.optimization import HierarchicalRiskParity

pred = cross_val_predict(HierarchicalRiskParity(), Xt,
                         cv=WalkForward(train_size=504, test_size=21))
# una fila por periodo: los pesos y su primera sesión fuera de muestra
pesos = pd.DataFrame([p.weights for p in pred.portfolios],
                     index=[pd.Timestamp(p.observations[0])
                            for p in pred.portfolios],
                     columns=Xt.columns)
hrp_bt = bt.Strategy("hrp_skfolio", [bt.algos.WeighTarget(pesos),
                                     bt.algos.Rebalance()])
coste = bt.core.SqrtCostModel(Y=0.6)     # impacto en raíz cuadrada
res = bt.run(bt.Backtest(hrp_bt, precios[A], commissions=coste,
                         volume=volumen, volatility=volatilidad,
                         integer_positions=False))
res.display()
```

`WeighTarget` fija los pesos solo en las fechas de la tabla (aquí, la primera sesión de cada mes fuera de muestra) y
`Rebalance` opera hasta ellos. Además de una función de comisión `commissions=f(cantidad, precio)`, bt 1.4.1 admite
modelos de coste no lineales: `SqrtCostModel` (la ley de la raíz cuadrada, $\tfrac23 Y\sigma|q|\sqrt{|q|/V}\,p$ según
su código) y `AlmgrenChrissCostModel`, que exigen pasar a `Backtest` el volumen y la volatilidad de cada barra
(`volume=`, `volatility=`, aquí `volumen` y `volatilidad`, dos tablas con la forma de los precios). Esos modelos de
impacto son los de la lección 4.3.1; el parámetro `Y` hay que estimarlo, no copiarlo del valor por defecto.

## Cuál usar

| Necesidad | Opción razonable | Por qué |
|---|---|---|
| Media-varianza sencilla, Black-Litterman, pesos a acciones enteras | PyPortfolioOpt (o código propio) | API mínima; vigilar su HRP y su mantenimiento |
| Muchas medidas de riesgo, restricciones de factores, HERC, NCO | Riskfolio-Lib | La más amplia; revisar su árbol de dependencias |
| Validar asignaciones fuera de muestra, pipelines | skfolio | API de scikit-learn; cambia rápido, fijar versión |
| Rebalanceos, costes y comparación de estrategias de asignación | bt o un simulador propio (4.1.7) | Separa la decisión de pesos de su ejecución |
| Un problema propio con restricciones raras | cvxpy directamente | Las tres librerías son, por dentro, cvxpy |

## Ejercicios

1. Repite la mínima varianza con la covarianza de Ledoit-Wolf en las tres librerías (`CovarianceShrinkage` en
   PyPortfolioOpt, `method_cov="ledoit"` en Riskfolio-Lib, el prior empírico con `LedoitWolf` de `skfolio.moments`
   en skfolio). ¿Siguen coincidiendo?

**Solución.**

```python
from pypfopt.risk_models import CovarianceShrinkage
from skfolio.moments import LedoitWolf
from skfolio.prior import EmpiricalPrior

S_lw = CovarianceShrinkage(X, returns_data=True,
                           frequency=252).ledoit_wolf()
p_lw = rp.Portfolio(returns=X)
p_lw.assets_stats(method_mu="hist", method_cov="ledoit")
lw = pd.DataFrame({
    "pypfopt": pd.Series(EfficientFrontier(None, S_lw)
                         .min_volatility()),
    "riskfolio": p_lw.optimization(model="Classic", rm="MV",
                                   obj="MinRisk", hist=True)["weights"],
    "skfolio": pd.Series(MeanRisk(prior_estimator=EmpiricalPrior(
        covariance_estimator=LedoitWolf())).fit(X).weights_, A)})
lw["muestral"] = propia
lw.round(4)
```
```output
          pypfopt  riskfolio  skfolio  muestral
RV_EEUU    0.0000     0.0000   0.0000    0.0000
RV_EUR_C   0.0000     0.0000   0.0000    0.0000
RV_JAP_C   0.0313     0.0313   0.0312    0.0305
RV_EMG     0.0000     0.0000   0.0000    0.0000
RF_T710    0.6693     0.6693   0.6693    0.6810
RF_CIG     0.0000     0.0000   0.0001    0.0000
RF_CHY     0.2317     0.2317   0.2316    0.2260
MP_ORO     0.0678     0.0678   0.0678    0.0625
```

Las tres coinciden entre sí al cuarto decimal (Tesoro 66,9 %, alto rendimiento 23,2 %, oro 6,8 %, Japón 3,1 %): las
tres aplican la contracción de Ledoit y Wolf hacia una matriz con la varianza media en la diagonal. Respecto a la
covarianza muestral, los pesos se mueven poco (1,2 puntos menos de Tesoro): con ocho activos y 2.514 sesiones hay
datos de sobra para estimar 36 covarianzas, y la intensidad de la contracción es pequeña. El shrinkage importa cuando
hay muchos activos y pocas observaciones (lección 4.1.2).

2. Calcula la cartera de máximo Sharpe con skfolio para 2016-2020 y para 2021-2025 por separado. ¿Cuánto cambian los
   pesos? ¿Qué dice eso de usar medias muestrales?

**Solución.**

```python
mitades = {}
for a, b in [("2016", "2020"), ("2021", "2025")]:
    m = MeanRisk(objective_function=ObjectiveFunction.MAXIMIZE_RATIO)
    mitades[f"{a}-{b}"] = pd.Series(m.fit(X.loc[a:b]).weights_, A)
mitades = pd.DataFrame(mitades)
print(mitades.round(3))
print("suma de |cambios|:",
      round(mitades.diff(axis=1).iloc[:, 1].abs().sum(), 3))
```
```output
          2016-2020  2021-2025
RV_EEUU       0.242      0.047
RV_EUR_C      0.000      0.000
RV_JAP_C      0.000      0.128
RV_EMG        0.071      0.000
RF_T710       0.351      0.000
RF_CIG        0.000      0.000
RF_CHY        0.000      0.394
MP_ORO        0.336      0.431
suma de |cambios|: 1.234
```

Con 2016-2020, la cartera de máximo Sharpe tiene un 35,1 % de Tesoro, un 33,6 % de oro, un 24,2 % de bolsa de EE. UU.
y un 7,1 % de emergentes; con 2021-2025, nada de Tesoro ni de emergentes, un 43,1 % de oro, un 39,4 % de alto
rendimiento, un 12,8 % de Japón y un 4,7 % de EE. UU. La suma de los cambios absolutos es 1,234: habría que rotar el
62 % de la cartera. No es un fallo de skfolio (las tres librerías dan lo mismo, como se vio), sino la maximización del
error de estimación de la media-varianza (lección 4.1.1): con cinco años, el error típico de una media anual es la
volatilidad dividida por √5, mayor que las diferencias entre activos, y el optimizador persigue ese ruido. Por eso se
contraen las medias, se usa Black-Litterman (4.1.3) o se prefieren métodos que no usan las medias (mínima varianza,
risk parity, HRP).

3. Un compañero quiere publicar un servicio de pago que envíe a sus clientes las carteras de Riskfolio-Lib calculadas
   cada semana. ¿Qué tendría que revisar respecto a las licencias del entorno?

**Solución.**

La licencia de Riskfolio-Lib (BSD de tres cláusulas) permite el uso comercial con la nota de copyright, y lo mismo
cvxpy (Apache 2.0) y la mayoría de las dependencias. Pero el entorno incluye vectorbt, que llega como dependencia
obligatoria de Riskfolio-Lib 7.4.0 y se distribuye con Apache 2.0 más la Commons Clause, que no concede el derecho a
vender un producto o servicio cuyo valor venga sustancialmente de la funcionalidad de vectorbt. Si el servicio no usa
vectorbt, su valor no viene de él, pero la imagen que se despliega lo contiene; lo prudente es inventariar las
licencias de todo el entorno (por ejemplo con `importlib.metadata`, como en la primera celda, completándolo con el
fichero de licencia de cada repositorio cuando el metadato está vacío), consultar a un jurista y, si conviene, fijar
una versión de Riskfolio-Lib anterior a la 7.1.0 (la primera que exige vectorbt, según PyPI) o excluir la dependencia
y comprobar con pruebas que todo sigue funcionando.

## Autoevaluación

1. PyPortfolioOpt y Riskfolio-Lib dan pesos de máximo Sharpe distintos (31,0 % frente a 31,9 % en la bolsa de EE. UU.) con los mismos datos. ¿Cuál es la causa más probable?

   - A. Que `mean_historical_return` de PyPortfolioOpt devuelve por defecto la media geométrica (compounding=True) y Riskfolio-Lib usa la aritmética
   - B. Un error de uno de los dos solucionadores
   - C. Que uno trabaja con precios y otro con rendimientos
   - D. Que el tipo libre de PyPortfolioOpt es del 2 %

   *Respuesta A: Que `mean_historical_return` de PyPortfolioOpt devuelve por defecto la media geométrica (compounding=True) y Riskfolio-Lib usa la aritmética.* Con `compounding=False` PyPortfolioOpt da exactamente los pesos de Riskfolio-Lib y de la implementación propia. La geométrica es menor que la aritmética en aproximadamente σ²/2, más en los activos volátiles (14,03 % frente a 16,26 % en emergentes), y eso cambia la solución.

2. El HRP por defecto de Riskfolio-Lib pone un 31,8 % en el bono a 7-10 años y el de PyPortfolioOpt un 40,0 %. ¿Por qué?

   - A. Porque usan covarianzas distintas
   - B. Porque Riskfolio-Lib reordena las hojas del árbol (`leaf_order=True`, ordenación óptima) antes de la bisección; con `leaf_order=False` coinciden
   - C. Porque PyPortfolioOpt usa el enlace de Ward
   - D. Porque HRP no es determinista

   *Respuesta B: Porque Riskfolio-Lib reordena las hojas del árbol (`leaf_order=True`, ordenación óptima) antes de la bisección; con `leaf_order=False` coinciden.* La bisección recursiva de HRP parte la lista ordenada de activos por la mitad. Cambiar el orden de las hojas (sin cambiar el árbol) cambia qué activos quedan juntos en cada mitad y, con ello, los pesos. Las dos variantes son defendibles; lo que no se puede es comparar resultados sin saber cuál se usó.

3. La mínima varianza con cvxpy y la covarianza diaria se aleja 0,065 puntos de la de las librerías; con la covarianza anual (×252) coincide. ¿Qué enseña?

   - A. Que cvxpy no sirve para carteras
   - B. Que hay que usar siempre rendimientos anuales
   - C. Que las librerías redondean
   - D. Que los solucionadores trabajan con tolerancias absolutas: con números del orden de 1e-4 se paran antes de tiempo, y conviene escalar el problema

   *Respuesta D: Que los solucionadores trabajan con tolerancias absolutas: con números del orden de 1e-4 se paran antes de tiempo, y conviene escalar el problema.* El problema es el mismo multiplicado por una constante, pero el solucionador decide que ha terminado cuando la mejora es menor que una tolerancia fija. Con una función objetivo de tamaño 1e-5 esa tolerancia es grande. PyPortfolioOpt anualiza la covarianza por convención y lo evita.

4. ¿Qué ocurre al ejecutar `HRPOpt(X).optimize()` con PyPortfolioOpt 1.6.0 y SciPy 1.18?

   - A. Da los pesos de HRP
   - B. Da los pesos de mínima varianza
   - C. Lanza un AttributeError: usa `scipy.cluster.hierarchy._LINKAGE_METHODS`, un atributo privado que ya no existe; hay una incidencia abierta (#754) y propuestas de corrección sin publicar
   - D. Pide instalar vectorbt

   *Respuesta C: Lanza un AttributeError: usa `scipy.cluster.hierarchy._LINKAGE_METHODS`, un atributo privado que ya no existe; hay una incidencia abierta (#754) y propuestas de corrección sin publicar.* Es un ejemplo de por qué el mantenimiento importa: una librería que depende de un detalle privado de otra se rompe cuando esta cambia. Mientras no salga una versión corregida, las opciones son fijar SciPy < 1.18, usar otra librería o el código propio de la lección.

5. Al instalar Riskfolio-Lib 7.4.0 llega vectorbt 1.2.0. ¿Por qué importa en un proyecto comercial?

   - A. Porque vectorbt se distribuye con Apache 2.0 más la condición Commons Clause, que no concede el derecho a vender un producto o servicio cuyo valor venga sustancialmente de su funcionalidad; hay que revisar la licencia de todo lo que se instala, no solo de lo que se importa
   - B. Porque vectorbt es lento
   - C. Porque vectorbt sustituye a cvxpy
   - D. No importa: Riskfolio-Lib no lo importa nunca

   *Respuesta A: Porque vectorbt se distribuye con Apache 2.0 más la condición Commons Clause, que no concede el derecho a vender un producto o servicio cuyo valor venga sustancialmente de su funcionalidad; hay que revisar la licencia de todo lo que se instala, no solo de lo que se importa.* Riskfolio-Lib declara `vectorbt>=0.28.0` como dependencia desde la 7.1.0 aunque su código instalado no lo importa (comprobado en la lección). Una dependencia instalada forma parte de lo que se distribuye en una imagen o un entorno, y su licencia hay que conocerla.

## Ideas clave

- Con el problema bien definido, las tres librerías y el código propio coinciden: mínima varianza a 0,003 puntos, risk parity a 0,003 y mínimo CVaR a menos de 0,0001.
- Las diferencias vienen de convenciones por defecto: PyPortfolioOpt usa la media geométrica y la covarianza anual; Riskfolio-Lib y skfolio, rendimientos diarios sin anualizar; en HRP, Riskfolio-Lib reordena las hojas (8,2 puntos de diferencia) y skfolio usa el enlace de Ward.
- Escalar el problema importa: la misma mínima varianza con la covarianza diaria se separa 0,065 puntos por la tolerancia del solucionador.
- skfolio sigue la API de scikit-learn y permite validar asignaciones fuera de muestra con WalkForward; Riskfolio-Lib ofrece 26 medidas de riesgo en su optimizador clásico; PyPortfolioOpt es la más sencilla, pero su HRP no funciona con SciPy 1.18 y su documentación publicada es de la 1.5.4.
- Mantenimiento y licencias se comprueban en PyPI y GitHub: skfolio (BSD-3) publica casi a diario, Riskfolio-Lib (BSD-3) cada pocos meses, PyPortfolioOpt (MIT) tres versiones desde 2023; Riskfolio-Lib arrastra vectorbt, con Commons Clause.

## Recursos

### Documentación

- [Riskfolio-Lib: documentación de Portfolio y HCPortfolio](https://riskfolio-lib.readthedocs.io/en/latest/riskfoliolib/portfolio.html) · riskfolio-lib.readthedocs.io
- [PyPortfolioOpt: optimizadores de media-varianza (documentación publicada)](https://pyportfolioopt.readthedocs.io/en/latest/MeanVariance.html) · pyportfolioopt.readthedocs.io
- [PyPortfolioOpt, incidencia #754: HRPOpt falla con SciPy reciente](https://github.com/PyPortfolio/PyPortfolioOpt/issues/754) · github.com
- [skfolio: referencia de la API](https://skfolio.org/api.html) · skfolio.org
- [bt v1.4.1: modelos de costes (CostModel, SqrtCostModel, AlmgrenChrissCostModel)](https://github.com/pmorissette/bt/blob/v1.4.1/bt/core.py) · github.com
- [vectorbt: licencia (Apache 2.0 con Commons Clause)](https://github.com/polakowo/vectorbt/blob/master/LICENSE.md) · github.com

### Libros

- [López de Prado (2016): Building Diversified Portfolios that Outperform Out of Sample, Journal of Portfolio Management 42(4)](https://doi.org/10.3905/jpm.2016.42.4.059) · doi.org

### Datos

- [Datos del nivel 4: precios del universo multiactivo (Parquet)](https://pythonparatrading.com/curso/datos/nivel-4/universo4_precios.parquet) · Descarga · universo4_precios.parquet

Lección anterior: [4.1.7 · Rebalanceo y carteras de estrategias](https://pythonparatrading.com/curso-python-trading/nivel-4/rebalanceo-y-carteras-de-estrategias/)

Lección siguiente: [4.2.1 · Riesgo por operación, stops y riesgo de ruina](https://pythonparatrading.com/curso-python-trading/nivel-4/riesgo-por-operacion-stops-y-riesgo-de-ruina/)
