# Occurrence excess

> The term that applies an attachment and limit to each occurrence, keeping the slice of its loss between attachment and layer top, with no state carried over.

Source: https://reinsurance.dev/financial-modelling/terms/occurrence-excess/

[Step 3 of the catastrophe excess of loss (CatXoL) walkthrough](https://reinsurance.dev/financial-modelling/dissection/#step-3-apply-the-occurrence-layer) applied the occurrence excess to Contract 1's covered losses. The occurrence excess applies an [attachment point](https://reinsurance.dev/glossary/#attachment-point) $A$ and a [limit](https://reinsurance.dev/glossary/#limit) $\ell$ to each occurrence independently:

$$
L_{j,t,e}^{\text{gross}} = \min(\max(L_{j,t,e}^{\text{subject}} - A, 0), \ell)
$$

In plain English:
1. Subtract the attachment: $L - A$
2. If negative (loss below attachment), gross loss is 0
3. If positive but above the limit, cap at the limit

Every occurrence is evaluated independently; the trial total is the sum of per-occurrence gross losses.

The formula is written on the occurrence total, and the output is not one row per occurrence. A [year event loss table (YELT)](https://reinsurance.dev/glossary/#yelt-year-event-loss-table) holds one row per occurrence, cedent, geography, and line of business, and the term returns those rows, each with its share of the occurrence's gross. The rows of one occurrence have no order among them, so the split is proportional: a row $r$ of occurrence $(j,t,e)$ with subject loss $L_r^{\text{subject}}$ books

$$
L_r^{\text{gross}} = L_{j,t,e}^{\text{gross}} \cdot \frac{L_r^{\text{subject}}}{L_{j,t,e}^{\text{subject}}}
$$

In English: each row takes the fraction of the occurrence's gross that it contributed to the occurrence's loss. The rows sum back to the occurrence's gross, to floating-point precision, so the split changes where the gross is booked and never how much. This is proportional [back-allocation](https://reinsurance.dev/glossary/#back-allocation), the method the [aggregate excess](https://reinsurance.dev/financial-modelling/terms/aggregate-excess/) also uses inside an occurrence. It keeps the peril, geography, and line-of-business columns on the output: a [filter](https://reinsurance.dev/financial-modelling/terms/filter/) or a slice by line of business applied after this term still has rows to select. Those rows carry attributed gross. Where the filter splits an occurrence, as a filter on line of business or geography does, the filtered rows report a share of the layer's payout, which is not the payout of a layer applied to the filtered losses. A filter that keeps occurrences whole gives the same result on either side of the term; in the demo data every occurrence has one peril, so a peril filter is of that kind.

```mermaid
%%{init: {'flowchart': {'defaultRenderer': 'elk'}}}%%
graph LR
    G[Subject Occurrence Loss]:::Losses --> DED{{Subtract Attachment A}}:::Process
    DED -->|Below A: 0| ZERO[Gross = 0]:::Contract
    DED -->|Above A| CAP{{Cap at Limit ℓ}}:::Process
    CAP -->|Within layer| GROSS[Gross = L - A]:::Metric
    CAP -->|Exceeds layer| MAX[Gross = ℓ]:::Metric

    classDef Losses fill:#F5CEBC,stroke:#B16C4B,stroke-width:1.5px,color:#2B2620
    classDef Contract fill:#BFDBF8,stroke:#4F84BA,stroke-width:1.5px,color:#2B2620
    classDef Process fill:#F7CBCB,stroke:#B46669,stroke-width:1.5px,color:#2B2620
    classDef Metric fill:#BDE2CB,stroke:#459269,stroke-width:1.5px,color:#2B2620
```

:::tip[Intuition]
The term keeps the slice of each occurrence between $A$ and $A + \ell$ and discards the rest. It selects a band of loss amount within each occurrence, not a subset of the occurrences: on the \$15M xs \$5M layer worked below, a \$3M occurrence pays nothing, a \$12M occurrence pays \$7M, and a \$500M occurrence pays the full \$15M, the part of it inside the layer. The gross distribution is concentrated between 0 and the limit, with a mass point at 0 (occurrences below the attachment) and another at the limit (occurrences that exhaust it).
:::

The cedent buys per-occurrence cover against the severity of one event. Each occurrence stands on its own (the layer carries no state through the year, pays the same on a storm in December as on the identical storm in January, and never accumulates small losses toward its attachment), so what transfers is the size of the largest events and what stays with the cedent is their count. Frequency risk, a year of many storms each below the attachment, remains the cedent's, which is why an occurrence layer is paired with [reinstatements](https://reinsurance.dev/financial-modelling/contracts/catxol/#reinstatements-as-the-aggregate-limit) or an [aggregate cover](https://reinsurance.dev/financial-modelling/contracts/aggxol/) when the count matters too.

Applied directly to SunCoast's Trial 9 subject losses (all perils, no filter) at an illustrative \$15M xs \$5M layer, set lower than [Contract 1](https://reinsurance.dev/caldera-re/#portfolio)'s so that more of the trial participates, every occurrence above the \$5M attachment contributes. Eight occurrences do: the three large Florida hurricanes (each exhausting the \$15M limit), plus an Arizona earthquake and four smaller Gulf and Atlantic hurricanes. The larger \$30M xs \$10M Contract 1 layer would have ignored the last five entirely. Everything below \$5M still pays nothing:

*[Interactive figure: Term explorer. See the web page: https://reinsurance.dev/financial-modelling/terms/occurrence-excess/]*

| Occurrence | Subject | Occurrence gross |
| --- | ---: | ---: |
| FL hurricane, Jul 12 | \$29.7M | \$15.0M |
| FL hurricane, Sep 6 | \$102.4M | \$15.0M |
| FL hurricane, Oct 5 | \$80.8M | \$15.0M |
| 5 smaller occurrences (AZ earthquake; hurricanes in LA, GA, TX, and one spanning LA and TX) | \$34.2M | \$9.2M |
| 22 occurrences below the \$5M attachment | \$25.0M | \$0.0M |
| **Trial 9 total** | **\$272.1M** | **\$54.2M** |

The table shows occurrence totals; the output rows sit one level below them. The 19 August hurricane spans Louisiana and Texas, two lines of business in each. Its \$7.95M total exceeds the \$5M attachment by \$2.95M, and the four rows divide that gross in proportion to their losses:

| Row (19 Aug hurricane) | Subject | Share of occurrence | Gross |
| --- | ---: | ---: | ---: |
| TX residential | \$4.02M | 50.6% | \$1.49M |
| TX commercial | \$2.17M | 27.3% | \$0.80M |
| LA residential | \$1.14M | 14.4% | \$0.42M |
| LA commercial | \$0.62M | 7.7% | \$0.23M |
| **Occurrence total** | **\$7.95M** | **100%** | **\$2.95M** |

The Texas rows book \$2.30M of this occurrence's gross and the Louisiana rows \$0.65M, a split no per-occurrence output could report.[^rows-rounding]

[^rows-rounding]: The row values are computed from the unrounded losses and displayed to two decimals; the displayed gross column sums to \$2.94M against the unrounded \$2.95M. The rounding rule is stated in the [Caldera Re appendix](https://reinsurance.dev/caldera-re/#portfolio).

Python (`python/caldera_re/occurrence_excess.py`, runnable on the web page):

```python
import numpy as np
import pandas as pd

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.utils import OCCURRENCE_KEY
from caldera_re.var import var

def occurrence_excess(
    loss: pd.DataFrame,
    attachment: float,
    limit: float,
    loss_column: str = 'loss',
) -> pd.DataFrame:
    """Apply an excess-of-loss layer per occurrence, attributing each
    occurrence's gross back to its rows (YELT in, YELT out).

    An occurrence is the rows sharing (trial_id, timestamp, event_id); it
    spans several rows when the event touches several geographies or lines
    of business. The layer applies to the occurrence total:

        gross = min(max(occurrence_total - attachment, 0), limit)

    The rows of one occurrence have no order among them, so the occurrence's
    gross is split across them in proportion to each row's share of the
    occurrence loss. Per occurrence the row values sum back to the layer's
    gross to floating-point precision (the division and multiplication by the
    occurrence total round; observed error on the demo data is below 1e-14),
    and every input column (peril, geography, LOB, cedent) is kept, so a
    filter or a slice applied after this term selects rows that carry their
    attributed share of the gross.

    Assumes a valid YELT: no NaN in the loss column. The grouped sum skips
    NaN, so a NaN row would understate its occurrence total and shift gross
    onto the occurrence's other rows.

    Vectorized: one hash-grouped transform plus elementwise arithmetic, O(R)
    over the R input rows. No sort and no state across occurrences.

    Parameters
    ----------
    loss : pd.DataFrame
        YELT with the occurrence key columns (trial_id, timestamp,
        event_id) and a loss column.
    attachment : float
        Attachment point. Occurrences at or below it produce zero gross.
    limit : float
        Maximum gross loss per occurrence.
    loss_column : str
        Name of the column containing losses (default: 'loss'). When
        chaining, this may be a prior term's 'gross_loss'; the output's
        'gross_loss' column replaces it.

    Returns the input rows, in input order, with 'gross_loss' carrying each
    row's attributed share of its occurrence's gross.
    """
    out = loss.copy()
    row_loss = out[loss_column].to_numpy()
    occ_total = (out.groupby(OCCURRENCE_KEY, sort=False)[loss_column]
                 .transform('sum').to_numpy())
    occ_gross = np.clip(occ_total - attachment, 0.0, limit)

    # Attribute each occurrence's gross proportionally to its rows.
    share = np.divide(row_loss, occ_total,
                      out=np.zeros(len(out)), where=occ_total > 0)
    out['gross_loss'] = occ_gross * share
    return out

if __name__ == '__main__':
    # Demo: occurrence excess on all SunCoast subject losses, 15M xs 5M.
    # Applied directly to the subject (no peril/geography filter) — every
    # occurrence above the 5M attachment contributes, not just the large ones.
    A = 5.0
    L = 15.0

    subject = contract_period(cedents.suncoast, '2024-01-01', '2024-12-31')
    result = occurrence_excess(subject, attachment=A, limit=L)

    print(f"Occurrence excess: {L:.0f}M xs {A:.0f}M (all SunCoast subject)")

    N = 20
    gross_per_trial = []
    lines = []
    for s in range(1, N + 1):
        rows = result[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
        gross_per_trial.append(gross)
        if subj > 0:
            lines.append((s, n_occ, subj, gross, subj - gross))
    table(['Trial', 'Occs', 'Subject', 'Gross', 'Retained'], lines,
          fmt=['{}', '{}', '{:.1f}M', '{:.1f}M', '{:.1f}M'])

    # Row resolution: one occurrence's gross split across its rows.
    # The occurrence is selected by its full key, not by date alone.
    split = result[(result['trial_id'] == 9)
                   & (result['timestamp'] == '2024-08-19')
                   & (result['event_id'] == 10011)]
    occ_total = split['loss'].sum()
    print(f"\nTrial 9, 19 Aug hurricane: the occurrence's rows")
    lines = [(r.geography_id, r.lob_id, r.loss, r.loss / occ_total,
              r.gross_loss) for r in split.itertuples()]
    lines.append(('', 'total', occ_total, 1, split['gross_loss'].sum()))
    table(['Geo', 'LOB', 'Subject', 'Share', 'Gross'], lines,
          fmt=['{}', '{}', '{:.2f}M', '{:.1%}', '{:.2f}M'])

    gross_arr = np.array(gross_per_trial)
    subject_arr = np.array([
        result[result['trial_id'] == s]['loss'].sum()
        if s in result['trial_id'].values else 0
        for s in range(1, N + 1)
    ])

    print(f"\nSubject EL (all SunCoast): {np.mean(subject_arr):.2f}M")
    print(f"Gross 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")
```

:::tip[Key Insight]
The occurrence excess carries no state across occurrences, and that is its computational signature. The computation is a hash-grouped sum broadcast back to the rows, one vectorised clip of the occurrence totals, and one elementwise multiplication for the row shares. It has no ordering dependency and no per-trial accumulator, so it costs $O(R)$ over $R$ rows with no sort, and it runs on any partition of the data that keeps an occurrence's rows together. The [aggregate excess](https://reinsurance.dev/financial-modelling/terms/aggregate-excess/) is the contrast: it must see a trial's occurrences in claims order and carry a running sum, which is why it, not this term, sets the cost of a [CatXoL](https://reinsurance.dev/financial-modelling/contracts/catxol/) at production scale.
:::

Across all 20 trials of the [demo tier](https://reinsurance.dev/toolkit/trials/#demo-scale), the occurrence excess clips each trial's subject down to the sum of its per-occurrence layered losses:

*[Interactive figure: Term explorer. See the web page: https://reinsurance.dev/financial-modelling/terms/occurrence-excess/]*

The occurrence excess is the per-occurrence layer of a [CatXoL](https://reinsurance.dev/financial-modelling/contracts/catxol/), and with zero attachment it is a [quota share's event limit](https://reinsurance.dev/financial-modelling/contracts/quota-share/#with-event-limit). Its counterpart, the [aggregate excess](https://reinsurance.dev/financial-modelling/terms/aggregate-excess/), applies the same attachment-and-limit shape to the trial total instead of each occurrence.

## Occurrence definition

Every per-occurrence formula on this site takes the `event_id` grouping of the [YELT](https://reinsurance.dev/toolkit/trials/#the-yelt) as the contractual occurrence: one catalog event, one occurrence, one application of the layer. The treaty defines the occurrence itself, and for property cat it does so with an **[hours clause](https://reinsurance.dev/glossary/#hours-clause)**: all loss from one peril within a stated number of consecutive hours counts as one occurrence, and the cedent chooses where the window starts.[^window-start] A common windstorm window is 72 hours, in European wordings and in the United States alike; a tornado clause may run 24 hours and a US flood clause 168.[^hours] Filed wordings vary: one US property-cat treaty sets 96 consecutive hours for the windstorm perils and 168 as its general default.[^window-start] The model's event boundaries and the treaty's window do not always agree. Two clustered European windstorms inside one 72-hour window can be one contractual occurrence and two modelled events. An event-based clause, which requires all loss in the window to come from a single storm, blocks that merger. How many occurrences a catastrophe produces is a question the courts have had to settle more than once,[^aggregation] and this site assumes the model's events and the treaty's occurrences coincide. The **[occurrence definition](https://reinsurance.dev/glossary/#occurrence-definition)** as a term of its own is out of scope for this page.

[^hours]: Verisk, ["Modeling Fundamentals: Accounting for the Hours Clause"](https://www.verisk.com/blog/modeling-fundamental--accounting-for-the-hours-clause/), Verisk blog, 2019. Defines the clause as covering all loss accumulated within a defined number of hours (72 for windstorm in typical European policies, up to 168; 24 for tornado, 168 for US flood), notes that clustered European windstorms can fall inside one window, and describes the event-based variant that restricts the window to a single storm.

Everything above this heading takes the two as coinciding; the [conventions index](https://reinsurance.dev/conventions/) records the assumption, and the [trial worldview](https://reinsurance.dev/toolkit/trials/#events-and-occurrences) draws the event-versus-occurrence distinction the assumption rests on.

[^window-start]: Homeowners Choice Property & Casualty Insurance Company, [Property Catastrophe Excess of Loss Reinsurance Contract, 2016](https://www.sec.gov/Archives/edgar/data/1400810/000119312516670071/d190243dex1011.htm), *SEC EDGAR*. Its Definitions article sets a loss occurrence as the individual losses "occurring during any period of 168 consecutive hours arising out of and directly occasioned by the same event", with 96 hours for windstorm, hail, tornado, and hurricane and 168 for earthquake and winter weather, and it gives the election to the cedent: "The Company may choose the date and time when any such period of consecutive hours commences provided that it is not earlier than the date and time of the occurrence of the first recorded individual loss".

[^aggregation]: *Unipolsai Assicurazioni SpA v Covéa Insurance plc* [2024] EWCA Civ 1110, [Court of Appeal](https://caselaw.nationalarchives.gov.uk/ewca/civ/2024/1110), September 2024, litigated whether a property catastrophe excess of loss reinsurance with a 168-hour clause responded to one catastrophe or several. *Simmonds v Gammell* [2016] EWHC 2515 (Comm), [High Court](https://caselaw.nationalarchives.gov.uk/ewhc/comm/2016/2515), October 2016, decides the same question for the World Trade Center attacks: "A plurality of losses is to be regarded as a single aggregated loss if they can be sufficiently linked to a single unifying event by being causally connected with it." Both are English law, and neither concerns a catastrophe model.
