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.

Fig. — Largest weight difference between each library, with its default options, and the lesson's own implementation, by problem (eight assets, 2016-2025)
Level 4 lessons
  1. 4.1 · Portfolio theory and optimisation

    1. 4.1.1 Markowitz: efficient frontier and real constraints
    2. 4.1.2 Covariance estimation: shrinkage, factor models and denoising
    3. 4.1.3 Black-Litterman: from market equilibrium to views
    4. 4.1.4 Risk parity, minimum variance, HRP and HERC
    5. 4.1.5 Optimization with CVaR, drawdown, turnover and costs
    6. 4.1.6 Factor models: CAPM, Fama-French, PCA and attribution
    7. 4.1.7 Rebalancing and strategy portfolios
    8. 4.1.8 Portfolio tools: Riskfolio-Lib, PyPortfolioOpt, skfolio and bt
  2. 4.2 · Risk management and position sizing

    1. 4.2.1 Risk per trade, stops and risk of ruin
    2. 4.2.2 Kelly criterion and volatility targeting: how much to risk
    3. 4.2.3 VaR, Expected Shortfall and VaR backtesting
    4. 4.2.4 Stress testing and correlation, liquidity, gap and counterparty risk
    5. 4.2.5 Drawdown control and kill switch
    6. 4.2.6 Derivatives risk: portfolio Greeks, margin and liquidations
  3. 4.3 · Microstructure and execution

    1. 4.3.1 Implementation shortfall, VWAP, TWAP and Almgren-Chriss
    2. 4.3.2 Order flow imbalance, microstructure signals and latency
  4. 4.4 · Brokers and APIs

    1. 4.4.1 Choosing a broker from Spain or the EU: regulation, assets, costs and API
    2. 4.4.2 Interactive Brokers: TWS and IB Gateway, ib_async, contracts, data and orders
    3. 4.4.3 Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20
    4. 4.4.4 Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase
    5. 4.4.5 Secure credentials: environment variables, secret managers and least privilege
  5. 4.5 · Live trading system

    1. 4.5.1 Live system architecture: from data to reconciliation
    2. 4.5.2 Order management: states, partial fills, cancellations and reconnections
    3. 4.5.3 Paper trading, shadow trading and slippage monitoring
    4. 4.5.4 Task scheduling and alerts: cron, APScheduler, Prefect, Telegram, Slack and email
    5. 4.5.5 Fault tolerance: idempotency, persistent state and reconciliation at startup
    6. 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 fit and predict, 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:

ReturnsEstimate μ and ΣOptimizerWeightsBacktest with btOut-of-sample validation
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:

IN [01]python
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))
OUT [01]The environment's libraries and their declared licenses
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: False

The 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):

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()          # 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)
OUT [02]Eight assets, ten years of excess returns
(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 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.0Riskfolio-Lib 7.4.0skfolio 1.9.13
InputPrices by default (returns_data=False); returns with returns_data=TrueReturns (Portfolio(returns=...))Simple returns (fit(X))
UnitsAnnualizes: frequency=252 in means and covariancesThose of the data (daily), not annualizedThose of the data; the metrics of the resulting Portfolio are annualized with 252
Default meanGeometric (compounding=True)Arithmetic (method_mu="hist")Arithmetic (EmpiricalPrior)
Risk-free ratemax_sharpe(risk_free_rate=0.0)rf=0, in the unit of the datarisk_free_rate=0.0
OutputOrderedDict of weightsDataFrame with the weights columnweights_ (array) and predict(X), which returns a Portfolio
Default weightsBetween 0 and 1Long only (sht=False), up to 1Between 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 (w=Σ−11/1′Σ−11w = \Sigma^{-1}\mathbf{1} / \mathbf{1}'\Sigma^{-1}\mathbf{1}, lesson 0.7.3) and cvxpy with the constraint, solved twice, with the daily covariance and with the annual one:

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)       # the same problem, better scaled
pd.DataFrame({"cerrada": cerrada, "largos_diaria": propia_diaria,
              "largos_anual": propia}).round(5)
OUT [03]Custom reference: closed form and 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

The 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:

IN [04]python
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())
OUT [04]The same problem in the three libraries
          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 y′Σyy'\Sigma y with μ′y=1\mu'y = 1 and y≥0y \ge 0, and normalize w=y/1′yw = y / \mathbf{1}'y. With the sample mean:

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]Maximum Sharpe: the arithmetic and the geometric mean
     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

