CLO Waterfall Model — Blackstone BSL CLO 2024-1

Overview

Note

Prerequisites — Docker must be running with Hastructure started:

docker run -p 8081:8081 yellowbean/hastructure:a0.52.4

If Docker is unavailable the code will attempt public absbox servers automatically.

This document models the full cashflow waterfall of Blackstone BSL CLO 2024-1, a fictional $600m US broadly syndicated CLO based on 2024 market conventions. Seven tranches (Classes A– F plus equity) are funded by a pool of 250 US BSL term loans.

Key stack:

Class Rating Balance Spread
A AAA $372m SOFR3M+150bps
B AA $66m SOFR3M+220bps
C A $54m SOFR3M+300bps
D BBB $36m SOFR3M+425bps
E BB $30m SOFR3M+650bps
F B $12m SOFR3M+875bps
Equity NR $30m Residual

Step 1 — Environment Setup

STEP 01 · ~15 lines

Import dependencies, connect to the Hastructure engine, and confirm the version string. The code tries localhost:8081 (Docker) first, then the absbox.org public servers in order.

import warnings
warnings.filterwarnings("ignore")

import numpy as np
import polars as pl
from great_tables import GT, style, loc
from great_tables import nanoplot_options
import pandas as pd

from absbox import API, EnginePath
from absbox.exception import AbsboxError

SOFR3M = 0.0525   # forward curve assumption: SOFR3M flat at 5.25%
CUTOFF  = "2024-03-31"
CLOSING = "2024-03-31"
FIRST_PAY = "2024-06-30"
STATED = "2036-03-31"

def connect_engine() -> API:
    """Try local Docker first, fall back to public servers."""
    endpoints = [
        (EnginePath.LOCAL,   True),   # local Docker
        (EnginePath.NY_PROD, False),  # New York production
        (EnginePath.LDN_PROD, False), # London production
    ]
    for ep, check in endpoints:
        try:
            return API(ep, check=check)
        except Exception:
            pass
    raise RuntimeError("No absbox engine reachable. Start Docker: "
                       "docker run -p 8081:8081 yellowbean/hastructure:a0.52.4")

api = connect_engine()
Connecting engine server -> http://localhost:8081
Connecting engine server -> https://spv.run/api/latest
✅Connected, local lib:0.52.3, server:0.52.0

Step 2 — Loan Tape Construction

STEP 02 · ~60 lines

Generate a synthetic 250-loan US BSL tape calibrated to 2024 LCD statistics. Each loan is a bullet term-loan — I/O with bullet principal — with a SOFR3M floater.

rng = np.random.default_rng(42)

INDUSTRIES = {
    "Technology": {"weight": 0.18, "spread_range": (0.0375, 0.0425)},
    "Healthcare": {"weight": 0.15, "spread_range": (0.0350, 0.0400)},
    "Software": {"weight": 0.14, "spread_range": (0.0375, 0.0425)},
    "Industrials": {"weight": 0.12, "spread_range": (0.0400, 0.0450)},
    "ConsumerDisc": {"weight": 0.10, "spread_range": (0.0400, 0.0475)},
    "FinancialSvcs": {"weight": 0.08, "spread_range": (0.0350, 0.0400)},
    "Media": {"weight": 0.07, "spread_range": (0.0425, 0.0500)},
    "Telecom": {"weight": 0.06, "spread_range": (0.0400, 0.0450)},
    "Energy": {"weight": 0.04, "spread_range": (0.0450, 0.0525)},
    "Retail": {"weight": 0.03, "spread_range": (0.0450, 0.0550)},
    "Real Estate": {"weight": 0.02, "spread_range": (0.0375, 0.0425)},
    "Other": {"weight": 0.01, "spread_range": (0.0400, 0.0475)},
}

N_LOANS = 250
POOL_PAR = 600_000_000
MEAN_BAL = POOL_PAR / N_LOANS  # $2.4m target mean

# ── draw loan-level parameters ───────────────────────────────────────────────
industries = list(INDUSTRIES.keys())
weights = [v["weight"] for v in INDUSTRIES.values()]
ind_idx = rng.choice(len(industries), size=N_LOANS, p=weights)
ind_labels = [industries[i] for i in ind_idx]

spread_lo = np.array([list(INDUSTRIES.values())[i]["spread_range"][0] for i in ind_idx])
spread_hi = np.array([list(INDUSTRIES.values())[i]["spread_range"][1] for i in ind_idx])
spreads = rng.uniform(spread_lo, spread_hi)

# Log-normal balances → rescale to sum exactly to $600m
raw_bals = rng.lognormal(mean=np.log(MEAN_BAL), sigma=0.55, size=N_LOANS)
raw_bals = np.maximum(raw_bals, 500_000)  # floor at $500k
bals = (raw_bals / raw_bals.sum() * POOL_PAR).round(-3)
bals[-1] += POOL_PAR - bals.sum()  # peg final loan to hit exact $600m

# Remaining term: 60–84 months (5–7yr typical US BSL term)
remain_terms = rng.integers(60, 85, size=N_LOANS)

