import QuantLib as ql
import pandas as pd
today = ql.Date(21, ql.September, 2023)
ql.Settings.instance().evaluationDate = today
bps = 1e-4Different kinds of swaps
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; 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.18261457735
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%}"})| date | rate | tenor | amount | |
|---|---|---|---|---|
| 0 | September 23rd, 2024 | 0.40% | 1.022222 | 4088.89 |
| 1 | September 22nd, 2025 | 0.40% | 1.011111 | 4044.44 |
| 2 | September 21st, 2026 | 0.40% | 1.011111 | 4044.44 |
| 3 | September 21st, 2027 | 0.40% | 1.013889 | 4055.56 |
| 4 | September 21st, 2028 | 0.40% | 1.016667 | 4066.67 |
| 5 | September 21st, 2029 | 0.40% | 1.013889 | 4055.56 |
| 6 | September 23rd, 2030 | 0.40% | 1.019444 | 4077.78 |
| 7 | September 22nd, 2031 | 0.40% | 1.011111 | 4044.44 |
| 8 | September 21st, 2032 | 0.40% | 1.013889 | 4055.56 |
| 9 | September 21st, 2033 | 0.40% | 1.013889 | 4055.56 |
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%}"})| date | rate | tenor | amount | |
|---|---|---|---|---|
| 0 | September 23rd, 2024 | 2.03% | 1.022222 | 20783.73 |
| 1 | September 22nd, 2025 | 2.11% | 1.011111 | 21371.70 |
| 2 | September 21st, 2026 | 2.19% | 1.011111 | 22184.07 |
| 3 | September 21st, 2027 | 2.27% | 1.013889 | 23062.11 |
| 4 | September 21st, 2028 | 2.36% | 1.016667 | 23947.63 |
| 5 | September 21st, 2029 | 2.44% | 1.013889 | 24701.40 |
| 6 | September 23rd, 2030 | 2.52% | 1.019444 | 25664.78 |
| 7 | September 22nd, 2031 | 2.60% | 1.011111 | 26271.32 |
| 8 | September 21st, 2032 | 2.68% | 1.013889 | 27164.13 |
| 9 | September 21st, 2033 | 2.76% | 1.013889 | 27985.60 |
The wrappers also provides a MakeOIS function that supports keyword arguments; for instance, the swap above could have been instantiated by writing:
swap = ql.MakeOIS(
swapTenor=ql.Period(10, ql.Years),
effectiveDate=start_date,
paymentFrequency=ql.Annual,
overnightIndex=sofr,
swapType=ql.Swap.Payer,
nominal=1_000_000,
fixedRate=fixed_rate,
fixedLegDayCount=fixed_day_counter,
)
swap.setPricingEngine(ql.DiscountingSwapEngine(sofr_handle))swap.NPV()177641.18261457735
In C++, the same instantiation would be written as
ext::shared_ptr<OvernightIndexedSwap> swap =
MakeOIS(10 * Years, sofr, fixed_rate)
.withEffectiveDate(start_date)
.withPaymentFrequency(Annual)
.withType(Swap::Payer)
.withNominal(1000000)
.withFixedLegDayCount(fixed_day_counter);
This makes it more convenient to pass additional parameter without having to specify all the ones that come before them in the constructor signature; for instance, you might want to pass a flag that tells the swap that it’s ok to use the telescopic rule to simplify the calculation of the floating leg.
swap = ql.MakeOIS(
swapTenor=ql.Period(10, ql.Years),
effectiveDate=start_date,
paymentFrequency=ql.Annual,
overnightIndex=sofr,
swapType=ql.Swap.Payer,
nominal=1_000_000,
fixedRate=fixed_rate,
fixedLegDayCount=fixed_day_counter,
telescopicValueDates=True,
)
swap.setPricingEngine(ql.DiscountingSwapEngine(sofr_handle))swap.NPV()177641.18261457735
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.02379864737943738
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()-2.9103830456733704e-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.7419789353
swap.fixedLegBPS()-897.2389841089508
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))try:
swap.NPV()
except Exception as e:
print(f"{type(e).__name__}: {e}")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 += 1swap.NPV()176996.59653466725
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.4310675247
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}"})| date | nominal | rate | tenor | amount | |
|---|---|---|---|---|---|
| 0 | June 21st, 2024 | 1000000 | 2.02% | 1.016667 | 20559.03 |
| 1 | June 23rd, 2025 | 900000 | 2.09% | 1.019444 | 19207.48 |
| 2 | June 22nd, 2026 | 800000 | 2.17% | 1.011111 | 17584.73 |
| 3 | June 21st, 2027 | 700000 | 2.25% | 1.011111 | 15955.64 |
| 4 | June 21st, 2028 | 600000 | 2.34% | 1.016667 | 14244.46 |
| 5 | June 21st, 2029 | 500000 | 2.42% | 1.013889 | 12247.47 |
| 6 | June 21st, 2030 | 400000 | 2.50% | 1.013889 | 10125.71 |
| 7 | June 23rd, 2031 | 300000 | 2.58% | 1.019444 | 7884.48 |
| 8 | June 21st, 2032 | 200000 | 2.66% | 1.011111 | 5376.69 |
| 9 | June 21st, 2033 | 100000 | 2.74% | 1.013889 | 2777.85 |
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.83097733647
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%}"})| date | rate | tenor | amount | |
|---|---|---|---|---|
| 0 | June 25th, 2024 | 2.12% | 1.016667 | 21575.69 |
| 1 | June 25th, 2025 | 2.19% | 1.019444 | 22361.09 |
| 2 | June 24th, 2026 | 2.27% | 1.011111 | 22992.03 |
| 3 | June 23rd, 2027 | 2.35% | 1.011111 | 23804.88 |
| 4 | June 23rd, 2028 | 2.44% | 1.016667 | 24757.43 |
| 5 | June 25th, 2029 | 2.52% | 1.013889 | 25508.83 |
| 6 | June 25th, 2030 | 2.60% | 1.013889 | 26328.17 |
| 7 | June 25th, 2031 | 2.68% | 1.019444 | 27301.05 |
| 8 | June 23rd, 2032 | 2.76% | 1.011111 | 27894.57 |
| 9 | June 23rd, 2033 | 2.84% | 1.013889 | 28792.37 |
It’s also possible for the swap to calculate the spread that would make it fair:
swap.fairSpread()-0.01960712998027658
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()3.637978807091713e-11
As before, the NPV is null within numerical accuracy.
Starting with release 1.35, features like lookback days and lockout days are also provided:
swap = ql.MakeOIS(
swapTenor=ql.Period(10, ql.Years),
effectiveDate=today,
paymentFrequency=ql.Annual,
overnightIndex=sofr,
swapType=ql.Swap.Payer,
nominal=1_000_000,
fixedRate=fixed_rate,
fixedLegDayCount=fixed_day_counter,
lookbackDays=2,
lockoutDays=5,
applyObservationShift=True,
)
swap.setPricingEngine(ql.DiscountingSwapEngine(sofr_handle))swap.NPV()177582.673346285
However, note that some of those parameters are not compatible with using the telescoping formula; in particular, passing an explicit number of lookback days usually prevents it, unless the observation shift is also applied. If in doubt, you can try; when the telescopic rule is requested but the parameters are not compatible, the library will raise an exception.
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))try:
swap.NPV()
except Exception as e:
print(f"{type(e).__name__}: {e}")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%}"})| date | rate | tenor | amount | |
|---|---|---|---|---|
| 0 | June 21st, 2024 | 0.50% | 1.000000 | 5000.00 |
| 1 | June 23rd, 2025 | 0.50% | 1.000000 | 5000.00 |
| 2 | June 22nd, 2026 | 0.50% | 1.000000 | 5000.00 |
| 3 | June 21st, 2027 | 0.50% | 1.000000 | 5000.00 |
| 4 | June 21st, 2028 | 0.50% | 1.000000 | 5000.00 |
| 5 | June 21st, 2029 | 0.50% | 1.000000 | 5000.00 |
| 6 | June 21st, 2030 | 0.50% | 1.000000 | 5000.00 |
| 7 | June 23rd, 2031 | 0.50% | 1.000000 | 5000.00 |
| 8 | June 21st, 2032 | 0.50% | 1.000000 | 5000.00 |
| 9 | June 21st, 2033 | 0.50% | 1.000000 | 5000.00 |
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%}"})| date | rate | tenor | amount | |
|---|---|---|---|---|
| 0 | December 21st, 2023 | 3.00% | 0.508333 | 15250.00 |
| 1 | June 21st, 2024 | 3.02% | 0.508333 | 15358.25 |
| 2 | December 23rd, 2024 | 3.06% | 0.513889 | 15734.84 |
| 3 | June 23rd, 2025 | 3.10% | 0.505556 | 15681.24 |
| 4 | December 22nd, 2025 | 3.14% | 0.505556 | 15883.15 |
| 5 | June 22nd, 2026 | 3.18% | 0.505556 | 16085.09 |
| 6 | December 21st, 2026 | 3.22% | 0.505556 | 16287.07 |
| 7 | June 21st, 2027 | 3.26% | 0.505556 | 16489.09 |
| 8 | December 21st, 2027 | 3.30% | 0.508333 | 16784.18 |
| 9 | June 21st, 2028 | 3.34% | 0.508333 | 16988.53 |
| 10 | December 21st, 2028 | 3.38% | 0.508333 | 17192.92 |
| 11 | June 21st, 2029 | 3.42% | 0.505556 | 17300.91 |
| 12 | December 21st, 2029 | 3.46% | 0.508333 | 17600.70 |
| 13 | June 21st, 2030 | 3.50% | 0.505556 | 17706.51 |
| 14 | December 23rd, 2030 | 3.54% | 0.513889 | 18208.38 |
| 15 | June 23rd, 2031 | 3.58% | 0.505556 | 18114.49 |
| 16 | December 22nd, 2031 | 3.62% | 0.505556 | 18316.87 |
| 17 | June 21st, 2032 | 3.66% | 0.505556 | 18519.30 |
| 18 | December 21st, 2032 | 3.70% | 0.508333 | 18826.15 |
| 19 | June 21st, 2033 | 3.74% | 0.505556 | 18925.38 |
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.4025020590461
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.1593043667651
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 += 1euribor_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.