@@ -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