Distill the verbose waterfall in CLO
When modeling with absbox, the rookie may model the waterfall payment action with 1:1 maping to deal prospectus.
That’s OKayish as it is clear and straightforward. But it just increase the burden of mind which is not absbox ‘s intention.
With a data-drive design of absbox, the whole deal model can be very clean and straightforward.
I’ll demo a fairly-complex CLO deal to show how to distill the verbose waterfall.
FIDELITY GRAND HARBOUR CLO 2023-1 DAC Prospectus : https://live.euronext.com/en/product/bonds-detail/30218/documents
Model Bonds
It can be very clear just model the bonds with absbox syntax, but we just assign it to a variable bonds for later use.
And create a bndNames holding the bond names for future reference.
bonds = (
("AR",{"balance":198_000_000
,"rate":0.07
,"originBalance":198_000_000
,"originRate":0.07
,"startDate":"2025-02-18"
,"rateType":{"floater":[0.05,"EURIBOR3M",0.0123,"QuarterEnd"]}
,"bondType":{"Sequential":None}})
,("B1R",{"balance":29_000_000
,"rate":0.0
,"originBalance":29_000_000
,"originRate":0.07
,"startDate":"2025-02-18"
,"rateType":{"floater":[0.05,"EURIBOR3M",0.0175,"QuarterEnd"]}
,"bondType":{"Sequential":None}
})
,("B2R",{"balance":15_000_000
,"rate":0.046
,"originBalance":15_000_000
,"originRate":0.046
,"startDate":"2025-02-18"
,"rateType":{"Fixed":0.046}
,"bondType":{"Sequential":None}
})
,("CR",{"balance":24_000_000
,"rate":0.0
,"originBalance":24_000_000
,"originRate":0.07
,"startDate":"2025-02-18"
,"rateType":{"floater":[0.05,"EURIBOR3M",0.0205,"QuarterEnd"]}
,"bondType":{"Sequential":None}
})
,("DR",{"balance":28_000_000
,"rate":0.0
,"originBalance":28_000_000
,"originRate":0.07
,"startDate":"2025-02-18"
,"rateType":{"floater":[0.05,"EURIBOR3M",0.027,"QuarterEnd"]}
,"bondType":{"Sequential":None}
})
,("ER",{"balance":18_000_000
,"rate":0.0
,"originBalance":18_000_000
,"originRate":0.07
,"startDate":"2025-02-18"
,"rateType":{"floater":[0.05,"EURIBOR3M",0.0475,"QuarterEnd"]}
,"bondType":{"Sequential":None}
})
,("FR",{"balance":12_000_000
,"rate":0.0
,"originBalance":12_000_000
,"originRate":0.07
,"startDate":"2025-02-18"
,"rateType":{"floater":[0.05,"EURIBOR3M",0.0757,"QuarterEnd"]}
,"bondType":{"Sequential":None}
})
,("SUB",{"balance":28_427_000
,"rate":0.0
,"originBalance":28_427_000
,"originRate":0.07
,"startDate":"2025-02-18"
,"rateType":{"Fixed":0.00}
,"bondType":{"Equity":None}
,'stmt': [
["2025-02-18",0,0,0,0,-28_427_000,0,0,None,""]
]
})
)
## Tricks: extract the bond names for later use
bndNames = [ bn for bn,_ in bonds ]
Adjusted_Collateral_Principal_Amount is defined as the sum of the principal amount of the collateral pool and the accrued interest of the collateral pool. It is being used for OC ratio.
Adjusted_Collateral_Principal_Amount = ("sum", ("poolBalance",),("accountBalance","prinAcc"),("poolAccruedInterest",))
Model Coverage Test Triggers
One of the verbose items in the waterfall is the coverage test. To model each test, a rookie may follow each item and build a single expression for each threshold test.
Class Required Par Value Ratio
Class |
Required Ratio |
|---|---|
A/B |
127.99% |
C |
119.58% |
D |
110.28% |
E |
105.50% |
F |
102.45% |
Class Required Interest Coverage Ratio
Class |
Required Ratio |
|---|---|
A/B |
120.0% |
C |
110.0% |
D |
105.0% |
Par Value ratios
The verbose way to model each formula to represent the Par Value Ratio
Class_A_B_Par_Value_Ratio = ("ratio", Adjusted_Collateral_Principal_Amount , ("bondBalance","AR","B1R","B2R"))
TargetAB = ("ratio", Adjusted_Collateral_Principal_Amount, ("const", 1.2799 ))
Class_C_Par_Value_Ratio = ("ratio", Adjusted_Collateral_Principal_Amount , ("bondBalance","AR","B1R","B2R","CR"))
TargetC = ("ratio", Adjusted_Collateral_Principal_Amount, ("const", 1.1958 ))
Class_D_Par_Value_Ratio = ("ratio", Adjusted_Collateral_Principal_Amount , ("bondBalance","AR","B1R","B2R","CR","DR"))
TargetD = ("ratio", Adjusted_Collateral_Principal_Amount, ("const", 1.1028 ))
Class_E_Par_Value_Ratio = ("ratio", Adjusted_Collateral_Principal_Amount , ("bondBalance","AR","B1R","B2R","CR","DR","ER"))
TargetE = ("ratio", Adjusted_Collateral_Principal_Amount, ("const", 1.055 ))
Class_F_Par_Value_Ratio = ("ratio", Adjusted_Collateral_Principal_Amount , ("bondBalance","AR","B1R","B2R","CR","DR","ER","FR"))
TargetF = ("ratio", Adjusted_Collateral_Principal_Amount, ("const", 1.0245 ))
Oh man, that’s verbose !
If you look the pattern carefully, the only variable part is for each threshold ,there is associate with a list of bond names.i.e (1.2799 vs “AR”,”B1R”,”B2R”)
Let’s simplify it with bond name list with the candy function from absbox
from absbox.local.util import genDescList
genDescList(bndNames[:-1],5)
[['AR', 'B1R', 'B2R', 'CR', 'DR', 'ER', 'FR'],
['AR', 'B1R', 'B2R', 'CR', 'DR', 'ER'],
['AR', 'B1R', 'B2R', 'CR', 'DR'],
['AR', 'B1R', 'B2R', 'CR'],
['AR', 'B1R', 'B2R']]
OC Formulas
With the shortcut function above ,we can build ratios list like
[
[("ratio", Adjusted_Collateral_Principal_Amount , ("bondBalance",*bndNames)),"<=", threshold]
for (n, threshold, bndNames) in
zip(["OC_AB","OC_C","OC_D","OC_E","OC_F"],[1.2799,1.1958,1.1028,1.055,1.0245],genDescList(bndNames[:-1],5,reverse=True))
]
[[('ratio',
('sum',
('poolBalance',),
('accountBalance', 'prinAcc'),
('poolAccruedInterest',)),
('bondBalance', 'AR', 'B1R', 'B2R')),
'<=',
1.2799],
[('ratio',
('sum',
('poolBalance',),
('accountBalance', 'prinAcc'),
('poolAccruedInterest',)),
('bondBalance', 'AR', 'B1R', 'B2R', 'CR')),
'<=',
1.1958],
[('ratio',
('sum',
('poolBalance',),
('accountBalance', 'prinAcc'),
('poolAccruedInterest',)),
('bondBalance', 'AR', 'B1R', 'B2R', 'CR', 'DR')),
'<=',
1.1028],
[('ratio',
('sum',
('poolBalance',),
('accountBalance', 'prinAcc'),
('poolAccruedInterest',)),
('bondBalance', 'AR', 'B1R', 'B2R', 'CR', 'DR', 'ER')),
'<=',
1.055],
[('ratio',
('sum',
('poolBalance',),
('accountBalance', 'prinAcc'),
('poolAccruedInterest',)),
('bondBalance', 'AR', 'B1R', 'B2R', 'CR', 'DR', 'ER', 'FR')),
'<=',
1.0245]]
Now with a simple dictionary comprehension, we plug the data into a trigger syntax ({condition:…,effects:None,status:False,curable:True})
Now you just build the 5 OC triggers!
OC Triggers
OC_Triggers = \
{n:
{
"condition":["all"
,[("sum",("bondBalance",*bndNames)),">",0]
,[("ratio", Adjusted_Collateral_Principal_Amount , ("bondBalance",*bndNames))
,"<="
,threshold]
],
"effects":None,
"status":False,
"curable":True
}
for (n, threshold, bndNames) in
zip(["OC_AB","OC_C","OC_D","OC_E","OC_F"],[1.2799,1.1958,1.1028,1.055,1.0245],genDescList(bndNames[:-1],5,reverse=True))
}
Same method for IC tests trigger !
IC Triggers
IC_Triggers = \
{n:
{
"condition":["all"
,[("sum",("bondDueInt",*bndNames)),">",0]
,[("ratio", ("accountBalance",'intAcc') , ("bondDueInt",*bndNames))
,"<="
,threshold]
],
"effects":None,
"status":False,
"curable":True
}
for (n, threshold, bndNames) in
zip(["IC_AB","IC_C","IC_D"],[1.20,1.1,1.05],genDescList(bndNames[:-1],3,reverse=True))
}
Now, we’d done with the Coverage Tests. Let’s move to the interest payment waterfall.
The crazy part of CLO waterfall is its really verbose /repeated waterfall as legal terms shall be rigids. The total steps of Interest Payment waterfall is 26 ! ( from A -> Z) , I do feel future CLO may run out of alphabets .
That’s say, if go with rookie way, there are 26 lists in the deal model ! that’s crazy ! The good news : we are able to identify the repeated part and abstract them out. For example:
Coverage Test Cures (Class A/B)
(H) Coverage Test Cure: If certain financial ratios (Par Value/Interest Coverage tests) are failing, funds are used to redeem senior debt until the tests are satisfied.
Junior Mezzanine Note Interest (Classes C, D, E, F)
(I) Class C Interest: Payment of current interest on Class C Notes.
(J) Class C Deferred Interest: Payment of any previously deferred interest on Class C Notes.
(K) Coverage Test Cure (Class C): Cure for Class C coverage tests.
(L) Class D Interest: Payment of current interest on Class D Notes.
(M) Class D Deferred Interest: Payment of any previously deferred interest on Class D Notes.
(N) Coverage Test Cure (Class D): Cure for Class D coverage tests.
(O) Class E Interest: Payment of current interest on Class E Notes.
(P) Class E Deferred Interest: Payment of any previously deferred interest on Class E Notes.
(Q) Coverage Test Cure (Class E): Cure for Class E coverage tests.
(R) Class F Interest: Payment of current interest on Class F Notes.
(S) Class F Deferred Interest: Payment of any previously deferred interest on Class F Notes.
(T) Coverage Test Cure (Class F): Cure for Class F coverage tests (post-reinvestment period).
Rating Agency Cure
(U) Rating Event Cure: Redemption of debt if a specific “Rating Event” is ongoing.
Here, in the steps above , there is a repeated pattern for class C/D/E/F:
Class X Interest
Class X Deferred Interest
Run Coverage Test on Class X and Cure
Then we can compress (4*3) steps into a loop!
Feature |
Class C Notes |
Class D Notes |
Class E Notes |
Class F Notes |
|---|---|---|---|---|
Par Value Test Ratio |
127.99% (for A/B) then 119.58% |
110.28% |
105.50% |
102.45% |
Interest Coverage Test |
Yes (110.0%) |
Yes (105.0%) |
No |
No |
When Par Value Test Applies |
On and after the Effective Date |
On and after the Effective Date |
On and after the Effective Date |
After Reinvestment Period |
Subordination |
Subordinated to A & B |
Subordinated to A, B, & C |
Subordinated to A, B, C, & D |
Subordinated to A, B, C, D, & E |
In code below, it says:
accure and pay interest for class
xwith accountinterestAccif any
trigger(for IC and OC) is failed, then repay the principal portion of classthe repayment amount is defined by a upper limit, hard code to 100.00
[
[
["accrueAndPayInt",'interestAcc', [x] ]
,["if",["any",("trigger","InDistribution",ocTrigger),("trigger","InDistribution",icTrigger) if icTrigger else ("always",False)]
, ["payPrin",'interestAcc',[x],{"limit":{"ds": ("const",100)}}]
]
]
for x,ocTrigger,icTrigger in zip(['C','D','E','F']
,["OC_C","OC_D","OC_E","OC_F"]
,["IC_C","IC_D",None,None])
]
[[['accrueAndPayInt', 'interestAcc', ['C']],
['if',
['any',
('trigger', 'InDistribution', 'OC_C'),
('trigger', 'InDistribution', 'IC_C')],
['payPrin', 'interestAcc', ['C'], {'limit': {'ds': ('const', 100)}}]]],
[['accrueAndPayInt', 'interestAcc', ['D']],
['if',
['any',
('trigger', 'InDistribution', 'OC_D'),
('trigger', 'InDistribution', 'IC_D')],
['payPrin', 'interestAcc', ['D'], {'limit': {'ds': ('const', 100)}}]]],
[['accrueAndPayInt', 'interestAcc', ['E']],
['if',
['any', ('trigger', 'InDistribution', 'OC_E'), ('always', False)],
['payPrin', 'interestAcc', ['E'], {'limit': {'ds': ('const', 100)}}]]],
[['accrueAndPayInt', 'interestAcc', ['F']],
['if',
['any', ('trigger', 'InDistribution', 'OC_F'), ('always', False)],
['payPrin', 'interestAcc', ['F'], {'limit': {'ds': ('const', 100)}}]]]]
Now we can spot the hardcode value (const,100), in the payPrin, there is a dict with key limit to describle how much cash to pay down the principal portion of the bond.
But how to describle the amount to be paid out ? we are using a formula which is a dict with {"ds": <formula>} to describle the cure amount .
Build Cure Amount Formula
wit a little algebra transformation, the amount to cure the test will be:
max(0, sum of bond balance - (expected bond balance))
expected bond balance = Adjusted_Collateral_Principal_Amount / threshold
I’ll make it a dict, then it can be referenced by name later
cureFormula = {ocName: ("excess"
, ("sum", ('bondBalance',*bonds))
, ("/", Adjusted_Collateral_Principal_Amount, threshold)
)
for (ocName,threshold,bonds) in
zip(["OC_AB","OC_C","OC_D","OC_E","OC_F"],[1.2799,1.1958,1.1028,1.055,1.0245],genDescList(bndNames[:-1],5,reverse=True))
}
cureFormula
{'OC_AB': ('excess',
('sum', ('bondBalance', 'AR', 'B1R', 'B2R')),
('/',
('sum',
('poolBalance',),
('accountBalance', 'prinAcc'),
('poolAccruedInterest',)),
1.2799)),
'OC_C': ('excess',
('sum', ('bondBalance', 'AR', 'B1R', 'B2R', 'CR')),
('/',
('sum',
('poolBalance',),
('accountBalance', 'prinAcc'),
('poolAccruedInterest',)),
1.1958)),
'OC_D': ('excess',
('sum', ('bondBalance', 'AR', 'B1R', 'B2R', 'CR', 'DR')),
('/',
('sum',
('poolBalance',),
('accountBalance', 'prinAcc'),
('poolAccruedInterest',)),
1.1028)),
'OC_E': ('excess',
('sum', ('bondBalance', 'AR', 'B1R', 'B2R', 'CR', 'DR', 'ER')),
('/',
('sum',
('poolBalance',),
('accountBalance', 'prinAcc'),
('poolAccruedInterest',)),
1.055)),
'OC_F': ('excess',
('sum', ('bondBalance', 'AR', 'B1R', 'B2R', 'CR', 'DR', 'ER', 'FR')),
('/',
('sum',
('poolBalance',),
('accountBalance', 'prinAcc'),
('poolAccruedInterest',)),
1.0245))}
Now, we can plugin back the data back to the waterfall actions
partialWaterfallActions = [
[
["accrueAndPayInt",'interestAcc', [x] ]
,["if",["any",("trigger","InDistribution",ocTrigger),("trigger","InDistribution",icTrigger) if icTrigger else ("always",False)]
, ["payPrin",'interestAcc',[x],{"limit":{"ds": cureFormula[ocTrigger] }}]
]
]
for x,ocTrigger,icTrigger in zip(['C','D','E','F']
,["OC_C","OC_D","OC_E","OC_F"]
,["IC_C","IC_D",None,None])
]
partialWaterfallActions
[[['accrueAndPayInt', 'interestAcc', ['C']],
['if',
['any',
('trigger', 'InDistribution', 'OC_C'),
('trigger', 'InDistribution', 'IC_C')],
['payPrin',
'interestAcc',
['C'],
{'limit': {'ds': ('excess',
('sum', ('bondBalance', 'AR', 'B1R', 'B2R', 'CR')),
('/',
('sum',
('poolBalance',),
('accountBalance', 'prinAcc'),
('poolAccruedInterest',)),
1.1958))}}]]],
[['accrueAndPayInt', 'interestAcc', ['D']],
['if',
['any',
('trigger', 'InDistribution', 'OC_D'),
('trigger', 'InDistribution', 'IC_D')],
['payPrin',
'interestAcc',
['D'],
{'limit': {'ds': ('excess',
('sum', ('bondBalance', 'AR', 'B1R', 'B2R', 'CR', 'DR')),
('/',
('sum',
('poolBalance',),
('accountBalance', 'prinAcc'),
('poolAccruedInterest',)),
1.1028))}}]]],
[['accrueAndPayInt', 'interestAcc', ['E']],
['if',
['any', ('trigger', 'InDistribution', 'OC_E'), ('always', False)],
['payPrin',
'interestAcc',
['E'],
{'limit': {'ds': ('excess',
('sum', ('bondBalance', 'AR', 'B1R', 'B2R', 'CR', 'DR', 'ER')),
('/',
('sum',
('poolBalance',),
('accountBalance', 'prinAcc'),
('poolAccruedInterest',)),
1.055))}}]]],
[['accrueAndPayInt', 'interestAcc', ['F']],
['if',
['any', ('trigger', 'InDistribution', 'OC_F'), ('always', False)],
['payPrin',
'interestAcc',
['F'],
{'limit': {'ds': ('excess',
('sum', ('bondBalance', 'AR', 'B1R', 'B2R', 'CR', 'DR', 'ER', 'FR')),
('/',
('sum',
('poolBalance',),
('accountBalance', 'prinAcc'),
('poolAccruedInterest',)),
1.0245))}}]]]]
Wait, it’s a nested list of action ! Let’s just use the my favorite package toolz to flatten the list
from toolz import concat
list(concat(partialWaterfallActions))
[['accrueAndPayInt', 'interestAcc', ['C']],
['if',
['any',
('trigger', 'InDistribution', 'OC_C'),
('trigger', 'InDistribution', 'IC_C')],
['payPrin',
'interestAcc',
['C'],
{'limit': {'ds': ('excess',
('sum', ('bondBalance', 'AR', 'B1R', 'B2R', 'CR')),
('/',
('sum',
('poolBalance',),
('accountBalance', 'prinAcc'),
('poolAccruedInterest',)),
1.1958))}}]],
['accrueAndPayInt', 'interestAcc', ['D']],
['if',
['any',
('trigger', 'InDistribution', 'OC_D'),
('trigger', 'InDistribution', 'IC_D')],
['payPrin',
'interestAcc',
['D'],
{'limit': {'ds': ('excess',
('sum', ('bondBalance', 'AR', 'B1R', 'B2R', 'CR', 'DR')),
('/',
('sum',
('poolBalance',),
('accountBalance', 'prinAcc'),
('poolAccruedInterest',)),
1.1028))}}]],
['accrueAndPayInt', 'interestAcc', ['E']],
['if',
['any', ('trigger', 'InDistribution', 'OC_E'), ('always', False)],
['payPrin',
'interestAcc',
['E'],
{'limit': {'ds': ('excess',
('sum', ('bondBalance', 'AR', 'B1R', 'B2R', 'CR', 'DR', 'ER')),
('/',
('sum',
('poolBalance',),
('accountBalance', 'prinAcc'),
('poolAccruedInterest',)),
1.055))}}]],
['accrueAndPayInt', 'interestAcc', ['F']],
['if',
['any', ('trigger', 'InDistribution', 'OC_F'), ('always', False)],
['payPrin',
'interestAcc',
['F'],
{'limit': {'ds': ('excess',
('sum', ('bondBalance', 'AR', 'B1R', 'B2R', 'CR', 'DR', 'ER', 'FR')),
('/',
('sum',
('poolBalance',),
('accountBalance', 'prinAcc'),
('poolAccruedInterest',)),
1.0245))}}]]]
Whoa, that’s much cleaner way !
Happy hacking !