Coverage for src/ifunnel/models/lifecycle/MVOlifecycleModel.py: 0%

125 statements  

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

1import cvxpy as cp 

2import numpy as np 

3import pandas as pd 

4from loguru import logger 

5 

6from ..MVOmodel import cholesky_psd 

7 

8 

9def calculate_risk_metrics( 

10 yearly_returns: pd.Series, risk_free_rate: float = 0.02 

11) -> (float, float, float, float, float): 

12 """Calculate risk metrics for a given set of yearly returns.""" 

13 annual_return = yearly_returns.mean() 

14 annual_std_dev = yearly_returns.std() 

15 

16 sharpe_ratio = (annual_return - risk_free_rate) / annual_std_dev if annual_std_dev != 0 else None 

17 

18 # Calculate downside deviation 

19 downside_diffs = [min(0, return_ - risk_free_rate) for return_ in yearly_returns] 

20 downside_std_dev = np.std(downside_diffs) 

21 

22 # Calculate Sortino ratio 

23 sortino_ratio = (annual_return - risk_free_rate) / downside_std_dev if downside_std_dev != 0 else None 

24 

25 return annual_return, annual_std_dev, sharpe_ratio, downside_std_dev, sortino_ratio 

26 

27 

28def calculate_analysis_metrics(terminal_values: pd.Series) -> pd.DataFrame: 

29 """Calculate various metrics from the terminal values of the portfolio.""" 

30 mean_terminal_value = np.mean(terminal_values) 

31 stdev_terminal_value = np.std(terminal_values) 

32 max_terminal_value = np.max(terminal_values) 

33 min_terminal_value = np.min(terminal_values) 

34 

35 # Sorting for decile and quartile calculations 

36 sorted_terminal_values = sorted(terminal_values) 

37 lower_decile_count = len(sorted_terminal_values) // 10 

38 upper_decile_count = len(sorted_terminal_values) - lower_decile_count 

39 lower_quartile_count = len(sorted_terminal_values) // 4 

40 upper_quartile_count = len(sorted_terminal_values) - lower_quartile_count 

41 

42 lower_decile_avg = np.mean(sorted_terminal_values[:lower_decile_count]) 

43 upper_decile_avg = np.mean(sorted_terminal_values[-upper_decile_count:]) 

44 lower_quartile_avg = np.mean(sorted_terminal_values[:lower_quartile_count]) 

45 upper_quartile_avg = np.mean(sorted_terminal_values[-upper_quartile_count:]) 

46 

47 # Creating a DataFrame to store the metrics 

48 metrics_df = pd.DataFrame( 

49 { 

50 "Mean Terminal Value": [mean_terminal_value], 

51 "Standard Deviation Terminal Value": [stdev_terminal_value], 

52 "Max Terminal Value": [max_terminal_value], 

53 "Min Terminal Value": [min_terminal_value], 

54 "Lower Decile Average": [lower_decile_avg], 

55 "Upper Decile Average": [upper_decile_avg], 

56 "Lower Quartile Average": [lower_quartile_avg], 

57 "Upper Quartile Average": [upper_quartile_avg], 

58 } 

59 ) 

60 

61 return metrics_df 

62 

63 

64def lifecycle_rebalance_model( 

65 mu: pd.DataFrame, 

66 sigma: pd.DataFrame, 

67 vol_target: float, 

68 max_weight: float, 

69 solver: str, 

70 inaccurate: bool = True, 

71 lower_bound: float = 0, 

72) -> (pd.Series, float): 

73 """ 

74 Optimizes asset allocations within a portfolio to maximize expected returns 

75 while adhering to a risk budget glide path. 

76 

77 Parameters: 

78 - mu: Expected returns for each asset. 

79 - sigma: Covariance matrix of asset returns. 

80 - vol_target: Target portfolio volatility for period. 

81 - max_weight: Maximum weight allowed for any single asset (excluding cash). 

82 - solver: Solver to be used by CVXPY for optimization. 

83 - inaccurate: If True, accepts 'optimal_inaccurate' as a successful solve status. 

84 

85 Returns: 

86 - port_nom: Nominal allocations for each asset in the optimized portfolio. 

87 - port_val: Total value of the portfolio based on the allocations. 

88 

89 This function uses convex optimization to find the asset weights that maximize 

90 expected returns subject to constraints on total weight, individual asset weights, 

91 and portfolio volatility. It optionally includes binary selection variables to enforce 

92 a minimum allocation to any selected asset. 

93 """ 

