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.
- Módulo
- 4.1 · Teoría y optimización de carteras
- Duración
- 60 min
Lecciones del nivel 4
4.1 · Teoría y optimización de carteras
- 4.1.1 Markowitz: frontera eficiente y restricciones reales
- 4.1.2 Estimación de covarianzas: shrinkage, modelos factoriales y denoising
- 4.1.3 Black-Litterman: del equilibrio del mercado a las visiones
- 4.1.4 Risk parity, mínima varianza, HRP y HERC
- 4.1.5 Optimización con CVaR, drawdown, rotación y costes
- 4.1.6 Modelos factoriales: CAPM, Fama-French, PCA y atribución
- 4.1.7 Rebalanceo y carteras de estrategias
- 4.1.8 Herramientas de carteras: Riskfolio-Lib, PyPortfolioOpt, skfolio y bt
4.2 · Gestión del riesgo y tamaño de posición
- 4.2.1 Riesgo por operación, stops y riesgo de ruina
- 4.2.2 Criterio de Kelly y volatility targeting: cuánto arriesgar
- 4.2.3 VaR, Expected Shortfall y backtesting del VaR
- 4.2.4 Stress testing y riesgos de correlación, liquidez, gap y contraparte
- 4.2.5 Control del drawdown y kill switch
- 4.2.6 Riesgo de derivados: griegas de cartera, margen y liquidaciones
4.3 · Microestructura y ejecución
4.4 · Brokers y APIs
- 4.4.1 Elegir bróker desde España o la UE: regulación, activos, costes y API
- 4.4.2 Interactive Brokers: TWS e IB Gateway, ib_async, contratos, datos y órdenes
- 4.4.3 Alpaca, Tradier, MetaTrader 5 con Python y OANDA v20
- 4.4.4 APIs de exchanges cripto: ccxt, Binance, Bybit, Kraken y Coinbase
- 4.4.5 Credenciales seguras: variables de entorno, gestores de secretos y permisos mínimos
4.5 · Sistema de trading en vivo
- 4.5.1 Arquitectura de un sistema en vivo: de los datos a la reconciliación
- 4.5.2 Gestión de órdenes: estados, fills parciales, cancelaciones y reconexiones
- 4.5.3 Paper trading, shadow trading y seguimiento del slippage
- 4.5.4 Programación de tareas y alertas: cron, APScheduler, Prefect, Telegram, Slack y email
- 4.5.5 Tolerancia a fallos: idempotencia, estado persistente y reconciliación al arrancar
- 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
fitypredict, 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:
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:
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))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: FalseLas 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):
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)(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.402.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 (, lección 0.7.3) y cvxpy con la restricción, resuelto dos veces, con la covarianza diaria y con la anual:
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) 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.06245La 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:
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()) 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 con e , y normalizar . Con la media muestral:
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) 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.5164La 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 , 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:
import inspect
print(inspect.signature(EfficientFrontier.max_sharpe))
print(inspect.signature(EfficientFrontier.portfolio_performance))(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
y normalizar. PyPortfolioOpt no la incluye (su única cartera «por
riesgo» es HRP); Riskfolio-Lib la tiene como rp_optimization y skfolio como RiskBudgeting:
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) 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.4995Las 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:
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 ; 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:
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)['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: float64El 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:
from pypfopt import HRPOpt
HRPOpt(X).optimize()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.
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()) 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. Conleaf_order=Falsecoincide 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 enHierarchicalRiskParity, 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:
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} %") 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:
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")])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:
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) 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.29cross_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 |
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:
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, 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
- Repite la mínima varianza con la covarianza de Ledoit-Wolf en las tres librerías (
CovarianceShrinkageen PyPortfolioOpt,method_cov="ledoit"en Riskfolio-Lib, el prior empírico conLedoitWolfdeskfolio.momentsen skfolio). ¿Siguen coincidiendo?
Solución
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) 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.0625Las 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).
- 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
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)) 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.234Con 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).
- 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
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_returnde 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_returnde PyPortfolioOpt devuelve por defecto la media geométrica (compounding=True) y Riskfolio-Lib usa la aritméticaCon
compounding=FalsePyPortfolioOpt 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.- AQue
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; conleaf_order=Falsecoinciden - 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; conleaf_order=FalsecoincidenLa 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ó.
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.
¿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 publicarEs 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.
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.0como 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
- PyPortfolioOpt: optimizadores de media-varianza (documentación publicada)
- PyPortfolioOpt, incidencia #754: HRPOpt falla con SciPy reciente
- skfolio: referencia de la API
- bt v1.4.1: modelos de costes (CostModel, SqrtCostModel, AlmgrenChrissCostModel)
- vectorbt: licencia (Apache 2.0 con Commons Clause)