Coverage for src/ifunnel/models/MVOmodel.py: 76%

74 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-12 09:14 +0000

1import pickle 

2 

3import cvxpy as cp 

4import numpy as np 

5import pandas as pd 

6import scipy as sp 

7from loguru import logger 

8 

9 

10def cholesky_psd(m): 

11 """ 

12 Computes the Cholesky decomposition of the given matrix, that is not positive definite, only semidefinite. 

13 """ 

14 lu, d, perm = sp.linalg.ldl(m) 

15 assert np.max(np.abs(d - np.diag(np.diag(d)))) < 1e-12, "Matrix 'd' is not diagonal!" 

16 

17 # Do non-negativity fix 

18 min_eig = np.min(np.diag(d)) 

19 if min_eig < 0: 

20 d -= 5 * min_eig * np.eye(*d.shape) 

21 

22 sqrtd = sp.linalg.sqrtm(d) 

23 C = (lu @ sqrtd).T 

24 return C 

25 

26 

27# ---------------------------------------------------------------------- 

28# MODEL FOR OPTIMIZING THE BACKTEST PERIODS 

29# ---------------------------------------------------------------------- 

30def rebalancing_model( 

31 mu, 

32 covariance, 

33 vty_target, 

34 cash, 

35 x_old, 

36 trans_cost, 

37 max_weight, 

38 solver, 

39 inaccurate, 

40 lower_bound, 

41): 

42 """This function finds the optimal enhanced index portfolio according to some benchmark. 

43 The portfolio corresponds to the tangency portfolio where risk is evaluated according to 

44 the volatility of the tracking error. The model is formulated using quadratic programming. 

45 

46 Parameters 

47 ---------- 

48 mu : pandas.Series with float values 

49 asset point forecast 

50 covariance : pandas.DataFrame with covariances 

51 Asset covariances 

52 vty_target: 

53 targets for our optimal portfolio 

54 cash: 

55 additional budget for our portfolio 

56 x_old: 

57 old portfolio allocation 

58 trans_cost: 

59 transaction costs 

60 max_weight : float 

61 Maximum allowed weight 

62 solver: str 

63 The name of the solver to use, as returned by cvxpy.installed_solvers() 

64 inaccurate: bool 

65 Whether to also use solution with status "optimal_inaccurate" 

66 lower_bound: int 

67 Minimum weight given to each selected asset. 

68 

69 Returns 

70 ------- 

71 float 

72 Asset weights in an optimal portfolio 

73 """ 

74 # Number of assets 

75 N = covariance.shape[1] 

76 

77 # Variable transaction costs 

78 c = trans_cost 

79 

80 # Factorize the covariance 

81 # G = cholesky_psd(covariance) 

82 G = np.linalg.cholesky(covariance) 

83 

84 # Define variables 

85 # - portfolio 

86 x = cp.Variable(N, name="x", nonneg=True) 

87 # - |x - x_old| 

88 absdiff = cp.Variable(N, name="absdiff", nonneg=True) 

89 # - cost 

90 cost = cp.Variable(name="cost", nonneg=True) 

91 

92 # Define objective (max expected portfolio return) 

93 objective = cp.Maximize(mu.to_numpy() @ x) 

94 

95 # Define constraints 

96 constraints = [ 

97 # - Volatility limit 

98 cp.norm(G @ x, 2) <= vty_target, 

99 # - Cost of rebalancing 

100 c * cp.sum(absdiff) == cost, 

101 x - x_old <= absdiff, 

102 x - x_old >= -absdiff, 

103 # - Budget 

104 x_old.sum() + cash - cp.sum(x) - cost == 0, 

105 # - Concentration limits 

106 x <= max_weight * cp.sum(x), 

107 ] 

108 

109 if lower_bound != 0: 

110 z = cp.Variable(N, boolean=True) # Binary variable indicates if asset is selected 

111 upper_bound = 100 

112 

113 constraints.append(lower_bound * z <= x) 

114 constraints.append(x <= upper_bound * z) 

115 constraints.append(cp.sum(z) >= 1) 

116 

117 # Define model 

118 model = cp.Problem(objective=objective, constraints=constraints) 

119 

120 # Solve 

121 model.solve(solver=solver, verbose=False) 

122 

123 # Get positions 

124 accepted_statuses = ["optimal"] 

125 if inaccurate: 

126 accepted_statuses.append("optimal_inaccurate") 

