Skip to content
Property-cat reinsurance analytics for software engineers

Financial Modelling · Section 5.2

Catastrophe excess of loss

The contract that composes filter, contract period, occurrence excess, and aggregate excess, with reinstatements encoded in the aggregate limit.

A catastrophe excess of loss contract (CatXoL) composes four building blocks. The filter and contract period select the covered events, the occurrence excess clips each one, and the aggregate excess caps the annual total. For a CatXoL with nn reinstatements:

CatXoL(A,ℓ,n)=aggexcess(0,  (1+n)⋅ℓ)∘occexcess(A,ℓ)∘period(t0,t1)∘filter(P)\text{CatXoL}(A, \ell, n) = \text{agg}_{\text{excess}}(0,\; (1+n) \cdot \ell) \circ \text{occ}_{\text{excess}}(A, \ell) \circ \text{period}(t_0, t_1) \circ \text{filter}(P)

The occurrence layer attaches at AA (the attachment point) and pays up to ℓ\ell (the limit); Contract 1 is $30M excess of $10M, so A=10MA = 10\text{M} and ℓ=30M\ell = 30\text{M} (in dollars). The nn reinstatements, introduced conceptually in Foundations, set the aggregate limit.

Read the formula as every composition is read. It defines the contract at 100% (Caldera Re’s 14.5% participation enters at portfolio roll-up), and it is exactly the pipeline we dissected on Trial 9, now named term by term. For Contract 1:

Building blockContract 1
Loss filterperil = HU, geography = FL
Contract period2024-01-01 to 2024-12-31
Occurrence excess$30M xs $10M per event
Aggregate excesscap at $60M (1 reinstatement)

The composition represents the reinstatement as an aggregate limit, which encodes the recoveries; the reinstatement premium is computed from the composition’s output, outside it.

The aggregate limit (1+n)⋅ℓ(1 + n) \cdot \ell controls how many times the full occurrence limit can be paid. Without reinstatements (n=0n = 0), the aggregate limit equals the occurrence limit: one full event. Each reinstatement restores the per-occurrence limit once, free or in exchange for an additional premium.2

With one reinstatement Contract 1’s aggregate limit is $60M, and Trial 9 is a trial in which it binds.

On Trial 9, the four Florida hurricanes’ per-occurrence gross accumulate against the $60M aggregate limit: $19.7M, $30M, $0 (a $4.0M storm short of the attachment), and $30M after the $30M xs $10M layer. The cumulative would reach $79.7M, but with one reinstatement (two full limits of cover) the limit caps it at $60M:

Hurricane

Contract 1 (30M xs 10M, 1 reinstatement) on Trial 9: each Florida hurricane's gross after the occurrence layer, accumulating against the $60M aggregate limit. The cumulative line is clipped at that limit, and the trial's gross is $60.0M. The sliders set the attachment, the occurrence limit and the number of reinstatements, and the EP curve chart on this page follows them.

caldera_re/catxol.py Python
import numpy as np
import pandas as pd

from caldera_re.aggregate_excess import aggregate_excess
from caldera_re.loss_filter import loss_filter
from caldera_re.occurrence_excess import OCCURRENCE_KEY, occurrence_excess
from caldera_re.aep import aep
from caldera_re.data import cedents
from caldera_re.contract_period import contract_period
from caldera_re.report import table
from caldera_re.tvar import tvar
from caldera_re.var import var


