import QuantLib as ql
import pandas as pd
= ql.Date(21, ql.September, 2023)
today = today
ql.Settings.instance().evaluationDate
= 1e-4 bps
Different 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.
= ql.ZeroCurve(
sofr_curve + ql.Period(50, ql.Years)],
[today, today 0.02, 0.04],
[
ql.Actual365Fixed(), )
Given the curve, we can instantiate index objects able to forecast their future fixings.
= ql.YieldTermStructureHandle(sofr_curve) sofr_handle
= ql.Sofr(sofr_handle) sofr
7, ql.February, 2025)) sofr.fixing(ql.Date(
0.020821983903180907
In turn, we can use the index to build an OIS:
= today
start_date = start_date + ql.Period(10, ql.Years)
end_date = ql.Period(1, ql.Years)
coupon_tenor = ql.UnitedStates(ql.UnitedStates.GovernmentBond)
calendar = ql.Following
convention = ql.DateGeneration.Forward
rule = False
end_of_month
= 40 * bps
fixed_rate = sofr.dayCounter()
fixed_day_counter
= ql.Schedule(
schedule
start_date,
end_date,
coupon_tenor,
calendar,
convention,
convention,
rule,
end_of_month,
)= ql.OvernightIndexedSwap(
swap 1_000_000, schedule, fixed_rate, fixed_day_counter, sofr
ql.Swap.Payer, )
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():
= ql.as_fixed_rate_coupon(cf)
coupon
data.append(
(
coupon.date(),
coupon.rate(),
coupon.accrualPeriod(),
coupon.amount(),
) )
pd.DataFrame(=["date", "rate", "tenor", "amount"]
data, columnsformat({"amount": "{:.2f}", "rate": "{:.2%}"}) ).style.
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():
= ql.as_floating_rate_coupon(cf)
coupon
data.append(
(
coupon.date(),
coupon.rate(),
coupon.accrualPeriod(),
coupon.amount(),
) )
pd.DataFrame(=["date", "rate", "tenor", "amount"]
data, columnsformat({"amount": "{:.2f}", "rate": "{:.2%}"}) ).style.
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 |
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:
= ql.OvernightIndexedSwap(
test_swap
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.fixedLegBPS() swap.fixedLegNPV()
-36786.7983484669
…with the value of an actual swap paying that modified rate:
= ql.OvernightIndexedSwap(
test_swap
ql.Swap.Payer,1_000_000,
schedule,+ 1 * bps,
fixed_rate
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.
= today - ql.Period(3, ql.Months)
start_date = start_date + ql.Period(10, ql.Years)
end_date
= ql.Schedule(
schedule
start_date,
end_date,
coupon_tenor,
calendar,
convention,
convention,
rule,
end_of_month,
)= ql.OvernightIndexedSwap(
swap 1_000_000, schedule, fixed_rate, fixed_day_counter, sofr
ql.Swap.Payer,
) 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.
= ql.Date(21, ql.June, 2023)
d while d <= today:
if sofr.isValidFixingDate(d):
0.02)
sofr.addFixing(d, += 1 d
swap.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,
]
= ql.OvernightIndexedSwap(
swap
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():
= ql.as_floating_rate_coupon(cf)
coupon
data.append(
(
coupon.date(),
coupon.nominal(),
coupon.rate(),
coupon.accrualPeriod(),
coupon.amount(),
) )
pd.DataFrame(=["date", "nominal", "rate", "tenor", "amount"]
data, columnsformat({"amount": "{:.2f}", "rate": "{:.2%}", "nominal": "{:.0f}"}) ).style.
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:
= 10 * bps
spread = 2
payment_lag
= ql.OvernightIndexedSwap(
swap
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():
= ql.as_floating_rate_coupon(cf)
coupon
data.append(
(
coupon.date(),
coupon.rate(),
coupon.accrualPeriod(),
coupon.amount(),
) )
pd.DataFrame(=["date", "rate", "tenor", "amount"]
data, columnsformat({"amount": "{:.2f}", "rate": "{:.2%}"}) ).style.
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:
= ql.OvernightIndexedSwap(
test_swap
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.
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.
= ql.ZeroCurve(
estr_curve + ql.Period(50, ql.Years)],
[today, today 0.02, 0.04],
[
ql.Actual365Fixed(),
)
= ql.ZeroCurve(
euribor6m_curve + ql.Period(50, ql.Years)],
[today, today 0.03, 0.05],
[
ql.Actual365Fixed(), )
= ql.YieldTermStructureHandle(estr_curve) estr_handle
= ql.YieldTermStructureHandle(euribor6m_curve) euribor6m_handle
= ql.Euribor(ql.Period(6, ql.Months), euribor6m_handle) euribor6m
8, ql.February, 2024)) euribor6m.fixing(ql.Date(
0.03032682683069796
Vanilla swaps are built using a different class, but work in the same way.
= today + 2
start_date = start_date + ql.Period(10, ql.Years)
end_date = ql.TARGET()
calendar = ql.DateGeneration.Forward
rule = ql.Annual
fixed_frequency = ql.Unadjusted
fixed_convention = ql.Thirty360(ql.Thirty360.BondBasis)
fixed_day_count = euribor6m.businessDayConvention()
float_convention = False
end_of_month = 50 * bps
fixed_rate
= ql.Schedule(
fixed_schedule
start_date,
end_date,
ql.Period(fixed_frequency),
calendar,
fixed_convention,
fixed_convention,
rule,
end_of_month,
)= ql.Schedule(
float_schedule
start_date,
end_date,
euribor6m.tenor(),
calendar,
float_convention,
float_convention,
rule,
end_of_month,
)= ql.VanillaSwap(
swap
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.
= today - ql.Period(3, ql.Months)
start_date = start_date + ql.Period(10, ql.Years)
end_date
= ql.Schedule(
fixed_schedule
start_date,
end_date,
ql.Period(fixed_frequency),
calendar,
fixed_convention,
fixed_convention,
rule,
end_of_month,
)= ql.Schedule(
float_schedule
start_date,
end_date,
euribor6m.tenor(),
calendar,
float_convention,
float_convention,
rule,
end_of_month,
)= ql.VanillaSwap(
swap
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
19, 6, 2023), 0.03) euribor6m.addFixing(ql.Date(
swap.NPV()
259487.36632473825
And again, we can dive into the cashflows:
= []
data for cf in swap.fixedLeg():
= ql.as_fixed_rate_coupon(cf)
coupon
data.append(
(
coupon.date(),
coupon.rate(),
coupon.accrualPeriod(),
coupon.amount(),
) )
pd.DataFrame(=["date", "rate", "tenor", "amount"]
data, columnsformat({"amount": "{:.2f}", "rate": "{:.2%}"}) ).style.
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():
= ql.as_floating_rate_coupon(cf)
coupon
data.append(
(
coupon.date(),
coupon.rate(),
coupon.accrualPeriod(),
coupon.amount(),
) )
pd.DataFrame(=["date", "rate", "tenor", "amount"]
data, columnsformat({"amount": "{:.2f}", "rate": "{:.2%}"}) ).style.
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.
= ql.FixedRateLeg(
fixed_leg =fixed_schedule,
schedule=ql.Thirty360(ql.Thirty360.BondBasis),
dayCount=[10000, 8000, 6000, 4000, 2000],
nominals=[0.01],
couponRates
)= ql.IborLeg(
floating_leg =float_schedule,
schedule=euribor6m,
index=[
nominals10000,
10000,
8000,
8000,
6000,
6000,
4000,
4000,
2000,
2000,
],=[0.8],
gearings
)= ql.Swap(fixed_leg, floating_leg)
swap
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.
= ql.Estr(estr_handle)
estr
= ql.Date(21, ql.June, 2023)
d while d < today:
if estr.isValidFixingDate(d):
-0.0035)
estr.addFixing(d, += 1 d
= ql.IborLeg([10000], float_schedule, euribor6m)
euribor_leg = ql.OvernightLeg([10000], float_schedule, estr)
estr_leg
= ql.Swap(estr_leg, euribor_leg)
swap
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.