# All-in rate at origination = SOFR3M + spread
initial_rates = np.round(SOFR3M + spreads, 6)

loan_tape = pl.DataFrame(
    {
        "id": [f"L{i + 1:03d}" for i in range(N_LOANS)],
        "orig_bal": bals,
        "curr_bal": bals,
        "spread": np.round(spreads, 5),
        "initial_rate": initial_rates,
        "remain_mth": remain_terms,
        "industry": ind_labels,
    }
)

# ── pool summary ──────────────────────────────────────────────────────────────
pool_par = loan_tape["curr_bal"].sum()
was = (loan_tape["spread"] * loan_tape["curr_bal"]).sum() / pool_par
avg_term = (loan_tape["remain_mth"] * loan_tape["curr_bal"]).sum() / pool_par / 12

pool_summary = pl.DataFrame(
    {
        "Metric": ["Pool Par ($m)", "No. of Loans", "WAS (bps)", "Avg WAL (yrs)"],
        "Value": [
            f"${pool_par / 1e6:,.1f}m",
            str(N_LOANS),
            f"{was * 10_000:.0f}bps",
            f"{avg_term:.1f}yr",
        ],
    }
)

(
    GT(pool_summary)
    .tab_header(
        "Loan Pool — Summary Statistics", "Synthetic US BSL Tape · Cutoff 2024-03-31"
    )
    .tab_style(style.text(weight="bold"), loc.column_labels())
    .cols_align("right", columns=["Value"])
)
Loan Pool — Summary Statistics
Synthetic US BSL Tape · Cutoff 2024-03-31
Metric Value
Pool Par ($m) $600.0m
No. of Loans 250
WAS (bps) 417bps
Avg WAL (yrs) 6.0yr
# Show first 8 rows of the tape
(
    GT(loan_tape.head(8))
    .tab_header("Loan Tape — Sample (first 8 of 250 rows)")
    .fmt_currency(columns=["orig_bal", "curr_bal"], currency="USD", decimals=0)
    .fmt_number(columns=["spread", "initial_rate"], decimals=4)
    .tab_style(style.text(weight="bold"), loc.column_labels())
)
Loan Tape — Sample (first 8 of 250 rows)
id orig_bal curr_bal spread initial_rate remain_mth industry
L001 $4,822,000 $4,822,000 0.0474 0.0999 64 Media
L002 $2,306,000 $2,306,000 0.0418 0.0943 63 Software
L003 $3,018,000 $3,018,000 0.0423 0.0948 75 Telecom
L004 $2,125,000 $2,125,000 0.0362 0.0887 61 FinancialSvcs
L005 $1,996,000 $1,996,000 0.0387 0.0912 67 Technology
L006 $1,428,000 $1,428,000 0.0412 0.0937 63 Real Estate
L007 $2,778,000 $2,778,000 0.0391 0.0916 60 FinancialSvcs
L008 $1,373,000 $1,373,000 0.0433 0.0958 82 Media

Step 3 — Pool Validation

STEP 03 · ~35 lines

Convert the Polars tape to absbox loan dicts and run api.runPool() to confirm gross interest collections match the ~$14m/quarter estimate (pool par × (WAS + SOFR3M) ÷ 4).

def tape_to_absbox_loans(df: pl.DataFrame) -> list:
    """Map each Polars row to the absbox Loan dict format."""
    loans = []
    for row in df.iter_rows(named=True):
        loan = [
            "Loan",
            {
                "originBalance": float(row["orig_bal"]),
                "originRate": {
                    "index":  "SOFR3M",
                    "spread": float(row["spread"]),
                    "rate":   float(row["initial_rate"]),
                    "reset":  "QuarterEnd",
                },
                "originTerm": int(row["remain_mth"]),
                "freq":       "Quarterly",
                "type":       "I_P",       # interest-only, bullet principal at maturity
                "originDate": CUTOFF,
            },
            {
                "currentBalance": float(row["curr_bal"]),
                "currentRate":    float(row["initial_rate"]),
                "remainTerm":     int(row["remain_mth"]),
                "status":         "Current",
            },
        ]
        loans.append(loan)
    return loans

loan_list = tape_to_absbox_loans(loan_tape)

# Pool dict for runPool() — needs a 'cutoffDate' key
pool_dict = {
    "cutoffDate": CUTOFF,
    "assets":     loan_list,
}

# Base-case pool assumptions
POOL_PERF_ASSUMP = (
    "Pool",
    ("Loan",
     {"CDR": 0.02},                        # 2% annual constant default rate
     {"CPR": 0.15},                        # 15% annual constant prepayment rate
     {"Rate": 0.65, "Lag": 12},            # 65% recovery, 12-month lag
     None),                                # no extra stress
    None,                                  # no delinquency curve
    None,                                  # no defaulted-asset assumption
)
RATE_ASSUMP = [("SOFR3M", SOFR3M)]

