---
title: "4.1.8 Riskfolio-Lib, PyPortfolioOpt, skfolio and bt"
description: "The same portfolio problems with Riskfolio-Lib, PyPortfolioOpt, skfolio and custom code: conventions, defaults, maintenance and licenses."
url: "https://pythonparatrading.com/en/python-trading-course/level-4/riskfolio-pyportfolioopt-skfolio-and-bt/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-4/riskfolio-pyportfolioopt-skfolio-and-bt.png"
---

# 4.1.8 · 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.

- Course: [Level 4: Portfolios, risk, execution and live trading](https://pythonparatrading.com/en/python-trading-course/level-4/)
- Module: 4.1 · Portfolio theory and optimisation
- Duration: 60 min
- Prerequisites: [2.4.6 · LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation](https://pythonparatrading.com/en/python-trading-course/level-2/lean-zipline-reloaded-and-bt/), [0.7.3 · Intuitive linear algebra: vectors and matrices as groundwork for portfolios](https://pythonparatrading.com/en/python-trading-course/level-0/linear-algebra-for-portfolios/), [4.1.7 · Rebalancing and strategy portfolios](https://pythonparatrading.com/en/python-trading-course/level-4/rebalancing-and-strategy-portfolios/)

## By the end of this lesson

- Solve minimum variance, maximum Sharpe, risk parity, HRP and minimum CVaR with Riskfolio-Lib 7.4.0, PyPortfolioOpt 1.6.0 and skfolio 1.9.13, and check each result against custom code.
- Identify the conventions that change the weights: returns or prices, daily or annual units, arithmetic or geometric mean, risk-free rate, linkage and leaf order in HRP.
- Evaluate allocations out of sample with skfolio's walk-forward validation and take the weights into a backtest with bt.
- Assess the maintenance and license of each library with data from PyPI and GitHub, including the dependencies that arrive without being asked for.

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

```mermaid
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:

```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))
```
```output
riskfolio-lib   7.4.0   BSD (3-clause)
pyportfolioopt  1.6.0   MIT License
skfolio         1.9.13  BSD 3-Clause License
cvxpy           1.9.3   Apache-2.0
vectorbt        1.2.0   (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):

```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)
```
```output
(2514, 8) 2016-01-04 2025-12-31
          media_anual_%  vol_anual_%
RV_EEUU           15.01        18.90
RV_EUR_C          11.80        18.24
RV_JAP_C           8.49        18.03
RV_EMG            16.26        25.00
RF_T710           -2.40         5.85
RF_CIG            -0.20         5.48
RF_CHY             5.81         9.56
MP_ORO            11.84        14.40
```

2,514 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 ($w = \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:

```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)
```
```output
          cerrada  largos_diaria  largos_anual
RV_EEUU  -0.05391        0.00008       0.00000
RV_EUR_C  0.02514        0.00065       0.00000
RV_JAP_C  0.04572        0.03002       0.03050
RV_EMG   -0.03133        0.00003       0.00000
RF_T710   1.30731        0.68115       0.68101
RF_CIG   -0.90739        0.00001       0.00000
RF_CHY    0.54828        0.22564       0.22604
MP_ORO    0.06618        0.06241       0.06245
```

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:

```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())
```
```output
          propia  pypfopt  riskfolio  skfolio
RV_EEUU   0.0000   0.0000     0.0000   0.0000
RV_EUR_C  0.0000   0.0000     0.0000   0.0000
RV_JAP_C  0.0305   0.0305     0.0305   0.0305
RV_EMG    0.0000   0.0000     0.0000   0.0000
RF_T710   0.6810   0.6810     0.6810   0.6810
RF_CIG    0.0000   0.0000     0.0000   0.0000
RF_CHY    0.2260   0.2260     0.2260   0.2260
MP_ORO    0.0625   0.0625     0.0624   0.0624
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'\Sigma y$ with $\mu'y = 1$ and $y \ge 0$, and normalize $w = y / \mathbf{1}'y$. With the sample mean:

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

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

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

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 $\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:

```python
import inspect

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

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
$\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`:

```python
from skfolio.optimization import RiskBudgeting

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

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:

```mermaid
flowchart TB
  C[Correlations] --> D[Distance]
  D --> T[Linkage tree]
  T --> O[Leaf order]
  O --> B[Bisection]
  B --> W[Weights]
```

The distance is $d_{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:

```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)
```
```output
['MP_ORO', 'RF_T710', 'RF_CIG', 'RF_CHY', 'RV_JAP_C', 'RV_EMG', 'RV_EEUU', 'RV_EUR_C']
RV_EEUU     0.0180
RV_EUR_C    0.0194
RV_JAP_C    0.0223
RV_EMG      0.0116
RF_T710     0.4001
RF_CIG      0.3480
RF_CHY      0.1146
MP_ORO      0.0661
dtype: float64
```

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:

```python raises
from pypfopt import HRPOpt

HRPOpt(X).optimize()
```
```output
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.

```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())
```
```output
          propia  pypfopt  rf_def  rf_sin_orden  sk_def  sk_single  sk_sin_orden  propia_ward
RV_EEUU   0.0180   0.0180  0.0193        0.0180  0.0187     0.0193        0.0180       0.0187
RV_EUR_C  0.0194   0.0194  0.0208        0.0194  0.0207     0.0208        0.0194       0.0207
RV_JAP_C  0.0223   0.0223  0.0379        0.0223  0.0212     0.0379        0.0223       0.0212
RV_EMG    0.0116   0.0116  0.0232        0.0116  0.0107     0.0232        0.0116       0.0107
RF_T710   0.4001   0.4001  0.3184        0.4001  0.4001     0.3184        0.4001       0.4001
RF_CIG    0.3480   0.3480  0.3626        0.3480  0.3480     0.3626        0.3480       0.3480
RF_CHY    0.1146   0.1146  0.1586        0.1146  0.1146     0.1586        0.1146       0.1146
MP_ORO    0.0661   0.0661  0.0593        0.0661  0.0661     0.0593        0.0661       0.0661
{'propia': 0.0, 'pypfopt': 0.0, 'rf_def': 0.0817, 'rf_sin_orden': 0.0, 'sk_def': 0.0013, 'sk_single': 0.0817, 'sk_sin_orden': 0.0, 'propia_ward': 0.0013}
```

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:

```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} %")
```
```output
          propia  pypfopt  riskfolio  skfolio
