Skip to content

Commit 36e2dfa

Browse files
hass-nationclaude
andcommitted
Add ERCOpt: Equal Risk Contribution / Risk Parity portfolio optimizer
Adds ERCOpt to hierarchical_portfolio.py, implementing the Equal Risk Contribution (ERC) portfolio (Maillard, Roncalli & Teiletche 2010). ERCOpt finds weights w ≥ 0, sum(w)=1 such that each asset contributes an equal fraction of total portfolio variance: w_i · (Σw)_i / (w'Σw) = 1/n for all i This is also known as the Risk Parity portfolio; when Σ is diagonal it reduces to inverse-volatility weighting. The optimizer uses the Spinu (2013) cyclical coordinate descent algorithm, which solves the exact one-dimensional sub-problem at each coordinate: Σᵢᵢ·wᵢ² + (Σw − Σᵢᵢ·wᵢ)·wᵢ − 1/n = 0 (positive root) Weights are NOT normalised between coordinate updates, which is crucial for convergence — the unconstrained Spinu formulation normalises only after the full pass over all assets. API matches HRPOpt exactly: erc = ERCOpt(returns=returns_df) # or cov_matrix=cov_df weights = erc.optimize() erc.portfolio_performance(verbose=True) Also adds _erc_weights_ccd as a standalone helper (used internally). 29 new tests; 294 existing tests pass (0 regressions). References ---------- Maillard, S., Roncalli, T., & Teiletche, J. (2010). The Properties of Equally Weighted Risk Contribution Portfolios. Journal of Portfolio Management, 36(4), 60-70. Spinu, F. (2013). An Algorithm for Computing Risk Parity Weights. SSRN. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c524c6e commit 36e2dfa

3 files changed

Lines changed: 488 additions & 1 deletion

File tree

pypfopt/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
EfficientFrontier,
1212
EfficientSemivariance,
1313
)
14-
from .hierarchical_portfolio import HRPOpt
14+
from .hierarchical_portfolio import ERCOpt, HRPOpt
1515
from .risk_models import CovarianceShrinkage
1616

1717
__version__ = "1.6.0"
@@ -27,6 +27,7 @@
2727
"EfficientSemivariance",
2828
"EfficientCVaR",
2929
"EfficientCDaR",
30+
"ERCOpt",
3031
"HRPOpt",
3132
"CovarianceShrinkage",
3233
]