def catxol(
    loss: pd.DataFrame,
    attachment: float,
    limit: float,
    reinstatements: int = 0,
    period_start: str = '2024-01-01',
    period_end: str = '2024-12-31',
    **criteria,
) -> pd.DataFrame:
    """Apply CatXoL: filter + contract period + occurrence layer + aggregate
    cap. The result is the 100% layer; participation is applied at roll-up.

    Composition:
        agg_excess(0, agg_limit) . occ_excess(A, l)
                                 . period(t0, t1) . filter(criteria)

    Filters to the covered rows and contract period, applies the occurrence
    excess per event, and caps total gross at the aggregate limit
    (1 + reinstatements) * limit. The cap erodes per occurrence (YELT in,
    YELT out): each occurrence's 'gross_loss' is its contribution to the
    capped aggregate, split across the occurrence's rows in proportion to
    their losses, so per-trial sums give the trial-level result exactly.

    Parameters
    ----------
    loss : pd.DataFrame
        YELT with 'trial_id' and 'loss' columns.
    attachment : float
        Per-occurrence attachment point.
    limit : float
        Per-occurrence limit.
    reinstatements : int
        Number of reinstatements (default 0 = single coverage).
    period_start, period_end : str
        Contract period, inclusive. Defaults to full-year 2024.
    **criteria
        Filter column = value pairs, e.g. peril='HU', geography_id='FL'.

    Returns the covered input rows in claims order, every input column
    kept, with 'gross_loss' carrying each row's share of the occurrence-layer
    result after the aggregate cap's erosion.
    """
    covered = loss_filter(loss, **criteria)
    covered = contract_period(covered, period_start, period_end)
    occ = occurrence_excess(covered, attachment, limit)
    agg_limit = limit * (1 + reinstatements)
    result = aggregate_excess(occ, attachment=0, limit=agg_limit,
                              loss_column='gross_loss')
    return result


if __name__ == '__main__':
    # Contract 1: SunCoast FL HU CatXoL, $30M xs $10M, 1 reinstatement.
    A, L, REINST = 10.0, 30.0, 1
    crit = dict(peril='HU', geography_id='FL')

    # Covered subject (filter + contract period) for the per-occurrence view.
    covered = contract_period(loss_filter(cedents.suncoast, **crit),
                            '2024-01-01', '2024-12-31')
    occ_result = occurrence_excess(covered, attachment=A, limit=L)
    print(f"Contract 1: Florida Hurricane CatXoL ({L:.0f}M xs {A:.0f}M, "
          f"{REINST} reinst.)")
    print(f"\nPer-occurrence layer application:")

    N = 20
    lines = []
    for s in range(1, N + 1):
        rows = occ_result[occ_result['trial_id'] == s]
        n_occ = rows.groupby(OCCURRENCE_KEY).ngroups
        subj = rows['loss'].sum() if n_occ > 0 else 0
        gross = rows['gross_loss'].sum() if n_occ > 0 else 0
        if subj > 0:
            lines.append((s, n_occ, subj, gross))
    table(['Trial', 'Occs', 'Subject', 'Occ Gross'], lines,
          fmt=['{}', '{}', '{:.1f}M', '{:.1f}M'])

    # Step 2: full CatXoL composition on the raw YELT
    # (filter -> period -> occ_excess -> agg_excess), at the 100% layer.
    result = catxol(cedents.suncoast, attachment=A, limit=L,
                    reinstatements=REINST, **crit)
    by_trial = (result.groupby('trial_id')
                .agg(aggregate_subject=('loss', 'sum'),
                     gross_loss=('gross_loss', 'sum'))
                .reset_index())
    occ_total = occ_result.groupby('trial_id')['gross_loss'].sum()
    agg_limit = L * (1 + REINST)
    print(f"\nAfter aggregate cap (limit = {agg_limit:.0f}M, "
          f"{REINST} reinstatement):")

    gross_arr = np.zeros(N)
    lines = []
    for s in range(1, N + 1):
        row = by_trial[by_trial['trial_id'] == s]
        if len(row) > 0:
            subj = float(occ_total.get(s, 0.0))
            gross = row['gross_loss'].iloc[0]
            gross_arr[s - 1] = gross
            if subj > 0:
                lines.append((s, subj, gross))
    table(['Trial', 'Occ Total', 'Final Gross'], lines,
          fmt=['{}', '{:.1f}M', '{:.1f}M'])

    print(f"\nGross EL: {np.mean(gross_arr):.2f}M")

    gross_curve = aep(pd.DataFrame({
        'trial_id': range(1, N + 1), 'loss': gross_arr,
    }), n_trials=N)
    print(f"Gross VaR(90%): {var(gross_curve, 0.90):.1f}M")
    print(f"Gross TVaR(90%): {tvar(gross_curve, 0.90):.1f}M")
    print(f"\nNote: With multiple FL HU occurrences per trial,")
    print(f"the aggregate cap binds when per-occurrence payouts")
    print(f"sum to more than the aggregate limit ({agg_limit:.0f}M).")
    print(f"The cap erodes per occurrence, so the output stays a YELT.")

