Cash flows and bonds

In another notebook, I’ve shown how to build sequences of regular dates (schedules, in QuantLib lingo) and I hinted that they could be used in turn to build sequences of coupons. In this notebook, we’ll do just that; and furthermore, we’ll see how to use those coupons to build a few simple bonds.

import pandas as pd
import QuantLib as ql

ql.Settings.instance().evaluationDate = ql.Date(15, ql.January, 2024)

A simple fixed-rate bond

As an example, we’re going to create a simple 5-years bond paying 3% semiannually. The first thing we do is a call to MakeSchedule, like the ones I described in the previous issue; it takes the start and end date for the coupons, their frequency, and some other information such as the calendar used to adjust dates when they fall on a holiday.

As a reminder, the understanding is that the first date in the schedule is the start of the first coupon; the second date is both the end of the first coupon and the start of the second; the third date is the end of the second coupon and the start of the third; until we get to the last date, which is the end of the last coupon.

schedule = ql.MakeSchedule(
    effectiveDate=ql.Date(8, ql.February, 2021),
    terminationDate=ql.Date(8, ql.February, 2026),
    frequency=ql.Semiannual,
    calendar=ql.TARGET(),
    convention=ql.Following,
    backwards=True,
)

In C++, the same call would be written as

Schedule schedule =
    MakeSchedule()
    .from(Date(8, February, 2021))
    .to(Date(8, February, 2026))
    .withFrequency(Semiannual)
    .withCalendar(TARGET())
    .withConvention(Following)
    .backwards();

The actual task of building the coupons is done by FixedRateLeg, another function provided by the library, which is the next thing we call (after defining some other bits of information such as the day-count convention for accruing the coupon rate). It takes the schedule, to be used as described above, and the other data we need.

day_count = ql.Thirty360(ql.Thirty360.BondBasis)

fixed_coupons = ql.FixedRateLeg(
    schedule=schedule,
    couponRates=[0.03],
    dayCount=day_count,
    nominals=[10_000],
)

FixedRateLeg, like MakeSchedule, has a number of parameters we can pass to specify the coupons—or, in C++, of methods we can chain: in this case, the C++ code would be

Leg fixedCoupons =
    FixedRateLeg(schedule)
    .withCouponRates(0.03, dayCount)
    .withNotionals(10000.0);

Here we’re doing the base minimum; we’re passing the rate to be paid by the coupons, their day-count convention, and the notional used for the calculation of the interest. If needed, we could pass additional specifications: for instance, a payment lag with respect to the end of the coupons, or a number of ex-coupon days.

The name of the C++ return type, Leg, is probably more suited to swaps than to bonds. Like many things in the library, which grew bit by bit, it was defined in a particular context and it stuck. It’s not a class in its own right, but an alias for std::vector<ext::shared_ptr<CashFlow>> which would be a mouthful to use; CashFlow is the base class for cash flows, and derived classes model both coupons (an interest paid based on some kind of rate, fixed or otherwise) and more simple payments like the final redemption of a bond.

The last step, in order to get a working bond instance, is passing the coupons and some additional information to the constructor of the Bond class.

settlement_days = 3
issue_date = ql.Date(5, ql.February, 2021)

bond1 = ql.Bond(
    settlement_days,
    ql.TARGET(),
    issue_date,
    fixed_coupons,
)

The constructor not only stores the coupons, but also figures out that the bond must make a final payment to reimburse its notional. We can see it by asking the bond for its cash flows (via its cashflows method) and inspecting them: the last row corresponds to the redemption.

Note that the cashflow method returns a list of instances of the base CashFlow class; in order to access their more specific methods, we need to downcast them. In C++, we would use dynamic_pointer_cast<Coupon>; in Python, where template are not available, the latter is exported as the as_coupon function, which returns None if the cast doesn’t succeed (as in the case of the redemption).

data = []
for cf in bond1.cashflows():
    c = ql.as_coupon(cf)
    if c is not None:
        data.append((c.date(), c.nominal(), c.rate(), c.amount()))
    else:
        data.append((cf.date(), None, None, cf.amount()))

pd.DataFrame(
    data, columns=["date", "nominal", "rate", "amount"]
).style.format({"nominal": "{:.0f}", "rate": "{:.3%}", "amount": "{:.3f}"})
  date nominal rate amount
0 August 9th, 2021 10000 3.000% 150.833
1 February 8th, 2022 10000 3.000% 149.167
2 August 8th, 2022 10000 3.000% 150.000
3 February 8th, 2023 10000 3.000% 150.000
4 August 8th, 2023 10000 3.000% 150.000
5 February 8th, 2024 10000 3.000% 150.000
6 August 8th, 2024 10000 3.000% 150.000
7 February 10th, 2025 10000 3.000% 151.667
8 August 8th, 2025 10000 3.000% 148.333
9 February 9th, 2026 10000 3.000% 150.833
10 February 9th, 2026 nan nan% 10000.000

