Portfolio construction and risk analytics
Two independent problems live here. §1 picks an optimizer. §2 is the one you cannot skip: the
same return series produces different Sharpe ratios in different libraries, and one popular
function silently ignores an argument you passed it.
1. Pick an optimizer
| Task |
Use |
Not |
| Textbook mean-variance / Black-Litterman, small universe, prototype |
PyPortfolioOpt |
— |
| Broadest risk-measure menu (26 convex: CVaR, CDaR, EVaR, RLVaR, OWA), robust worst-case, integer constraints |
Riskfolio-Lib |
PyPortfolioOpt — too few risk measures |
sklearn Pipeline / GridSearchCV / cross-validation over portfolio models; entropy pooling; vine copulas |
skfolio |
Riskfolio-Lib — no sklearn API |
| Multi-period, transaction-cost-aware simulation of the trading policy |
cvxportfolio — 🚨 GPL-3.0 |
PyPortfolioOpt — single-period only |
| HRP |
Riskfolio-Lib / skfolio / PyPortfolioOpt HRPOpt |
🚨 mlfinlab — proprietary, delisted from PyPI |
| HERC, NCO, Schur complementary |
Riskfolio-Lib or skfolio only |
PyPortfolioOpt does not implement these |
| Marcenko–Pastur denoising / detoning |
Riskfolio-Lib denoiseCov, skfolio Denoise/Detone |
🚨 PyPortfolioOpt has neither — its CovarianceShrinkage is Ledoit-Wolf, a different estimator |
| Deep-learning end-to-end allocation |
deepdow — ⚠️ dormant since Jan 2024 |
— |
Gotchas that bite immediately:
- 🚨
HRPOpt(returns=...) takes a RETURNS matrix. Passing prices runs silently and produces
garbage.
- Linkage defaults differ: skfolio uses Ward, PyPortfolioOpt uses single (the AFML
original). Results differ by design, not by bug.
- skfolio's
CombinatorialPurgedCV.split() yields (train, [test_0, test_1, ...]) — multiple
test folds per split, not sklearn's 2-tuple contract. And purged_size/embargo_size are counted
in observations, not time — with dollar/volume bars a fixed count is a wildly varying span.
- The solver layer (cvxpy → ECOS/OSQP/SCS/Clarabel/MOSEK/Gurobi) constrains what problems are
expressible and which licences apply. See
references/_solver-layer.md first if you hit a
SolverError.
2. 🚨 Metric correctness audit
Verified by executing the libraries (quantstats 0.0.81, empyrical-reloaded 0.5.12, ffn 1.1.5,
pandas 3.0.5) on the same seeded 1,260-day return series — not inferred from docs.
2.1 Identical input, different answers (all defaults, rf=0)
| Metric |
quantstats |
empyrical |
ffn |
Hand-computed |
| Sharpe |
1.279985 |
1.279985 |
1.273744 ¹ |
1.279985 (ddof=1) |
| Sortino |
1.955876 |
1.955876 |
— |
1.955876 |
| Max drawdown |
−0.226178 |
−0.226178 |
−0.226178 |
−0.226178 |
| Annual volatility |
0.188495 |
0.188495 |
0.188554 ¹ |
0.188495 (ddof=1) |
| CAGR |
0.250378 |
0.250378 |
0.258761 |
0.250378 (len/252) / 0.260442 (calendar) |
| VaR 95% |
−0.018574 |
−0.018111 |
— |
−0.018111 (historical) / −0.018574 (Gaussian) |
| CVaR 95% |
−0.023346 |
−0.022786 |
— |
−0.022786 (historical tail mean) |
¹ ffn derives returns from prices via pct_change(), losing the first observation (n=1259 vs 1260).
The CAGR and VaR/CVaR rows are real, systematic disagreements, not rounding. quantstats' VaR is
Gaussian; empyrical's is historical. If your returns have fat tails — and they do — these
are different risk numbers. Know which one you are quoting.
2.2 🔴 The risk-free-rate unit trap — the #1 real-world Sharpe bug
Libraries interpret the same rf number in incompatible units:
| Library |
Parameter |
Units it expects |
| quantstats |
rf= |
ANNUAL (converts geometrically) |
| empyrical / pyfolio-reloaded |
risk_free= |
🔴 PER-PERIOD (daily!) — subtracted raw, no conversion |
| ffn |
rf= |
ANNUAL |
| PyPortfolioOpt |
risk_free_rate= |
ANNUAL |
empyrical's docstring is explicit — "risk_free : int, float — Constant daily risk-free return" —
but the parameter is named risk_free, defaults to 0.0, and sits next to period='daily', so
almost everyone passes an annual rate. Measured:
qs.stats.sharpe(r, rf=0.05) # 1.021119 correct: 5% annual
ep.sharpe_ratio(r, risk_free=0.05) # -65.565185 ← subtracts 5% PER DAY
ep.sharpe_ratio(r, risk_free=(1.05**(1/252)-1)) # 1.021119 correct usage
🔴 A wildly negative Sharpe is the diagnostic signature of this bug. If you ever see one out of
empyrical or pyfolio, check the units before debugging anything else.
2.3 🔴 CONFIRMED BUG: quantstats.stats.cagr() silently ignores rf
Signature is cagr(returns, rf=0.0, compounded=True, periods=252) and the docstring says it
computes the CAGR "of excess returns". The parameter has no effect. Root cause in
quantstats/utils.py::_prepare_returns — it dispatches on the caller's function name:
function = inspect.stack()[1][3]
unnecessary_function_calls = ["_prepare_benchmark", "cagr", "gain_to_pain_ratio", "rolling_volatility"]
if function not in unnecessary_function_calls:
if rf > 0:
return to_excess_returns(data, rf, nperiods)
"cagr" is on the exclusion list, so rf is accepted and discarded. Compute excess-return CAGR
yourself. The same exclusion list silently disables rf for gain_to_pain_ratio and
rolling_volatility.
2.4 Practical rules
- For any number you will publish or trade on, use
ffn (it infers annualization from the
index) plus a hand-rolled check, not quantstats defaults.
- Always state the risk-free convention and the annualization factor alongside the metric.
252 is not universal — crypto is 365, and a strategy that trades weekly is not 252.
- Never subtract a risk-free rate from raw returns and regress raw on raw — the intercept
absorbs the rate and your "alpha" is the cash yield.
- Recompute one metric by hand for every new pipeline. Disagreement is the point of the exercise.
3. Risk measures
- Drawdown is path-dependent — it is not recoverable from mean and variance, and it is the metric
most sensitive to the exact rebalance timing you assumed.
- VaR/CVaR: know whether you are getting historical, Gaussian, or Cornish-Fisher. They diverge
exactly in the tail you care about.
- Volatility modelling:
arch (Kevin Sheppard) for GARCH/EGARCH/HAR. It is the reference
implementation and is actively maintained.
- Confidence intervals matter. A 3-year Sharpe of 1.0 has a standard error near 0.58. Reporting
it without an interval implies precision that does not exist.
4. Statistical significance → a separate skill
For "is this result real after I tried many things" — Deflated Sharpe, PBO, White's Reality Check,
Hansen's SPA, StepM, Model Confidence Set (all in arch.bootstrap) — go to
backtest-validation. Do not settle that question with a tearsheet.
5. Attribution — a genuine ecosystem gap
⚠️ No mature Brinson-attribution library exists in Python. Single-period Brinson-Fachler is
~20 lines and is included in references/_attribution.md; multi-period linking (Carino, Menchero)
you must implement. Factor attribution is better served by regressing against explicit factor
returns — see factor-and-timeseries-research.
6. Reference files
references/<library>.md — version, licence, what it implements that others don't, exact
signatures, and its measured quirks. references/_solver-layer.md covers cvxpy and solver choice.
grep -ril "risk_free\|annualiz" plugins/fin-core/skills/portfolio-and-risk/references/
Per-library deep dives
The optional fin-libraries plugin carries a dedicated skill for each library below. Load one
only after this skill has told you which library you want:
lib-pyportfolioopt — pyportfolioopt
lib-quantstats — quantstats
lib-riskfolio — riskfolio
lib-skfolio — skfolio
1---2name: portfolio-and-risk3description: Turn signals into weights, and compute performance metrics that are actually correct. TRIGGER - portfolio weights, allocation, rebalancing, mean-variance, Black-Litterman, risk parity, HRP, HERC, NCO, efficient frontier, covariance shrinkage or denoising, PyPortfolioOpt, riskfolio, skfolio, cvxportfolio; or computing Sharpe, Sortino, Calmar, CAGR, annualized volatility, max drawdown, VaR, CVaR, beta, alpha, a tearsheet, quantstats, pyfolio, empyrical, ffn, or performance attribution. Load before quoting any performance number: popular libraries disagree on identical input, one silently discards the risk-free rate you pass it, and an absurdly negative Sharpe has one known cause. SKIP for the optimizer's own mathematics and what it does to estimation error (portfolio-optimizers), and for whether the result survives multiple testing (backtest-validation).4license: MIT5---67# Portfolio construction and risk analytics89Two independent problems live here. §1 picks an optimizer. §2 is the one you cannot skip: **the10same return series produces different Sharpe ratios in different libraries, and one popular11function silently ignores an argument you passed it.**1213## 1. Pick an optimizer1415| Task | Use | Not |16|---|---|---|17| Textbook mean-variance / Black-Litterman, small universe, prototype | **PyPortfolioOpt** | — |18| Broadest risk-measure menu (26 convex: CVaR, CDaR, EVaR, RLVaR, OWA), robust worst-case, integer constraints | **Riskfolio-Lib** | PyPortfolioOpt — too few risk measures |19| sklearn `Pipeline` / `GridSearchCV` / cross-validation over portfolio models; entropy pooling; vine copulas | **skfolio** | Riskfolio-Lib — no sklearn API |20| Multi-period, **transaction-cost-aware** simulation of the trading *policy* | **cvxportfolio** — 🚨 **GPL-3.0** | PyPortfolioOpt — single-period only |21| **HRP** | Riskfolio-Lib / skfolio / PyPortfolioOpt `HRPOpt` | 🚨 **mlfinlab — proprietary, delisted from PyPI** |22| **HERC, NCO, Schur complementary** | Riskfolio-Lib or skfolio **only** | PyPortfolioOpt does not implement these |23| **Marcenko–Pastur denoising / detoning** | Riskfolio-Lib `denoiseCov`, skfolio `Denoise`/`Detone` | 🚨 **PyPortfolioOpt has neither** — its `CovarianceShrinkage` is Ledoit-Wolf, a *different* estimator |24| Deep-learning end-to-end allocation | deepdow — ⚠️ dormant since Jan 2024 | — |2526**Gotchas that bite immediately:**27- 🚨 **`HRPOpt(returns=...)` takes a RETURNS matrix.** Passing prices runs silently and produces28 garbage.29- **Linkage defaults differ:** skfolio uses **Ward**, PyPortfolioOpt uses **single** (the AFML30 original). Results differ by design, not by bug.31- **skfolio's `CombinatorialPurgedCV.split()` yields `(train, [test_0, test_1, ...])`** — multiple32 test folds per split, *not* sklearn's 2-tuple contract. And `purged_size`/`embargo_size` are counted33 in **observations, not time** — with dollar/volume bars a fixed count is a wildly varying span.34- The solver layer (cvxpy → ECOS/OSQP/SCS/Clarabel/MOSEK/Gurobi) constrains what problems are35 expressible and which licences apply. See `references/_solver-layer.md` first if you hit a36 `SolverError`.3738## 2. 🚨 Metric correctness audit3940> Verified by **executing** the libraries (quantstats 0.0.81, empyrical-reloaded 0.5.12, ffn 1.1.5,41> pandas 3.0.5) on the same seeded 1,260-day return series — not inferred from docs.4243### 2.1 Identical input, different answers (all defaults, `rf=0`)4445| Metric | quantstats | empyrical | ffn | Hand-computed |46|---|---|---|---|---|47| Sharpe | 1.279985 | 1.279985 | 1.273744 ¹ | **1.279985** (ddof=1) |48| Sortino | 1.955876 | 1.955876 | — | **1.955876** |49| Max drawdown | −0.226178 | −0.226178 | −0.226178 | **−0.226178** |50| Annual volatility | 0.188495 | 0.188495 | 0.188554 ¹ | **0.188495** (ddof=1) |51| **CAGR** | **0.250378** | **0.250378** | **0.258761** | 0.250378 (`len/252`) / 0.260442 (calendar) |52| **VaR 95%** | **−0.018574** | **−0.018111** | — | −0.018111 (historical) / −0.018574 (**Gaussian**) |53| **CVaR 95%** | **−0.023346** | **−0.022786** | — | −0.022786 (historical tail mean) |5455¹ ffn derives returns from prices via `pct_change()`, losing the first observation (n=1259 vs 1260).5657**The CAGR and VaR/CVaR rows are real, systematic disagreements, not rounding.** quantstats' VaR is58**Gaussian**; empyrical's is **historical**. If your returns have fat tails — and they do — these59are different risk numbers. Know which one you are quoting.6061### 2.2 🔴 The risk-free-rate unit trap — the #1 real-world Sharpe bug6263**Libraries interpret the same `rf` number in incompatible units:**6465| Library | Parameter | Units it expects |66|---|---|---|67| **quantstats** | `rf=` | **ANNUAL** (converts geometrically) |68| **empyrical / pyfolio-reloaded** | `risk_free=` | 🔴 **PER-PERIOD (daily!)** — subtracted raw, no conversion |69| **ffn** | `rf=` | **ANNUAL** |70| **PyPortfolioOpt** | `risk_free_rate=` | **ANNUAL** |7172empyrical's docstring is explicit — *"risk_free : int, float — Constant **daily** risk-free return"* —73but the parameter is named `risk_free`, defaults to `0.0`, and sits next to `period='daily'`, so74almost everyone passes an annual rate. Measured:7576```python77qs.stats.sharpe(r, rf=0.05) # 1.021119 correct: 5% annual78ep.sharpe_ratio(r, risk_free=0.05) # -65.565185 ← subtracts 5% PER DAY79ep.sharpe_ratio(r, risk_free=(1.05**(1/252)-1)) # 1.021119 correct usage80```8182🔴 **A wildly negative Sharpe is the diagnostic signature of this bug.** If you ever see one out of83empyrical or pyfolio, check the units before debugging anything else.8485### 2.3 🔴 CONFIRMED BUG: `quantstats.stats.cagr()` silently ignores `rf`8687Signature is `cagr(returns, rf=0.0, compounded=True, periods=252)` and the docstring says it88computes the CAGR *"of excess returns"*. **The parameter has no effect.** Root cause in89`quantstats/utils.py::_prepare_returns` — it dispatches on the **caller's function name**:9091```python92function = inspect.stack()[1][3]93unnecessary_function_calls = ["_prepare_benchmark", "cagr", "gain_to_pain_ratio", "rolling_volatility"]94if function not in unnecessary_function_calls:95 if rf > 0:96 return to_excess_returns(data, rf, nperiods)97```9899`"cagr"` is on the exclusion list, so `rf` is accepted and discarded. **Compute excess-return CAGR100yourself.** The same exclusion list silently disables `rf` for `gain_to_pain_ratio` and101`rolling_volatility`.102103### 2.4 Practical rules1041051. **For any number you will publish or trade on, use `ffn`** (it infers annualization from the106 index) **plus a hand-rolled check**, not quantstats defaults.1072. **Always state the risk-free convention and the annualization factor** alongside the metric.108 `252` is not universal — crypto is `365`, and a strategy that trades weekly is not `252`.1093. **Never subtract a risk-free rate from raw returns and regress raw on raw** — the intercept110 absorbs the rate and your "alpha" is the cash yield.1114. Recompute one metric by hand for every new pipeline. Disagreement is the point of the exercise.112113## 3. Risk measures114115- **Drawdown** is path-dependent — it is not recoverable from mean and variance, and it is the metric116 most sensitive to the exact rebalance timing you assumed.117- **VaR/CVaR**: know whether you are getting historical, Gaussian, or Cornish-Fisher. They diverge118 exactly in the tail you care about.119- **Volatility modelling:** `arch` (Kevin Sheppard) for GARCH/EGARCH/HAR. It is the reference120 implementation and is actively maintained.121- **Confidence intervals matter.** A 3-year Sharpe of 1.0 has a standard error near 0.58. Reporting122 it without an interval implies precision that does not exist.123124## 4. Statistical significance → a separate skill125126For "is this result real after I tried many things" — Deflated Sharpe, PBO, White's Reality Check,127Hansen's SPA, StepM, Model Confidence Set (**all in `arch.bootstrap`**) — go to128**`backtest-validation`**. Do not settle that question with a tearsheet.129130## 5. Attribution — a genuine ecosystem gap131132⚠️ **No mature Brinson-attribution library exists in Python.** Single-period Brinson-Fachler is133~20 lines and is included in `references/_attribution.md`; multi-period linking (Carino, Menchero)134you must implement. Factor attribution is better served by regressing against explicit factor135returns — see `factor-and-timeseries-research`.136137## 6. Reference files138139`references/<library>.md` — version, licence, what it implements that others don't, exact140signatures, and its measured quirks. `references/_solver-layer.md` covers cvxpy and solver choice.141142```bash143grep -ril "risk_free\|annualiz" plugins/fin-core/skills/portfolio-and-risk/references/144```145146## Per-library deep dives147148The optional `fin-libraries` plugin carries a dedicated skill for each library below. Load one149only after this skill has told you which library you want:150151- **`lib-pyportfolioopt`** — pyportfolioopt152- **`lib-quantstats`** — quantstats153- **`lib-riskfolio`** — riskfolio154- **`lib-skfolio`** — skfolio