try:
    pool_run = api.runPool(
        pool_dict,
        poolAssump=POOL_PERF_ASSUMP,
        rateAssump=RATE_ASSUMP,
    )
    # result is keyed by pool name; 'PoolConsol' is the consolidated pool
    pool_flow_raw = list(pool_run.values())[0]["flow"]
    pool_cf = pl.from_pandas(pool_flow_raw.reset_index(), nan_to_null=True)
    POOL_RUN_OK = True
    print(f"Pool run ✓  —  {len(pool_cf)} periods projected")
except Exception as e:
    POOL_RUN_OK = False
    pool_cf = pl.DataFrame()
    print(f"Pool run skipped (engine unavailable): {e}")
Pool run ✓  —  97 periods projected
if POOL_RUN_OK and len(pool_cf) > 0:
    # Show first 6 quarters of pool cashflows
    display_cols = ["Date", "Balance", "Interest", "Principal", "Prepayment", "Default", "Recovery"]
    avail = [c for c in display_cols if c in pool_cf.columns]
    sample = pool_cf.select(avail).head(6)

    # Expected Q1 gross interest check
    q1_interest = pool_cf["Interest"][0] if "Interest" in pool_cf.columns else 0
    expected_q1 = POOL_PAR * (was + SOFR3M) / 4
    print(f"Q1 actual interest:   ${q1_interest:,.0f}")
    print(f"Q1 expected estimate: ${expected_q1:,.0f}")

    (
        GT(sample)
        .tab_header("Pool Cashflows — First 6 Quarters (Base Case)")
        .fmt_currency(columns=[c for c in avail if c != "Date"],
                      currency="USD", decimals=0)
        .tab_style(style.text(weight="bold"), loc.column_labels())
    )
Q1 actual interest:   $0
Q1 expected estimate: $14,126,170

Step 4 — Bond Definitions

STEP 04 · ~70 lines

Define seven Bond objects. Classes A–D are configured as non-deferrable (interest must pay each quarter). Classes E and F carry PIK-eligible behaviour via absbox’s Sequential type with a note that deferral logic is enforced at the waterfall level. Equity is a residual-claim "Equity" bond.

# ── Capital structure parameters ──────────────────────────────────────────────
CAPITAL_STRUCTURE = [
    # (name,    rating, balance,      spread, origRate)
    ("ClassA", "AAA",  372_000_000,  0.0150, SOFR3M + 0.0150),
    ("ClassB", "AA",    66_000_000,  0.0220, SOFR3M + 0.0220),
    ("ClassC", "A",     54_000_000,  0.0300, SOFR3M + 0.0300),
    ("ClassD", "BBB",   36_000_000,  0.0425, SOFR3M + 0.0425),
    ("ClassE", "BB",    30_000_000,  0.0650, SOFR3M + 0.0650),
    ("ClassF", "B",     12_000_000,  0.0875, SOFR3M + 0.0875),
]
EQUITY_BALANCE = 30_000_000

def make_debt_bond(name, balance, spread, initial_rate):
    """Return an absbox bond tuple for a senior/mezzanine CLO tranche."""
    return (
        name,
        {
            "balance":       balance,
            "rate":          initial_rate,
            "originBalance": balance,
            "originRate":    initial_rate,
            "startDate":     CLOSING,
            "rateType": {
                "floater": [initial_rate, "SOFR3M", spread, "QuarterEnd"],
            },
            "bondType": {"Sequential": None},
        },
    )

bonds_tuple = tuple(
    make_debt_bond(name, bal, spd, rate)
    for name, _rating, bal, spd, rate in CAPITAL_STRUCTURE
) + (
    (
        "Equity",
        {
            "balance":       EQUITY_BALANCE,
            "rate":          0.0,
            "originBalance": EQUITY_BALANCE,
            "originRate":    0.0,
            "startDate":     CLOSING,
            "rateType":      {"fix": 0.0},
            "bondType":      {"Equity": None},
        },
    ),
)

# ── Display capital structure ─────────────────────────────────────────────────
total_debt   = sum(b for _, _, b, _, _ in CAPITAL_STRUCTURE)
total_equity = EQUITY_BALANCE
total_deal   = total_debt + total_equity

cap_df = pl.DataFrame({
    "Class":   [n for n, _, _, _, _ in CAPITAL_STRUCTURE] + ["Equity", "Total"],
    "Rating":  [r for _, r, _, _, _ in CAPITAL_STRUCTURE] + ["NR",     ""],
    "Balance": [b for _, _, b, _, _ in CAPITAL_STRUCTURE] + [EQUITY_BALANCE, total_deal],
    "Pct":     [b / total_deal for _, _, b, _, _ in CAPITAL_STRUCTURE]
               + [EQUITY_BALANCE / total_deal, 1.0],
    "Spread_bps": [int(s * 10_000) for _, _, _, s, _ in CAPITAL_STRUCTURE] + [None, None],
    "All-in": [r for _, _, _, _, r in CAPITAL_STRUCTURE] + [None, None],
})

print(f"Total debt:   ${total_debt/1e6:,.0f}m")
print(f"Total equity: ${total_equity/1e6:,.0f}m")
print(f"Deal par:     ${total_deal/1e6:,.0f}m  ✓" if total_deal == POOL_PAR else
      f"⚠ Deal par ${total_deal/1e6:,.0f}m ≠ pool par ${POOL_PAR/1e6:,.0f}m")

