Lección 4.1.8 · 60 min

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.

Fig. — Diferencia máxima de peso entre cada librería, con sus opciones por defecto, y la implementación propia de la lección, por problema (ocho activos, 2016-2025)
Lecciones del nivel 4
  1. 4.1 · Teoría y optimización de carteras

    1. 4.1.1 Markowitz: frontera eficiente y restricciones reales
    2. 4.1.2 Estimación de covarianzas: shrinkage, modelos factoriales y denoising
    3. 4.1.3 Black-Litterman: del equilibrio del mercado a las visiones
    4. 4.1.4 Risk parity, mínima varianza, HRP y HERC
    5. 4.1.5 Optimización con CVaR, drawdown, rotación y costes
    6. 4.1.6 Modelos factoriales: CAPM, Fama-French, PCA y atribución
    7. 4.1.7 Rebalanceo y carteras de estrategias
    8. 4.1.8 Herramientas de carteras: Riskfolio-Lib, PyPortfolioOpt, skfolio y bt
  2. 4.2 · Gestión del riesgo y tamaño de posición

    1. 4.2.1 Riesgo por operación, stops y riesgo de ruina
    2. 4.2.2 Criterio de Kelly y volatility targeting: cuánto arriesgar
    3. 4.2.3 VaR, Expected Shortfall y backtesting del VaR
    4. 4.2.4 Stress testing y riesgos de correlación, liquidez, gap y contraparte
    5. 4.2.5 Control del drawdown y kill switch
    6. 4.2.6 Riesgo de derivados: griegas de cartera, margen y liquidaciones
  3. 4.3 · Microestructura y ejecución

    1. 4.3.1 Implementation shortfall, VWAP, TWAP y Almgren-Chriss
    2. 4.3.2 Order flow imbalance, señales de microestructura y latencia
  4. 4.4 · Brokers y APIs

    1. 4.4.1 Elegir bróker desde España o la UE: regulación, activos, costes y API
    2. 4.4.2 Interactive Brokers: TWS e IB Gateway, ib_async, contratos, datos y órdenes
    3. 4.4.3 Alpaca, Tradier, MetaTrader 5 con Python y OANDA v20
    4. 4.4.4 APIs de exchanges cripto: ccxt, Binance, Bybit, Kraken y Coinbase
    5. 4.4.5 Credenciales seguras: variables de entorno, gestores de secretos y permisos mínimos
  5. 4.5 · Sistema de trading en vivo

    1. 4.5.1 Arquitectura de un sistema en vivo: de los datos a la reconciliación
    2. 4.5.2 Gestión de órdenes: estados, fills parciales, cancelaciones y reconexiones
    3. 4.5.3 Paper trading, shadow trading y seguimiento del slippage
    4. 4.5.4 Programación de tareas y alertas: cron, APScheduler, Prefect, Telegram, Slack y email
    5. 4.5.5 Tolerancia a fallos: idempotencia, estado persistente y reconciliación al arrancar
    6. 4.5.6 NautilusTrader o LEAN en real: del backtest a producción con el mismo código

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:

RendimientosEstimar μ y ΣOptimizadorPesosBacktest con btValidación fuera
Fuente 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:

IN [01]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))
OUT [01]Las librerías del entorno y sus licencias declaradas
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):

IN [02]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)
OUT [02]Ocho activos, diez años de rendimientos en exceso
(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.0Riskfolio-Lib 7.4.0skfolio 1.9.13
EntradaPrecios por defecto (returns_data=False); rendimientos con returns_data=TrueRendimientos (Portfolio(returns=...))Rendimientos simples (fit(X))
UnidadesAnualiza: frequency=252 en medias y covarianzasLas de los datos (diarias), sin anualizarLas de los datos; las métricas del Portfolio resultante se anualizan con 252
Media por defectoGeométrica (compounding=True)Aritmética (method_mu="hist")Aritmética (EmpiricalPrior)
Tipo libremax_sharpe(risk_free_rate=0.0)rf=0, en la unidad de los datosrisk_free_rate=0.0
SalidaOrderedDict de pesosDataFrame con la columna weightsweights_ (array) y predict(X), que devuelve un Portfolio
Pesos por defectoEntre 0 y 1Solo largos (sht=False), hasta 1Entre 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=Σ−11/1′Σ−11w = \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:

IN [03]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)
OUT [03]Referencia propia: fórmula cerrada y cvxpy
          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:

IN [04]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())
OUT [04]El mismo problema en las tres librerías
          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′Σyy'\Sigma y con μ′y=1\mu'y = 1 e y≥0y \ge 0, y normalizar w=y/1′yw = y / \mathbf{1}'y. Con la media muestral:

