11import numpy as np
2+ import warnings
23from tqdm import tqdm
34
45try :
@@ -101,9 +102,25 @@ def maxiter(self, value):
101102
102103class 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
255303def project_line_and_box (weights , lower_bound , upper_bound ):
256304 def objective_function (variable , weights ):
0 commit comments