Finally, we can check that the bond is working by asking, for instance, what would be the yield for a clean price of 98. In C++, the corresponding Bond method is called yield; in Python, it needed to be renamed to the redundant bondYield because yield is a reserved keyword.

bond1.bondYield(98.0, day_count, ql.Compounded, ql.Semiannual)
0.04021360635757447

A shortcut to the same bond

As you might have guessed, a fixed-rate bond is common enough that we have a more convenient way to instantiate it. Instead of the two separate calls to FixedRateLeg and the Bond constructor, we can call the constructor of the derived FixedRateBond class and pass the same information. Displaying the cash flows of the resulting bond would show that they are the same as the ones above.

bond1b = ql.FixedRateBond(
    settlement_days,
    10000.0,
    schedule,
    [0.03],
    day_count,
)

A note on overloading

If you were to head over to GitHub and look at changes in the FixedRateBond class across versions, you would see that it used to have multiple constructors, but most of them were deprecated and now only one remains. Is that some kind of design principle that should be followed?

Not really, no. In this case, it was a matter of balancing convenience of use in C++ against other languages, such as Python, to which the library is exported. Overloaded constructors would be more convenient in C++, as they would provide shortcuts to different use cases. However, with our current toolchain (the excellent SWIG) they would make it impossible to use keyword arguments in Python; instead, with a single constructor, we can enable them in our Python wrappers and allow writing calls like

bond = FixedRateBond(
    settlement_days,
    10000.0,
    schedule,
    [0.03],
    day_count,
    firstPeriodDayCounter=another_day_count,
)

where we don’t need to pass (like in C++) all the default parameters defined by the constructor between the two day counts.

So, who should win? C++ or Python? I don’t have any figures about the number of people that use QuantLib in either language, but in this case C++ could graciously yield without suffering much harm; as we’ve seen, FixedRateLeg already gave it a pretty convenient way to instantiate various use cases. As usual, the mileage in your code might vary.

An amortizing fixed-rate bond

I mentioned above that FixedRateLeg has a number of additional methods I didn’t show; moreover, the ones I did show can take additional information. In the next call, for instance, the code passes a sequence of different notionals for the generated coupons, starting from 10000 in the first year and decreasing by 2000 each year; that is, every second coupon, since the schedule is semiannual.

amortizing_coupons = ql.FixedRateLeg(
    schedule,
    couponRates=[0.03],
    dayCount=day_count,
    nominals=[
        10000.0,
        10000.0,
        8000.0,
        8000.0,
        6000.0,
        6000.0,
        4000.0,
        4000.0,
        2000.0,
        2000.0,
    ],
)

The C++ call would be

Leg amortizingCoupons =
    FixedRateLeg(schedule)
    .withCouponRates(0.03, dayCount)
    .withNotionals({
        10000.0, 10000.0,
        8000.0, 8000.0,
        6000.0, 6000.0,
        4000.0, 4000.0,
        2000.0, 2000.0
    });

In this case, passing the returned coupons to the Bond constructor results in an amortizing bond; again, we can check that by displaying its cash flows. As before, the machinery in the constructor looked at the notionals of the coupons and figured out that, when the notional varies between two consecutive coupons, a notional payment needed to be inserted.

bond2 = ql.Bond(
    settlement_days,
    ql.TARGET(),
    issue_date,
    amortizing_coupons,
)
data = []
for cf in bond2.cashflows():
    c = ql.as_coupon(cf)
    if c is not None:
        data.append((c.date(), c.nominal(), c.rate(), c.amount()))
    else:
        data.append((cf.date(), None, None, cf.amount()))

pd.DataFrame(
    data, columns=["date", "nominal", "rate", "amount"]
).style.format({"nominal": "{:.0f}", "rate": "{:.3%}", "amount": "{:.3f}"})
  date nominal rate amount
0 August 9th, 2021 10000 3.000% 150.833
1 February 8th, 2022 10000 3.000% 149.167
2 February 8th, 2022 nan nan% 2000.000
3 August 8th, 2022 8000 3.000% 120.000
4 February 8th, 2023 8000 3.000% 120.000
5 February 8th, 2023 nan nan% 2000.000
6 August 8th, 2023 6000 3.000% 90.000
7 February 8th, 2024 6000 3.000% 90.000
8 February 8th, 2024 nan nan% 2000.000
9 August 8th, 2024 4000 3.000% 60.000
10 February 10th, 2025 4000 3.000% 60.667
11 February 10th, 2025 nan nan% 2000.000
12 August 8th, 2025 2000 3.000% 29.667
13 February 9th, 2026 2000 3.000% 30.167
14 February 9th, 2026 nan nan% 2000.000

More cash flows and bonds

The library, of course, is not limited to fixed-rate coupons. For instance, a C++ call like

Leg cashflows =
    IborLeg(schedule, euribor6m)
    .withSpreads(0.001)
    .withNotionals(10000.0);

would generate coupons paying the fixings of the 6-months Euribor rate plus 10 basis points. You’ll forgive me for only showing this in passing; adding it as an example to the code would require setting up the index and its forecast curve, and that is probably best done in another notebook with more details.