5
$\begingroup$

I am trying to model a term loan in QuantLib-Python that makes quarterly interest payments at CME Term SOFR 3M + 10bps + 525bps paid in arrears with a 2 business day fixing.

The amortization schedule is custom in that it does not start until after the first interest payment date and only occurs on the last business day of March, June, September and December. Annual amortization is 5% of the initial principal amount.

My code successfully generates the principal and interest cashflows for an assumed SOFR rate of 5%. Rather than wrap these cashflows in ql.SimpleCashFlow, I'd rather take advantage of the ql.AmortizingFloatingRateBond class so I can pass a SOFR curve. What is the recommended approach to set up this instrument given the custom schedule?

import QuantLib as ql
    
def quarter_end_business_days(start_date, end_date, calendar = ql.UnitedStates(ql.UnitedStates.GovernmentBond), quarter_end_months = [3,6,9,12]):
    
    # Initialize the current date to the start date
    current_date = start_date

    # List to store the last business days of the quarter-end months
    last_business_days = []

    # Loop to iterate through the months and find the last business day of the quarter-end months
    while current_date <= end_date:
        # Determine the end of the current month
        eom_date = ql.Date.endOfMonth(current_date)
        # Check if it is a quarter-end month
        if eom_date.month() in quarter_end_months:
            # Adjust to the last business day
            last_business_day = calendar.adjust(eom_date, ql.Preceding)
            last_business_days.append(last_business_day)
        
        # Move to the first day of the next month, adjusting the year if necessary
        if current_date.month() == 12:
            current_date = ql.Date(1, 1, current_date.year() + 1)
        else:
            current_date = ql.Date(1, current_date.month() + 1, current_date.year())

    # Return the list of last business days
    return last_business_days

# Set evaluation date and basic parameters
today = ql.Date(9, 5, 2024)
ql.Settings.instance().evaluationDate = today
calendar = ql.UnitedStates(ql.UnitedStates.GovernmentBond)

# Day count convention
day_count = ql.Actual360()

# Loan schedule setup
effective_date = ql.Date(10, 5, 2022)
maturity_date = ql.Date(1, 2, 2027)
first_amortization_date = ql.Date(30,9,2022)
tenor = ql.Period(ql.Quarterly)


# Manually define the dates to ensure they are quarter ends
dates = [effective_date] + quarter_end_business_days(start_date=effective_date, end_date=maturity_date, calendar=calendar) + [maturity_date]

# Create the schedule directly with these dates
schedule = ql.Schedule(dates, calendar, ql.Unadjusted)

# Define daily SOFR index and setup the forward curve for 3-month SOFR
sofr_index = ql.Sofr()
dates = [schedule[i] for i in range(len(schedule))]
rates = [0.05 + 0.0010 + 0.0525] * len(dates)  # Consistent rates for simplification; Base SOFR + 10 bps + 525 bps
day_count = ql.Actual360()
sofr_curve = ql.ZeroCurve(dates, rates, day_count, calendar)
sofr_curve_handle = ql.YieldTermStructureHandle(sofr_curve)

# Create an overnight index linked to the constructed yield curve
three_month_sofr = ql.OvernightIndex("3M SOFR", 0, ql.USDCurrency(), calendar, day_count, sofr_curve_handle)

# Setting up the bond (loan) mechanics
face_value = 100  # Initial principal
principal_payment = face_value * 0.05 / 4  # 5% annually, divided by 4 for quarterly payments

# Initialize loan cashflows
principal_remaining = face_value
cashflows = []

for i in range(1, len(schedule)):
    date = schedule[i]
    # Calculate effective interest rate with floor
    floor_rate = 0.005  # 50 bps floor
    three_month_rate = max(floor_rate, sofr_curve_handle.zeroRate(date, ql.Actual360(), ql.Continuous).rate())
    interest_payment = principal_remaining * three_month_rate * day_count.yearFraction(schedule[i-1], schedule[i])  # Quarterly payments
    
    current_principal_payment = principal_remaining if date == maturity_date else (principal_payment if date >= first_amortization_date else 0)

    total_payment = current_principal_payment + interest_payment
    principal_remaining -= current_principal_payment
    principal_remaining = max(0, principal_remaining)  # Ensure no negative principal
    cashflows.append((date, total_payment, interest_payment, current_principal_payment, principal_remaining))

# Display the amortization schedule
for date, total, interest, principal, remaining in cashflows:
    print(f"Date: {date.ISO()}, Total Payment: {total:.2f}, Interest: {interest:.2f}, Principal: {principal:.2f}, Remaining: {remaining:.2f}")