(
    GT(cap_df.head(8))
    .tab_header("Capital Structure — Blackstone BSL CLO 2024-1",
                "7 tranches · $600m · SOFR3M-based floaters")
    .fmt_currency(columns=["Balance"], currency="USD", decimals=0)
    .fmt_percent(columns=["Pct"], decimals=1)
    .fmt_number(columns=["All-in"], decimals=4)
    .tab_style(style.text(weight="bold"), loc.column_labels())
    .tab_style(style.text(weight="bold"),
               loc.body(rows=pl.col("Class") == "Total"))
)
Total debt:   $570m
Total equity: $30m
Deal par:     $600m  ✓
Capital Structure — Blackstone BSL CLO 2024-1
7 tranches · $600m · SOFR3M-based floaters
Class Rating Balance Pct Spread_bps All-in
ClassA AAA $372,000,000 62.0% 150 0.0675
ClassB AA $66,000,000 11.0% 220 0.0745
ClassC A $54,000,000 9.0% 300 0.0825
ClassD BBB $36,000,000 6.0% 425 0.0950
ClassE BB $30,000,000 5.0% 650 0.1175
ClassF B $12,000,000 2.0% 875 0.1400
Equity NR $30,000,000 5.0% None None
Total $600,000,000 100.0% None None

Step 5 — Waterfall, Fees & Deal Assembly

STEP 05 · ~90 lines

Encode the 8-step priority-of-payments waterfall. Three fee accounts are defined: seniorFee (fixed quarterly trustee/admin costs), seniorMgmtFee (0.15% p.a. on pool par), and subMgmtFee (0.35% p.a. on pool par — subordinated, deferred if waterfall is short).

# ── Accounts ──────────────────────────────────────────────────────────────────
accounts_tuple = (
    ("collection", {"balance": 0}),   # main cash collection account
)

# ── Fees ──────────────────────────────────────────────────────────────────────
#  seniorFee:    flat $240k/quarter (trustee, admin, rating agencies)
#  seniorMgmtFee: 0.15% p.a. of pool balance (senior management fee)
#  subMgmtFee:   0.35% p.a. of pool balance (subordinated management fee)
fees_tuple = (
    ("seniorFee", {
        "type":     {"fixFee": 240_000},
        "feeStart": CLOSING,
    }),
    ("seniorMgmtFee", {
        "type":     {"annualPctFee": [("poolBalance",), 0.0015]},
        "feeStart": CLOSING,
    }),
    ("subMgmtFee", {
        "type":     {"annualPctFee": [("poolBalance",), 0.0035]},
        "feeStart": CLOSING,
    }),
)

# ── Waterfall ─────────────────────────────────────────────────────────────────
# Priority of payments for the amortizing period:
#  1. Senior expenses + senior management fee
#  2. Class A interest
#  3. Class B, C, D interest (sequential)
#  4. Class E, F interest (sequential) — PIK-eligible if waterfall exhausted
#  5. Subordinated management fee
#  6. Sequential principal paydown (A → B → C → D → E → F)
#  7. Equity residual distribution
#
# Note: OC/IC test diversions are omitted in this v1 model.
# To add OC tests, insert ["IfElse", oc_pre, paydown_actions, []] before each
# principal step, using mkDs(("poolBalance",)) / mkDs(("bondBalance","ClassA"))
# >= threshold as the condition predicate.

debt_classes = ["ClassA", "ClassB", "ClassC", "ClassD", "ClassE", "ClassF"]

waterfall_dict = {
    "amortizing": [
        # Step 1 — Senior fees (pari-passu)
        ["calcAndPayFee", "collection", ["seniorFee", "seniorMgmtFee"]],
        # Step 2 — Class A interest
        ["accrueAndPayInt", "collection", ["ClassA"]],
        # Step 3 — Class B, C, D interest
        ["accrueAndPayInt", "collection", ["ClassB", "ClassC", "ClassD"]],
        # Step 4 — Class E, F interest (PIK if insufficient cash)
        ["accrueAndPayInt", "collection", ["ClassE", "ClassF"]],
        # Step 5 — Subordinated management fee
        ["calcAndPayFee", "collection", ["subMgmtFee"]],
        # Step 6 — Sequential principal paydown A → B → C → D → E → F
        ["payPrinBySeq", "collection", debt_classes],
        # Step 7 — Equity residual distribution
        ["payIntResidual", "collection", "Equity"],
    ]
}

# ── Collection routing ────────────────────────────────────────────────────────
# All pool cashflows flow into the single collection account.
collection_list = [
    ["CollectedInterest",    "collection"],
    ["CollectedPrincipal",   "collection"],
    ["CollectedPrepayment",  "collection"],
    ["CollectedRecoveries",  "collection"],
]

# ── Assemble the deal ─────────────────────────────────────────────────────────
from absbox import Generic