IN [05]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)
OUT [05]Máximo Sharpe: la media aritmética y la geométrica
     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 σ2/2\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:

IN [06]python
import inspect

print(inspect.signature(EfficientFrontier.max_sharpe))
print(inspect.signature(EfficientFrontier.portfolio_performance))
OUT [06]El valor por defecto de la versión instalada
(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 12y′Σy−1n∑iln⁡yi\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:

IN [07]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)
OUT [07]Contribuciones iguales al riesgo
          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:

CorrelacionesDistanciaÁrbol de enlaceOrden de hojasBisecciónPesos
Fuente 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 dij=(1−ρij)/2d_{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:

IN [08]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)
OUT [08]HRP propio, como en López de Prado
['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:

IN [09]python
from pypfopt import HRPOpt

HRPOpt(X).optimize()
OUT [09]PyPortfolioOpt 1.6.0 con SciPy 1.18
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.

IN [10]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())
OUT [10]HRP en las tres librerías: valores por defecto y alineados
          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:

IN [11]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} %")
OUT [11]Mínimo CVaR al 95 %: un programa lineal
          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:

IN [12]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")])
OUT [12]Medidas de riesgo disponibles en cada optimizador
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:

IN [13]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)
OUT [13]Validación walk-forward al estilo de scikit-learn
               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ónLicenciaRepositorio
skfolio1.9.13, 22-10-20260.0.1, 14-12-2023BSD-3-Clauseskfolio/skfolio, último push el 22-10-2026
riskfolio-lib7.4.0, 04-10-20260.0.1, 03-03-2020BSD de tres cláusulasdcajasn/Riskfolio-Lib, último push el 22-10-2026
pyportfolioopt1.6.0, 26-02-20260.1.0rc1, 10-06-2018MITPyPortfolio/PyPortfolioOpt, último commit en la rama principal el 07-07-2026
bt1.4.1, 10-10-20260.1.1, 26-06-2014MITpmorissette/bt, último push el 20-10-2026
cvxpy1.9.3, 19-09-20260.1, 05-05-2014Apache-2.0cvxpy/cvxpy, último push el 21-10-2026
vectorbt1.2.0, 19-10-20260.15.0, 06-12-2020Apache 2.0 con Commons Clausepolakowo/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)

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.

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:

IN [14]hrp_skfolio_bt.pypython
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, 23Yσ∣q∣∣q∣/V p\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

NecesidadOpción razonablePor qué
Media-varianza sencilla, Black-Litterman, pesos a acciones enterasPyPortfolioOpt (o código propio)API mínima; vigilar su HRP y su mantenimiento
Muchas medidas de riesgo, restricciones de factores, HERC, NCORiskfolio-LibLa más amplia; revisar su árbol de dependencias
Validar asignaciones fuera de muestra, pipelinesskfolioAPI de scikit-learn; cambia rápido, fijar versión
Rebalanceos, costes y comparación de estrategias de asignaciónbt o un simulador propio (4.1.7)Separa la decisión de pesos de su ejecución
Un problema propio con restricciones rarascvxpy directamenteLas 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
IN [15]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)
OUT [15]Mínima varianza con Ledoit-Wolf
          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).

  1. 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
IN [16]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))
OUT [16]Máximo Sharpe en dos mitades de la muestra
          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).

  1. 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.

Respuesta plegada en cada pregunta

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?

    • AQue mean_historical_return de PyPortfolioOpt devuelve por defecto la media geométrica (compounding=True) y Riskfolio-Lib usa la aritmética
    • BUn error de uno de los dos solucionadores
    • CQue uno trabaja con precios y otro con rendimientos
    • DQue el tipo libre de PyPortfolioOpt es del 2 %
    Ver respuesta

    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é?

    • APorque usan covarianzas distintas
    • BPorque Riskfolio-Lib reordena las hojas del árbol (leaf_order=True, ordenación óptima) antes de la bisección; con leaf_order=False coinciden
    • CPorque PyPortfolioOpt usa el enlace de Ward
    • DPorque HRP no es determinista
    Ver respuesta

    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?

    • AQue cvxpy no sirve para carteras
    • BQue hay que usar siempre rendimientos anuales
    • CQue las librerías redondean
    • DQue los solucionadores trabajan con tolerancias absolutas: con números del orden de 1e-4 se paran antes de tiempo, y conviene escalar el problema
    Ver respuesta

    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?

    • ADa los pesos de HRP
    • BDa los pesos de mínima varianza
    • CLanza 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
    • DPide instalar vectorbt
    Ver respuesta

    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?

    • APorque 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
    • BPorque vectorbt es lento
    • CPorque vectorbt sustituye a cvxpy
    • DNo importa: Riskfolio-Lib no lo importa nunca
    Ver respuesta

    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

Datos