The reinstatement makes the contract stateful within a trial. The October storm’s gross depends on what July and September consumed: the same $30M occurrence pays $30M early in the year and $10.3M once $49.7M of the aggregate limit is gone, so occurrences must be evaluated in claims order and the aggregate cap is a running quantity, not a per-trial constant. The cap erodes occurrence by occurrence, so the output stays a year event loss table (YELT): occurrence exceedance probability (OEP) metrics and occurrence-term retrocession still work downstream. Across trials there is no state at all: the partition by trial holds, and what the state costs at production scale is one sort into claims order plus a running sum per trial. The aggregate excess page gives the vectorised form.

Across all 20 trials of the demo tier, the standalone gross distribution is reported at 100%, before participation. The $60M aggregate limit binds in two trials, Trial 9 and Trial 19:

Contract 1 (30M xs 10M, 1 reinstatement) over the 20 trials: the Florida-hurricane subject against the gross loss. 13 of 20 trials pay; the expected gross loss is $17.39M. The sliders are shared with the Trial 9 chart on this page.

This composition turns Trial 9’s $272.1M subject into a $60M standalone gross (Caldera Re books its 14.5% share, $8.7M, at roll-up) and across all 20 trials produces a standalone expected loss of $17.4M.

Drop the per-occurrence layer and you have an aggregate excess of loss (AggXoL) contract; replace the occurrence excess with a trigger and you have a franchise structure, which pays the whole loss once the threshold is exceeded rather than the excess over it.4 The programs page works through how a CatXoL chains with other contracts.


  1. David R. Clark, “Basics of Reinsurance Pricing”, Casualty Actuarial Society study note, rev. 2014, §5A: a catastrophe cover is written per occurrence, and “because the limit is often a substantial dollar amount, the contract provides a limited number of reinstatements”, without which “after the full layer is exhausted, there is no more protection”. United Property and Casualty Insurance Company, “Property Catastrophe Excess of Loss Reinsurance Agreement”, SEC EDGAR exhibit 10.4, 2012, places three occurrence layers over retentions of $15M, $25M, and $63.3M, each reinstated once. ↩

  2. Clark, “Basics of Reinsurance Pricing”, §5A: “the treaty effectively has an aggregate limit equal to one plus the number of reinstatements, times the occurrence limit.” United Property and Casualty Insurance Company, Property Catastrophe Excess of Loss Reinsurance Agreement, whose first layer states both numbers, “the Reinsurer’s liability hereunder shall never exceed $10,000,000 for any one Loss Occurrence and $20,000,000 for all Loss Occurrences during the term of this Agreement”, and whose schedules reinstate one layer free and another “in consideration of the payment by the Company of an additional premium”. ↩

  3. Richard R. Anderson and Weimin Dong, “Pricing Catastrophe Reinsurance with Reinstatement Provisions Using a Catastrophe Model”, CAS Forum, Summer 1998. Prices layers whose reinstatement premiums are pro rata to the amount of limit reinstated and, optionally, to the time remaining. On the free-versus-paid distinction and the variations in practice, see IRMI, “More Porridge Please: Reinstatements in Reinsurance”, and IRMI, “Reinstatement Premium”. ↩

  4. Guy Carpenter, Glossary of Reinsurance Terms, Franchise Covers: “no loss is payable until the loss exceeds a certain amount but, when that amount is exceeded, the whole loss is paid.” ↩