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!

Subscribe to my Substack to receive my posts in your inbox, or follow me on Twitter or LinkedIn if you want to be notified of new posts, or subscribe via RSS if you’re the tech type: the buttons for all that are in the footer. Also, I’m available for training, both online and (when possible) on-site: visit my Training page for more information.

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%}"}
)
  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

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}"})
  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.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%}"}
)
  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.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%}"}
)
  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.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.