Basic autotime/reload set up with aimports done
to load auto time ip.magic('load_ext autotime')
doesn't work with qmd
{'autoreload', 'storemagic'}
3 Case Studies and Calculations
- Case studies and examples
This section presents a series of case studies that are used throughout the remainder of the Monograph. It starts with an overview of the aggregate Python package used to generate most exhibits and graphs.
3.1 The aggregate Python Package
My AAS paper S. Mildenhall (2024).
3.2 Python Environment Setup
How to setup env. Prefob code. Etc.
3.3 Case Study 1: Cat/Non-Cat
This Study uses the Cat/NonCat Case Study portfolio from S. J. Mildenhall and Major (2022), described in Chs 2 and 7. The two units proxy a tame non-cat exposed unit and a severely cat exposed one.
Code
port = build('''
port CNC
agg Cat 1 claim sev lognorm 20 cv 1.00 fixed
agg NonCat 1 claim sev gamma 80 cv 0.15 fixed
''')
hGT(port.describe)| unit | X | E[X] | Est E[X] | Err E[X] | CV(X) | Est CV(X) | Err CV(X) | Skew(X) | Est Skew(X) |
|---|---|---|---|---|---|---|---|---|---|
| Cat | Freq | 1.00 | |||||||
| Sev | 20.00 | 20.00 | -0.00 | 1.00 | 1.00 | -0.00 | 4.00 | 4.00 | |
| Agg | 20.00 | 20.00 | -0.00 | 1.00 | 1.00 | -0.00 | 4.00 | 4.00 | |
| NonCat | Freq | 1.00 | |||||||
| Sev | 80.00 | 80.00 | 0.15 | 0.15 | 0.00 | 0.30 | 0.30 | ||
| Agg | 80.00 | 80.00 | 0.15 | 0.15 | 0.00 | 0.30 | 0.30 | ||
| total | Freq | 2.00 | |||||||
| Sev | 50.00 | 50.00 | -0.00 | 0.68 | 0.12 | ||||
| Agg | 100.00 | 100.00 | -0.00 | 0.23 | 0.23 | -0.00 | 2.56 | 2.56 |
Table 3.2 shows quantiles for the portfolio.
Code
quantile_ps = np.array([.5, .8, .9, .95, .98, .99, .995, .999, .9999, .99999, .999999, 1-1e-7, 1-1e-8, 1-1e-9, 1-1e-10, 1])
impact = pd.DataFrame({'p': quantile_ps, 'Independent': port.q(quantile_ps)}).set_index('p')
f = lambda x: f'{x:,.1f}' if x > 1 else ('1' if x== 1 else (f'{x:.4g}' if x < 1-1e-5 else f'1-{1-x:.0e}'))
hGT(impact, table_float_format=f)| p | Independent |
|---|---|
| 0.5 | 96.2 |
| 0.8 | 113.5 |
| 0.9 | 126.0 |
| 0.95 | 139.9 |
| 0.98 | 161.7 |
| 0.99 | 181.1 |
| 0.995 | 203.3 |
| 0.999 | 267.2 |
| 0.9999 | 394.1 |
| 1-1e-05 | 573.6 |
| 1-1e-06 | 820.5 |
| 1-1e-07 | 1,151.2 |
| 1-1e-08 | 1,569.4 |
| 1-1e-09 | 1,959.5 |
| 1-1e-10 | 2,048.0 |
| 1 | 2,048.0 |
The regulatory capital standard is set at 99.9% and the total cost of capital at 10% mirroring S. J. Mildenhall and Major (2022). These quantities are used to calibrate a consistent set of distortions, with parameters shown in Table 3.3
Code
reg_p = 0.999
coc = 0.10
port.calibrate_distortions(COCs=[coc], Ps=[reg_p])
hGT(port.distortion_df[['P', 'param', 'error']].droplevel([0, 1]), 5)| method | P | param | error |
|---|---|---|---|
| ccoc | 115.14968 | 0.10000 | |
| ph | 115.14968 | 0.59651 | 0.00000 |
| wang | 115.14968 | 0.61082 | 0.00000 |
| dual | 115.14968 | 2.46322 | -0.00000 |
| tvar | 115.14968 | 0.48158 | 0.00001 |
Table 3.4 shows the lifted natural allocation (S. J. Mildenhall and Major (2022) Chapter 14, and compare exhibits in Chapter 15.4) premium, margin, loss ratio, allocated assets and capital, leverage (PQ) and cost of capital by unit and by distortion.
Code
ans = port.analyze_distortions(p=reg_p, efficient=False, add_comps=False)
# rename
ans.comp_df = ans.comp_df.rename(index={'ROE': 'COC'})
ans.comp_df = ans.comp_df.rename(index=lambda x: x.replace('Dist ', ''), level=0)
# extract loss ratios and coc
bit1 = ans.comp_df.xs('LR', 0, 1)
bit1 = bit1.iloc[[0, 2, 4, 1, 3]]
bit2 = ans.comp_df.xs('COC', 0, 1)
bit2 = bit2.iloc[[0, 2, 4, 1, 3]]
bit = pd.concat((bit1, bit2), keys=['LR', 'COC'])
hGT(bit.T, ratio_cols='all', vrule_widths=(1.5, 0, 0))| LR | COC | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| line | ccoc | ph | wang | dual | tvar | ccoc | ph | wang | dual | tvar |
| Cat | 48.1% | 60.7% | 63.8% | 67.3% | 69.6% | 10.7% | 12.5% | 12.3% | 10.9% | 9.8% |
| NonCat | 108.6% | 97.2% | 95.4% | 93.6% | 92.5% | 12.9% | 4.7% | 6.5% | 8.7% | 10.2% |
| total | 86.8% | 86.8% | 86.8% | 86.8% | 86.8% | 10.0% | 10.0% | 10.0% | 10.0% | 10.0% |
Table 3.5 and subsequent exhibits focus on the Wang distortion, a “middle of the road” choice. Two units have COCs to NA Q which are hard to interpret. The loss ratio for Cat is lower than NonCat as expected, but the COCs are the opposite of a basic intuition. The reason is the transfers between units caused by limited liability.
Code
dist = 'wang'
basic_order = ['L', 'P', 'M', 'LR', 'a', 'Q', 'PQ', 'COC']
hGT(ans.comp_df.loc[dist].T[basic_order])| line | L | P | M | LR | a | Q | PQ | COC |
|---|---|---|---|---|---|---|---|---|
| Cat | 19.96 | 31.26 | 11.31 | 0.64 | 123.43 | 92.17 | 0.34 | 0.12 |
| NonCat | 79.99 | 83.89 | 3.90 | 0.95 | 143.75 | 59.87 | 1.40 | 0.07 |
| total | 99.95 | 115.15 | 15.20 | 0.87 | 267.19 | 152.04 | 0.76 | 0.10 |
Table 3.6 shows the stand-alone pricing. Here we see the expected ordering of cost of capital for the two units. In this table, assets are determined as the 99.9% quantile of each unit’s losses and the pricing is determined using the Wang distortion calibrated on the total portfolio. Notice that the loss payments are different: the Cat line recovery is lower and NonCat higher, reflecting the inter-unit payments brought about by the limited liability pool. The sum of the stand-alone premiums is obviuosly higher than the pool’s total premium, resulting in a higher cost of capital. In this exhibit we see the expected lower cost of capital for the cat line.
Code
capital_structure = [reg_p]
capital_structure_names = ['Equity', 'Debt', 'Senior']
bit, bit2 = optimal.standalone(port, dist, capital_structure, capital_structure_names)
hGT(bit)| Unit | Tranche | L | P | M | LR | a | Q | PQ | COC |
|---|---|---|---|---|---|---|---|---|---|
| Cat | Equity | 19.95 | 32.82 | 12.87 | 0.61 | 185.28 | 152.46 | 0.22 | 0.08 |
| NonCat | 80.00 | 87.50 | 7.51 | 0.91 | 122.25 | 34.75 | 2.52 | 0.22 | |
| Sum of parts | 99.94 | 120.32 | 20.38 | 0.83 | 307.53 | 187.21 | 0.64 | 0.11 |
Figure 3.1 shows how the natural allocation premium compensates for inter-unit transfers caused by limited liability. The effect is most pronounced at low pool asset levels and can result in a negative margin for the NonCat line.
Code
def rner(x):
x = x.split('_')
if x[0] == 'T.P':
return x[1] + ' premium'
elif x[0] == 'T.L':
return x[1] + ' loss'
else:
return x[1] + ' margin'
fig, axs = plt.subplots(1, 2, figsize=(7, 2.75), constrained_layout=True)
ax0, ax1 = axs.flat
t = ans.augmented_dfs[dist].loc[port.q(.00001):port.q(reg_p)].filter(regex='T\.P_[A-Zt]')
t = t.rename(columns=rner)
t.plot(ax=ax0)
t = ans.augmented_dfs[dist].loc[port.q(.00001):port.q(reg_p)].filter(regex='T\.L_[A-Zt]')
t = t.rename(columns=rner)
t.plot(ax=ax0, lw=.5, color=['C0', 'C1', 'C2'])
ax0.legend(ncols=2, loc='lower right')
ax0.set(title='Cumulative premium and loss', xlabel='Pool assets')
tg = ans.augmented_dfs[dist].loc[port.q(.00001):port.q(reg_p)].filter(regex='T\.M_[A-Zt]')
tg = tg.rename(columns=rner)
tg.plot(ax=ax1)
ax1.axhline(0, lw=.5, c='k')
ax1.legend(loc='lower right')
ax1.set(title='Cumulative margin', xlabel='Pool assets');Table 3.7 compares the natural allocation of capital and the constant allocation for an all-equity capital structure. The two allocations are markedly different. The margin and loss ratios are unchanged.
Code
capital_structure = np.array([0, port.q(reg_p) ])
capital_names = ['All Equity' ]
bit = optimal.make_tranching(ans, dist, capital_structure, capital_names)
hGT(bit, ratio_cols=['LR', 'COC', 'ΔQ%'])| Tranche | Unit | L | P | M | LR | a | COC | Q | Q_const | ΔQ | ΔQ% |
|---|---|---|---|---|---|---|---|---|---|---|---|
| All Equity | Cat | 19.96 | 31.26 | 11.31 | 63.8% | 123.43 | 12.3% | 92.17 | 113.06 | 20.89 | 22.7% |
| NonCat | 79.99 | 83.89 | 3.90 | 95.4% | 143.75 | 6.5% | 59.87 | 38.98 | -20.89 | -34.9% | |
| total | 99.95 | 115.15 | 15.20 | 86.8% | 267.19 | 10.0% | 152.04 | 152.04 | 0.0% |
Table 3.8 uses a debt and equity capital structure. Consistent with rating agency limits on the ratio of debt to total capital, the debt attachment probability is computed to a 30% limit. The table shows that almost all of the difference in allocation is due to the equity layer. This is to be expected from Figure 3.1, which shows all the “weirdness” happens at low asset levels. Table 3.9 shows the same thing for a more complicated capital structure not constrained by the 30% limit.
Code
debt_to_capital_limit = 0.3
# from last table, pull out premium
premium = bit.iloc[-1, 0]
# figure total capital
q = port.q(reg_p) - premium
# debt attachment level
da = premium + q * (1 - debt_to_capital_limit)
debt_attach_prob = port.cdf(da)
capital_structure = np.array([0, port.q(debt_attach_prob), port.q(reg_p), 0, port.q(reg_p)])
capital_names = ['Equity', 'Debt', 'drop', 'Total Capital']
bit = optimal.make_tranching(ans, dist, capital_structure, capital_names)
# x = bit.T.swaplevel(1)
hGT(bit, ratio_cols=['LR', 'COC', 'ΔQ%'])| Tranche | Unit | L | P | M | LR | a | COC | Q | Q_const | ΔQ | ΔQ% |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Equity | Cat | 19.89 | 30.87 | 10.98 | 64.4% | 87.64 | 19.3% | 56.78 | 76.27 | 19.49 | 34.3% |
| NonCat | 79.96 | 83.72 | 3.76 | 95.5% | 129.36 | 8.2% | 45.64 | 26.14 | -19.49 | -42.7% | |
| total | 99.85 | 114.59 | 14.74 | 87.1% | 217.00 | 14.4% | 102.41 | 102.41 | -0.0% | ||
| Debt | Cat | 0.07 | 0.40 | 0.33 | 16.9% | 35.79 | 0.9% | 35.39 | 35.15 | -0.25 | -0.7% |
| NonCat | 0.03 | 0.17 | 0.14 | 17.7% | 14.40 | 1.0% | 14.23 | 14.48 | 0.25 | 1.7% | |
| total | 0.10 | 0.56 | 0.47 | 17.2% | 50.19 | 0.9% | 49.63 | 49.63 | 0.0% | ||
| Total Capital | Cat | 19.96 | 31.26 | 11.31 | 63.8% | 123.43 | 12.3% | 92.17 | 113.06 | 20.89 | 22.7% |
| NonCat | 79.99 | 83.89 | 3.90 | 95.4% | 143.75 | 6.5% | 59.87 | 38.98 | -20.89 | -34.9% | |
| total | 99.95 | 115.15 | 15.20 | 86.8% | 267.19 | 10.0% | 152.04 | 152.04 | 0.0% |
Code
capital_structure = np.array([0, port.q(.95), port.q(.98), port.q(.99), port.q(reg_p), 0, port.q(reg_p)])
capital_names = ['Equity', 'Junk', 'Debt', 'Senior', 'drop', 'Total Capital']
bit = optimal.make_tranching(ans, dist, capital_structure, capital_names)
hGT(bit, ratio_cols=['LR', 'COC', 'ΔQ%'])| Tranche | Unit | L | P | M | LR | a | COC | Q | Q_const | ΔQ | ΔQ% |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Equity | Cat | 19.25 | 28.36 | 9.11 | 67.9% | 41.33 | 70.2% | 12.97 | 23.66 | 10.69 | 82.4% |
| NonCat | 79.41 | 81.77 | 2.37 | 97.1% | 98.61 | 14.1% | 16.84 | 6.15 | -10.69 | -63.5% | |
| total | 98.65 | 110.13 | 11.48 | 89.6% | 139.94 | 38.5% | 29.81 | 29.81 | 0.0% | ||
| Junk | Cat | 0.35 | 1.21 | 0.86 | 28.9% | 11.52 | 8.3% | 10.31 | 10.23 | -0.08 | -0.8% |
| NonCat | 0.35 | 1.12 | 0.77 | 31.4% | 10.20 | 8.5% | 9.08 | 9.16 | 0.08 | 0.9% | |
| total | 0.70 | 2.33 | 1.63 | 30.1% | 21.72 | 8.4% | 19.39 | 19.39 | 0.0% | ||
| Debt | Cat | 0.16 | 0.65 | 0.49 | 24.4% | 11.48 | 4.5% | 10.83 | 10.79 | -0.04 | -0.4% |
| NonCat | 0.12 | 0.46 | 0.34 | 26.0% | 7.96 | 4.6% | 7.49 | 7.53 | 0.04 | 0.5% | |
| total | 0.28 | 1.11 | 0.83 | 25.1% | 19.44 | 4.5% | 18.33 | 18.33 | 0.0% | ||
| Senior | Cat | 0.20 | 1.05 | 0.85 | 19.3% | 59.11 | 1.5% | 58.06 | 56.47 | -1.58 | -2.7% |
| NonCat | 0.11 | 0.53 | 0.42 | 20.6% | 26.99 | 1.6% | 26.46 | 28.04 | 1.58 | 6.0% | |
| total | 0.31 | 1.58 | 1.27 | 19.7% | 86.09 | 1.5% | 84.51 | 84.51 | 0.0% | ||
| Total Capital | Cat | 19.96 | 31.26 | 11.31 | 63.8% | 123.43 | 12.3% | 92.17 | 113.06 | 20.89 | 22.7% |
| NonCat | 79.99 | 83.89 | 3.90 | 95.4% | 143.75 | 6.5% | 59.87 | 38.98 | -20.89 | -34.9% | |
| total | 99.95 | 115.15 | 15.20 | 86.8% | 267.19 | 10.0% | 152.04 | 152.04 | 0.0% |
Figure 3.2 shows that outside the equity layer the return (discount rate) changes quite slowly, explaining why the average allocation is very similar to the natural allocation for the non-equity layers. All of the allocations “add-up” and so the total capital row in Table 3.7, Table 3.8 and Table 3.9 are all identical.
Code
fig, axs = plt.subplots(1, 2, figsize=(2 * 3.5, 2.75), constrained_layout=True)
ax0, ax1 = axs.flat
g = port.dists[dist]
S = port.density_df.loc[:port.q(reg_p), 'S']
gS = g.g(S)
d = (gS - S) / (1 - S)
ax0.plot(S.index, S, label='S')
ax0.plot(S.index, d, label='Discount return')
for x in capital_structure:
if x > 0:
ax0.axvline(x, lw=.5, c='C4')
ax0.legend(loc='upper right')
ax0.set(title='S and rate of discount by layer', xlabel='Asset level')
S = S.loc[port.q(0.01):]
d = d.loc[port.q(0.01):]
ax1.plot(S.index, np.gradient(d, S.index))
for x in capital_structure:
if x > 0:
ax1.axvline(x, lw=.5, c='C4')
ax1.set(title='Gradient of layer rate of discount', xlabel='Asset level');