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