Different kinds of swaps
Hello, dear reader.
In this post, a notebook that I had written for a training a while ago and that I updated recently in response to some off-list questions. (Thanks, Terry!) It’s not everything you always wanted to know about swaps, but it’s a start. Enjoy!
Different kinds of swaps
import QuantLib as ql
import pandas as pd
today = ql.Date(21, ql.September, 2023)
ql.Settings.instance().evaluationDate = today
bps = 1e-4
Overnight-indexed swaps
Overnight-indexed swaps (OIS) need a single interest-rate curve that will be used both for forecasting the values of the underlying index and for discounting the value of the resulting cashflows. The bootstrapping process used to create the curve is the subject of another notebook that I might post some other time; for brevity, here I’ll use a mock curve with zero rates increasing linearly over time.
sofr_curve = ql.ZeroCurve(
[today, today + ql.Period(50, ql.Years)],
[0.02, 0.04],
ql.Actual365Fixed(),
)
Given the curve, we can instantiate index objects able to forecast their future fixings.
sofr_handle = ql.YieldTermStructureHandle(sofr_curve)
sofr = ql.Sofr(sofr_handle)
sofr.fixing(ql.Date(7, ql.February, 2025))
0.020821983903180907
In turn, we can use the index to build an OIS:
start_date = today
end_date = start_date + ql.Period(10, ql.Years)
coupon_tenor = ql.Period(1, ql.Years)
calendar = ql.UnitedStates(ql.UnitedStates.GovernmentBond)
convention = ql.Following
rule = ql.DateGeneration.Forward
end_of_month = False
fixed_rate = 40 * bps
fixed_day_counter = sofr.dayCounter()
schedule = ql.Schedule(
start_date,
end_date,
coupon_tenor,
calendar,
convention,
convention,
rule,
end_of_month,
)
swap = ql.OvernightIndexedSwap(
ql.Swap.Payer,
1_000_000,
schedule,
fixed_rate,
fixed_day_counter,
sofr,
)
We’ll also use the SOFR curve to discount the cashflows…
swap.setPricingEngine(
ql.DiscountingSwapEngine(sofr_handle)
)
…and thus get the value of the swap.
swap.NPV()
177641.1826145773
For more details, it’s possible to extract the cashflows from the swap and call their methods.
data = []
for cf in swap.fixedLeg():
coupon = ql.as_fixed_rate_coupon(cf)
data.append(
(
coupon.date(),
coupon.rate(),
coupon.accrualPeriod(),
coupon.amount(),
)
)
pd.DataFrame(
data, columns=["date", "rate", "tenor", "amount"]
).style.format(
{"amount": "{:.2f}", "rate": "{:.2%}"}
)
data = []
for cf in swap.overnightLeg():
coupon = ql.as_floating_rate_coupon(cf)
data.append(
(
coupon.date(),
coupon.rate(),
coupon.accrualPeriod(),
coupon.amount(),
)
)
pd.DataFrame(
data, columns=["date", "rate", "tenor", "amount"]
).style.format(
{"amount": "{:.2f}", "rate": "{:.2%}"}
)
Other results
Besides its present value, the OIS can return other figures, such as the fixed rate that would make the swap fair:
swap.fairRate()
0.02379864737943737
We can test it by building a second swap, identical to the first but paying the fair rate:
test_swap = ql.OvernightIndexedSwap(
ql.Swap.Payer,
1_000_000,
schedule,
swap.fairRate(),
fixed_day_counter,
sofr,
)
test_swap.setPricingEngine(
ql.DiscountingSwapEngine(sofr_handle)
)
test_swap.NPV()
-5.820766091346741e-11
As expected, the NPV of the fair swap is zero within numerical accuracy.
Other results include the NPV of each leg and their BPS, that is, the change in their value if their rate increases by 1 bp:
swap.fixedLegNPV()
-35889.55936435795
swap.overnightLegNPV()
213530.74197893526
swap.fixedLegBPS()
-897.238984108951
Again, we can test it by comparing the expected value of a swap whose fixed leg pays 1 bps more…
swap.fixedLegNPV() + swap.fixedLegBPS()
-36786.7983484669
…with the value of an actual swap paying that modified rate:
test_swap = ql.OvernightIndexedSwap(
ql.Swap.Payer,
1_000_000,
schedule,
fixed_rate + 1 * bps,
fixed_day_counter,
sofr,
)
test_swap.setPricingEngine(
ql.DiscountingSwapEngine(sofr_handle)
)
test_swap.fixedLegNPV()
-36786.79834846717
Known fixings
An added twist: if the swap already started, it needs fixings in the past that the curve can’t forecast.
start_date = today - ql.Period(3, ql.Months)
end_date = start_date + ql.Period(10, ql.Years)
schedule = ql.Schedule(
start_date,
end_date,
coupon_tenor,
calendar,
convention,
convention,
rule,
end_of_month,
)
swap = ql.OvernightIndexedSwap(
ql.Swap.Payer,
1_000_000,
schedule,
fixed_rate,
fixed_day_counter,
sofr,
)
swap.setPricingEngine(
ql.DiscountingSwapEngine(sofr_handle)
)
swap.NPV()
RuntimeError: 2nd leg: Missing SOFRON Actual/360 fixing for June 21st, 2023
The information can be stored through the index (and will be shared by all instances of that index). If it’s already available and stored, today’s fixing will be used, too; if not, it will be forecast from the curve.
d = ql.Date(21, ql.June, 2023)
while d <= today:
if sofr.isValidFixingDate(d):
sofr.addFixing(d, 0.02)
d += 1
swap.NPV()
176996.5965346672
More features
Overnight-indexed swaps make it possible to specify other aspects of the contract; for instance, the notionals of the coupons can vary, as in the following swap:
notionals = [
1_000_000,
900_000,
800_000,
700_000,
600_000,
500_000,
400_000,
300_000,
200_000,
100_000,
]
swap = ql.OvernightIndexedSwap(
ql.Swap.Payer,
notionals,
schedule,
fixed_rate,
fixed_day_counter,
sofr,
)
swap.setPricingEngine(
ql.DiscountingSwapEngine(sofr_handle)
)
swap.NPV()
94959.43106752468
Here are the corresponding floating-rate coupons:
data = []
for cf in swap.overnightLeg():
coupon = ql.as_floating_rate_coupon(cf)
data.append(
(
coupon.date(),
coupon.nominal(),
coupon.rate(),
coupon.accrualPeriod(),
coupon.amount(),
)
)
pd.DataFrame(
data, columns=[
"date", "nominal", "rate", "tenor", "amount"
]
).style.format(
{
"amount": "{:.2f}",
"rate": "{:.2%}",
"nominal": "{:.0f}"
}
)
Other features make it possible for the floating-rate leg to pay an added spread on top of the SOFR fixings, or for the coupons to have a payment lag of a few days:
spread = 10 * bps
payment_lag = 2
swap = ql.OvernightIndexedSwap(
ql.Swap.Payer,
1_000_000,
schedule,
fixed_rate,
fixed_day_counter,
sofr,
spread,
payment_lag,
)
swap.setPricingEngine(
ql.DiscountingSwapEngine(sofr_handle)
)
swap.NPV()
185991.83097733645
Again, we can check the coupons and compare the payment dates and the rates with the previous cases:
data = []
for cf in swap.overnightLeg():
coupon = ql.as_floating_rate_coupon(cf)
data.append(
(
coupon.date(),
coupon.rate(),
coupon.accrualPeriod(),
coupon.amount(),
)
)
pd.DataFrame(
data, columns=["date", "rate", "tenor", "amount"]
).style.format(
{"amount": "{:.2f}", "rate": "{:.2%}"}
)
It’s also possible for the swap to calculate the spread that would make it fair:
swap.fairSpread()
-0.019607129980276583
And again, we can check this by creating a swap with the fair spread:
test_swap = ql.OvernightIndexedSwap(
ql.Swap.Payer,
1_000_000,
schedule,
fixed_rate,
fixed_day_counter,
sofr,
swap.fairSpread(),
payment_lag,
)
test_swap.setPricingEngine(
ql.DiscountingSwapEngine(sofr_handle)
)
test_swap.NPV()
0.0
As before, the NPV is null within numerical accuracy.
At the time of this writing, features like lookback and lockout days are being worked on; they should be available starting from release 1.35, scheduled for July 2024.
Fixed-vs-floater swaps
With some differences, the same ideas apply to vanilla fixed-vs-floater swaps in the markets where they’re still relevant. For instance, a swap paying a fixed rate vs 6-months Euribor will need a discount curve (probably calculated from ESTR rates) and a forecast curve for Euribor. As before, I’ll use mocks.
estr_curve = ql.ZeroCurve(
[today, today + ql.Period(50, ql.Years)],
[0.02, 0.04],
ql.Actual365Fixed(),
)
euribor6m_curve = ql.ZeroCurve(
[today, today + ql.Period(50, ql.Years)],
[0.03, 0.05],
ql.Actual365Fixed(),
)
estr_handle = ql.YieldTermStructureHandle(estr_curve)
euribor6m_handle = ql.YieldTermStructureHandle(
euribor6m_curve
)
euribor6m = ql.Euribor(
ql.Period(6, ql.Months), euribor6m_handle
)
euribor6m.fixing(ql.Date(8, ql.February, 2024))
0.03032682683069796
Vanilla swaps are built using a different class, but work in the same way.
start_date = today + 2
end_date = start_date + ql.Period(10, ql.Years)
calendar = ql.TARGET()
rule = ql.DateGeneration.Forward
fixed_frequency = ql.Annual
fixed_convention = ql.Unadjusted
fixed_day_count = ql.Thirty360(
ql.Thirty360.BondBasis
)
float_convention = euribor6m.businessDayConvention()
end_of_month = False
fixed_rate = 50 * bps
fixed_schedule = ql.Schedule(
start_date,
end_date,
ql.Period(fixed_frequency),
calendar,
fixed_convention,
fixed_convention,
rule,
end_of_month,
)
float_schedule = ql.Schedule(
start_date,
end_date,
euribor6m.tenor(),
calendar,
float_convention,
float_convention,
rule,
end_of_month,
)
swap = ql.VanillaSwap(
ql.Swap.Payer,
1_000_000,
fixed_schedule,
fixed_rate,
fixed_day_count,
float_schedule,
euribor6m,
0.0,
euribor6m.dayCounter(),
)
This time, though, we’ll take care to use the correct discount curve:
swap.setPricingEngine(
ql.DiscountingSwapEngine(estr_handle)
)
Once the swap is set up, it can return a number of results:
swap.NPV()
259485.7403164844
swap.fairRate()
0.0343510181748013
swap.fairSpread()
-0.02876783996070111
Again, seasoned swaps need past-fixing information.
start_date = today - ql.Period(3, ql.Months)
end_date = start_date + ql.Period(10, ql.Years)
fixed_schedule = ql.Schedule(
start_date,
end_date,
ql.Period(fixed_frequency),
calendar,
fixed_convention,
fixed_convention,
rule,
end_of_month,
)
float_schedule = ql.Schedule(
start_date,
end_date,
euribor6m.tenor(),
calendar,
float_convention,
float_convention,
rule,
end_of_month,
)
swap = ql.VanillaSwap(
ql.Swap.Payer,
1_000_000,
fixed_schedule,
fixed_rate,
fixed_day_count,
float_schedule,
euribor6m,
0.0,
euribor6m.dayCounter(),
)
swap.setPricingEngine(
ql.DiscountingSwapEngine(estr_handle)
)
swap.NPV()
RuntimeError: 2nd leg: Missing Euribor6M Actual/360 fixing for June 19th, 2023
euribor6m.addFixing(ql.Date(19, 6, 2023), 0.03)
swap.NPV()
259487.36632473825
And again, we can dive into the cashflows:
data = []
for cf in swap.fixedLeg():
coupon = ql.as_fixed_rate_coupon(cf)
data.append(
(
coupon.date(),
coupon.rate(),
coupon.accrualPeriod(),
coupon.amount(),
)
)
pd.DataFrame(
data, columns=["date", "rate", "tenor", "amount"]
).style.format(
{"amount": "{:.2f}", "rate": "{:.2%}"}
)
data = []
for cf in swap.floatingLeg():
coupon = ql.as_floating_rate_coupon(cf)
data.append(
(
coupon.date(),
coupon.rate(),
coupon.accrualPeriod(),
coupon.amount(),
)
)
pd.DataFrame(
data, columns=["date", "rate", "tenor", "amount"]
).style.format(
{"amount": "{:.2f}", "rate": "{:.2%}"}
)
More generic swaps
To build fixed-vs-floating swaps with less common features (such as decreasing notionals or floating-rate gearings) we can build the two legs separately and put them together in an instance of the Swap
class.
fixed_leg = ql.FixedRateLeg(
schedule=fixed_schedule,
dayCount=ql.Thirty360(ql.Thirty360.BondBasis),
nominals=[
10000,
8000,
6000,
4000,
2000,
],
couponRates=[0.01],
)
floating_leg = ql.IborLeg(
schedule=float_schedule,
index=euribor6m,
nominals=[
10000,
10000,
8000,
8000,
6000,
6000,
4000,
4000,
2000,
2000,
],
gearings=[0.8],
)
swap = ql.Swap(fixed_leg, floating_leg)
swap.setPricingEngine(
ql.DiscountingSwapEngine(estr_handle)
)
swap.NPV()
601.402502059046
Some results are still available, and can be addressed by the index of the leg.
print(swap.legNPV(0))
print(swap.legNPV(1))
-370.756802307719
972.159304366765
Some others are currently available through other classes.
print(
ql.CashFlows.bps(
swap.leg(0), estr_curve, False
)
)
print(
ql.CashFlows.bps(
swap.leg(1), estr_curve, False
)
)
3.707568023077187
3.7857461356076967
Other calculations require a bit more logic.
Basis swaps
We don’t necessarily need one fixed-rate leg and one floating-rate leg. By combining two floating-rate legs with different indexes, we can build a basis swap.
estr = ql.Estr(estr_handle)
d = ql.Date(21, ql.June, 2023)
while d < today:
if estr.isValidFixingDate(d):
estr.addFixing(d, -0.0035)
d += 1
euribor_leg = ql.IborLeg(
[10000], float_schedule, euribor6m
)
estr_leg = ql.OvernightLeg(
[10000], float_schedule, estr
)
swap = ql.Swap(estr_leg, euribor_leg)
swap.setPricingEngine(
ql.DiscountingSwapEngine(estr_handle)
)
swap.NPV()
968.7991051046724
print(swap.legNPV(0))
print(swap.legNPV(1))
-2070.8646132177128
3039.663718322385
Cross-currency swaps
Finally, like for basis swaps, there is no specific class modeling cross-currency swaps; and it’s not always possible to use the Swap
class, either. We can price them by creating the two legs explicitly (including the final notional exchange) and using library functions to get their NPV. This is the subject of another notebook I posted last year.