$\endgroup$
5
  • $\begingroup$ Do all amortization dates fall also on coupon payment dates? Unfortunately, QL doesn't support amortizations in the middle of coupon periods. $\endgroup$ May 10 at 10:32
  • $\begingroup$ All amortization dates fall on interest payment dates but there is no amortization on the first interest payment date. $\endgroup$
    – cpage
    May 10 at 11:27
  • $\begingroup$ Thanks, then it should work. Oh, and the amortization amounts are fixed? I've seen amortizations being linked to index, but it's pretty rare. $\endgroup$ May 10 at 13:06
  • $\begingroup$ Yes rather than being a percentage of current principal (which would decline over time), amortizations are a percentage of the initial principal so are a fixed amount. $\endgroup$
    – cpage
    May 10 at 13:09
  • 1
    $\begingroup$ Related: stackoverflow.com/questions/63884857 (by the way, they do have bonds reset from SOFR $\times$ gearing), quant.stackexchange.com/questions/68160 $\endgroup$ May 12 at 12:29

1 Answer 1

8
+250
$\begingroup$

A few things before creating the bond:

1) You can delegate to the library the calculation of the dates. Your code is equivalent to:

schedule = ql.MakeSchedule(
    effectiveDate=effective_date,
    firstDate=ql.Date(30,ql.June,2022),
    terminationDate=maturity_date,
    frequency=ql.Quarterly,
    calendar=calendar,
    forwards=True,
    endOfMonth=True,
)

which creates the same schedule.

2) It's not entirely clear what index you want to use. When you write

three_month_sofr = ql.OvernightIndex(
    "3M SOFR", 0, ql.USDCurrency(), calendar, day_count, sofr_curve_handle
)

it seems like you want your bond to pay the 3-months term SOFR fixed in advance. But in this case, you don't want to use ql.OvernightIndex, which has a tenor of 1 day: you want instead something like

fixing_days = 2
three_month_sofr = ql.IborIndex(
    "3M SOFR", ql.Period(3, ql.Months), fixing_days, ql.USDCurrency(), calendar,
    ql.ModifiedFollowing, True, day_count, sofr_curve_handle
)

which will use the curve to forecast a 3-months rate.

If, instead, you want your coupons to pay the daily SOFR fixings compounded day by day and ultimately fixed in arrears, you'll need to use

sofr = ql.SOFR(sofr_curve_handle)

and let the coupon forecast the compounded rate.

3) You're extracting past fixings from the curve. Your today is in 2024, but you're creating a curve starting in 2022 and extracting all rates from it, even from past coupons. The past fixings you need should be added to the index via the addFixing method. If you want to use the curve to simplify the example, you'll need to move today back to the start of the bond.

You're also calculating the rates as

sofr_curve_handle.zeroRate(schedule[i], ql.Actual360(), ql.Continuous).rate()

but the zero rate is the rate from the start of the curve to the passed date. You're only getting the results you expect because the curve is flat. It should be the forward rate between schedule[i-1] and schedule[i]. Finally, the term SOFR is a simple rate, not a continuous rate. But in any case, the coupons will take care of the calculation.

And finally, the bond. First, calculate the sequence of the principals for each coupon, almost as you're already doing; you need to include an additional face_value at the beginning and exclude the 0 at the end:

face_value = 100  # Initial principal
principal_payment = face_value * 0.05 / 4  # 5% annually, divided by 4 for quarterly payments

# Initialize loan cashflows
principal_remaining = face_value
principals = [face_value]

for i in range(1, len(schedule)-1):
    date = schedule[i]
    current_principal_payment = principal_payment if date >= first_amortization_date else 0

    principal_remaining -= current_principal_payment
    principal_remaining = max(0, principal_remaining)  # Ensure no negative principal
    principals.append(principal_remaining)

If you print them out, you'll see 100 twice at the beginning, as it's the principal for both the first and second coupon, and you won't see the 0 at the end, because 0 isn't a principal for any coupon.

Finally, here is the bond:

bond = ql.AmortizingFloatingRateBond(
    settlement_days, principals, schedule, three_month_sofr, day_count,
    spreads=[0.0535], floors=[0.0050]
)

if you didn't have a floor, that would be it; but since you do have it, you also need to tell the bond which interest-rate volatility you want to use to evaluate it. If you want a simple deterministic cut at the floor level, you can pass a constant volatility equal to 0. You'll have to pass a displacement, because a floor of 50bps on SOFR + 535 bps means a floor of -485bps on SOFR alone, and the Black model won't work with a negative strike. Alternatively, you could use a normal vol.

