CLO Waterfall Model
Implementation Plan
Overview
We are building a Quarto document that models the full lifecycle cashflow
waterfall of a realistic US broadly syndicated CLO — from loan pool collections
through to tranche-by-tranche distributions and equity residual. The model runs
a single base case scenario and produces Polars DataFrames for each tranche's
cashflows, the OC/IC test timeline, and standard analytics (WAL, IRR, discount margin).
All assumptions are cleanly separated so that stress scenarios can be added later
by simply providing a second assumption dict to api.runDeal().
The computation engine is Hastructure, a high-performance structured finance cashflow engine written in Haskell. It runs as a local Docker container and exposes a REST API. The absbox Python library is the thin client that serialises deal definitions to JSON, POSTs them to Hastructure, and deserialises the cashflow results back into Python. All result DataFrames are converted from pandas to Polars immediately for downstream processing.
~250 US BSL loans
deal definition API
Docker :8081
cashflows + metrics
Docker Desktop must be running with the Hastructure container started:
docker run -p 8081:8081 yellowbean/hastructure:a0.52.4.
Python packages: absbox==0.52.3, quarto, polars,
great-tables, pandas (absbox internal dependency),
numpy.
The Quarto document uses a large number of structured finance acronyms (CDR, CPR,
WAL, IRR, OC, IC, PIK, SOFR, WAS, WARF, BSL, LGD and others). Every acronym
should be wrapped in an HTML <abbr> tag with a
title attribute carrying the full form. This gives a native browser
tooltip on hover. A small CSS rule added to the document's custom stylesheet
renders a dotted underline so readers know the tooltip is available:
abbr[title] { text-decoration: underline dotted; text-underline-offset: 3px; cursor: help; }
Usage in prose cells — wrap every acronym on first use and wherever clarity helps:
The pool's <abbr title="Weighted Average Spread">WAS</abbr> is 411bps over <abbr title="Secured Overnight Financing Rate, 3-month term">SOFR3M</abbr>. The annual <abbr title="Constant Default Rate">CDR</abbr> assumption is 2%.
Deal Architecture — Blackstone BSL CLO 2024-1
We model a representative US broadly syndicated CLO based on standard 2024 market conventions. The fictional deal — "Blackstone BSL CLO 2024-1" — is sized at $600m with seven tranches: six rated debt classes (A through F) and an unrated equity tranche. All rated tranches are SOFR3M-based floaters resetting quarterly.
The capital structure is calibrated to typical 2024 US CLO arbitrage parameters: Class A at 62% of the stack (consistent with Moody's AAA sizing methodology), a debt-to-equity ratio of 19:1, and an equity tranche sized at 5% — the minimum risk retention required under US CLO 2.0 / Dodd-Frank Credit Risk Retention rules (79 FR 77601, December 2014).
| Class | Rating | Balance | % Stack | Spread (bps) | All-In @ SOFR 5.25% | Quarterly Interest |
|---|---|---|---|---|---|---|
| A | $372.0m | 62.0% | 150 | 6.75% | $6.278m | |
| B | $66.0m | 11.0% | 220 | 7.45% | $1.229m | |
| C | $54.0m | 9.0% | 300 | 8.25% | $1.114m | |
| D | $36.0m | 6.0% | 425 | 9.50% | $0.855m | |
| E | $30.0m | 5.0% | 650 | 11.75% | $0.881m | |
| F | $12.0m | 2.0% | 875 | 14.00% | $0.420m | |
| Equity | $30.0m | 5.0% | — | Residual | — | |
| Total | $600.0m | 100% | $10.777m debt service |
par— $600m face valueloans— ~250 US BSL obligorsWAS— ~410bps over SOFR3MWARF— ~2800 (B/B− average)diversity score— ≥ 60 (Moody's)WAL— ~5.7 years
reinvestment period— 2 yearslegal maturity— 12 yearspayment frequency— quarterlySOFR reset— 3-month (SOFR3M)senior mgmt fee— 0.15% p.a.sub mgmt fee— 0.35% p.a.
Under Dodd-Frank Credit Risk Retention rules, the CLO manager must hold at least 5% of each tranche or a "vertical slice" — hence equity sized at exactly 5%. US BSL CLOs use SOFR3M (3-month term SOFR), not overnight SOFR, because the 3-month tenor matches the quarterly payment cycle and avoids daily compounding complexity.
Loan Pool Construction
The loan pool is the engine of the CLO — all cash flowing through the waterfall originates here. We will construct a statistically representative synthetic tape of ~250 US broadly syndicated leveraged loans, calibrated to publicly available 2024 Leveraged Commentary & Data (LCD) market statistics. No Bloomberg or CUSIP licence is required.
Loans are diversified across 12 GICS industries, with technology, healthcare, and industrials making up the largest concentrations — consistent with the current US leveraged loan index composition. Each loan is represented by 7 fields that absbox needs to project its cashflows.
| id str |
orig_bal f64 |
curr_bal f64 |
spread f64 |
remain_mth i32 |
industry str |
|---|---|---|---|---|---|
| L001 | 3,200,000.0 | 3,200,000.0 | 0.0400 | 78 | Technology |
| L002 | 1,800,000.0 | 1,800,000.0 | 0.0375 | 66 | Healthcare |
| L003 | 2,600,000.0 | 2,600,000.0 | 0.0450 | 72 | Industrials |
| L004 | 2,100,000.0 | 2,100,000.0 | 0.0425 | 60 | Consumer Disc. |
| … | … | … | … | … | … |
| L250 | 2,400,000.0 | 2,400,000.0 | 0.0410 | 75 | Software |
absbox expects loans as Python dicts, not DataFrames. We write a helper
df_to_pool(df: pl.DataFrame) → list[dict] that maps each row
to the absbox loan schema. The pool object also receives a cutoffDate
of "2024-03-31" — the date from which cashflows are projected.
absbox API Primer
absbox has three main building blocks: the Pool (the loan assets),
the Bond (an individual CLO tranche), and the Deal
(the full CLO structure combining pool, bonds, accounts, fees, and waterfall).
The API object connects to the Docker engine and exposes
runPool() and runDeal().
Pool(assets=[loan_dicts], cutoff="2024-03-31") → loan pool
Bond(balance=372e6, rate=Floater("SOFR3M", 0.015)) → AAA tranche
api.runDeal(deal, assump) → dict[str, pd.DataFrame]
from absbox import API, EnginePath import polars as pl # EnginePath.LOCAL resolves to "http://localhost:8081" # Hastructure must be running: docker run -p 8081:8081 yellowbean/hastructure api = API(EnginePath.LOCAL) # Sanity-check — prints the engine version print(api.version())
from absbox.local.component import Bond, Floater # Class A: $372m, SOFR3M + 150bps, quarterly payments class_a = Bond( balance = 372_000_000, rate = Floater("SOFR3M", 0.0150), originDate = "2024-03-31", startDate = "2024-03-31", rateResetDates = ["2024-06-30", "2024-09-30", "..."], paymentDates = ["2024-06-30", "2024-09-30", "..."] )
result = api.runDeal(deal, base_assumptions) # result is a dict keyed by bond/account name → pandas DataFrames # Convert everything to Polars in one pass pool_cf = pl.from_pandas(result["pool"]) bonds = { k: pl.from_pandas(v) for k, v in result.items() if k.startswith("Class") or k == "Equity" } # bonds["ClassA"], bonds["ClassB"], ..., bonds["Equity"]
The absbox Python package minor version must align with the Hastructure Docker image.
We use absbox==0.52.3 with the image tag
yellowbean/hastructure:a0.52.4. Mismatched versions produce JSON
parse errors that are hard to debug — always pin both together.
Waterfall Logic — Priority of Payments
The CLO waterfall defines the strict order in which quarterly cash collected from the loan pool is distributed. Cash flows top-down — senior creditors are paid first. OC and IC tests are evaluated at designated steps; a failure diverts remaining cash upward to sequentially pay down senior principal until the ratio recovers.
In absbox, the waterfall is encoded as a Python list of action tuples. Each action
is a two-element list: an action type string (e.g. "payFee",
"payInt", "runOCTest") and the name of the target account
or bond. absbox handles the conditional OC/IC diversion logic automatically.
A
D
− Debt service = $10.777m − Fees = $0.990m
= Equity residual ≈ $2.258m/qtr → ~$9.0m p.a. → ~30% on $30m equity
Interest Deferral — PIK vs Non-PIK by Tranche
When OC tests fail and the waterfall diverts cash upward, not every tranche is affected in the same way. The critical distinction is whether unpaid interest is non-deferrable — meaning it must be paid in full in cash every quarter — or PIK-eligible (PIK = Payment In Kind) — meaning unpaid interest capitalises onto the outstanding note balance and accrues at the stated coupon until the waterfall recovers.
- Interest must be paid in cash every quarter, no exceptions
- OC test failure triggers principal paydown, not interest deferral — cash is diverted from junior tranches to retire senior principal until the ratio recovers
- Senior coupons continue uninterrupted throughout
- A missed cash payment on any of these classes is a hard default event
- Unpaid interest capitalises onto the outstanding note balance each quarter
- Accrues at the full stated spread (SOFR3M + spread) on the grown balance — it compounds
- No cash default event — deferral is a structural feature, not a breach
- Deferred interest must be fully cleared before equity receives any residual once OC tests cure
- Compounding means equity IRR deteriorates faster than the face shortfall alone implies
In absbox, deferral behaviour is set on each Bond object individually. Non-deferrable tranches (A–D) use a standard sequential-pay configuration — if the waterfall runs short, absbox records a hard loss rather than accruing the shortfall. PIK-eligible tranches (E and F) require a deferrable flag so that the Hastructure engine tracks unpaid interest separately, adds it to the note balance each period it goes unpaid, and clears it ahead of equity distributions once tests recover. If this flag is omitted, absbox silently defaults to non-deferrable behaviour for all tranches — understating junior tranche balances in any quarter where the waterfall falls short.
Equity receives whatever cash remains after all debt tranches and fees are settled. If nothing remains, equity simply receives zero that quarter — there is no accrual, no PIK, and no catch-up mechanism. Missed equity distributions are gone permanently. This is why equity IRR is so sensitive to the number of quarters where the waterfall is fully consumed by OC-triggered paydowns.
Base Case Assumptions
All modelling assumptions are defined in a single dictionary before being passed to
api.runDeal(). Keeping them in one place makes the model easy to audit
and extend — adding a stress scenario later is as simple as creating a second dict
that overrides the specific values you want to change.
The base case reflects typical 2024 US leveraged loan market conditions: a 2% annual CDR (broadly in line with the trailing 12-month US leveraged loan default rate), 15% CPR (driven by refinancing and repricing activity), 65% recovery rate (consistent with Moody's LGD database for US senior secured loans), and SOFR3M held flat at 5.25%.
# ── Pool cashflow assumptions ───────────────────────────────────────────── # All scenario inputs live here. To add a stress case, create a new dict # overriding only the keys that differ: # mild_assump = {**BASE_ASSUMPTIONS, "default": ("CDR", 0.05), ...} # ───────────────────────────────────────────────────────────────────────── BASE_ASSUMPTIONS = { # Annual Constant Default Rate — 2% reflects 2024 US BSL market conditions "default" : ("CDR", 0.02), # Annual Constant Prepayment Rate — 15% reflects active refi/reprice market "prepay" : ("CPR", 0.15), # Recovery rate on defaulted par — 65% is the Moody's US senior secured avg "recovery" : 0.65, # Months from default event to cash recovery (typical US workout timeline) "recoveryLag" : 12, # Forward curve: hold SOFR3M flat at 5.25% for the full projection horizon "rates" : {"SOFR3M": 0.0525}, }
CDR— 2.0% annual default rateCPR— 15% annual prepayment rateRecovery— 65% of defaulted parRecovery lag— 12 monthsSOFR3M— 5.25% (flat)Equity IRR (est.)— ~16–18%
- Create a new dict overriding specific keys only
- Mild stress: CDR 5%, CPR 10%, Recovery 55%
- Severe stress: CDR 10%, CPR 5%, Recovery 40%
- Pass each dict to
api.runDeal()separately - Compare results with Polars joins on the date column
recoveryLag = 12 means defaulted par is removed from the pool
immediately (reducing OC ratios) but recovered cash only arrives 12 months later —
the typical workout timeline for US leveraged loans. This creates a short window
where OC tests are most at risk: pool par has shrunk from defaults but recoveries
have not yet arrived to replenish it.
Outputs & Analytics
api.runDeal() returns a dict of pandas DataFrames keyed by the names
given to bonds and accounts in the Deal definition. We convert all of them to Polars
immediately. The key tables are described below — these are the DataFrames we will
work with throughout Steps 6–8.
Pool Cashflow Table — pool_cf
| Column | Type | Description |
|---|---|---|
| Date | Date | Quarterly payment date |
| Balance | Float64 | Outstanding pool par at period start — shrinks as defaults accumulate outside the reinvestment period |
| Interest | Float64 | Gross interest collected from performing loans this quarter |
| Principal | Float64 | Scheduled principal (bullet repayments at loan maturity) |
| Prepayment | Float64 | Unscheduled prepayments driven by the CPR assumption |
| Default | Float64 | Par removed from pool this quarter due to defaults (CDR × balance) |
| Recovery | Float64 | Cash recovered from prior defaults (arrives after recoveryLag months) |
Bond Cashflow Table — bonds["ClassA"] etc.
| Column | Type | Description |
|---|---|---|
| Date | Date | Payment date |
| Balance | Float64 | Note balance at period start — $0 during reinvestment period for all tranches except when OC test forces early paydown |
| Interest | Float64 | Coupon payment received — SOFR3M × spread × balance ÷ 4 |
| Principal | Float64 | Principal received — zero during reinvestment; sequential after |
| Loss | Float64 | Credit loss allocated to this tranche (junior tranches absorb first) |
date interest principal equity_dist 2024-06-30 0.0 0.0 2_258_000 2024-09-30 0.0 0.0 2_258_000 2024-12-31 0.0 0.0 2_258_000 2025-03-31 0.0 0.0 2_258_000 2025-06-30 0.0 0.0 2_190_000 ← minor CDR erosion begins 2025-09-30 0.0 0.0 2_127_000 2026-03-31 0.0 0.0 1_940_000 2026-06-30 0.0 0.0 1_880_000 ← recoveries arriving, par stabilising
Analytics We Compute in Step 7
WAL— Weighted Average Life (years)IRR— Internal Rate of ReturnDM— Discount Margin vs SOFR3Mtotal_interest— total coupon receivedloss_rate— % of face value lost
OC_ratio_A— per quarter timelineOC_ratio_D— per quarter timelinefirst_breach_date— if any OC test failsequity_irr— base case equity returnexcess_spread— quarterly timeline
Computing OC ratios requires joining pool par values against cumulative note
balances across 40+ quarters for all 7 tranches and 3 scenarios — roughly 840
cells of state. Polars' lazy execution plan handles this as a single vectorised
expression without materialising intermediate DataFrames. WAL calculation
(time-weighted average of principal cashflows) is also a natural Polars
with_columns expression over the bond cashflow table.
Implementation Roadmap — 8 Coding Steps
The document is organised into 8 Quarto code sections, each building on the previous one. Every section is self-contained: a short prose block explains the absbox concept being introduced, followed by a code cell that implements it and a great_tables display of the key outputs. You can inspect what absbox is consuming and producing at each stage before moving on.
Import dependencies, instantiate the API object pointing to
localhost:8081, and verify the connection is live. Print the Hastructure
engine version to confirm it matches the absbox package version.
great_tables · numpy
engine version string
Generate a 250-loan US BSL tape using numpy random draws calibrated to 2024 LCD market statistics. Assign loans to 12 GICS industries with industry-specific spread ranges. Compute and display pool summary: par, WAS, WARF, WAL, loan count by sector.
market calibration dict
pool_summary: pl.DataFrame
Write df_to_pool(df) to map the Polars DataFrame to a list of
absbox loan dicts. Run api.runPool() with Base assumptions to validate
pool cashflows before adding deal structure. Inspect total interest collections
match the expected $14m/qtr.
base_assump dict
pool_cf: pl.DataFrame
Instantiate Bond objects for Classes A, B, C, D, E, F, and Equity. Each bond
receives its balance, a Floater("SOFR3M", spread) rate, and the
deal's quarterly payment and rate reset schedule. Classes A–D are configured as
non-deferrable — interest must pay in cash each quarter. Classes E and F are
configured as PIK-eligible so that any interest the waterfall cannot reach
capitalises onto the note balance rather than recording a hard shortfall. Equity
is defined as a residual claim with no accrual. Print a summary table confirming
total debt ($570m) + equity ($30m) = pool par ($600m).
quarterly date schedule
capital_structure_df
Encode the 8-step priority of payments as an absbox waterfall list. Define fee accounts (senior expenses, senior management fee, subordinated management fee). Set OC test floors per tranche. Assemble the complete Deal object combining pool, bonds, accounts, triggers, and waterfall. Print a human-readable waterfall summary.
OC/IC floor thresholds
waterfall_summary: str
Pass the deal and BASE_ASSUMPTIONS dict to api.runDeal().
Convert all results to Polars immediately. Organise by tranche name:
bonds["ClassA"], bonds["ClassB"], etc. Show a Q1
Class A cashflow preview and confirm the equity residual matches the $2.26m estimate.
The assumption dict is already structured so a second scenario can be added with one
extra runDeal() call.
pool_cf: pl.DataFrame
Using Polars expressions: compute WAL per tranche (time-weighted principal cashflows), IRR per tranche, and OC ratios by joining pool and bond balance columns. Flag the first quarter where each OC test breaches (if at all in the base case). Display the equity IRR and the full OC ratio timeline across all 48 quarters.
OC floor constants
oc_timeline: great_tables GT
Produce the final deliverables as great_tables: (1) a full quarterly waterfall distribution table — all tranches, all 48 periods, with currency formatting and alternating row shading; (2) a per-tranche analytics summary with WAL, IRR, and loss rate, colour-coded by seniority; (3) an OC ratio timeline confirming no test breaches in the base case. All tables render inline in the Quarto HTML output.
tranche_summary GT table
After all 8 steps: a validated $600m US BSL CLO running in Hastructure; quarterly cashflows for all 7 tranches over a 12-year horizon as Polars DataFrames; an OC/IC test timeline confirming test passage in the base case; equity IRR of ~16–18%; and WAL / discount margin per tranche. All outputs are in Polars and ready for further analysis, export, or extension with stress scenarios.
Each section introduces exactly one new absbox concept. Prose before the code cell
explains the concept and the key parameters involved. Output is rendered inline as
great_tables HTML tables — no separate visualisation step needed. To re-run under
different assumptions, update BASE_ASSUMPTIONS and re-render with
quarto render clo_model.qmd.