The 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 σ2/2\sigma^2/2, 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:

IN [06]python
import inspect

print(inspect.signature(EfficientFrontier.max_sharpe))
print(inspect.signature(EfficientFrontier.portfolio_performance))
OUT [06]The default value of the installed version
(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 12y′Σy−1n∑iln⁡yi\tfrac12 y'\Sigma y - \tfrac1n\sum_i \ln y_i 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:

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]Equal risk contributions
          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

The 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:

CorrelationsDistanceLinkage treeLeaf orderBisectionWeights
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 dij=(1−ρij)/2d_{ij} = \sqrt{(1-\rho_{ij})/2}; 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:

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)])      # 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)
OUT [08]Custom HRP, as in 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

The 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:

IN [09]python
from pypfopt import HRPOpt

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

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

# 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())
OUT [10]HRP in the three libraries: defaults and aligned settings
          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. With leaf_order=False it 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_ward column, 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 in HierarchicalRiskParity, 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:

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"daily CVaR at 95%: {100 * cvar:.3f} %")
OUT [11]Minimum CVaR at 95%: a linear program
          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:

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 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")])
OUT [12]Risk measures available in each optimizer
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:

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]Walk-forward validation in the scikit-learn style
               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 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:

PackageLatest version (PyPI)First versionLicenseRepository
skfolio1.9.13, 2026-10-220.0.1, 2023-12-14BSD-3-Clauseskfolio/skfolio, last push on 2026-10-22
riskfolio-lib7.4.0, 2026-10-040.0.1, 2020-03-03Three-clause BSDdcajasn/Riskfolio-Lib, last push on 2026-10-22
pyportfolioopt1.6.0, 2026-02-260.1.0rc1, 2018-06-10MITPyPortfolio/PyPortfolioOpt, last commit on the main branch on 2026-07-07
bt1.4.1, 2026-10-100.1.1, 2014-06-26MITpmorissette/bt, last push on 2026-10-20
cvxpy1.9.3, 2026-09-190.1, 2014-05-05Apache-2.0cvxpy/cvxpy, last push on 2026-10-21
vectorbt1.2.0, 2026-10-190.15.0, 2020-12-06Apache 2.0 with Commons Clausepolakowo/vectorbt, last push on 2026-10-19
Fig. — Quarters with at least one release on PyPI of cvxpy, bt, PyPortfolioOpt, Riskfolio-Lib and skfolio, and their latest version (checked on 22 October 2026)

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:

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))
# 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, 23Yσ∣q∣∣q∣/V p\tfrac23 Y\sigma|q|\sqrt{|q|/V}\,p 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

NeedReasonable optionWhy
Simple mean-variance, Black-Litterman, weights to whole sharesPyPortfolioOpt (or custom code)Minimal API; watch its HRP and its maintenance
Many risk measures, factor constraints, HERC, NCORiskfolio-LibThe broadest; review its dependency tree
Validating allocations out of sample, pipelinesskfolioscikit-learn API; changes fast, pin the version
Rebalancing, costs and comparison of allocation strategiesbt or a custom simulator (4.1.7)Separates the weight decision from its execution
A custom problem with unusual constraintscvxpy directlyThe three libraries are, inside, cvxpy

Exercises

  1. Repeat minimum variance with the Ledoit-Wolf covariance in the three libraries (CovarianceShrinkage in PyPortfolioOpt, method_cov="ledoit" in Riskfolio-Lib, the empirical prior with LedoitWolf from skfolio.moments in skfolio). Do they still agree?
Solution
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]Minimum variance with 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

The 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).

  1. 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
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("sum of |changes|:",
      round(mitades.diff(axis=1).iloc[:, 1].abs().sum(), 3))
OUT [16]Maximum Sharpe in two halves of the sample
          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.234

With 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).

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

  1. 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_return returns 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_return returns the geometric mean by default (compounding=True) and Riskfolio-Lib uses the arithmetic one

    With compounding=False PyPortfolioOpt 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.

  2. 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; with leaf_order=False they 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; with leaf_order=False they agree

    HRP'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.

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

  4. 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 proposals

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

  5. 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.0 as 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

Data