pricer = ql.BlackIborCouponPricer(
    ql.OptionletVolatilityStructureHandle(
        ql.ConstantOptionletVolatility(
            today, calendar, ql.Following, 0.0, day_count,
            ql.ShiftedLognormal, 0.05
        )
    )
)
ql.setCouponPricer(bond.cashflows(), pricer)

Here is the complete code:

import QuantLib as ql

today = ql.Date(10, 5, 2022)
ql.Settings.instance().evaluationDate = today

calendar = ql.UnitedStates(ql.UnitedStates.GovernmentBond)
day_count = ql.Actual360()

effective_date = ql.Date(10, 5, 2022)
maturity_date = ql.Date(1, 2, 2027)
first_amortization_date = ql.Date(30,9,2022)
frequency = ql.Quarterly

schedule = ql.MakeSchedule(
    effectiveDate=effective_date,
    firstDate=calendar.endOfMonth(ql.Date(1,ql.June,2022)),
    terminationDate=maturity_date,
    frequency=frequency,
    calendar=calendar,
    forwards=True,
    endOfMonth=True,
)

sofr_curve = ql.FlatForward(today, 0.05, ql.Actual360())
sofr_curve_handle = ql.YieldTermStructureHandle(sofr_curve)

fixing_days = 2
three_month_sofr = ql.IborIndex(
    "3M SOFR", ql.Period(3, ql.Months), fixing_days, ql.USDCurrency(), calendar,
    ql.ModifiedFollowing, True, day_count, sofr_curve_handle
)

face_value = 100  # Initial principal
principal_payment = face_value * 0.05 / 4  # 5% annually, divided by 4 for quarterly payments

principal_remaining = face_value
principals = [face_value]

for i in range(1, len(schedule)-1):
    date = schedule[i]
    current_principal_payment = principal_payment if date >= first_amortization_date else 0

    principal_remaining -= current_principal_payment
    principal_remaining = max(0, principal_remaining)  # Ensure no negative principal
    principals.append(principal_remaining)

settlement_days = 0

bond = ql.AmortizingFloatingRateBond(
    settlement_days, principals, schedule, three_month_sofr, day_count,
    spreads=[0.0535], floors=[0.0050]
)

pricer = ql.BlackIborCouponPricer(
    ql.OptionletVolatilityStructureHandle(
        ql.ConstantOptionletVolatility(
            today, calendar, ql.Following, 0.0, day_count,
            ql.ShiftedLognormal, 0.05
        )
    )
)
ql.setCouponPricer(bond.cashflows(), pricer)

for cf in bond.cashflows():
    c = ql.as_coupon(cf)
    if c is not None:
        print(f"Date: {c.date().ISO()}, Principal: {c.nominal():.2f}, Interest payment: {c.amount():.2f}, Principal payment: {0.0:.2f}")
    else:
        print(f"Date: {cf.date().ISO()}, Interest payment: {0.0:.2f}, Principal payment: {cf.amount():.2f}")

If you want to pay daily-compounded SOFR fixings, the creation of the bond is different. However, it doesn't support floors at this time, so you'll have to open an issue on GitHub if you want that to work in one of the next releases. Ignoring the floor, the bond would be created as:

sofr = ql.Sofr(sofr_curve_handle)
coupons = ql.OvernightLeg(principals, schedule, sofr, day_count)
bond = ql.Bond(settlement_days, calendar, effective_date, coupons)
$\endgroup$
12
  • 1
    $\begingroup$ Brilliant as always! 1 is there a way to pass a large number of date-value pairs to addFixing, instead of separate calls for every day? 2 how do you specify whether daily SOFR should be averaged or compounded? 3 if a historical reset value is not found for an index on some date $t$, rather than throw, is there a way to specify "tolerance" $n$ days, to look for a value within t$\pm$n days with a warning? $\endgroup$ May 14 at 14:37
  • 2
    $\begingroup$ @DimitriVulis 1. the addFixings method takes a list of dates and a list of fixings in Python, or the corresponding iterators in C++. 2. OvernightLeg can take an optional averagingMethod parameter, see <github.com/lballabio/QuantLib-SWIG/blob/v1.34/SWIG/…>. 3. Sorry, no such feature. $\endgroup$ May 15 at 7:50
  • 2
    $\begingroup$ @cpage (1) I've moved the spread to the bond. I added a comment in the answer that explains the error you were getting and how to fix it. (2) I added the fixing days to the index. $\endgroup$ May 15 at 8:00
  • 2
    $\begingroup$ @cpage Now the code also outputs the principal used for the calculation of the interest. The 2024-12-31 payment does use 88.75 as you expected. $\endgroup$ May 15 at 8:01
  • 1
    $\begingroup$ The slippage is because of quant.stackexchange.com/questions/79258/… $\endgroup$ May 15 at 14:58

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.