127 if model.status in accepted_statuses: 

128 opt_port = pd.Series(x.value, index=mu.index) 

129 

130 # Set floating data points to zero and normalize 

131 opt_port[np.abs(opt_port) < 0.000001] = 0 

132 port_val = np.sum(opt_port) 

133 vty_result_p = np.linalg.norm(G @ x.value, 2) / port_val 

134 opt_port = opt_port / port_val 

135 

136 # Remaining cash 

137 cash = cash - (port_val + cost.value - x_old.sum()) 

138 

139 # return portfolio, CVaR, and alpha 

140 return opt_port, vty_result_p, port_val, cash 

141 

142 else: 

143 # Save inputs, so that failing problem can be investigated separately e.g. in a notebook 

144 inputs = { 

145 "mu": mu, 

146 "covariance": covariance, 

147 "vty_target": vty_target, 

148 "cash": cash, 

149 "x_old": x_old, 

150 "trans_cost": trans_cost, 

151 "max_weight": max_weight, 

152 "lower_bound": lower_bound, 

153 } 

154 file = open("rebalance_inputs.pkl", "wb") 

155 pickle.dump(inputs, file) 

156 file.close() 

157 

158 # Print an error if the model is not optimal 

159 logger.exception(f"❌ Solver does not find optimal solution. Status code is {model.status}") 

160 

161 

162# ---------------------------------------------------------------------- 

163# Mathematical Optimization: RUN THE MVO MODEL 

164# ---------------------------------------------------------------------- 

165def mvo_model( 

166 test_ret: pd.DataFrame, 

167 mu_lst: list, 

168 sigma_lst: list, 

169 targets: pd.DataFrame, 

170 budget: float, 

171 trans_cost: float, 

172 max_weight: float, 

173 solver: str, 

174 lower_bound: int, 

175 inaccurate: bool = True, 

176) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]: 

177 """ 

178 Method to run the MVO model over given periods 

179 """ 

180 p_points = len(mu_lst) # number of periods 

181 

182 assets = test_ret.columns # names of all assets 

183 

184 # LIST TO STORE VOLATILITY TARGETS 

185 list_portfolio_vty = [] 

186 # LIST TO STORE VALUE OF THE PORTFOLIO 

187 list_portfolio_value = [] 

188 # LIST TO STORE PORTFOLIO ALLOCATION 

189 list_portfolio_allocation = [] 

190 

191 x_old = pd.Series(0, index=assets) 

192 cash = budget 

193 portfolio_value_w = budget 

194 

195 logger.debug(f"🤖 Selected solver is {solver}") 

196 for p in range(p_points): 

197 logger.info(f"🚀 Optimizing period {p + 1} out of {p_points}.") 

198 

199 # Get MVO parameters 

200 mu = mu_lst[p] 

201 sigma = sigma_lst[p] 

202 

203 # run CVaR model 

204 p_alloc, vty_val, port_val, cash = rebalancing_model( 

205 mu=mu, 

206 covariance=sigma, 

207 vty_target=targets.loc[p, "Vty_Target"] * portfolio_value_w, 

208 cash=cash, 

209 x_old=x_old, 

210 trans_cost=trans_cost, 

211 max_weight=max_weight, 

212 solver=solver, 

213 inaccurate=inaccurate, 

214 lower_bound=lower_bound, 

215 ) 

216 

217 # save the result 

218 list_portfolio_vty.append(vty_val) 

219 # save allocation 

220 list_portfolio_allocation.append(p_alloc) 

221 

222 portfolio_value_w = port_val 

223 # COMPUTE PORTFOLIO VALUE 

224 for w in test_ret.index[(p * 4) : (4 + p * 4)]: 

225 portfolio_value_w = sum(p_alloc * portfolio_value_w * (1 + test_ret.loc[w, assets])) 

226 list_portfolio_value.append((w, portfolio_value_w)) 

227 

228 x_old = p_alloc * portfolio_value_w 

229 

230 portfolio_vty = pd.DataFrame(columns=["Volatility"], data=list_portfolio_vty) 

231 portfolio_value = pd.DataFrame(columns=["Date", "Portfolio_Value"], data=list_portfolio_value).set_index( 

232 "Date", drop=True 

233 ) 

234 portfolio_allocation = pd.DataFrame(columns=assets, data=list_portfolio_allocation) 

235 

236 return portfolio_allocation, portfolio_value, portfolio_vty