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:

  1. 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.

  1. 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).

  1. 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:

  1. Class X Interest

  2. Class X Deferred Interest

  3. 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:

  1. accure and pay interest for class x with account interestAcc

  2. if any trigger (for IC and OC) is failed, then repay the principal portion of class

    • the 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 !