94 

95 # Prepare basic variables and indices 

96 N = len(mu) # Number of assets 

97 cash_index = mu.index.get_loc("Cash") # Identify the index of the 'Cash' asset 

98 non_cash_indices = np.array([i for i in range(len(mu)) if i != cash_index]) 

99 

100 # Optimization variables 

101 x = cp.Variable(N, name="x", nonneg=True) # Asset weights 

102 

103 # Prepare matrix for volatility constraint 

104 G = cholesky_psd(sigma) # Transform to standard deviation matrix 

105 

106 # Define the optimization problem 

107 objective = cp.Maximize(x.T @ mu) 

108 

109 constraints = [ 

110 cp.sum(x) == 1, # Weights sum to 1 

111 cp.norm(G @ x, 2) <= vol_target, # Portfolio volatility constraint 

112 # cp.quad_form(x, sigma) <= vol_target ** 2, 

113 x[non_cash_indices] <= max_weight * cp.sum(x[non_cash_indices]), # Max weight constraint for non-cash assets 

114 ] 

115 

116 # Optional lower bound constraint 

117 if lower_bound != 0: 

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

119 upper_bound = 100 # Arbitrary upper bound for asset weights 

120 

121 constraints += [ 

122 lower_bound * z <= x, # Lower bound constraint 

123 x <= upper_bound * z, # Upper bound enables asset deselection 

124 cp.sum(z) >= 1, # At least one asset must be selected 

125 ] 

126 

127 # Solve the optimization problem 

128 model = cp.Problem(objective, constraints) 

129 model.solve(solver=solver, qcp=True) 

130 

131 # Process optimization results 

132 accepted_statuses = ["optimal"] 

133 if inaccurate: 

134 accepted_statuses.append("optimal_inaccurate") 

135 if model.status in accepted_statuses: 

136 port_nom = pd.Series(x.value, index=mu.index) # Nominal allocations in each asset 

137 port_val = np.sum(port_nom) # Total portfolio value 

138 port_nom[np.abs(port_nom) < 0.00001] = 0 # Eliminate numerical noise by zeroing very small weights 

139 return port_nom, port_val 

140 

141 else: 

142 # Handle non-optimal solve status 

143 logger.exception( 

144 f"The model is {model.status}. Look into the constraints. It might be an issue of too low risk targets." 

145 ) 

146 port_nom = pd.Series(np.nan, index=mu.index) # Use NaNs to indicate failure 

147 port_val = np.sum(port_nom) 

148 return port_nom, port_val 

149 

150 

151def get_port_allocations( 

152 mu_lst: pd.DataFrame, 

153 sigma_lst: pd.DataFrame, 

154 targets: pd.DataFrame, 

155 max_weight: float, 

156 solver: str, 

157) -> pd.DataFrame: 

158 """ 

159 Calculates optimal portfolio allocations for the glide paths. 

160 

161 Parameters: 

162 - mu_lst, sigma_lst: Expected returns and standard deviations for each year 

163 - targets: Target volatilities from the risk budget glide paths 

164 - budget: Initial budget 

165 - trans_cost: Transaction costs 

166 - max_weight: Maximum weight for any asset 

167 - withdrawal_lst: List of withdrawals for each year 

168 - solver: Optimization solver to use 

169 - interest_rate: Interest rate for borrowing 

170 

171 Returns: 

172 - allocation_df: DataFrame showing the optimal asset allocations for each year. 

173 

174 This function iterates through each year, using the provided expected returns, covariance matrices, 

175 and risk budget glide path to determine the optimal asset allocations. 

176 """ 

