Skip to content

Commit a810a26

Browse files
committed
Changes:
1) Documentation updated for RiskParityPortfolio(), SuccessiveConvexOptimizer(), and design() 2) Initial tau changed 3) Sign notation in Dmat changed for consistency (it does not affect anything from the outside). 4) Function design() now takes arguments verbose and control_numerical_ill_conditioning (which controls numerical issues). 5) Now the linear constraints can involve dummy variables apart from the portfolio weights. 6) Many more unit tests added.
1 parent 579ced6 commit a810a26

File tree

5 files changed

+260
-40
lines changed

5 files changed

+260
-40
lines changed

.gitignore

+2-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ build/
55
riskparityportfolio.egg-info/
66
var/
77
.DS_Store
8-
.coverage
8+
.idea/
9+
.coverage*
910
docs/source/tutorials/.ipynb_checkpoints/*
1011
.eggs/
1112
.ipynb_checkpoints/*

riskparityportfolio/rpp.py

+37-7
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,22 @@ def __init__(self):
1818

1919

2020
class RiskParityPortfolio:
21-
"""Designs risk parity portfolios by solving the following optimization problem
21+
"""Designs risk parity portfolios by solving the following optimization problem:
2222
23-
minimize R(w) - alpha * mu.T * w + lambda * w.T Sigma w
24-
subject to Cw = c, Dw <= d
23+
minimize R(w) - alpha * mu.T w + lambda * w.T Sigma w
24+
subject to Cw = c, Dw <= d
2525
26-
where R is a risk concentration function, and alpha and lambda are trade-off
26+
where R(w) is a risk concentration function, and alpha and lambda are trade-off
2727
parameters for the expected return and the variance, respectively.
2828
29+
The risk concentration R(w) is computed as the squared l2-norm of the risk concentration vector,
30+
which by default is obtained from RiskContribOverVarianceMinusBudget():
31+
32+
R(w) = sum_i (MR_i/sum(MR_i) - budget_i)^2
33+
34+
where MR_i = w_i * (Sigma @ w)_i are the marginal risks (sum(MR_i) = Var(w)), and
35+
budget_i are the risk budgets (by default 1/n).
36+
2937
Parameters
3038
----------
3139
covariance : array, shape=(n, n)
@@ -36,6 +44,23 @@ class RiskParityPortfolio:
3644
weights of the portfolio
3745
risk_concentration : class
3846
any valid child class of RiskConcentrationFunction
47+
48+
Examples:
49+
# Set up:
50+
>>> import numpy as np
51+
>>> import riskparityportfolio as rpp
52+
>>> n = 10
53+
>>> U = np.random.multivariate_normal(mean=np.zeros(n), cov=0.1 * np.eye(n), size=round(.7 * n)).T
54+
>>> Sigma = U @ U.T + np.eye(n)
55+
# Basic usage with equality constraints:
56+
>>> my_portfolio = rpp.RiskParityPortfolio(Sigma)
57+
>>> my_portfolio.design(Cmat=Cmat, cvec=cvec)
58+
>>> my_portfolio.weights
59+
# Basic usage with equality and inequality constraints:
60+
>>> my_portfolio.design(Cmat=Cmat, cvec=cvec, Dmat=Dmat, dvec=dvec)
61+
# Further control of the iterative algorithm:
62+
>>> my_portfolio.design(Cmat=Cmat, cvec=cvec, Dmat=Dmat, dvec=dvec,
63+
>>> verbose=False, tau=1e-10, control_numerical_ill_conditioning=True)
3964
"""
4065

4166
def __init__(
@@ -172,13 +197,18 @@ def add_variance(self, lmd):
172197
self.lmd = lmd
173198
self.has_variance = True
174199

175-
def design(self, **kwargs):
200+
def design(self, verbose=True, control_numerical_ill_conditioning=False, **kwargs):
176201
"""Optimize the portfolio.
177202
178203
Parameters
179204
----------
205+
verbose : boolean
206+
Whether to print the optimization process.
207+
control_numerical_ill_conditioning : boolean
208+
Whether to control numerical ill-conditioning of matrices over the iterations
209+
(at the expense of a computational cost of O(n^3) per iteration for n assets).
180210
kwargs : dict
181-
Dictionary of parameters to be passed to SuccessiveConvexOptimizer.
211+
Dictionary of parameters to be passed to SuccessiveConvexOptimizer().
182212
"""
183213
self.sca = SuccessiveConvexOptimizer(self, **kwargs)
184-
self.sca.solve()
214+
self.sca.solve(verbose=verbose, control_numerical_ill_conditioning=control_numerical_ill_conditioning)

riskparityportfolio/sca.py

+75-26
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,25 @@ def maxiter(self, value):
101101

102102
class SuccessiveConvexOptimizer:
103103
"""
104-
Successive Convex Approximation optimizer tailored for the risk parity problem.
104+
Successive Convex Approximation optimizer tailored for the risk parity problem including the linear constraints:
105+
Cmat @ w = cvec
106+
Dmat @ w <= dvec,
107+
where matrices Cmat and Dmat have n columns (n being the number of assets). Based on the paper:
108+
109+
Feng, Y., and Palomar, D. P. (2015). SCRIP: Successive convex optimization methods for risk parity portfolios design.
110+
IEEE Trans. Signal Processing, 63(19), 5285–5300.
111+
112+
By default, the constraints are set to sum(w) = 1 and w >= 0, i.e.,
113+
Cmat = np.ones((1, n))
114+
cvec = np.array([1.0])
115+
Dmat = -np.eye(n)
116+
dvec = np.zeros(n).
117+
118+
Notes:
119+
1) If equality constraints are not needed, set Cmat = np.empty((0, n)) and cvec = [].
120+
2) If the matrices Cmat and Dmat have more than n columns, it is assumed that the additional columns
121+
(same number for both matrices) correspond to dummy variables (which do not appear in the objective function).
105122
"""
106-
107123
def __init__(
108124
self,
109125
portfolio,
@@ -119,28 +135,27 @@ def __init__(
119135
dvec=None,
120136
):
121137
self.portfolio = portfolio
122-
self.tau = tau or 0.05 * np.sum(np.diag(self.portfolio.covariance)) / (
123-
2 * self.portfolio.number_of_assets
124-
)
138+
self.tau = tau or 1e-4 # 0.05 * np.trace(self.portfolio.covariance) / (2 * self.portfolio.number_of_assets)
125139
sca_validator = SuccessiveConvexOptimizerValidator()
126140
self.gamma = sca_validator.gamma = gamma
127141
self.zeta = sca_validator.zeta = zeta
128142
self.funtol = sca_validator.funtol = funtol
129143
self.wtol = sca_validator.wtol = wtol
130144
self.maxiter = sca_validator.maxiter = maxiter
131145
self.Cmat = Cmat
132-
self.Dmat = Dmat
146+
self.Dmat = Dmat # Dmat @ w <= dvec
133147
self.cvec = cvec
134148
self.dvec = dvec
135-
self.CCmat = np.vstack((self.Cmat, self.Dmat)).T
136-
self.bvec = np.concatenate((self.cvec, self.dvec))
149+
self.number_of_vars = self.Cmat.shape[1]
150+
self.number_of_dummy_vars = self.number_of_vars - self.portfolio.number_of_assets
151+
self.dummy_vars = np.zeros(self.number_of_dummy_vars)
152+
self.CCmat = np.vstack((self.Cmat, -self.Dmat)).T # CCmat.T @ w >= bvec
153+
self.bvec = np.concatenate((self.cvec, -self.dvec))
137154
self.meq = self.Cmat.shape[0]
138155
self._funk = self.get_objective_function_value()
139156
self.objective_function = [self._funk]
140157
self._tauI = self.tau * np.eye(self.portfolio.number_of_assets)
141-
self.Amat = (
142-
self.portfolio.risk_concentration.jacobian_risk_concentration_vector()
143-
)
158+
self.Amat = self.portfolio.risk_concentration.jacobian_risk_concentration_vector()
144159
self.gvec = self.portfolio.risk_concentration.risk_concentration_vector
145160

146161
@property
@@ -151,7 +166,7 @@ def Cmat(self):
151166
def Cmat(self, value):
152167
if value is None:
153168
self._Cmat = np.atleast_2d(np.ones(self.portfolio.number_of_assets))
154-
elif np.atleast_2d(value).shape[1] == self.portfolio.number_of_assets:
169+
elif np.atleast_2d(value).shape[1] >= self.portfolio.number_of_assets:
155170
self._Cmat = np.atleast_2d(value)
156171
else:
157172
raise ValueError(
@@ -166,9 +181,9 @@ def Dmat(self):
166181
@Dmat.setter
167182
def Dmat(self, value):
168183
if value is None:
169-
self._Dmat = np.eye(self.portfolio.number_of_assets)
170-
elif np.atleast_2d(value).shape[1] == self.portfolio.number_of_assets:
171-
self._Dmat = -np.atleast_2d(value)
184+
self._Dmat = -np.eye(self.portfolio.number_of_assets)
185+
elif np.atleast_2d(value).shape[1] == self.Cmat.shape[1]:
186+
self._Dmat = np.atleast_2d(value)
172187
else:
173188
raise ValueError(
174189
"Dmat shape {} doesnt agree with the number of"
@@ -200,7 +215,7 @@ def dvec(self, value):
200215
if value is None:
201216
self._dvec = np.zeros(self.portfolio.number_of_assets)
202217
elif len(value) == self.Dmat.shape[0]:
203-
self._dvec = -np.atleast_1d(value)
218+
self._dvec = np.atleast_1d(value)
204219
else:
205220
raise ValueError(
206221
"dvec shape {} doesnt agree with Dmat shape"
@@ -215,19 +230,43 @@ def get_objective_function_value(self):
215230
obj += self.portfolio.lmd * self.portfolio.volatility ** 2
216231
return obj
217232

218-
def iterate(self):
233+
def iterate(self, verbose=True, control_numerical_ill_conditioning=False):
219234
wk = self.portfolio.weights
220235
g = self.gvec(wk)
221236
A = np.ascontiguousarray(self.Amat(wk))
222237
At = np.transpose(A)
223238
Q = 2 * At @ A + self._tauI
224-
q = 2 * np.matmul(At, g) - np.matmul(Q, wk)
239+
q = 2 * np.matmul(At, g) - Q @ wk # np.matmul() is necessary here since g is not a numpy array
225240
if self.portfolio.has_variance:
226241
Q += self.portfolio.lmd * self.portfolio.covariance
227242
if self.portfolio.has_mean_return:
228243
q -= self.portfolio.alpha * self.portfolio.mean
244+
if self.number_of_dummy_vars > 0:
245+
Q = np.vstack([np.hstack([Q, np.zeros((self.portfolio.number_of_assets, self.number_of_dummy_vars))]),
246+
np.hstack([np.zeros((self.number_of_dummy_vars, self.portfolio.number_of_assets)),
247+
self.tau * np.eye(self.portfolio.number_of_assets)])])
248+
q = np.concatenate([q, -self.tau * self.dummy_vars])
249+
if control_numerical_ill_conditioning:
250+
eigvals = np.linalg.eigvals(Q)
251+
if min(eigvals) < 0 or abs(max(eigvals)/min(eigvals)) > 1e8:
252+
reg = 0
253+
if verbose:
254+
print("\n")
255+
if min(eigvals) < 0:
256+
if verbose:
257+
print("--> Q is not positive definite: adding regularization term before calling QP solver.")
258+
reg += 2*abs(min(eigvals))
259+
if max(eigvals + reg)/min(eigvals + reg) > 1e8:
260+
if verbose:
261+
print("--> Q is ill-conditioned: adding regularization term before calling QP solver.")
262+
reg += max(eigvals)/1e8
263+
if verbose:
264+
print(" Before regularization: cond. number = {:,.0f}".format(max(eigvals) / min(eigvals)))
265+
print(" After regularization: cond. number = {:,.0f}".format(max(eigvals + reg) / min(eigvals + reg)))
266+
Q += reg * np.eye(Q.shape[0])
267+
# Solver for: min 0.5*x.T G x + a.T x s.t. C.T x >= b
229268
w_hat = quadprog.solve_qp(Q, -q, C=self.CCmat, b=self.bvec, meq=self.meq)[0]
230-
self.portfolio.weights = wk + self.gamma * (w_hat - wk)
269+
self.portfolio.weights = wk + self.gamma * (w_hat[:self.portfolio.number_of_assets] - wk)
231270
fun_next = self.get_objective_function_value()
232271
self.objective_function.append(fun_next)
233272
has_w_converged = (
@@ -238,19 +277,29 @@ def iterate(self):
238277
np.abs(self._funk - fun_next)
239278
<= 0.5 * self.funtol * (np.abs(self._funk) + np.abs(fun_next))
240279
).all()
241-
if has_w_converged or has_fun_converged:
280+
if self.number_of_dummy_vars > 0:
281+
have_dummies_converged = (
282+
np.abs(w_hat[self.portfolio.number_of_assets:] - self.dummy_vars)
283+
<= 0.5 * self.wtol * (np.abs(w_hat[self.portfolio.number_of_assets:]) + np.abs(self.dummy_vars))
284+
).all()
285+
self.dummy_vars = w_hat[self.portfolio.number_of_assets:]
286+
else:
287+
have_dummies_converged = True
288+
if (has_w_converged and have_dummies_converged) or has_fun_converged:
242289
return False
243290
self.gamma = self.gamma * (1 - self.zeta * self.gamma)
244291
self._funk = fun_next
245292
return True
246293

247-
def solve(self):
294+
def solve(self, verbose=True, control_numerical_ill_conditioning=False):
248295
i = 0
249-
with tqdm(total=self.maxiter) as pbar:
250-
while self.iterate() and i < self.maxiter:
251-
i += 1
252-
pbar.update()
253-
296+
iterator = range(self.maxiter)
297+
if verbose:
298+
iterator = tqdm(iterator)
299+
for _ in iterator:
300+
if not self.iterate(verbose=verbose, control_numerical_ill_conditioning=control_numerical_ill_conditioning):
301+
break
302+
i += 1
254303

255304
def project_line_and_box(weights, lower_bound, upper_bound):
256305
def objective_function(variable, weights):

0 commit comments

Comments
 (0)