Source code for climada_petals.engine.cat_bonds.sng_bond_simulation

import pandas as pd
import numpy as np
from .utils_cat_bonds import multi_level_es

from climada_petals.util.config import LOGGER


[docs] class SingleCountryBondSimulation: """ Single-country catastrophe bond simulation utilities. This class simulates losses and returns for a single country's bond based on per-event payouts and damages. """
[docs] def __init__(self, subarea_calc, term: int, start_year: int, number_of_terms: int): """ Initialize with subarea data and simulation horizon. Parameters ---------- subarea_calc : object Subarea calculation instance. term : int Bond term in years for each simulated period. start_year : int The starting year of the simulation. number_of_terms : int Number of consecutive terms to simulate. """ self.term = term self.start_year = start_year self.number_of_terms = number_of_terms self.subarea_calc = subarea_calc
'''Simulate one term of bond to derive losses'''
[docs] def init_bond_loss(self, events_per_year: list[pd.DataFrame]): """ Calculates the expected losses for a catastrophe bond over its term. This function simulates the bond's loss experience given a sequence of event data per year and month, tracking payouts, damages, remaining princpal value, and the timing of losses. It returns the relative losses, the total payouts and damages per term, and a DataFrame detailing losses and their corresponding months. Parameters ---------- events_per_year : list[pandas.DataFrame] A list where each element is a DataFrame representing events in a year. Each DataFrame must contain at least 'month' and 'pay' columns, where 'pay' is the payout for each event. Returns ------- rel_annual_losses : numpy.ndarray Array of relative payouts/losses per year (losses divided by principal). rel_monthly_loss : pandas.DataFrame DataFrame with columns 'losses' and 'months', detailing the losses and their corresponding months for each year. summed_payouts : float The total summed payouts over the bond's term. summed_damages : float The total summed damages over the bond's term. """ principal0 = self.subarea_calc.principal principal = principal0 # Use Python lists only for month-level output (tiny) df_monthly = pd.DataFrame(columns=[ "losses", "months"], dtype=object ) annual_losses = pd.Series(0.0, index=range(self.term)) summed_damages = 0.0 for year, ev in enumerate(events_per_year): # Extract arrays months = ev["month"].to_numpy() pays = ev["pay"].to_numpy() damages = ev["damage"].to_numpy() summed_damages += damages.sum() # Running cumulative payout to detect exhaustion cum = np.cumsum(pays) # Identify first index where principal is exceeded exhaust_idx = np.searchsorted(cum, principal, side="right") if exhaust_idx == len(pays): # principal never exhausted → no capping needed payouts = pays.copy() principal -= payouts.sum() else: # principal exhausted at this index payouts = np.zeros_like(pays, dtype=float) # All payouts before exhaustion are exact if exhaust_idx > 0: payouts[:exhaust_idx] = pays[:exhaust_idx] # Payout at exhaustion month: whatever principal remains prev_cum = cum[exhaust_idx-1] if exhaust_idx > 0 else 0 payouts[exhaust_idx] = principal - prev_cum # After that → principal is 0, so payouts remain 0 principal = 0.0 # Store relative losses and months as arrays for consistent indexing df_monthly.loc[year, "losses"] = list(payouts / principal0) df_monthly.loc[year, "months"] = list(months) # Sum for annual loss annual_losses[year] = payouts.sum() rel_annual_losses = annual_losses / principal0 summed_payouts = annual_losses.sum() return rel_annual_losses, df_monthly, summed_payouts, summed_damages
[docs] def init_loss_simulation(self, confidence_levels: list[float] = [0.95, 0.99]): """ Simulate losses, payouts, damages, and risk metrics for a catastrophe bond. Parameters ---------- confidence_levels : list[float], optional Confidence levels for VaR and ES calculation. Sets ------- df_loss_month : pd.DataFrame Monthly loss data for all simulations. loss_metrics : dict Expected loss, attachment probability, total payouts/damages, VaR and ES metrics for given confidence levels. """ pay_vs_dam = self.subarea_calc.pay_vs_dam annual_losses = [] list_loss_month = [] total_payouts = 0 total_damages = 0 # Iterate over non-overlapping term blocks for bond_date in range(self.start_year, self.start_year + self.number_of_terms * self.term, self.term): # Collect events for the full term (vectorized selection) events_per_year = [ pay_vs_dam[pay_vs_dam['year'] == (bond_date + offset)].groupby(['month', 'year']).sum().reset_index().sort_values(by=['year','month']) for offset in range(self.term) ] ann_losses_term, monthly_losses, summed_payouts, summed_damages = ( self.init_bond_loss(events_per_year) ) annual_losses.extend(ann_losses_term) list_loss_month.append(monthly_losses) total_payouts += summed_payouts total_damages += summed_damages # Combine monthly losses self.df_loss_month = pd.concat(list_loss_month, ignore_index=True) annual_losses = pd.Series(annual_losses) exp_loss_ann = annual_losses.mean() att_prob = (annual_losses > 0).mean() # Save metrics self.loss_metrics = { 'Expected_annual_loss': exp_loss_ann, 'Annual_attachment_probability': att_prob, 'Total_payout': total_payouts, 'Total_damages': total_damages, } var_list, es_list = multi_level_es(annual_losses, confidence_levels) for cl, var, es in zip(confidence_levels, var_list, es_list): self.loss_metrics[f'Annual_Value_at_risk_{int(cl*100)}'] = var self.loss_metrics[f'Annual_Expected_shortfall_{int(cl*100)}'] = es LOGGER.info(f'Expected Loss = {exp_loss_ann}') LOGGER.info(f'Attachment Probability = {att_prob}')
'''Simulate over all terms of bond to derive returns'''
[docs] def init_return_simulation(self, premium: float): """ Simulates the performance of a catastrophe bond over the simulation period, premiums and returns. This function models the bond's payouts, premiums, and returns over a series of simulated years. It aggregates annual and total returns and computes Sharpe ratios. Parameters ---------- premium : float Annual premium rate for the bond. Returns ------- return_metrics : dict Dictionary with annual premiums, annual returns, total returns, total premiums, and Sharpe ratio. """ premiums_tot = [] ncf_tot = [] cur_nominal = 1 for i in range(len(self.df_loss_month)): losses = self.df_loss_month['losses'].iloc[i] months = self.df_loss_month['months'].iloc[i] if np.sum(losses) == 0: prem_tmp = cur_nominal * premium premiums_tot.append(prem_tmp) ncf_tot.append(prem_tmp) else: ncf_tot_tmp = [] premiums_tot_tmp = [] prem_tmp = cur_nominal * premium / 12 * months[0] premiums_tot_tmp.append(prem_tmp) ncf_tot_tmp.append(prem_tmp) for j in range(len(losses)): loss = losses[j] month = months[j] cur_nominal -= loss if cur_nominal < 0: loss += cur_nominal cur_nominal = 0 else: pass if j + 1 < len(losses): next_month = months[j+1] prem_tmp = ((cur_nominal * premium) / 12 * (next_month - month)) premiums_tot_tmp.append(prem_tmp) ncf_tot_tmp.append(prem_tmp - loss) else: prem_tmp = ((cur_nominal * premium) / 12 * (12- month)) premiums_tot_tmp.append(prem_tmp) ncf_tot_tmp.append(prem_tmp - loss) ncf_tot.append(np.sum(ncf_tot_tmp)) premiums_tot.append(np.sum(premiums_tot_tmp)) if (i + 1) % self.term == 0: cur_nominal = 1 sharpe_ratio = (np.mean(ncf_tot) / np.std(ncf_tot)) if np.std(ncf_tot) != 0 else np.nan self.return_metrics = {'annual_premiums': np.array(premiums_tot), 'annual_returns': np.array(ncf_tot), 'total_returns': np.sum(np.array(ncf_tot)) * self.subarea_calc.principal , 'total_premiums': np.sum(np.array(premiums_tot)) * self.subarea_calc.principal, 'sharpe_ratio': sharpe_ratio}