Skip to content

Commit 17e5eda

Browse files
authored
Merge pull request #31 from convexfi/add_dummy_variables
Changes: Finer control of the iterative algorithm and dummy variables allowed in the constraints.
2 parents 4ba5613 + 8dd382b commit 17e5eda

File tree

6 files changed

+274
-47
lines changed

6 files changed

+274
-47
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/*

setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import setuptools
55
import os
66

7-
__version__ = "0.5.1"
7+
__version__ = "0.6.0"
88

99
# Prepare and send a new release to PyPI
1010
if "release" in sys.argv[-1]:

src/riskparityportfolio/rpp.py

+31-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,20 @@ 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 default constraints sum(w) = 1 and w >= 0:
56+
>>> my_portfolio = rpp.RiskParityPortfolio(Sigma)
57+
>>> my_portfolio.design()
58+
>>> my_portfolio.weights
59+
# Basic usage with equality and inequality constraints:
60+
>>> my_portfolio.design(Cmat=Cmat, cvec=cvec, Dmat=Dmat, dvec=dvec)
3961
"""
4062

4163
def __init__(
@@ -172,13 +194,15 @@ def add_variance(self, lmd):
172194
self.lmd = lmd
173195
self.has_variance = True
174196

175-
def design(self, **kwargs):
197+
def design(self, verbose=True, **kwargs):
176198
"""Optimize the portfolio.
177199
178200
Parameters
179201
----------
202+
verbose : boolean
203+
Whether to print the optimization process.
180204
kwargs : dict
181-
Dictionary of parameters to be passed to SuccessiveConvexOptimizer.
205+
Dictionary of parameters to be passed to SuccessiveConvexOptimizer().
182206
"""
183207
self.sca = SuccessiveConvexOptimizer(self, **kwargs)
184-
self.sca.solve()
208+
self.sca.solve(verbose)

src/riskparityportfolio/sca.py

+80-32
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import numpy as np
2+
import warnings
23
from tqdm import tqdm
34

45
try:
@@ -101,9 +102,25 @@ def maxiter(self, value):
101102

102103
class SuccessiveConvexOptimizer:
103104
"""
104-
Successive Convex Approximation optimizer tailored for the risk parity problem.
105+
Successive Convex Approximation optimizer tailored for the risk parity problem including the linear constraints:
106+
Cmat @ w = cvec
107+
Dmat @ w <= dvec,
108+
where matrices Cmat and Dmat have n columns (n being the number of assets). Based on the paper:
109+
110+
Feng, Y., and Palomar, D. P. (2015). SCRIP: Successive convex optimization methods for risk parity portfolios design.
111+
IEEE Trans. Signal Processing, 63(19), 5285–5300.
112+
113+
By default, the constraints are set to sum(w) = 1 and w >= 0, i.e.,
114+
Cmat = np.ones((1, n))
115+
cvec = np.array([1.0])
116+
Dmat = -np.eye(n)
117+
dvec = np.zeros(n).
118+
119+
Notes:
120+
1) If equality constraints are not needed, set Cmat = np.empty((0, n)) and cvec = [].
121+
2) If the matrices Cmat and Dmat have more than n columns, it is assumed that the additional columns
122+
(same number for both matrices) correspond to dummy variables (which do not appear in the objective function).
105123
"""
106-
107124
def __init__(
108125
self,
109126
portfolio,
@@ -119,28 +136,27 @@ def __init__(
119136
dvec=None,
120137
):
121138
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-
)
139+
self.tau = tau or 1e-4 # 0.05 * np.trace(self.portfolio.covariance) / (2 * self.portfolio.number_of_assets)
125140
sca_validator = SuccessiveConvexOptimizerValidator()
126141
self.gamma = sca_validator.gamma = gamma
127142
self.zeta = sca_validator.zeta = zeta
128143
self.funtol = sca_validator.funtol = funtol
129144
self.wtol = sca_validator.wtol = wtol
130145
self.maxiter = sca_validator.maxiter = maxiter
131146
self.Cmat = Cmat
132-
self.Dmat = Dmat
147+
self.Dmat = Dmat # Dmat @ w <= dvec
133148
self.cvec = cvec
134149
self.dvec = dvec
135-
self.CCmat = np.vstack((self.Cmat, self.Dmat)).T
136-
self.bvec = np.concatenate((self.cvec, self.dvec))
150+
self.number_of_vars = self.Cmat.shape[1]
151+
self.number_of_dummy_vars = self.number_of_vars - self.portfolio.number_of_assets
152+
self.dummy_vars = np.zeros(self.number_of_dummy_vars)
153+
self.CCmat = np.vstack((self.Cmat, -self.Dmat)).T # CCmat.T @ w >= bvec
154+
self.bvec = np.concatenate((self.cvec, -self.dvec))
137155
self.meq = self.Cmat.shape[0]
138156
self._funk = self.get_objective_function_value()
139157
self.objective_function = [self._funk]
140158
self._tauI = self.tau * np.eye(self.portfolio.number_of_assets)
141-
self.Amat = (
142-
self.portfolio.risk_concentration.jacobian_risk_concentration_vector()
143-
)
159+
self.Amat = self.portfolio.risk_concentration.jacobian_risk_concentration_vector()
144160
self.gvec = self.portfolio.risk_concentration.risk_concentration_vector
145161

146162
@property
@@ -151,7 +167,7 @@ def Cmat(self):
151167
def Cmat(self, value):
152168
if value is None:
153169
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:
170+
elif np.atleast_2d(value).shape[1] >= self.portfolio.number_of_assets:
155171
self._Cmat = np.atleast_2d(value)
156172
else:
157173
raise ValueError(
@@ -166,9 +182,9 @@ def Dmat(self):
166182
@Dmat.setter
167183
def Dmat(self, value):
168184
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)
185+
self._Dmat = -np.eye(self.portfolio.number_of_assets)
186+
elif np.atleast_2d(value).shape[1] == self.Cmat.shape[1]:
187+
self._Dmat = np.atleast_2d(value)
172188
else:
173189
raise ValueError(
174190
"Dmat shape {} doesnt agree with the number of"
@@ -200,7 +216,7 @@ def dvec(self, value):
200216
if value is None:
201217
self._dvec = np.zeros(self.portfolio.number_of_assets)
202218
elif len(value) == self.Dmat.shape[0]:
203-
self._dvec = -np.atleast_1d(value)
219+
self._dvec = np.atleast_1d(value)
204220
else:
205221
raise ValueError(
206222
"dvec shape {} doesnt agree with Dmat shape"
@@ -215,42 +231,74 @@ def get_objective_function_value(self):
215231
obj += self.portfolio.lmd * self.portfolio.volatility ** 2
216232
return obj
217233

218-
def iterate(self):
234+
def iterate(self, verbose=True):
219235
wk = self.portfolio.weights
220236
g = self.gvec(wk)
221237
A = np.ascontiguousarray(self.Amat(wk))
222238
At = np.transpose(A)
223239
Q = 2 * At @ A + self._tauI
224-
q = 2 * np.matmul(At, g) - np.matmul(Q, wk)
240+
q = 2 * np.matmul(At, g) - Q @ wk # np.matmul() is necessary here since g is not a numpy array
225241
if self.portfolio.has_variance:
226242
Q += self.portfolio.lmd * self.portfolio.covariance
227243
if self.portfolio.has_mean_return:
228244
q -= self.portfolio.alpha * self.portfolio.mean
229-
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)
245+
if self.number_of_dummy_vars > 0:
246+
Q = np.vstack([np.hstack([Q, np.zeros((self.portfolio.number_of_assets, self.number_of_dummy_vars))]),
247+
np.hstack([np.zeros((self.number_of_dummy_vars, self.portfolio.number_of_assets)),
248+
self.tau * np.eye(self.portfolio.number_of_assets)])])
249+
q = np.concatenate([q, -self.tau * self.dummy_vars])
250+
# Call QP solver (min 0.5*x.T G x + a.T x s.t. C.T x >= b) controlling for ill-conditioning:
251+
try:
252+
w_hat = quadprog.solve_qp(Q, -q, C=self.CCmat, b=self.bvec, meq=self.meq)[0]
253+
except ValueError as e:
254+
if str(e) == "matrix G is not positive definite":
255+
warnings.warn(
256+
"Matrix Q is not positive definite: adding regularization term and then calling QP solver again.")
257+
# eigvals = np.linalg.eigvals(Q)
258+
# print(" - before regularization: cond. number = {:,.0f}".format(max(eigvals) / min(eigvals)))
259+
# print(" - after regularization: cond. number = {:,.0f}".format(max(eigvals + np.trace(Q)/1e7) / min(eigvals + np.trace(Q)/1e7)))
260+
Q += np.eye(Q.shape[0]) * np.trace(Q)/1e7
261+
w_hat = quadprog.solve_qp(Q, -q, C=self.CCmat, b=self.bvec, meq=self.meq)[0]
262+
else:
263+
# If the error is different, re-raise it
264+
raise
265+
self.portfolio.weights = wk + self.gamma * (w_hat[:self.portfolio.number_of_assets] - wk)
231266
fun_next = self.get_objective_function_value()
232267
self.objective_function.append(fun_next)
233268
has_w_converged = (
234-
np.abs(self.portfolio.weights - wk)
235-
<= 0.5 * self.wtol * (np.abs(self.portfolio.weights) + np.abs(wk))
269+
(np.abs(self.portfolio.weights - wk) <= self.wtol * 0.5 * (np.abs(self.portfolio.weights) + np.abs(wk)))
270+
| ((np.abs(self.portfolio.weights) < 1e-6) & (np.abs(wk) < 1e-6))
236271
).all()
237272
has_fun_converged = (
238-
np.abs(self._funk - fun_next)
239-
<= 0.5 * self.funtol * (np.abs(self._funk) + np.abs(fun_next))
240-
).all()
241-
if has_w_converged or has_fun_converged:
273+
(np.abs(self._funk - fun_next) <= self.funtol * 0.5 * (np.abs(self._funk) + np.abs(fun_next)))
274+
| ((np.abs(self._funk) <= 1e-10) & (np.abs(fun_next) <= 1e-10))
275+
)
276+
if self.number_of_dummy_vars > 0:
277+
have_dummies_converged = (
278+
(np.abs(w_hat[self.portfolio.number_of_assets:] - self.dummy_vars) <= self.wtol * 0.5 *
279+
(np.abs(w_hat[self.portfolio.number_of_assets:]) + np.abs(self.dummy_vars)))
280+
| ((np.abs(w_hat[self.portfolio.number_of_assets:]) < 1e-6) & (np.abs(self.dummy_vars) < 1e-6))
281+
).all()
282+
self.dummy_vars = w_hat[self.portfolio.number_of_assets:]
283+
else:
284+
have_dummies_converged = True
285+
if (has_w_converged and have_dummies_converged) or has_fun_converged:
286+
# if verbose:
287+
# print(f" Has func. converged: {has_fun_converged}; has w converged: {has_w_converged}")
242288
return False
243289
self.gamma = self.gamma * (1 - self.zeta * self.gamma)
244290
self._funk = fun_next
245291
return True
246292

247-
def solve(self):
293+
def solve(self, verbose=True):
248294
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-
295+
iterator = range(self.maxiter)
296+
if verbose:
297+
iterator = tqdm(iterator)
298+
for _ in iterator:
299+
if not self.iterate(verbose=verbose):
300+
break
301+
i += 1
254302

255303
def project_line_and_box(weights, lower_bound, upper_bound):
256304
def objective_function(variable, weights):

0 commit comments

Comments
 (0)