pypfopt/hierarchical_portfolio.py

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,3 +236,219 @@ def portfolio_performance(self, verbose=False, risk_free_rate=0.0, frequency=252
236236
mu = self.returns.mean() * frequency
237237

238238
return portfolio_performance(self.weights, mu, cov, verbose, risk_free_rate)
239+
240+
241+
def _erc_weights_ccd(cov: np.ndarray, tol: float = 1e-12, max_iter: int = 500) -> np.ndarray:
242+
"""
243+
Equal Risk Contribution weights via Spinu (2013) cyclical coordinate descent.
244+
245+
Finds w ≥ 0, sum(w)=1 such that every asset contributes the same fraction
246+
to total portfolio variance: w_i*(Σw)_i = w_j*(Σw)_j for all i, j.
247+
248+
At each CCD step the exact one-dimensional sub-problem is solved:
249+
250+
Σᵢᵢ·wᵢ² + (Σw − Σᵢᵢ·wᵢ)·wᵢ − 1/n = 0
251+
252+
taking its positive root. Weights are NOT normalised between coordinate
253+
updates; normalisation happens once per full pass, then at the end.
254+
This unconstrained formulation converges reliably for any PD covariance
255+
matrix, including those with negative off-diagonal entries.
256+
257+
Parameters
258+
----------
259+
cov : np.ndarray
260+
(n, n) covariance matrix (must be positive definite).
261+
tol : float
262+
Convergence threshold on max absolute change in weights.
263+
max_iter : int
264+
Maximum number of full passes over all coordinates.
265+
266+
Returns
267+
-------
268+
np.ndarray
269+
(n,) ERC weight vector summing to 1.
270+
271+
References
272+
----------
273+
Spinu, F. (2013). An Algorithm for Computing Risk Parity Weights.
274+
SSRN working paper.
275+
"""
276+
n = cov.shape[0]
277+
if n == 1:
278+
return np.array([1.0])
279+
280+
b = 1.0 / n # equal risk budget
281+
w = np.ones(n) / n
282+
for _ in range(max_iter):
283+
w_prev = w.copy()
284+
for i in range(n):
285+
a_ii = float(cov[i, i])
286+
cross = float(cov[i] @ w) - a_ii * w[i]
287+
disc = cross * cross + 4.0 * a_ii * b
288+
w[i] = (-cross + np.sqrt(max(disc, 0.0))) / (2.0 * a_ii)
289+
if np.max(np.abs(w - w_prev)) < tol:
290+
break
291+
292+
w /= w.sum()
293+
return w
294+
295+
296+
class ERCOpt(BaseOptimizer):
297+
"""
298+
Equal Risk Contribution (ERC) / Risk Parity portfolio optimizer.
299+
300+
Constructs weights w ≥ 0, sum(w)=1 such that each asset contributes
301+
an equal fraction of total portfolio variance:
302+
303+
.. code-block:: text
304+
305+
w_i · (Σw)_i / (w'Σw) = 1/n for all i
306+
307+
Equivalently, the marginal risk contributions w_i·(Σw)_i are all equal.
308+
This is also called the *Risk Parity* portfolio and satisfies
309+
310+
.. code-block:: text
311+
312+
w ∝ Σ⁻¹ 1 (inverse-variance) when Σ is diagonal.
313+
314+
Unlike mean-variance optimization, ERC requires only a covariance estimate
315+
and has been shown to be more robust out-of-sample than max-Sharpe or
316+
min-variance portfolios (Maillard, Roncalli & Teiletche 2010).
317+
318+
Instance variables:
319+
320+
- Inputs
321+
322+
- ``n_assets`` - int
323+
- ``tickers`` - str list
324+
- ``returns`` - pd.DataFrame (if provided)
325+
- ``cov_matrix`` - pd.DataFrame (if provided)
326+
327+
- Output:
328+
329+
- ``weights`` - np.ndarray
330+
331+
Public methods:
332+
333+
- ``optimize()`` calculates ERC weights
334+
- ``portfolio_performance()`` calculates expected return, volatility and Sharpe ratio
335+
- ``set_weights()`` creates self.weights from a weights dict
336+
- ``clean_weights()`` rounds the weights and clips near-zeros
337+
- ``save_weights_to_file()`` saves weights to csv, json, or txt
338+
339+
Examples
340+
--------
341+
::
342+
343+
from pypfopt import ERCOpt
344+
345+
erc = ERCOpt(returns=returns_df)
346+
weights = erc.optimize()
347+
erc.portfolio_performance(verbose=True)
348+
349+
# From a covariance matrix directly
350+
erc = ERCOpt(cov_matrix=cov_df)
351+
weights = erc.optimize()
352+
353+
References
354+
----------
355+
Maillard, S., Roncalli, T., & Teiletche, J. (2010). The Properties of
356+
Equally Weighted Risk Contribution Portfolios. *Journal of Portfolio
357+
Management*, 36(4), 60-70.
358+
359+
Spinu, F. (2013). An Algorithm for Computing Risk Parity Weights.
360+
SSRN working paper.
361+
"""
362+
363+
def __init__(self, returns=None, cov_matrix=None):
364+
"""
365+
Parameters
366+
----------
367+
returns : pd.DataFrame, optional
368+
Asset historical returns (T × n). Used to compute the sample
369+
covariance matrix if ``cov_matrix`` is not provided.
370+
cov_matrix : pd.DataFrame, optional
371+
Covariance matrix of asset returns (n × n). At least one of
372+
``returns`` or ``cov_matrix`` must be supplied.
373+
374+
Raises
375+
------
376+
ValueError
377+
If neither ``returns`` nor ``cov_matrix`` is provided.
378+
TypeError
379+
If ``returns`` is not a pandas DataFrame.
380+
"""
381+
if returns is None and cov_matrix is None:
382+
raise ValueError("Either returns or cov_matrix must be provided")
383+
if returns is not None and not isinstance(returns, pd.DataFrame):
384+
raise TypeError("returns must be a pandas DataFrame")
385+
386+
self.returns = returns
387+
self.cov_matrix = cov_matrix
388+
389+
tickers = list(cov_matrix.columns) if returns is None else list(returns.columns)
390+
super().__init__(len(tickers), tickers)
391+
392+
def optimize(self, tol=1e-12, max_iter=500):
393+
"""
394+
Compute the Equal Risk Contribution (Risk Parity) portfolio.
395+
396+
Uses the Spinu (2013) cyclical coordinate descent: iteratively solves
397+
the exact one-dimensional sub-problem for each asset until the
398+
maximum weight change falls below ``tol``.
399+
400+
Parameters
401+
----------
402+
tol : float, optional
403+
Convergence tolerance, default 1e-12.
404+
max_iter : int, optional
405+
Maximum CCD iterations, default 500.
406+
407+
Returns
408+
-------
409+
OrderedDict
410+
``{ticker: weight}`` mapping, weights sum to 1 and all ≥ 0.
411+
"""
412+
cov = (
413+
self.returns.cov()
414+
if self.cov_matrix is None
415+
else self.cov_matrix
416+
)
417+
cov_arr = np.asarray(cov)
418+
raw_w = _erc_weights_ccd(cov_arr, tol=tol, max_iter=max_iter)
419+
weights = collections.OrderedDict(zip(self.tickers, raw_w))
420+
self.set_weights(weights)
421+
return weights
422+
423+
def portfolio_performance(self, verbose=False, risk_free_rate=0.0, frequency=252):
424+
"""
425+
After optimising, calculate (and optionally print) the performance of
426+
the ERC portfolio.
427+
428+
Parameters
429+
----------
430+
verbose : bool, optional
431+
Whether to print the performance, default False.
432+
risk_free_rate : float, optional
433+
Annualised risk-free rate, default 0.0.
434+
frequency : int, optional
435+
Number of periods per year, default 252 (trading days).
436+
437+
Returns
438+
-------
439+
(float, float, float)
440+
Expected return, volatility, Sharpe ratio.
441+
442+
Raises
443+
------
444+
ValueError
445+
If ``optimize()`` has not been called yet.
446+
"""
447+
if self.returns is None:
448+
cov = self.cov_matrix
449+
mu = None
450+
else:
451+
cov = self.returns.cov() * frequency
452+
mu = self.returns.mean() * frequency
453+
454+
return portfolio_performance(self.weights, mu, cov, verbose, risk_free_rate)

0 commit comments

Comments
 (0)