3  Case Studies and Calculations

  1. 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.

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.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)
Table 3.1: Validation statistics for Cat/NonCat case study portfolio.
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)
Table 3.2: Quantiles for the Cat/NonCat case study portfolio.
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)
Table 3.3: Standard distortion parameters at 99.9% capital standard and a 10% overall cost of capital.
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))
Table 3.4: Loss ratio and cost of capital by unit by distortion, lifted allocation.
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])
Table 3.5: Pricing statistics for all-equity capital structure. Wang distortion; all equity capital with lifted natural allocation.
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)
Table 3.6: Cumulative stand-alone pricing statistics for all-equity capital structure, Wang distortion.
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');
Figure 3.1: Cumulative premium, loss, and margin by pool assets, up to 99.9% level.

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%'])
Table 3.7: Natural allocation vs. average return allocation of capital. Wang distortion and linear natural allocation.
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%'])
Table 3.8: Natural allocation vs. average return allocation of capital with a debt and equity capital structure.
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%'])
Table 3.9: Natural allocation vs. average return allocation of capital with a multi-tranche debt and equity capital structure.
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');
Figure 3.2: Discount rate by layer (left) and derivative of discount rate (right). The vertical lines show the capital structure tranche boundaries.

3.4 Case Study 2:

3.5 Case Study 3: