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 warningswarnings.filterwarnings("ignore")import numpy as npimport polars as plfrom great_tables import GT, style, locfrom great_tables import nanoplot_optionsimport pandas as pdfrom absbox import API, EnginePathfrom absbox.exception import AbsboxErrorSOFR3M =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)exceptException:passraiseRuntimeError("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 =250POOL_PAR =600_000_000MEAN_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 $600mraw_bals = rng.lognormal(mean=np.log(MEAN_BAL), sigma=0.55, size=N_LOANS)raw_bals = np.maximum(raw_bals, 500_000) # floor at $500kbals = (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 + spreadinitial_rates = np.round(SOFR3M + spreads, 6)loan_tape = pl.DataFrame( {"id": [f"L{i +1:03d}"for i inrange(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_paravg_term = (loan_tape["remain_mth"] * loan_tape["curr_bal"]).sum() / pool_par /12pool_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 loansloan_list = tape_to_absbox_loans(loan_tape)# Pool dict for runPool() — needs a 'cutoffDate' keypool_dict = {"cutoffDate": CUTOFF,"assets": loan_list,}# Base-case pool assumptionsPOOL_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 lagNone), # no extra stressNone, # no delinquency curveNone, # 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 =Trueprint(f"Pool run ✓ — {len(pool_cf)} periods projected")exceptExceptionas 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 andlen(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 else0 expected_q1 = POOL_PAR * (was + SOFR3M) /4print(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_000def 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_BALANCEtotal_deal = total_debt + total_equitycap_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 elsef"⚠ 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 Genericdeal = 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)}")
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 npfdef compute_wal(cf_df: pl.DataFrame, cutoff_date: str) ->float:"""Weighted average life (years) from principal cashflows."""if"principal"notin cf_df.columns or cf_df.is_empty():returnfloat("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:returnfloat("nan")return (prin * yrs).sum() / totaldef compute_irr(cf_df: pl.DataFrame, face: float) ->float:"""Approximate IRR: initial outflow at face value, then cashflows received."""if cf_df.is_empty():returnfloat("nan") cash = cf_df["cash"].fill_null(0).to_list() if"cash"in cf_df.columns else []ifnot cash:returnfloat("nan") flows = [-face] + cashtry: irr = npf.irr(flows)return irr *4# quarterly → annualisedexceptException:returnfloat("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 else0 total_prin = cf["principal"].sum() if"principal"in cf.columns else0 loss = bal - total_prin if total_prin < bal else0 metrics.append({"Class": name,"Balance": bal,"Spread_bps": int(rate *10_000) -int(SOFR3M *10_000),"WAL_yrs": round(wal, 2) if wal == wal elseNone,"IRR": round(irr, 4) if irr == irr elseNone,"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 elseNone,"Total_Int": round(eq_cf["cash"].sum()) if"cash"in eq_cf.columns else0,"Loss": 0, }) metrics_df = pl.DataFrame(metrics)print("Per-tranche analytics computed ✓")print(metrics_df)