177 # Initial setup 

178 num_years = len(targets) 

179 years = [str(i + 2023) for i in range(num_years)] 

180 assets = mu_lst.index 

181 

182 # Initialize DataFrames 

183 allocation_df = pd.DataFrame(index=years, columns=assets) 

184 

185 for year in range(num_years): 

186 port_weights, _ = lifecycle_rebalance_model( 

187 mu=mu_lst, 

188 sigma=sigma_lst, 

189 vol_target=targets.loc[year], 

190 max_weight=max_weight, 

191 solver=solver, 

192 ) 

193 

194 allocation_df.loc[allocation_df.index[year], :] = port_weights 

195 

196 logger.info(f"The optimal portfolio allocations has been obtained for the {num_years + 1} years.") 

197 return allocation_df 

198 

199 

200def portfolio_rebalancing( 

201 budget: float, 

202 targets: pd.DataFrame, 

203 withdrawal_lst: list[float], 

204 transaction_cost: float, 

205 scenarios: pd.DataFrame, 

206 interest_rate: float, 

207) -> (pd.DataFrame, pd.DataFrame): 

208 """ 

209 Simulates portfolio rebalancing over multiple years, accounting for withdrawals, 

210 transaction costs, returns based on scenarios, and handling defaults when withdrawals exceed portfolio value. 

211 

212 Parameters: 

213 - budget: Initial budget available for investment. 

214 - targets: DataFrame containing target asset allocations for each year. 

215 - withdrawal_lst: List of annual withdrawal amounts. 

216 - transaction_cost: Transaction cost rate applied to rebalancing and withdrawals. 

217 - scenarios: DataFrame containing asset return scenarios for each year. 

218 - interest_rate: Interest rate applied to borrowed amounts in case of default. 

219 

220 Returns: 

221 - ptf_performance: DataFrame for portfolio performance for each year. 

222 - allocation_df: DataFrame showing asset allocations for each year. 

223 """ 

224 

225 # Initialize the number of years and assets from the targets DataFrame 

226 n_years, n_assets = targets.shape 

227 years = [i + 2023 for i in range(n_years)] 

228 assets = targets.columns 

229 

230 # Prepare DataFrame to store portfolio performance 

231 ptf_performance_columns = [ 

232 "Portfolio Value Primo", 

233 "Portfolio Value Ultimo", 

234 "Withdrawal", 

235 "Returns in DKK", 

236 "Yearly Returns", 

237 "Transaction Costs", 

238 "Absolute DKK Rebalanced", 

239 "Borrowed Amount", 

240 ] 

241 ptf_performance = pd.DataFrame(index=years, columns=ptf_performance_columns) 

242 

243 # DataFrame for tracking asset allocations each year 

244 allocation_df = pd.DataFrame(0, index=years, columns=assets, dtype=float) 

245 

246 # Initialize portfolio values 

247 default_year, borrowed_amount, interest_for_the_year = 0, 0, 0 

248 x_old, portfolio_value_ultimo_aw = pd.Series(0, index=targets.columns), budget 

249 

250 for year in range(n_years): 

251 # Handle scenario where the portfolio is in default 

252 if default_year > 0: 

253 withdrawal_amount = withdrawal_lst[year] 

254 borrowed_amount += withdrawal_amount 

255 interest_for_the_year = borrowed_amount * interest_rate 

256 borrowed_amount += interest_for_the_year # Accumulate interest 

257 

258 # Update performance DataFrame for periods in default 

259 ptf_performance.loc[ptf_performance.index[year]] = { 

260 "Portfolio Value Primo": ptf_performance["Portfolio Value Ultimo"][ptf_performance.index[year - 1]], 

261 "Portfolio Value Ultimo": -borrowed_amount, 

262 "Withdrawal": withdrawal_lst[year], 

263 "Returns in DKK": -interest_for_the_year, 

264 "Yearly Returns": -interest_rate, 

265 "Transaction Costs": 0, 

266 "Absolute DKK Rebalanced": 0, 

267 "Borrowed Amount": borrowed_amount - interest_for_the_year, 

268 } 

