skfolio
The only portfolio library that is a real scikit-learn estimator collection — so portfolio
construction itself can be cross-validated, not just the return forecast feeding it.
|
|
| pip / import |
skfolio / skfolio |
| Version |
1.0.3 (2026-08-31) — 1.0.0 landed 2026-08-23 · Python >=3.10 |
| Licence |
BSD-3-Clause |
| Status |
✅ fastest-moving library in the category — 2,345★ / 41 issues, pushed 2026-09-03; 0.20.2 → 1.0.3 in 18 days |
⚠️ Positioning, precisely: skfolio is scikit-learn-compatible, part of the sklearn
ecosystem — not an official sub-project, not scikit-learn-contrib-governed.
⚠️ Pre-1.0 knowledge will break. 1.0.0 shipped 2026-08-23, so essentially every tutorial, blog post and
model-recalled snippet predates the stability commitment. Check any 0.x example first.
The trap that costs you money
🚨 CombinatorialPurgedCV.split() yields (train_index, [test_0, test_1, …]) — a list of test folds per split,
not sklearn's 2-tuple contract. Any loop written the normal way — for train, test in cv.split(X) — binds test
to a list of arrays and then indexes with it. It does not raise. It produces a wrong partition and a wrong score,
which is exactly the failure mode purged CV exists to prevent. The correct shape is in the snippet below.
purged_size and embargo_size are OBSERVATIONS, not time
🚨 Under time bars that is merely opaque. Under dollar or volume bars a fixed count is a wildly varying time span —
the same purged_size=10 purges an hour in one regime and three days in another. Convert from your longest label
horizon in bars, and redo it whenever the sampling scheme changes. ⚠️ CPCV is also combinatorially expensive:
C(n_folds, n_test_folds) fits, so C(10,3) is 120 model fits per grid point, multiplied by the grid.
HRP linkage defaults to Ward, not López de Prado's single
🚨 HierarchicalRiskParity results therefore differ from the AFML book and from PyPortfolioOpt's HRPOpt by design,
not by bug. Reproducing a paper? Set linkage explicitly. 🚨 fit(X) takes RETURNS. Prices run without
complaint and produce a nonsense covariance. Guarantee the input contains negative values before you hand it over. ⚠️
predict returns a Portfolio/Population, not a weights dict. Arrivals from PyPortfolioOpt look for
clean_weights(); the equivalents are .weights, .summary(), .plot_cumulative_returns(). ⚠️ Cardinality
constraints require a MIP-capable solver (HiGHS, SCIP, Gurobi, MOSEK); failures surface as opaque solver errors.
What it has that nothing else does
- The estimator protocol. Every model exposes
fit/predict and get_params(deep=True), so
Pipeline, GridSearchCV, RandomizedSearchCV and cross_val_predict all work. "Tune the
shrinkage coefficient and the risk measure jointly under purged CV" is three lines here.
- Optimizers: Mean-Risk, Risk Budgeting, Maximum Diversification, Distributionally Robust CVaR,
Benchmark Tracker; HRP, HERC, NCO, Schur Complementary Allocation; Stacking; naive baselines.
- 19 risk measures — variance, semi-variance, CVaR, EVaR, CDaR, EDaR, max drawdown, VaR, Gini
mean difference, skew, kurtosis.
- Covariance estimators: Empirical, Gerber, Denoise (Marcenko-Pastur), Detone, EWMA,
Ledoit-Wolf, OAS, Graphical-Lasso-CV, and Implied Covariance (option-implied vols on the
diagonal) — the last genuinely unique to skfolio.
- Entropy Pooling, Opinion Pooling, Vine Copula synthetic returns; Black-Litterman; factor models
with 46 descriptors; factor stress-testing; bootstrapped uncertainty sets on both μ and Σ.
- Model selection:
WalkForward, CombinatorialPurgedCV, Multiple Randomized CV.
Minimal correct call
from skfolio import RiskMeasure
from skfolio.optimization import MeanRisk, ObjectiveFunction
from skfolio.moments import DenoiseCovariance
from skfolio.prior import EmpiricalPrior
from skfolio.model_selection import WalkForward, CombinatorialPurgedCV
from sklearn.model_selection import GridSearchCV
model = MeanRisk(
risk_measure=RiskMeasure.CVAR,
objective_function=ObjectiveFunction.MAXIMIZE_RATIO,
prior_estimator=EmpiricalPrior(covariance_estimator=DenoiseCovariance()),
min_weights=0.0, max_weights=0.10, # set bounds explicitly, never rely on defaults
)
model.fit(X_train) # X_train: DataFrame of RETURNS, not prices
ptf = model.predict(X_test) # a Portfolio object; weights are ptf.weights
print(ptf.summary())
grid = GridSearchCV(model, {"risk_measure": [RiskMeasure.VARIANCE, RiskMeasure.CVAR]},
cv=WalkForward(train_size=252, test_size=63)) # sizes in OBSERVATIONS
grid.fit(X)
cv = CombinatorialPurgedCV(n_folds=10, n_test_folds=2,
purged_size=5, embargo_size=5) # OBSERVATIONS, not time
for train_idx, test_idx_list in cv.split(X): # 🚨 non-sklearn contract
for test_idx in test_idx_list:
...
See also
../../../fin-core/skills/portfolio-and-risk/SKILL.md — optimizer choice, and the metric traps downstream
../../../fin-core/skills/portfolio-and-risk/references/skfolio.md — the source card
../../../fin-core/skills/portfolio-and-risk/references/optimizers.md — head-to-head table
../../../fin-core/skills/backtest-validation/references/purgedcv.md — CPCV alternatives compared
Where this sits
This file is the deep dive on one library and assumes the choice is already made.
For which library to pick, how it compares with the alternatives, and the traps that span
several of them, the entry point is the domain skill portfolio-and-risk (../../../fin-core/skills/portfolio-and-risk/SKILL.md).
1---2name: lib-skfolio3description: The sklearn-compatible portfolio estimator library whose CombinatorialPurgedCV breaks sklearn's own split() contract - it yields (train, [test_0, ...]), and normal two-variable unpacking mis-partitions your data without raising. TRIGGER - skfolio, skfolio.optimization, MeanRisk, RiskBudgeting, HierarchicalRiskParity, HierarchicalEqualRiskContribution, NestedClustersOptimization, skfolio.moments, DenoiseCovariance, GerberCovariance, ImpliedCovariance, EmpiricalPrior, EntropyPooling, VineCopula, CombinatorialPurgedCV, WalkForward, purged_size, embargo_size, RiskMeasure. Memory is stale and will break code - 1.0.0 landed 2026-08-23, so every recalled snippet predates the API stability commitment. SKIP for the widest risk-measure menu (lib-riskfolio) and for a strictly sklearn-compliant purged splitter (lib-purgedcv). SKIP for choosing between libraries, or when no library is named - the domain skill's job.4license: MIT5---67# skfolio89The only portfolio library that is a real scikit-learn estimator collection — so portfolio10*construction itself* can be cross-validated, not just the return forecast feeding it.1112| | |13|---|---|14| pip / import | `skfolio` / `skfolio` |15| Version | **1.0.3** (2026-08-31) — **1.0.0 landed 2026-08-23** · Python `>=3.10` |16| Licence | **BSD-3-Clause** |17| Status | ✅ **fastest-moving library in the category** — 2,345★ / 41 issues, pushed 2026-09-03; 0.20.2 → 1.0.3 in 18 days |1819⚠️ **Positioning, precisely:** skfolio is scikit-learn-***compatible***, part of the sklearn20*ecosystem* — **not** an official sub-project, not `scikit-learn-contrib`-governed.21⚠️ **Pre-1.0 knowledge will break.** 1.0.0 shipped 2026-08-23, so essentially every tutorial, blog post and22model-recalled snippet predates the stability commitment. Check any 0.x example first.2324## The trap that costs you money2526🚨 **`CombinatorialPurgedCV.split()` yields `(train_index, [test_0, test_1, …])`** — a *list* of test folds per split,27**not** sklearn's 2-tuple contract. Any loop written the normal way — `for train, test in cv.split(X)` — binds `test`28to a list of arrays and then indexes with it. It does not raise. It produces a wrong partition and a wrong score,29which is exactly the failure mode purged CV exists to prevent. The correct shape is in the snippet below.3031## `purged_size` and `embargo_size` are OBSERVATIONS, not time3233🚨 Under time bars that is merely opaque. Under **dollar or volume bars a fixed count is a wildly varying time span** —34the same `purged_size=10` purges an hour in one regime and three days in another. Convert from your longest label35horizon *in bars*, and redo it whenever the sampling scheme changes. ⚠️ CPCV is also combinatorially expensive:36`C(n_folds, n_test_folds)` fits, so `C(10,3)` is 120 model fits per grid point, multiplied by the grid.3738## HRP linkage defaults to Ward, not López de Prado's `single`3940🚨 `HierarchicalRiskParity` results therefore differ from the AFML book and from PyPortfolioOpt's `HRPOpt` **by design,41not by bug**. Reproducing a paper? Set `linkage` explicitly. 🚨 **`fit(X)` takes RETURNS.** Prices run without42complaint and produce a nonsense covariance. Guarantee the input contains negative values before you hand it over. ⚠️43`predict` returns a `Portfolio`/`Population`, not a weights dict. Arrivals from PyPortfolioOpt look for44`clean_weights()`; the equivalents are `.weights`, `.summary()`, `.plot_cumulative_returns()`. ⚠️ Cardinality45constraints require a MIP-capable solver (HiGHS, SCIP, Gurobi, MOSEK); failures surface as opaque solver errors.4647## What it has that nothing else does4849- **The estimator protocol.** Every model exposes `fit`/`predict` and `get_params(deep=True)`, so50 `Pipeline`, `GridSearchCV`, `RandomizedSearchCV` and `cross_val_predict` all work. "Tune the51 shrinkage coefficient and the risk measure jointly under purged CV" is three lines here.52- **Optimizers:** Mean-Risk, Risk Budgeting, Maximum Diversification, Distributionally Robust CVaR,53 Benchmark Tracker; HRP, HERC, NCO, **Schur Complementary Allocation**; Stacking; naive baselines.54- **19 risk measures** — variance, semi-variance, CVaR, EVaR, CDaR, EDaR, max drawdown, VaR, Gini55 mean difference, skew, kurtosis.56- **Covariance estimators:** Empirical, Gerber, **Denoise** (Marcenko-Pastur), **Detone**, EWMA,57 Ledoit-Wolf, OAS, Graphical-Lasso-CV, and **Implied Covariance** (option-implied vols on the58 diagonal) — the last genuinely unique to skfolio.59- Entropy Pooling, Opinion Pooling, Vine Copula synthetic returns; Black-Litterman; factor models60 with 46 descriptors; factor stress-testing; bootstrapped uncertainty sets on both μ and Σ.61- **Model selection:** `WalkForward`, `CombinatorialPurgedCV`, Multiple Randomized CV.6263## Minimal correct call6465```python66from skfolio import RiskMeasure67from skfolio.optimization import MeanRisk, ObjectiveFunction68from skfolio.moments import DenoiseCovariance69from skfolio.prior import EmpiricalPrior70from skfolio.model_selection import WalkForward, CombinatorialPurgedCV71from sklearn.model_selection import GridSearchCV7273model = MeanRisk(74 risk_measure=RiskMeasure.CVAR,75 objective_function=ObjectiveFunction.MAXIMIZE_RATIO,76 prior_estimator=EmpiricalPrior(covariance_estimator=DenoiseCovariance()),77 min_weights=0.0, max_weights=0.10, # set bounds explicitly, never rely on defaults78)79model.fit(X_train) # X_train: DataFrame of RETURNS, not prices80ptf = model.predict(X_test) # a Portfolio object; weights are ptf.weights81print(ptf.summary())8283grid = GridSearchCV(model, {"risk_measure": [RiskMeasure.VARIANCE, RiskMeasure.CVAR]},84 cv=WalkForward(train_size=252, test_size=63)) # sizes in OBSERVATIONS85grid.fit(X)8687cv = CombinatorialPurgedCV(n_folds=10, n_test_folds=2,88 purged_size=5, embargo_size=5) # OBSERVATIONS, not time89for train_idx, test_idx_list in cv.split(X): # 🚨 non-sklearn contract90 for test_idx in test_idx_list:91 ...92```9394## See also9596- `../../../fin-core/skills/portfolio-and-risk/SKILL.md` — optimizer choice, and the metric traps downstream97- `../../../fin-core/skills/portfolio-and-risk/references/skfolio.md` — the source card98- `../../../fin-core/skills/portfolio-and-risk/references/optimizers.md` — head-to-head table99- `../../../fin-core/skills/backtest-validation/references/purgedcv.md` — CPCV alternatives compared100101## Where this sits102103This file is the deep dive on **one** library and assumes the choice is already made.104For which library to pick, how it compares with the alternatives, and the traps that span105several of them, the entry point is the domain skill **`portfolio-and-risk`** (`../../../fin-core/skills/portfolio-and-risk/SKILL.md`).