deal = Generic(
    "BlackstoneBSLCLO2024-1",
    {
        "cutoff":    CUTOFF,
        "closing":   CLOSING,
        "firstPay":  FIRST_PAY,
        "payFreq":   "QuarterEnd",
        "poolFreq":  "QuarterEnd",
        "stated":    STATED,
    },
    {"assets": loan_list},   # pool
    accounts_tuple,
    bonds_tuple,
    fees_tuple,
    waterfall_dict,
    collection_list,
    status=("PreClosing", "Amortizing"),   # PreClosing → Amortizing at closing date
)

print("Deal assembled ✓")
print(f"  Name:       {deal.name}")
print(f"  Cutoff:     {CUTOFF}")
print(f"  Stated:     {STATED}")
print(f"  Tranches:   {len(bonds_tuple)}")
print(f"  Waterfall:  {len(waterfall_dict['amortizing'])} steps")
print(f"  Pool loans: {len(loan_list)}")
Deal assembled ✓
  Name:       BlackstoneBSLCLO2024-1
  Cutoff:     2024-03-31
  Stated:     2036-03-31
  Tranches:   7
  Waterfall:  7 steps
  Pool loans: 250

Step 6 — Run Base Case

STEP 06 · ~35 lines

Pass the assembled deal and base-case assumption set to api.run(). All result DataFrames are converted from pandas to Polars immediately.

# Deal-level run assumption: SOFR3M flat at 5.25%
RUN_ASSUMP = [("interest", ("SOFR3M", SOFR3M))]

try:
    raw = api.run(
        deal,
        poolAssump=POOL_PERF_ASSUMP,
        runAssump=RUN_ASSUMP,
    )
    DEAL_RUN_OK = True
    print("Deal run ✓")
except Exception as e:
    DEAL_RUN_OK = False
    raw = {}
    print(f"Deal run skipped (engine unavailable): {e}")
Warning Message from server:Bond ClassD is not paid off
Bond ClassE is not paid off
Bond ClassF is not paid off
Bond Equity is not paid off
Deal run ✓
def safe_from_pandas(df: pd.DataFrame) -> pl.DataFrame:
    """Convert pandas → Polars, dropping object columns containing list/dict values."""
    df = df.reset_index().copy()
    drop_cols = [
        col for col in df.select_dtypes(include=["object"]).columns
        if df[col].apply(lambda x: isinstance(x, (list, dict))).any()
    ]
    if drop_cols:
        df = df.drop(columns=drop_cols)
    return pl.from_pandas(df, nan_to_null=True)

if DEAL_RUN_OK:
    # ── Convert all bond cashflows to Polars ──────────────────────────────────
    bonds_cf = {
        name: safe_from_pandas(df)
        for name, df in raw["bonds"].items()
    }

    # ── Extract pool cashflows ────────────────────────────────────────────────
    # Prefer the deal's pool flow; if empty fall back to the standalone pool run.
    _deal_pool = raw.get("pool_outstanding", {}).get("flow", {})
    if _deal_pool:
        _key = list(_deal_pool.keys())[0]
        _raw_pool_df = _deal_pool[_key]
        if len(_raw_pool_df) > 0:
            pool_cf = safe_from_pandas(_raw_pool_df)
        else:
            pool_cf = pool_cf if (POOL_RUN_OK and len(pool_cf) > 0) else pl.DataFrame()
    else:
        pool_cf = pool_cf if (POOL_RUN_OK and len(pool_cf) > 0) else pl.DataFrame()

    # ── Extract fee cashflows ────────────────────────────────────────────────
    fees_cf = {
        name: safe_from_pandas(df)
        for name, df in raw["fees"].items()
    }

    # ── Summary ───────────────────────────────────────────────────────────────
    print(f"Projection periods: {len(pool_cf)}")
    print(f"Bond DataFrames:    {list(bonds_cf.keys())}")
    print(f"Fee DataFrames:     {list(fees_cf.keys())}")

    # Quick-look: Q1 Class A cashflow
    if "ClassA" in bonds_cf:
        print("\nClass A — Q1 cashflow:")
        print(bonds_cf["ClassA"].head(1))
Projection periods: 97
Bond DataFrames:    ['ClassA', 'ClassB', 'ClassC', 'ClassD', 'ClassE', 'ClassF', 'Equity']
Fee DataFrames:     ['seniorFee', 'seniorMgmtFee', 'subMgmtFee']