269 ptf_performance["Default Year"] = default_year 

270 continue 

271 

272 # Normal operation: calculate returns, rebalancing, and manage withdrawals 

273 port_weights = targets.iloc[year] 

274 absolute_rebalance = (port_weights - x_old).abs().sum() * portfolio_value_ultimo_aw 

275 costs_rebalance = absolute_rebalance * transaction_cost 

276 

277 portfolio_value_primo = portfolio_value_ultimo_aw - costs_rebalance 

278 

279 year_return = np.dot(scenarios.loc[year], port_weights) 

280 portfolio_value_ultimo_bw = portfolio_value_primo * (1 + year_return) 

281 

282 # Check if withdrawals exceed the portfolio value, leading to default 

283 if withdrawal_lst[year] >= portfolio_value_ultimo_bw * (1 + transaction_cost): 

284 withdrawal_amount = portfolio_value_ultimo_bw * (1 - transaction_cost) 

285 default_year = year + 2023 

286 borrowed_amount = withdrawal_lst[year] - withdrawal_amount 

287 interest_for_the_year = borrowed_amount * interest_rate 

288 borrowed_amount += interest_for_the_year 

289 

290 else: 

291 withdrawal_amount = withdrawal_lst[year] 

292 

293 costs_withdraw = withdrawal_amount * transaction_cost 

294 costs_total = costs_rebalance + costs_withdraw 

295 portfolio_value_ultimo_aw = portfolio_value_ultimo_bw - (withdrawal_amount + costs_withdraw) 

296 

297 x_old = port_weights 

298 

299 # Update allocation DataFrame for the year 

300 allocation_df.loc[allocation_df.index[year], :] = port_weights 

301 

302 # Update summary DataFrame for normal operation 

303 ptf_performance.loc[ptf_performance.index[year]] = { 

304 "Portfolio Value Primo": portfolio_value_primo, 

305 "Portfolio Value Ultimo": portfolio_value_ultimo_aw - borrowed_amount, 

306 "Withdrawal": withdrawal_lst[year], 

307 "Returns in DKK": portfolio_value_primo * year_return, 

308 "Yearly Returns": year_return, 

309 "Transaction Costs": costs_total, 

310 "Absolute DKK Rebalanced": absolute_rebalance, 

311 "Borrowed Amount": borrowed_amount, 

312 } 

313 ptf_performance["Default Year"] = default_year 

314 

315 return ptf_performance, allocation_df 

316 

317 

318def riskadjust_model_scen( 

319 scen: np.ndarray, 

320 targets: pd.DataFrame, 

321 budget: float, 

322 trans_cost: float, 

323 withdrawal_lst: list[float], 

324 interest_rate: float, 

325) -> (pd.DataFrame, pd.DataFrame, pd.DataFrame): 

326 """ 

327 Simulates portfolio performance across different scenarios, adjusting for risk and calculating 

328 various financial metrics based on the portfolio's rebalancing strategy. 

329 

330 Parameters: 

331 - scen: 3D numpy array containing return scenarios (scenarios, periods, assets). 

332 - targets: DataFrame specifying target asset allocations for each period. 

333 - budget: Initial budget for investment. 

334 - trans_cost: Transaction costs as a fraction of the trade amount. 

335 - withdrawal_lst: List of annual withdrawal amounts. 

336 - interest_rate: Interest rate applied to borrowed amounts in case of default. 

337 

338 Returns: 

339 - portfolio_df: DataFrame summarizing the performance metrics for each scenario. 

340 - mean_allocations_df: DataFrame showing the average asset allocations across all scenarios for each period. 

341 - analysis_metrics: Dictionary containing overall analysis metrics calculated from portfolio_df. 

342 

343 The function iterates through each scenario, rebalances the portfolio according to the targets, 

344 and calculates performance metrics such as total returns, costs, withdrawals, and risk-adjusted measures. 

345 It also tracks the occurrence of default events and calculates average allocations and other analysis metrics. 

346 """ 

