Lesson 4.1.8 · 60 min
Portfolio tools: Riskfolio-Lib, PyPortfolioOpt, skfolio and bt
Three portfolio optimization libraries and one backtesting library solve the module's problems with one line each. This lesson gives them the same five problems and compares them with custom implementations: they agree when the problem is the same, and stop agreeing because of conventions that are not visible (geometric mean, covariance scale, leaf order of a tree). It ends with what does not show up in any result: who maintains each library, under what license and what it drags in when installed.
- Module
- 4.1 · Portfolio theory and optimisation
- Duration
- 60 min
- Prerequisites
- 2.4.6 · LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation, 0.7.3 · Intuitive linear algebra: vectors and matrices as groundwork for portfolios, 4.1.7 · Rebalancing and strategy portfolios
- Author
- Jesús Cuesta
- Updated
Level 4 lessons
4.1 · Portfolio theory and optimisation
- 4.1.1 Markowitz: efficient frontier and real constraints
- 4.1.2 Covariance estimation: shrinkage, factor models and denoising
- 4.1.3 Black-Litterman: from market equilibrium to views
- 4.1.4 Risk parity, minimum variance, HRP and HERC
- 4.1.5 Optimization with CVaR, drawdown, turnover and costs
- 4.1.6 Factor models: CAPM, Fama-French, PCA and attribution
- 4.1.7 Rebalancing and strategy portfolios
- 4.1.8 Portfolio tools: Riskfolio-Lib, PyPortfolioOpt, skfolio and bt
4.2 · Risk management and position sizing
- 4.2.1 Risk per trade, stops and risk of ruin
- 4.2.2 Kelly criterion and volatility targeting: how much to risk
- 4.2.3 VaR, Expected Shortfall and VaR backtesting
- 4.2.4 Stress testing and correlation, liquidity, gap and counterparty risk
- 4.2.5 Drawdown control and kill switch
- 4.2.6 Derivatives risk: portfolio Greeks, margin and liquidations
4.3 · Microstructure and execution
4.4 · Brokers and APIs
- 4.4.1 Choosing a broker from Spain or the EU: regulation, assets, costs and API
- 4.4.2 Interactive Brokers: TWS and IB Gateway, ib_async, contracts, data and orders
- 4.4.3 Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20
- 4.4.4 Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase
- 4.4.5 Secure credentials: environment variables, secret managers and least privilege
4.5 · Live trading system
- 4.5.1 Live system architecture: from data to reconciliation
- 4.5.2 Order management: states, partial fills, cancellations and reconnections
- 4.5.3 Paper trading, shadow trading and slippage monitoring
- 4.5.4 Task scheduling and alerts: cron, APScheduler, Prefect, Telegram, Slack and email
- 4.5.5 Fault tolerance: idempotency, persistent state and reconciliation at startup
- 4.5.6 NautilusTrader or LEAN live: from backtest to production with the same code
Four tools and a reference
The lessons of the module build each method from scratch: Markowitz (4.1.1), covariance estimators (4.1.2), Black-Litterman (4.1.3), risk parity and HRP (4.1.4), CVaR and costs (4.1.5). In practice almost nobody rewrites it all: a library is used. The three most used optimization libraries in Python are:
- PyPortfolioOpt (
pypfopt), the oldest and simplest: mean-variance, Black-Litterman, HRP, CVaR and CDaR, and a utility for turning weights into whole shares. MIT license. - Riskfolio-Lib (
riskfolio), the broadest in risk measures, built on cvxpy: 26 measures in the classic optimizer, hierarchical portfolios (HRP, HERC, NCO), factors, Excel reports. Three-clause BSD license. - skfolio, the most recent, built on the scikit-learn API: each optimizer is an estimator with
fitandpredict, and it can be validated with the model selection tools. Three-clause BSD license.
bt does not optimize: it is a portfolio backtesting library (lesson 2.4.6), useful for taking the weights of any of the above into a simulation with rebalancing and costs. The way of working is similar in all of them, and what changes is where each step lives:
Mermaid source
flowchart TB R[Returns] --> P[Estimate μ and Σ] P --> O[Optimizer] O --> W[Weights] W --> B[Backtest with bt] W --> V[Out-of-sample validation]
The method of the lesson: the same five problems in the three libraries and in custom code (closed-form formula or cvxpy, as in the previous lessons), with the same data. If the weights agree, the library does what it is believed to do; if not, there is a convention worth knowing before using it in live trading. First, which versions are installed and under what license they are declared:
import sys
import warnings
from importlib import metadata
# Riskfolio-Lib uses cvxpy's `*` to multiply matrices and cvxpy
# warns (UserWarning) on every problem; the warning does not change the
# result and only that message is silenced
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 ["(empty)"])[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 imported:",
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 (empty)
['vectorbt>=0.28.0']
vectorbt imported: FalseThe versions are those of the course environment (curso-code/uv.lock): Riskfolio-Lib 7.4.0, PyPortfolioOpt 1.6.0,
skfolio 1.9.13 and cvxpy 1.9.3, which is the engine of all three. The last two lines of the output are surprising:
Riskfolio-Lib declares vectorbt>=0.28.0 as a dependency (vectorbt 1.2.0 is installed because of that, not by a
decision of the course), but after importing Riskfolio-Lib, vectorbt does not appear among the loaded modules, and a
search of Riskfolio-Lib's installed code finds no reference to it. Its license metadata is empty; we come back to it
in the licenses section.
The data and the conventions
Eight assets from the level's universe (US equities, Europe and Japan hedged to USD, emerging markets, 7-10 year Treasuries, investment-grade credit, high yield and gold), ten years of simple daily returns in excess of the risk-free rate. The data are synthetic (level 4 README):
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() # daily, not annualized
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 sessions from 2016 to 2025. In this decade equities and gold returned a lot (15.0% a year in excess for US equities, 16.3% emerging markets, 11.8% gold) and Treasury bonds lost (−2.4% a year in excess): a sample with very disparate means, exactly the kind of sample that makes maximum Sharpe weights unstable (4.1.1).
Before calling anything, the input conventions of each library, read in its documentation and checked against the signatures of the installed versions:
| PyPortfolioOpt 1.6.0 | Riskfolio-Lib 7.4.0 | skfolio 1.9.13 | |
|---|---|---|---|
| Input | Prices by default (returns_data=False); returns with returns_data=True | Returns (Portfolio(returns=...)) | Simple returns (fit(X)) |
| Units | Annualizes: frequency=252 in means and covariances | Those of the data (daily), not annualized | Those of the data; the metrics of the resulting Portfolio are annualized with 252 |
| Default mean | Geometric (compounding=True) | Arithmetic (method_mu="hist") | Arithmetic (EmpiricalPrior) |
| Risk-free rate | max_sharpe(risk_free_rate=0.0) | rf=0, in the unit of the data | risk_free_rate=0.0 |
| Output | OrderedDict of weights | DataFrame with the weights column | weights_ (array) and predict(X), which returns a Portfolio |
| Default weights | Between 0 and 1 | Long only (sht=False), up to 1 | Between 0 and 1 (min_weights, max_weights) |
Minimum variance: the same answer, if scaled
The long-only minimum variance portfolio is a small quadratic problem. As a reference, the closed-form formula without a sign constraint (, lesson 0.7.3) and cvxpy with the constraint, solved twice, with the daily covariance and with the annual one:
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) # the same problem, better scaled
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.06245The closed-form formula puts 130.7% in the Treasury bond and −90.7% in credit: two highly correlated assets, one against the other. Long only, four assets remain: 68.1% Treasuries, 22.6% high yield, 6.2% gold and 3.0% Japan. The instructive part is the middle column. With the daily covariance (variances of the order of 1e-4 and an objective function of the order of 1e-5), CLARABEL stops with weights of 0.00065 where they should be zero: its tolerances are absolute and, at that scale, large. With the annual covariance, the same problem multiplied by 252, the solution comes out clean. Scaling the data before calling a solver is a cheap habit that avoids differences of this kind. Now the three libraries:
from pypfopt import EfficientFrontier
from skfolio.optimization import MeanRisk
ef = EfficientFrontier(None, S * 252) # annual by convention
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("maximum difference with the custom one:",
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
maximum difference with the custom one: {'propia': 0.0, 'pypfopt': 1e-06, 'riskfolio': 3.3e-05, 'skfolio': 2.8e-05}The three agree with the reference to within 0.0033 percentage points of weight (PyPortfolioOpt, which annualizes, to within 0.0001). Riskfolio-Lib and skfolio receive the daily data, and the residual difference is of the order of their solvers' tolerance: it has no practical importance. The header figure summarizes these differences for the five problems of the lesson.
Maximum Sharpe: which mean
Long-only maximum Sharpe is solved as a quadratic problem with the Charnes-Cooper change of variable: minimize with and , and normalize . With the sample mean:
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.5164The maximum Sharpe portfolio of this sample has only three assets: 31.9% US equities, 16.5% high yield and 51.6% gold, the three with the best ratio of return to volatility over the decade. Riskfolio-Lib, skfolio and PyPortfolioOpt with the arithmetic mean give the same weights. PyPortfolioOpt with its default mean, the geometric one (the compound annual rate), gives 31.0%, 16.9% and 52.1%: the geometric mean is lower than the arithmetic one by approximately , a little for bonds (−2.54% versus −2.40%) and a lot for volatile assets (14.03% versus 16.26% in emerging markets). Neither is “the correct one”: the arithmetic mean is the expected one-period return, the one that corresponds to one-period mean-variance; what matters is knowing which one each library uses.
One more trap of the same family, and from the same package. PyPortfolioOpt's published documentation (Read the Docs,
titled “PyPortfolioOpt 1.5.4 documentation” as of October 22, 2026) shows max_sharpe(risk_free_rate=0.02); the
installed version has another default value:
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)In 1.6.0, risk_free_rate=0.0. With excess returns (those of the lesson) that is correct; with total returns and the
documentation in hand, someone would expect a 2% that is no longer applied. The general rule: the signature of the
installed version (inspect.signature, or help) prevails over the documentation and over what one remembers.
Risk parity: equal contributions
Equal risk contribution parity (ERC, lesson 4.1.4) has a convex formulation: minimize
and normalize. PyPortfolioOpt does not include it (its only “risk-based”
portfolio is HRP); Riskfolio-Lib has it as rp_optimization and skfolio as 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.4995The three agree and each asset contributes 12.50% of the variance (with differences in the fourth decimal because of the tolerance). Treasuries receive 33.1% of the capital and emerging markets 4.3%: the split is by risk, not by capital.
HRP: three libraries, three answers
HRP (López de Prado, 2016) does not solve an optimization problem; it applies a procedure, and each step has variants:
Mermaid source
flowchart TB C[Correlations] --> D[Distance] D --> T[Linkage tree] T --> O[Leaf order] O --> B[Bisection] B --> W[Weights]
The distance is ; the tree is built with a linkage method (the paper uses single linkage); the order of the leaves of the tree gives a list of assets in which similar ones stay together (“quasi-diagonalization”); the bisection splits the list in half again and again and divides the weight between the two halves in inverse proportion to their variance (that of an inverse-variance portfolio within each half). The custom implementation, in a few lines:
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)]) # quasi-diagonal
w = pd.Series(1.0, index=orden)
grupos = [orden]
while grupos: # bisection
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: float64The leaf order puts gold together with fixed income and leaves the four equity markets at the end. The first bisection pits gold, Treasuries, credit and high yield against the four equity markets, and the fixed income half, much less volatile, takes almost all the weight: 40.0% Treasuries, 34.8% credit and between 1.2% and 2.2% in each equity market.
PyPortfolioOpt has HRP in HRPOpt. With the installed version of SciPy, it does not work:
from pypfopt import HRPOpt
HRPOpt(X).optimize()AttributeError: module 'scipy.cluster.hierarchy' has no attribute '_LINKAGE_METHODS'HRPOpt.optimize checks the linkage method against scipy.cluster.hierarchy._LINKAGE_METHODS, a private attribute
(the leading underscore says so) that SciPy 1.18 no longer exposes in that module. The failure is documented in
issue #754 of the PyPortfolioOpt repository, opened on August 2, 2026, with several fix proposals also open; the latest
released version is still 1.6.0 from February 2026 (checked on October 22, 2026). For the comparison, the next cell
restores the attribute for a moment and deletes it afterward: it is a patch for this comparison, not something that
should go to production, where the prudent course is to pin the SciPy version or use another implementation.
import scipy.cluster.hierarchy as sch
from skfolio.cluster import HierarchicalClustering, LinkageMethod
from skfolio.optimization import HierarchicalRiskParity
from skfolio.seriation import HierarchicalSeriation
# temporary patch ONLY for the comparison (open issue #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}Three different results with the same data and the same “HRP” method:
- PyPortfolioOpt (with the patch) exactly reproduces the custom implementation: single linkage and the leaf order that comes out of the tree, as in the paper.
- Riskfolio-Lib, by default, puts 31.8% in Treasuries instead of 40.0%, and 15.9% in high yield instead of 11.5%:
a difference of up to 8.2 points. The cause is
leaf_order=True: before the bisection it reorders the leaves with SciPy's optimal ordering (optimal_ordering), which leaves the tree unchanged but changes which assets fall in each half. Withleaf_order=Falseit agrees with the custom one. - skfolio, by default, uses Ward linkage with optimal ordering: 0.13 points of difference, because here the
Ward tree resembles the single one (the
propia_wardcolumn, the custom implementation with Ward and optimal ordering, gives the same as skfolio). With single linkage and optimal ordering it gives the same as Riskfolio-Lib by default; without optimal ordering, the same as the custom one. The old way of choosing the linkage,hierarchical_clustering_estimator=directly inHierarchicalRiskParity, warns in 1.9.13 that it will be removed in version 2.0; the cell uses the new one,seriation_estimator=HierarchicalSeriation(...).
No variant is an error. What this teaches is that “HRP” is not a number but a family of procedures, and that an HRP result without the linkage, the distance and the leaf order cannot be reproduced.
Minimum CVaR: a linear program
Historical 95% CVaR (the average loss on the worst 5% of days, lesson 4.1.5) is minimized with the Rockafellar and Uryasev formulation, a linear program with one auxiliary variable per day:
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"daily CVaR at 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
daily CVaR at 95%: 0.705 %The four agree: 65.9% Treasuries, 19.6% high yield, 10.2% gold and 4.3% Japan, with a daily CVaR of 0.705%. The
parameter names, on the other hand, do not: beta=0.95 in PyPortfolioOpt, alpha=0.05 (the default) in
Riskfolio-Lib's Portfolio and cvar_beta=0.95 in skfolio, three ways of saying the same thing. A detail of
PyPortfolioOpt: if EfficientCVaR receives None as expected returns, the weights come out indexed by number and not
by asset; with a named series, they come out with names.
What only each one does
The breadth of each library, counted with its own code:
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 of the classic optimizer:", 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 of the classic optimizer: 26
skfolio, RiskMeasure + ExtraRiskMeasure: 19 + 7
PyPortfolioOpt: ['EfficientCDaR', 'EfficientCVaR', 'EfficientFrontier', 'EfficientSemivariance']Riskfolio-Lib documents 26 risk measures for its classic optimizer (variance, semivariance, CVaR, EVaR, drawdowns,
Gini…); skfolio, 19 in RiskMeasure plus 7 in ExtraRiskMeasure (for the hierarchical methods); and PyPortfolioOpt,
four frontiers: variance, semivariance, CVaR and CDaR. But skfolio's most useful difference is not the number of
measures, but that each optimizer is a scikit-learn estimator and can be validated out of sample with the same
tools as a machine learning model. With 20 years of data, two-year windows to estimate and one month to apply:
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 re-estimates each model with the two years before each month and returns a
MultiPeriodPortfolio with the chained out-of-sample returns. From 2008 to 2025, equal weight gives a Sharpe of 0.38
with a maximum drawdown of 46.3%; inverse volatility, 0.43 (24.8%); HRP, 0.39 (22.3%); and minimum variance, 0.27
(23.7%). The portfolios that look at risk cut the drawdown in half; none improves much on the Sharpe of equal weight,
which is the usual outcome (lesson 4.1.4). skfolio's metrics are annualized with 252 and the maximum drawdown comes
out positive: another convention worth checking before comparing with another tool.
The other two have their specialties. PyPortfolioOpt comes with DiscreteAllocation, which turns weights into whole
shares for a given capital, and a very direct Black-Litterman (lesson 4.1.3). Riskfolio-Lib comes with hierarchical
portfolios besides HRP (HERC and NCO, lesson 4.1.4), constraints on factors and on the correlation network, and Excel
reports. skfolio comes, besides validation, with priors (factors, Black-Litterman, entropy), covariance estimators and
the possibility of chaining everything into a scikit-learn Pipeline.
Maintenance and licenses
A portfolio library ends up inside a process that runs every day with real money. Before depending on it, it pays to
look at who maintains it, at what pace and under what license, in the primary sources: the PyPI API
(https://pypi.org/pypi/<package>/json) and the repository. The data, checked on October 22, 2026:
| Package | Latest version (PyPI) | First version | License | Repository |
|---|---|---|---|---|
skfolio | 1.9.13, 2026-10-22 | 0.0.1, 2023-12-14 | BSD-3-Clause | skfolio/skfolio, last push on 2026-10-22 |
riskfolio-lib | 7.4.0, 2026-10-04 | 0.0.1, 2020-03-03 | Three-clause BSD | dcajasn/Riskfolio-Lib, last push on 2026-10-22 |
pyportfolioopt | 1.6.0, 2026-02-26 | 0.1.0rc1, 2018-06-10 | MIT | PyPortfolio/PyPortfolioOpt, last commit on the main branch on 2026-07-07 |
bt | 1.4.1, 2026-10-10 | 0.1.1, 2014-06-26 | MIT | pmorissette/bt, last push on 2026-10-20 |
cvxpy | 1.9.3, 2026-09-19 | 0.1, 2014-05-05 | Apache-2.0 | cvxpy/cvxpy, last push on 2026-10-21 |
vectorbt | 1.2.0, 2026-10-19 | 0.15.0, 2020-12-06 | Apache 2.0 with Commons Clause | polakowo/vectorbt, last push on 2026-10-19 |
The figure draws the quarters with at least one release on PyPI. skfolio releases at a very high pace (117 versions
since December 2023, five of them between October 21 and 22, 2026): a lot of activity, and also an API that changes
(the warning in the HRP section). Riskfolio-Lib releases every few months. PyPortfolioOpt has released three versions
since 2023; its repository moved from robertmartin8/PyPortfolioOpt to the PyPortfolio organization, it has had the
HRP issue open since August with no version that fixes it, and its published documentation is still titled 1.5.4. bt,
with gaps of years, has released six versions between September and October 2026. None of these signals is a verdict;
together they say how much margin to leave (pinned versions, custom tests like those in this lesson) before upgrading.
As for licenses, the three optimization libraries are permissive (MIT or BSD), like cvxpy (Apache 2.0). The surprise
is in what arrives without being asked for: Riskfolio-Lib 7.4.0 requires vectorbt, and vectorbt is distributed under
the Apache 2.0 license plus the Commons Clause condition, which does not grant the right to “sell” the software,
defined as offering third parties, for a fee, a product or service whose value derives entirely or substantially from
its functionality (LICENSE.md file in its repository). For personal use or research it changes nothing; for someone
building a commercial service on an image that includes the environment, it is a clause to read with a lawyer.
Dependencies are part of what is distributed even if the code does not import them.
bt: from weights to backtest
The optimization libraries give weights; to see how they behave with rebalancing, costs and whole positions a
backtest is needed. bt (lesson 2.4.6) accepts a table of target weights by date with WeighTarget. It is not in the
course environment (its code is not run, as in 2.4.6 and 4.1.7); the code below uses skfolio's out-of-sample weights
and has been checked against the source code of 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))
# one row per period: the weights and their first out-of-sample session
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) # square-root impact
res = bt.run(bt.Backtest(hrp_bt, precios[A], commissions=coste,
volume=volumen, volatility=volatilidad,
integer_positions=False))
res.display()WeighTarget sets the weights only on the dates in the table (here, the first out-of-sample session of each month)
and Rebalance trades up to them. Besides a commission function commissions=f(quantity, price), bt 1.4.1 supports
nonlinear cost models: SqrtCostModel (the square-root law, according to its
code) and AlmgrenChrissCostModel, which require passing Backtest the volume and volatility of each bar (volume=,
volatility=, here volumen and volatilidad, two tables with the shape of the prices). Those impact models are the
ones in lesson 4.3.1; the Y parameter has to be estimated, not copied from the default value.
Which one to use
| Need | Reasonable option | Why |
|---|---|---|
| Simple mean-variance, Black-Litterman, weights to whole shares | PyPortfolioOpt (or custom code) | Minimal API; watch its HRP and its maintenance |
| Many risk measures, factor constraints, HERC, NCO | Riskfolio-Lib | The broadest; review its dependency tree |
| Validating allocations out of sample, pipelines | skfolio | scikit-learn API; changes fast, pin the version |
| Rebalancing, costs and comparison of allocation strategies | bt or a custom simulator (4.1.7) | Separates the weight decision from its execution |
| A custom problem with unusual constraints | cvxpy directly | The three libraries are, inside, cvxpy |
Exercises
- Repeat minimum variance with the Ledoit-Wolf covariance in the three libraries (
CovarianceShrinkagein PyPortfolioOpt,method_cov="ledoit"in Riskfolio-Lib, the empirical prior withLedoitWolffromskfolio.momentsin skfolio). Do they still agree?
Solution
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.0625The three agree with each other to the fourth decimal (Treasuries 66.9%, high yield 23.2%, gold 6.8%, Japan 3.1%): all three apply Ledoit and Wolf's shrinkage toward a matrix with the average variance on the diagonal. Compared with the sample covariance, the weights move little (1.2 points less of Treasuries): with eight assets and 2,514 sessions there is plenty of data to estimate 36 covariances, and the shrinkage intensity is small. Shrinkage matters when there are many assets and few observations (lesson 4.1.2).
- Compute the maximum Sharpe portfolio with skfolio for 2016-2020 and for 2021-2025 separately. How much do the weights change? What does that say about using sample means?
Solution
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("sum of |changes|:",
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
sum of |changes|: 1.234With 2016-2020, the maximum Sharpe portfolio has 35.1% Treasuries, 33.6% gold, 24.2% US equities and 7.1% emerging markets; with 2021-2025, no Treasuries or emerging markets at all, 43.1% gold, 39.4% high yield, 12.8% Japan and 4.7% US. The sum of the absolute changes is 1.234: 62% of the portfolio would have to be turned over. It is not a skfolio failure (the three libraries give the same, as seen), but the estimation error maximization of mean-variance (lesson 4.1.1): with five years, the standard error of an annual mean is the volatility divided by √5, larger than the differences between assets, and the optimizer chases that noise. That is why means are shrunk, Black-Litterman (4.1.3) is used or methods that do not use the means are preferred (minimum variance, risk parity, HRP).
- A colleague wants to launch a paid service that sends clients the Riskfolio-Lib portfolios computed every week. What would they have to review regarding the licenses of the environment?
Solution
Riskfolio-Lib's license (three-clause BSD) allows commercial use with the copyright notice, and so do cvxpy (Apache
2.0) and most of the dependencies. But the environment includes vectorbt, which arrives as a mandatory dependency of
Riskfolio-Lib 7.4.0 and is distributed under Apache 2.0 plus the Commons Clause, which does not grant the right to sell
a product or service whose value derives substantially from vectorbt's functionality. If the service does not use
vectorbt, its value does not come from it, but the image being deployed contains it; the prudent course is to
inventory the licenses of the whole environment (for example with importlib.metadata, as in the first cell,
completing it with each repository's license file when the metadata is empty), consult a lawyer and, if appropriate,
pin a version of Riskfolio-Lib earlier than 7.1.0 (the first that requires vectorbt, according to PyPI) or exclude the
dependency and check with tests that everything still works.
Each answer is folded under its question
Self-assessment
PyPortfolioOpt and Riskfolio-Lib give different maximum Sharpe weights (31.0% versus 31.9% in US equities) with the same data. What is the most likely cause?
- AThat PyPortfolioOpt's
mean_historical_returnreturns the geometric mean by default (compounding=True) and Riskfolio-Lib uses the arithmetic one - BAn error in one of the two solvers
- CThat one works with prices and the other with returns
- DThat PyPortfolioOpt's risk-free rate is 2%
Show answer
Answer A. That PyPortfolioOpt's
mean_historical_returnreturns the geometric mean by default (compounding=True) and Riskfolio-Lib uses the arithmetic oneWith
compounding=FalsePyPortfolioOpt gives exactly the weights of Riskfolio-Lib and of the custom implementation. The geometric mean is lower than the arithmetic one by approximately σ²/2, more so for volatile assets (14.03% versus 16.26% in emerging markets), and that changes the solution.- AThat PyPortfolioOpt's
Riskfolio-Lib's default HRP puts 31.8% in the 7-10 year bond and PyPortfolioOpt's 40.0%. Why?
- ABecause they use different covariances
- BBecause Riskfolio-Lib reorders the leaves of the tree (
leaf_order=True, optimal ordering) before the bisection; withleaf_order=Falsethey agree - CBecause PyPortfolioOpt uses Ward linkage
- DBecause HRP is not deterministic
Show answer
Answer B. Because Riskfolio-Lib reorders the leaves of the tree (
leaf_order=True, optimal ordering) before the bisection; withleaf_order=Falsethey agreeHRP's recursive bisection splits the ordered list of assets in half. Changing the order of the leaves (without changing the tree) changes which assets end up together in each half and, with that, the weights. Both variants are defensible; what cannot be done is compare results without knowing which one was used.
Minimum variance with cvxpy and the daily covariance deviates 0.065 points from that of the libraries; with the annual covariance (×252) it agrees. What does it teach?
- AThat cvxpy is no good for portfolios
- BThat annual returns should always be used
- CThat the libraries round
- DThat solvers work with absolute tolerances: with numbers of the order of 1e-4 they stop too early, and it pays to scale the problem
Show answer
Answer D. That solvers work with absolute tolerances: with numbers of the order of 1e-4 they stop too early, and it pays to scale the problem
The problem is the same multiplied by a constant, but the solver decides it has finished when the improvement is smaller than a fixed tolerance. With an objective function of size 1e-5 that tolerance is large. PyPortfolioOpt annualizes the covariance by convention and avoids it.
What happens when running
HRPOpt(X).optimize()with PyPortfolioOpt 1.6.0 and SciPy 1.18?- AIt gives the HRP weights
- BIt gives the minimum variance weights
- CIt raises an AttributeError: it uses
scipy.cluster.hierarchy._LINKAGE_METHODS, a private attribute that no longer exists; there is an open issue (#754) and unreleased fix proposals - DIt asks to install vectorbt
Show answer
Answer C. It raises an AttributeError: it uses
scipy.cluster.hierarchy._LINKAGE_METHODS, a private attribute that no longer exists; there is an open issue (#754) and unreleased fix proposalsIt is an example of why maintenance matters: a library that depends on a private detail of another breaks when the latter changes. Until a fixed version is released, the options are pinning SciPy < 1.18, using another library or the lesson's custom code.
Installing Riskfolio-Lib 7.4.0 brings in vectorbt 1.2.0. Why does it matter in a commercial project?
- ABecause vectorbt is distributed under Apache 2.0 plus the Commons Clause condition, which does not grant the right to sell a product or service whose value derives substantially from its functionality; the license of everything that gets installed has to be reviewed, not only of what gets imported
- BBecause vectorbt is slow
- CBecause vectorbt replaces cvxpy
- DIt does not matter: Riskfolio-Lib never imports it
Show answer
Answer A. Because vectorbt is distributed under Apache 2.0 plus the Commons Clause condition, which does not grant the right to sell a product or service whose value derives substantially from its functionality; the license of everything that gets installed has to be reviewed, not only of what gets imported
Riskfolio-Lib declares
vectorbt>=0.28.0as a dependency since 7.1.0 even though its installed code does not import it (checked in the lesson). An installed dependency is part of what is distributed in an image or an environment, and its license has to be known.
Key points
- With the problem well defined, the three libraries and the custom code agree: minimum variance within 0.003 points, risk parity within 0.003 and minimum CVaR within less than 0.0001.
- The differences come from default conventions: PyPortfolioOpt uses the geometric mean and the annual covariance; Riskfolio-Lib and skfolio, unannualized daily returns; in HRP, Riskfolio-Lib reorders the leaves (8.2 points of difference) and skfolio uses Ward linkage.
- Scaling the problem matters: the same minimum variance with the daily covariance drifts 0.065 points apart because of the solver tolerance.
- skfolio follows the scikit-learn API and allows validating allocations out of sample with WalkForward; Riskfolio-Lib offers 26 risk measures in its classic optimizer; PyPortfolioOpt is the simplest, but its HRP does not work with SciPy 1.18 and its published documentation is for 1.5.4.
- Maintenance and licenses are checked on PyPI and GitHub: skfolio (BSD-3) releases almost daily, Riskfolio-Lib (BSD-3) every few months, PyPortfolioOpt (MIT) three versions since 2023; Riskfolio-Lib drags in vectorbt, with the Commons Clause.
Resources
Documentation
- Riskfolio-Lib: documentation of Portfolio and HCPortfolio
- PyPortfolioOpt: mean-variance optimizers (published documentation)
- PyPortfolioOpt, issue #754: HRPOpt fails with recent SciPy
- skfolio: API reference
- bt v1.4.1: cost models (CostModel, SqrtCostModel, AlmgrenChrissCostModel)
- vectorbt: license (Apache 2.0 with Commons Clause)