Class A — Q1 cashflow:
shape: (1, 9)
┌────────────┬──────────┬──────────┬───────────┬───┬──────────┬────────┬────────────┬──────────┐
│ date       ┆ balance  ┆ interest ┆ principal ┆ … ┆ cash     ┆ intDue ┆ intOverInt ┆ factor   │
│ ---        ┆ ---      ┆ ---      ┆ ---       ┆   ┆ ---      ┆ ---    ┆ ---        ┆ ---      │
│ str        ┆ f64      ┆ f64      ┆ f64       ┆   ┆ f64      ┆ i64    ┆ i64        ┆ f64      │
╞════════════╪══════════╪══════════╪═══════════╪═══╪══════════╪════════╪════════════╪══════════╡
│ 2024-06-30 ┆ 3.4657e8 ┆ 6.2603e6 ┆ 2.5430e7  ┆ … ┆ 3.1691e7 ┆ 0      ┆ 0          ┆ 0.931639 │
└────────────┴──────────┴──────────┴───────────┴───┴──────────┴────────┴────────────┴──────────┘
if DEAL_RUN_OK and "Equity" in bonds_cf:
    eq = bonds_cf["Equity"]
    print("Equity cashflows — first 8 quarters:")
    print(eq.select(["date", "interest", "principal", "cash"]).head(8))

    if "cash" in eq.columns:
        q1_eq = eq["cash"][0]
        expected_eq = POOL_PAR * (was + SOFR3M) / 4 \
                      - (sum(b * r for _, _, b, _, r in CAPITAL_STRUCTURE) / 4) \
                      - 240_000 \
                      - POOL_PAR * (0.0015 + 0.0035) / 4
        print(f"\nQ1 equity dist (actual):   ${q1_eq:,.0f}")
        print(f"Q1 equity dist (estimate): ${expected_eq:,.0f}")
Equity cashflows — first 8 quarters:
shape: (8, 4)
┌────────────┬──────────┬───────────┬──────┐
│ date       ┆ interest ┆ principal ┆ cash │
│ ---        ┆ ---      ┆ ---       ┆ ---  │
│ str        ┆ i64      ┆ i64       ┆ i64  │
╞════════════╪══════════╪═══════════╪══════╡
│ 2024-06-30 ┆ 0        ┆ 0         ┆ 0    │
│ 2024-09-30 ┆ 0        ┆ 0         ┆ 0    │
│ 2024-12-31 ┆ 0        ┆ 0         ┆ 0    │
│ 2025-03-31 ┆ 0        ┆ 0         ┆ 0    │
│ 2025-06-30 ┆ 0        ┆ 0         ┆ 0    │
│ 2025-09-30 ┆ 0        ┆ 0         ┆ 0    │
│ 2025-12-31 ┆ 0        ┆ 0         ┆ 0    │
│ 2026-03-31 ┆ 0        ┆ 0         ┆ 0    │
└────────────┴──────────┴───────────┴──────┘

Q1 equity dist (actual):   $0
Q1 equity dist (estimate): $2,359,420

Step 7 — Analytics: WAL, IRR, OC Ratios

STEP 07 · ~80 lines

Compute per-tranche analytics using Polars expressions: - WAL (weighted average principal receipt time in years) - IRR (using numpy-financial) - OC ratio timeline (pool par ÷ tranche-inclusive note balance)

import numpy_financial as npf

def compute_wal(cf_df: pl.DataFrame, cutoff_date: str) -> float:
    """Weighted average life (years) from principal cashflows."""
    if "principal" not in cf_df.columns or cf_df.is_empty():
        return float("nan")
    cutoff = pl.Series([cutoff_date]).str.to_date()
    dates  = cf_df["date"].cast(pl.Date)
    yrs    = ((dates - cutoff[0]).dt.total_days() / 365.25)
    prin   = cf_df["principal"].fill_null(0)
    total  = prin.sum()
    if total == 0:
        return float("nan")
    return (prin * yrs).sum() / total


def compute_irr(cf_df: pl.DataFrame, face: float) -> float:
    """Approximate IRR: initial outflow at face value, then cashflows received."""
    if cf_df.is_empty():
        return float("nan")
    cash = cf_df["cash"].fill_null(0).to_list() if "cash" in cf_df.columns else []
    if not cash:
        return float("nan")
    flows = [-face] + cash
    try:
        irr = npf.irr(flows)
        return irr * 4   # quarterly → annualised
    except Exception:
        return float("nan")


if DEAL_RUN_OK:
    # ── Per-tranche metrics ────────────────────────────────────────────────────
    metrics = []
    for name, _, bal, _, rate in CAPITAL_STRUCTURE:
        cf = bonds_cf.get(name, pl.DataFrame())
        wal  = compute_wal(cf, CUTOFF)
        irr  = compute_irr(cf, bal)
        total_int  = cf["interest"].sum() if "interest"  in cf.columns else 0
        total_prin = cf["principal"].sum() if "principal" in cf.columns else 0
        loss       = bal - total_prin if total_prin < bal else 0
        metrics.append({
            "Class":       name,
            "Balance":     bal,
            "Spread_bps":  int(rate * 10_000) - int(SOFR3M * 10_000),
            "WAL_yrs":     round(wal, 2) if wal == wal else None,
            "IRR":         round(irr, 4) if irr == irr else None,
            "Total_Int":   round(total_int),
            "Loss":        round(loss),
        })

    # Add equity
    eq_cf  = bonds_cf.get("Equity", pl.DataFrame())
    eq_irr = compute_irr(eq_cf, EQUITY_BALANCE)
    metrics.append({
        "Class":      "Equity",
        "Balance":    EQUITY_BALANCE,
        "Spread_bps": None,
        "WAL_yrs":    None,
        "IRR":        round(eq_irr, 4) if eq_irr == eq_irr else None,
        "Total_Int":  round(eq_cf["cash"].sum()) if "cash" in eq_cf.columns else 0,
        "Loss":       0,
    })

    metrics_df = pl.DataFrame(metrics)
    print("Per-tranche analytics computed ✓")
    print(metrics_df)
