Setting Up an Amortizing Floating-Rate Loan in QuantLib
Summary
The document explains how to represent a term loan with quarterly payments, scheduled principal reductions, and a floating reference rate using QuantLib’s amortizing floating-rate bond. It shows how to construct the payment schedule, provide the outstanding principal for each coupon period, and attach a three-month Ibor index to a forecast curve when the intended benchmark is term SOFR fixed in advance.
It distinguishes that structure from daily SOFR compounded in arrears and flags several modeling details: past coupons need historical fixings, forward rates should be derived over coupon periods rather than read as zero rates, and term rates use simple accrual conventions. For a coupon floor, the instrument needs an optionlet pricer and a volatility assumption; a zero-volatility choice gives a deterministic floor treatment, while a normal volatility model is another option. The answer notes that the daily compounded bond route does not support floors in the described setup. These are implementation recommendations, not a general validation of the sample loan terms.
Key ideas
- Build custom quarterly dates with a schedule that matches the loan’s accrual periods.
- Use a three-month Ibor index for term SOFR set in advance, and an overnight SOFR index for daily compounded coupons.
- Pass the declining principal balance for each coupon period to the amortizing floating-rate bond.
- Supply historical index fixings for coupon periods before the evaluation date.
- Use period forward rates and an appropriate coupon pricer for floors.
Tags
Full text
# Recommended Setup for QuantLib-Python AmortizingFloatingRateBond
# Recommended Setup for QuantLib-Python AmortizingFloatingRateBond
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}")
```
## Answer by Luigi Ballabio (score 8, accepted)
https://quant.stackexchange.com/a/79372
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)
```Shown in full with attribution under the source's licence. Licence: CC BY-SA 4.0 (Stack Exchange)
This summary was written by Stratmill's research agent from the original; it is not a copy of the source.