RV_EEUU   0.0000   0.0000     0.0000   0.0000
RV_EUR_C  0.0000   0.0000     0.0000   0.0000
RV_JAP_C  0.0425   0.0425     0.0425   0.0425
RV_EMG    0.0000   0.0000     0.0000   0.0000
RF_T710   0.6590   0.6590     0.6590   0.6590
RF_CIG    0.0000   0.0000     0.0000   0.0000
RF_CHY    0.1963   0.1963     0.1963   0.1963
MP_ORO    0.1022   0.1022     0.1022   0.1022
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:

```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")])
```
```output
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:

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

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

`cross_val_predict` 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 |

*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).* Five rows with a mark for each quarter in which the library published a release on PyPI, from 2014 to 2026. cvxpy has released continuously since 2014 (latest 1.9.3 in September 2026). bt since 2014, with long gaps and many releases in 2026 (1.4.1, October 2026). PyPortfolioOpt since 2018, with three releases since 2023: May 2023, December 2024 and 1.6.0 in February 2026 (highlighted). Riskfolio-Lib since 2020, with frequent releases (7.4.0, October 2026). skfolio since December 2023, almost every quarter and sometimes several a day (1.9.13, 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.

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

Maintenance figures change every week. For a project, what counts is the procedure: check PyPI and the repository at
the time of deciding, pin versions in `uv.lock` (lesson 0.2.2), and have tests that compare the library with a custom
reference, like the cells of this lesson, to detect a change of behavior when upgrading.

</aside>

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

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

pred = cross_val_predict(HierarchicalRiskParity(), Xt,
                         cv=WalkForward(train_size=504, test_size=21))
# 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, $\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

| 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

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

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

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

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

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

```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))
```
```output
          2016-2020  2021-2025
RV_EEUU       0.242      0.047
RV_EUR_C      0.000      0.000
RV_JAP_C      0.000      0.128
RV_EMG        0.071      0.000
RF_T710       0.351      0.000
RF_CIG        0.000      0.000
RF_CHY        0.000      0.394
MP_ORO        0.336      0.431
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).

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

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

   - A. That PyPortfolioOpt's `mean_historical_return` returns the geometric mean by default (compounding=True) and Riskfolio-Lib uses the arithmetic one
   - B. An error in one of the two solvers
   - C. That one works with prices and the other with returns
   - D. That PyPortfolioOpt's risk-free rate is 2%

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

   - A. Because they use different covariances
   - B. Because Riskfolio-Lib reorders the leaves of the tree (`leaf_order=True`, optimal ordering) before the bisection; with `leaf_order=False` they agree
   - C. Because PyPortfolioOpt uses Ward linkage
   - D. Because HRP is not deterministic

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

   - A. That cvxpy is no good for portfolios
   - B. That annual returns should always be used
   - C. That the libraries round
   - 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

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

   - A. It gives the HRP weights
   - B. It gives the minimum variance weights
   - 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
   - D. It asks to install vectorbt

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

   - 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
   - B. Because vectorbt is slow
   - C. Because vectorbt replaces cvxpy
   - D. It does not matter: Riskfolio-Lib never imports it

   *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

### Documentation

- [Riskfolio-Lib: documentation of Portfolio and HCPortfolio](https://riskfolio-lib.readthedocs.io/en/latest/riskfoliolib/portfolio.html) · riskfolio-lib.readthedocs.io
- [PyPortfolioOpt: mean-variance optimizers (published documentation)](https://pyportfolioopt.readthedocs.io/en/latest/MeanVariance.html) · pyportfolioopt.readthedocs.io
- [PyPortfolioOpt, issue #754: HRPOpt fails with recent SciPy](https://github.com/PyPortfolio/PyPortfolioOpt/issues/754) · github.com
- [skfolio: API reference](https://skfolio.org/api.html) · skfolio.org
- [bt v1.4.1: cost models (CostModel, SqrtCostModel, AlmgrenChrissCostModel)](https://github.com/pmorissette/bt/blob/v1.4.1/bt/core.py) · github.com
- [vectorbt: license (Apache 2.0 with Commons Clause)](https://github.com/polakowo/vectorbt/blob/master/LICENSE.md) · github.com

### Books

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

### Data

- [Level 4 data: prices of the multi-asset universe (Parquet)](https://pythonparatrading.com/curso/datos/nivel-4/universo4_precios.parquet) · Download · universo4_precios.parquet

Previous lesson: [4.1.7 · Rebalancing and strategy portfolios](https://pythonparatrading.com/en/python-trading-course/level-4/rebalancing-and-strategy-portfolios/)

Next lesson: [4.2.1 · Risk per trade, stops and risk of ruin](https://pythonparatrading.com/en/python-trading-course/level-4/risk-per-trade-stops-and-risk-of-ruin/)