Per-tranche analytics computed ✓
shape: (7, 7)
┌────────┬───────────┬────────────┬─────────┬────────┬───────────┬──────────┐
│ Class  ┆ Balance   ┆ Spread_bps ┆ WAL_yrs ┆ IRR    ┆ Total_Int ┆ Loss     │
│ ---    ┆ ---       ┆ ---        ┆ ---     ┆ ---    ┆ ---       ┆ ---      │
│ str    ┆ i64       ┆ i64        ┆ f64     ┆ f64    ┆ i64       ┆ i64      │
╞════════╪═══════════╪════════════╪═════════╪════════╪═══════════╪══════════╡
│ ClassA ┆ 372000000 ┆ 150        ┆ 2.47    ┆ 0.0675 ┆ 62012538  ┆ 0        │
│ ClassB ┆ 66000000  ┆ 220        ┆ 6.47    ┆ 0.0745 ┆ 31856437  ┆ 0        │
│ ClassC ┆ 54000000  ┆ 299        ┆ 8.51    ┆ 0.0826 ┆ 37939311  ┆ 0        │
│ ClassD ┆ 36000000  ┆ 425        ┆ 10.72   ┆ 0.0943 ┆ 36756494  ┆ 541081   │
│ ClassE ┆ 30000000  ┆ 650        ┆ null    ┆ 0.0574 ┆ 41450137  ┆ 30000000 │
│ ClassF ┆ 12000000  ┆ 874        ┆ null    ┆ 0.092  ┆ 19754959  ┆ 12000000 │
│ Equity ┆ 30000000  ┆ null       ┆ null    ┆ null   ┆ 0         ┆ 0        │
└────────┴───────────┴────────────┴─────────┴────────┴───────────┴──────────┘
# ── OC ratio timeline ─────────────────────────────────────────────────────────
# OC_A  = pool_par / ClassA_balance  (should be ≥ ~148%)
# OC_D  = pool_par / (ClassA+ClassB+ClassC+ClassD) balance
if DEAL_RUN_OK and len(pool_cf) > 0:
    # Build OC timeline from pool balance and cumulative bond balance
    classA_cf = bonds_cf.get("ClassA", pl.DataFrame())
    classD_cf = bonds_cf.get("ClassD", pl.DataFrame())

    if "Balance" in pool_cf.columns and not classA_cf.is_empty():
        # Align on date
        pool_bal = pool_cf.select(["Date", "Balance"]).rename({"Date": "date", "Balance": "pool_bal"})
        a_bal    = classA_cf.select(["date", "balance"]).rename({"balance": "a_bal"})

        # Sum A+B+C+D balances for OC_D
        def get_bal(name):
            cf = bonds_cf.get(name, pl.DataFrame())
            if cf.is_empty() or "balance" not in cf.columns:
                return None
            return cf.select(["date", "balance"]).rename({"balance": f"{name}_bal"})

        abcd = [get_bal(n) for n in ["ClassA", "ClassB", "ClassC", "ClassD"]]
        abcd = [x for x in abcd if x is not None]

        if abcd and len(pool_bal) > 0:
            oc_df = pool_bal.join(a_bal, on="date", how="left")
            for tdf in abcd[1:]:
                oc_df = oc_df.join(tdf, on="date", how="left")

            bal_cols = [c for c in oc_df.columns if c.endswith("_bal") and c != "pool_bal"]
            oc_df = oc_df.with_columns([
                (pl.col("pool_bal") / pl.col("a_bal")).alias("OC_A"),
                (pl.col("pool_bal") / pl.sum_horizontal([pl.col(c) for c in bal_cols])).alias("OC_ABCD"),
            ]).filter(pl.col("OC_A").is_not_null())

            print(f"OC timeline computed: {len(oc_df)} periods")
            print(f"  OC_A  range: {oc_df['OC_A'].min():.2%} – {oc_df['OC_A'].max():.2%}")
            print(f"  OC_ABCD range: {oc_df['OC_ABCD'].min():.2%} – {oc_df['OC_ABCD'].max():.2%}")
OC timeline computed: 22 periods
  OC_A  range: 165.42% – inf%
  OC_ABCD range: 114.07% – 142.01%

Step 8 — Final Output: Consolidated Tables

STEP 08 · ~55 lines

Render three polished great_tables deliverables:

  1. Per-tranche analytics — WAL, IRR, total interest, loss rate
  2. Quarterly waterfall — equity cashflows over the projection horizon
  3. OC ratio timeline — confirming no breach in the base case
