@@ -101,9 +101,25 @@ def maxiter(self, value):
101
101
102
102
class SuccessiveConvexOptimizer :
103
103
"""
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).
105
122
"""
106
-
107
123
def __init__ (
108
124
self ,
109
125
portfolio ,
@@ -119,28 +135,27 @@ def __init__(
119
135
dvec = None ,
120
136
):
121
137
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)
125
139
sca_validator = SuccessiveConvexOptimizerValidator ()
126
140
self .gamma = sca_validator .gamma = gamma
127
141
self .zeta = sca_validator .zeta = zeta
128
142
self .funtol = sca_validator .funtol = funtol
129
143
self .wtol = sca_validator .wtol = wtol
130
144
self .maxiter = sca_validator .maxiter = maxiter
131
145
self .Cmat = Cmat
132
- self .Dmat = Dmat
146
+ self .Dmat = Dmat # Dmat @ w <= dvec
133
147
self .cvec = cvec
134
148
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 ))
137
154
self .meq = self .Cmat .shape [0 ]
138
155
self ._funk = self .get_objective_function_value ()
139
156
self .objective_function = [self ._funk ]
140
157
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 ()
144
159
self .gvec = self .portfolio .risk_concentration .risk_concentration_vector
145
160
146
161
@property
@@ -151,7 +166,7 @@ def Cmat(self):
151
166
def Cmat (self , value ):
152
167
if value is None :
153
168
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 :
155
170
self ._Cmat = np .atleast_2d (value )
156
171
else :
157
172
raise ValueError (
@@ -166,9 +181,9 @@ def Dmat(self):
166
181
@Dmat .setter
167
182
def Dmat (self , value ):
168
183
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 )
172
187
else :
173
188
raise ValueError (
174
189
"Dmat shape {} doesnt agree with the number of"
@@ -200,7 +215,7 @@ def dvec(self, value):
200
215
if value is None :
201
216
self ._dvec = np .zeros (self .portfolio .number_of_assets )
202
217
elif len (value ) == self .Dmat .shape [0 ]:
203
- self ._dvec = - np .atleast_1d (value )
218
+ self ._dvec = np .atleast_1d (value )
204
219
else :
205
220
raise ValueError (
206
221
"dvec shape {} doesnt agree with Dmat shape"
@@ -215,19 +230,43 @@ def get_objective_function_value(self):
215
230
obj += self .portfolio .lmd * self .portfolio .volatility ** 2
216
231
return obj
217
232
218
- def iterate (self ):
233
+ def iterate (self , verbose = True , control_numerical_ill_conditioning = False ):
219
234
wk = self .portfolio .weights
220
235
g = self .gvec (wk )
221
236
A = np .ascontiguousarray (self .Amat (wk ))
222
237
At = np .transpose (A )
223
238
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
225
240
if self .portfolio .has_variance :
226
241
Q += self .portfolio .lmd * self .portfolio .covariance
227
242
if self .portfolio .has_mean_return :
228
243
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
229
268
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 )
231
270
fun_next = self .get_objective_function_value ()
232
271
self .objective_function .append (fun_next )
233
272
has_w_converged = (
@@ -238,19 +277,29 @@ def iterate(self):
238
277
np .abs (self ._funk - fun_next )
239
278
<= 0.5 * self .funtol * (np .abs (self ._funk ) + np .abs (fun_next ))
240
279
).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 :
242
289
return False
243
290
self .gamma = self .gamma * (1 - self .zeta * self .gamma )
244
291
self ._funk = fun_next
245
292
return True
246
293
247
- def solve (self ):
294
+ def solve (self , verbose = True , control_numerical_ill_conditioning = False ):
248
295
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
254
303
255
304
def project_line_and_box (weights , lower_bound , upper_bound ):
256
305
def objective_function (variable , weights ):
0 commit comments