347 

348 s_points, p_points, a_points = scen.shape # Scenario, periods, and assets dimensions 

349 assets = targets.columns # Asset names from the targets DataFrame 

350 

351 # Initialize DataFrame to hold portfolio performance metrics for each scenario 

352 portfolio_df = pd.DataFrame( 

353 columns=[ 

354 "Terminal Wealth", 

355 "Total Returns", 

356 "Total Costs", 

357 "Total Withdrawals", 

358 "Default Year", 

359 "Average Cash Hold", 

360 "Annual StDev", 

361 "Annual StDev_dd", 

362 "Average Annual Return", 

363 "Sharpe Ratio", 

364 "Sortino Ratio", 

365 "Total borrowed", 

366 ], 

367 index=range(s_points), 

368 ) 

369 # Initialize array to hold asset allocations for all scenarios 

370 all_allocations = np.zeros((s_points, p_points, a_points)) 

371 

372 for scenario in range(s_points): 

373 # Convert scenario data to DataFrame for processing 

374 scenarios_df = pd.DataFrame(scen[scenario, :, :], columns=assets) 

375 

376 # Perform portfolio rebalancing for the scenario 

377 res, res_alloc = portfolio_rebalancing( 

378 budget=budget, 

379 targets=targets, 

380 withdrawal_lst=withdrawal_lst, 

381 transaction_cost=trans_cost, 

382 scenarios=scenarios_df, 

383 interest_rate=interest_rate, 

384 ) 

385 

386 # Store the allocation results for this scenario 

387 all_allocations[scenario] = res_alloc.to_numpy() 

388 

389 # Calculate risk metrics for the scenario 

390 annual_return, annual_std_dev, sr, downside_std_dev, sortino_ratio = calculate_risk_metrics( 

391 res["Yearly Returns"] 

392 ) 

393 

394 # Update the portfolio DataFrame with calculated metrics for the scenario 

395 portfolio_df.loc[scenario] = { 

396 "Terminal Wealth": res["Portfolio Value Ultimo"].iloc[-1], 

397 "Total Returns": res["Returns in DKK"].sum(), 

398 "Total Costs": res["Transaction Costs"].sum(), 

399 "Total Withdrawals": res["Withdrawal"].sum(), 

400 "Default Year": res["Default Year"].max(), 

401 "Average Cash Hold": res_alloc["Cash"].mean(), 

402 "Annual StDev": annual_std_dev, 

403 "Annual StDev_dd": downside_std_dev, 

404 "Average Annual Return": annual_return, 

405 "Sharpe Ratio": sr, 

406 "Sortino Ratio": sortino_ratio, 

407 "Total borrowed": res["Borrowed Amount"].sum(), 

408 } 

409 

410 if scenario % (s_points // 2) == 0 and scenario != 0: 

411 logger.info(f"{scenario} out of {s_points} scenarios finished") 

412 

413 # Log progress and calculate default count 

414 default_count = (portfolio_df["Default Year"] != 0).sum() 

415 

416 # Reshape and aggregate allocation data for analysis 

417 index = pd.MultiIndex.from_product([range(s_points), range(p_points)], names=["scenario", "period"]) 

418 allocations_long = all_allocations.reshape(-1, a_points) # Reshape to long format 

419 allocations_df = pd.DataFrame(allocations_long, index=index, columns=assets) 

420 mean_allocations_df = allocations_df.groupby("period").mean() # Mean allocations by period 

421 

422 # Calculate overall analysis metrics from portfolio performance 

423 analysis_metrics = calculate_analysis_metrics(portfolio_df["Terminal Wealth"]) 

424 

425 logger.info( 

426 f"{s_points} out of {s_points} scenarios has now been made. We saw a total of {default_count} " 

427 f"scenarios where the risk budget glide path strategy defaulted over the period of {p_points} years." 

428 ) 

429 return portfolio_df, mean_allocations_df, analysis_metrics