if DEAL_RUN_OK and "metrics_df" in dir():
    (
        GT(metrics_df)
        .tab_header(
            "Per-Tranche Analytics — Base Case",
            "CDR 2% · CPR 15% · Recovery 65% · SOFR3M 5.25%"
        )
        .fmt_currency(columns=["Balance", "Total_Int", "Loss"],
                      currency="USD", decimals=0)
        .fmt_percent(columns=["IRR"], decimals=1)
        .fmt_number(columns=["WAL_yrs"], decimals=2)
        .tab_spanner(label="Cashflow Metrics",
                     columns=["WAL_yrs", "IRR", "Total_Int", "Loss"])
        .tab_spanner(label="Deal Terms",
                     columns=["Balance", "Spread_bps"])
        .cols_label(
            Class="Class",
            Balance="Balance",
            Spread_bps="Spread (bps)",
            WAL_yrs="WAL (yrs)",
            IRR="IRR (ann.)",
            Total_Int="Total Int / Dist",
            Loss="Loss",
        )
        .tab_style(style.text(weight="bold"), loc.column_labels())
        .tab_style(
            style.text(weight="bold"),
            loc.body(rows=pl.col("Class") == "Equity")
        )
        .tab_source_note("IRR: annualised from quarterly cashflows. Equity IRR includes all distributions.")
    )
if DEAL_RUN_OK and "Equity" in bonds_cf:
    eq_display = (
        bonds_cf["Equity"]
        .select(["date", "balance", "interest", "principal", "cash"])
        .head(20)
    )

    (
        GT(eq_display)
        .tab_header(
            "Equity Cashflows — First 20 Quarters",
            "All residual cash after debt service and fees"
        )
        .fmt_date(columns=["date"], date_style="iso")
        .fmt_currency(
            columns=["balance", "interest", "principal", "cash"],
            currency="USD", decimals=0
        )
        .cols_label(
            date="Payment Date",
            balance="Note Balance",
            interest="Interest Paid",
            principal="Principal Paid",
            cash="Total Cash",
        )
        .tab_style(style.text(weight="bold"), loc.column_labels())
        .tab_style(
            style.fill(color="#eaf9fa"),
            loc.body(rows=pl.col("cash") > 0)
        )
        .tab_source_note(
            "Equity receives zero in periods where the waterfall is fully consumed "
            "by senior debt service. Distributions decline as defaults erode pool par."
        )
    )
if DEAL_RUN_OK and "oc_df" in dir():
    oc_display = oc_df.select(["date", "pool_bal", "OC_A", "OC_ABCD"]).head(20)

    (
        GT(oc_display)
        .tab_header(
            "OC Ratio Timeline — Base Case",
            "No test breach expected in base case (CDR 2%, CPR 15%)"
        )
        .fmt_date(columns=["date"], date_style="iso")
        .fmt_currency(columns=["pool_bal"], currency="USD", decimals=0)
        .fmt_percent(columns=["OC_A", "OC_ABCD"], decimals=2)
        .cols_label(
            date="Date",
            pool_bal="Pool Par",
            OC_A="OC Ratio A",
            OC_ABCD="OC Ratio A-D",
        )
        .tab_spanner(label="OC Tests", columns=["OC_A", "OC_ABCD"])
        .tab_style(style.text(weight="bold"), loc.column_labels())
        .tab_source_note(
            "OC_A = pool par / Class A outstanding. "
            "OC_ABCD = pool par / (A+B+C+D outstanding). "
            "Typical floors: OC_A ≥ 148%, OC_ABCD ≥ 109%."
        )
    )

Summary

if DEAL_RUN_OK and "metrics_df" in dir():
    eq_row = metrics_df.filter(pl.col("Class") == "Equity")
    eq_irr_val = eq_row["IRR"][0] if len(eq_row) > 0 else None

    print("=" * 55)
    print("  BLACKSTONE BSL CLO 2024-1 — BASE CASE SUMMARY")
    print("=" * 55)
    print(f"  Deal size:          ${POOL_PAR/1e6:.0f}m")
    print(f"  Pool WAS:           {was*10_000:.0f}bps over SOFR3M")
    print(f"  SOFR3M (flat):      {SOFR3M:.2%}")
    print(f"  CDR assumption:     2.00%")
    print(f"  CPR assumption:     15.00%")
    print(f"  Recovery:           65.0%  (12-month lag)")
    print(f"  Projection periods: {len(pool_cf)}")
    if eq_irr_val and eq_irr_val == eq_irr_val:
        print(f"  Equity IRR (est.):  {eq_irr_val:.1%}")
    print("=" * 55)
    print("  ✓ All 8 steps complete. Polars DataFrames ready.")
    print("  ✓ Outputs available in: bonds_cf, pool_cf, fees_cf")
    print("=" * 55)
else:
    print("Engine not available — code is ready, start Docker to run:")
    print("  docker run -p 8081:8081 yellowbean/hastructure:a0.52.4")
    print("Then: quarto render clo_model.qmd")
=======================================================
  BLACKSTONE BSL CLO 2024-1 — BASE CASE SUMMARY
=======================================================
  Deal size:          $600m
  Pool WAS:           417bps over SOFR3M
  SOFR3M (flat):      5.25%
  CDR assumption:     2.00%
  CPR assumption:     15.00%
  Recovery:           65.0%  (12-month lag)
  Projection periods: 97
=======================================================
  ✓ All 8 steps complete. Polars DataFrames ready.
  ✓ Outputs available in: bonds_cf, pool_cf, fees_cf
=======================================================