Cash-flow analysis
Welcome back.
This time, I have a proper look at a bit of infrastructure that I’ve used in multiple notebooks before. How do we extract specific information from the cash flows of an instrument?
As usual, this notebook is also included in A QuantLib Guide.
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.
Cash-flow analysis
I’ve already shown in some other notebook that we can extract and display information on the cash flows of, e.g., a bond; but it was somewhat in passing. Let’s have a better look at that.
import QuantLib as ql
import pandas as pd
today = ql.Date(28, ql.July, 2025)
ql.Settings.instance().evaluationDate = today
A complicated family
The diagram below shows part of the hierarchy starting from the
CashFlow
class (which in turn inherits from Event
, but let’s ignore
it here). There are all kinds of different cash flows there, including
some sub-hierarchies like the one with its base in the Coupon
class.
Depending on their actual class, they might provide multiple different
pieces of information. The only things that they all have in common,
though, are those declared in the interface of the base CashFlow
class: a payment date and an amount. Other methods don’t belong there,
since they’re not applicable to all cash flows. A redemption doesn’t
have a rate; a fixed-rate coupon doesn’t have an observation lag.
Lost in translation
This poses a problem. Let’s take a sample fixed-rate bond as an example.
schedule = ql.MakeSchedule(
effectiveDate=ql.Date(8, ql.April, 2025),
terminationDate=ql.Date(8, ql.April, 2030),
frequency=ql.Semiannual,
calendar=ql.TARGET(),
convention=ql.Following,
backwards=True,
)
settlement_days = 3
face_amount = 10_000
coupon_rates = [0.03]
bond = ql.FixedRateBond(
settlementDays=3,
faceAmount=10_000,
schedule=schedule,
coupons=[0.03],
paymentDayCounter=ql.Thirty360(ql.Thirty360.BondBasis),
)
We can extract its cash flows and use the CashFlow
interface to
display their dates and amounts:
cashflows = bond.cashflows()
data = []
for cf in cashflows:
data.append((cf.date(), cf.amount()))
pd.DataFrame(data, columns=["date", "amount"]).style.format(
{"amount": "{:.2f}"}
)
date | amount |
---|---|
October 8th, 2025 | 150.00 |
April 8th, 2026 | 150.00 |
October 8th, 2026 | 150.00 |
April 8th, 2027 | 150.00 |
October 8th, 2027 | 150.00 |
April 10th, 2028 | 151.67 |
October 9th, 2028 | 149.17 |
April 9th, 2029 | 150.00 |
October 8th, 2029 | 149.17 |
April 8th, 2030 | 150.00 |
April 8th, 2030 | 10000.00 |
We can see from the table above that the returned cash flows contain the interest-paying coupons as well as the redemption; the latter is returned a separate cash flow, even though it has the same date as the last coupon.
The problem surfaces when we try to extract additional information from, say, the first coupon. If we ask it for its rate, it’s going to complain loudly.
try:
print(cashflows[0].rate())
except Exception as e:
print(f"{type(e).__name__}: {e}")
AttributeError: 'CashFlow' object has no attribute 'rate'
That’s because, even though we’re working in Python here, we’re wrapping
a C++ library and we’re subject to the constraints of the latter
language. To return the set of its cash flows, the Bond
class needs to
collect them in a vector, which is homogeneous in C++: all elements must
belong to a common type, and that would be the base CashFlow
class.
For polymorphism to work properly, the library also needs to work with
pointers (smart pointers, usually). This results in the following return
type:
typedef std::vector<ext::shared_ptr<CashFlow> > Leg;
We can confirm it by asking the Python interpreter to visualize the first coupon in the list; the SWIG-generated message contains its type.
cashflows[0]
<QuantLib.QuantLib.CashFlow; proxy of <Swig Object of type 'ext::shared_ptr< CashFlow > *' at 0x10d8c1050> >
How can we retrieve additional info, then? In C++, we could use a cast; something like
auto coupon = ext::dynamic_pointer_cast<Coupon>(cashflows[0]);
resulting in a pointer to a Coupon
instance (possibly a null one, if
the cast didn’t succeed because the type didn’t match). From that
pointer, we can access the additional interface of the Coupon
class.
The same goes for any other specific class.
However, there is no such cast operation in Python, where objects retain the type they’re created with. What we had to do was add to the wrappers a set of small functions performing the cast, such as
ext::shared_ptr<Coupon> as_coupon(ext::shared_ptr<CashFlow> cf) {
return ext::dynamic_pointer_cast<Coupon>(cf);
}
Once exported to the Python module, they give us the possibility to downcast the cash flows and ask for more specific information:
c = ql.as_coupon(cashflows[0])
print(f"{c.rate(): .2%}")
3.00%
Like the underlying cast operation, the function above returns a null
pointer if the cast is not possible; for instance, if we try to convert
the redemption (the last cash flow in the sequence) into a coupon. In
Python, that translates into a None
.
print(ql.as_coupon(cashflows[-1]))
None
As I mentioned, the QuantLib module provides a number of these functions for different classes:
[
getattr(ql, x)
for x in dir(ql)
if x.startswith("as_")
and (x.endswith("coupon") or x.endswith("cash_flow"))
]
[<function QuantLib.QuantLib.as_capped_floored_yoy_inflation_coupon(cf)>,
<function QuantLib.QuantLib.as_coupon(cf)>,
<function QuantLib.QuantLib.as_cpi_coupon(cf)>,
<function QuantLib.QuantLib.as_fixed_rate_coupon(cf)>,
<function QuantLib.QuantLib.as_floating_rate_coupon(cf)>,
<function QuantLib.QuantLib.as_inflation_coupon(cf)>,
<function QuantLib.QuantLib.as_multiple_resets_coupon(cf)>,
<function QuantLib.QuantLib.as_overnight_indexed_coupon(cf)>,
<function QuantLib.QuantLib.as_sub_periods_coupon(cf)>,
<function QuantLib.QuantLib.as_yoy_inflation_coupon(cf)>,
<function QuantLib.QuantLib.as_zero_inflation_cash_flow(cf)>]
Cash-flow analysis, at last
Given the functions above, we can collect a lot more information when cycling over cash flows. For instance, here we detect the coupons by trying to cast them and use their specialized interface to extract nominal, rate and accrual period; when the cast fail (that is, for the redemption) we fall back to extracting date and amount. By selecting the correct casting function, we can analyze cash flows from other kinds of bonds and collect the relevant information in each case.
data = []
for cf in cashflows:
c = ql.as_coupon(cf)
if c is not None:
data.append(
(
c.date(),
c.nominal(),
c.rate(),
c.accrualPeriod(),
c.amount(),
)
)
else:
data.append((cf.date(), None, None, None, cf.amount()))
pd.DataFrame(
data, columns=["date", "nominal", "rate", "accrual period", "amount"]
).style.format(
{
"nominal": "{:.0f}",
"rate": "{:.1%}",
"accrual period": "{:.4f}",
"amount": "{:.2f}",
}
)
date | nominal | rate | accrual period | amount |
---|---|---|---|---|
October 8th, 2025 | 10000 | 3.0% | 0.5000 | 150.00 |
April 8th, 2026 | 10000 | 3.0% | 0.5000 | 150.00 |
October 8th, 2026 | 10000 | 3.0% | 0.5000 | 150.00 |
April 8th, 2027 | 10000 | 3.0% | 0.5000 | 150.00 |
October 8th, 2027 | 10000 | 3.0% | 0.5000 | 150.00 |
April 10th, 2028 | 10000 | 3.0% | 0.5056 | 151.67 |
October 9th, 2028 | 10000 | 3.0% | 0.4972 | 149.17 |
April 9th, 2029 | 10000 | 3.0% | 0.5000 | 150.00 |
October 8th, 2029 | 10000 | 3.0% | 0.4972 | 149.17 |
April 8th, 2030 | 10000 | 3.0% | 0.5000 | 150.00 |
April 8th, 2030 | nan | nan% | nan | 10000.00 |
Other functions
QuantLib provides a few other functions that act on a sequence of cash
flows, rather than a single one. They are grouped as static methods of
the CashFlows
class; for instance, they include functions to calculate
the present value and basis-point sensitivity, given a discount curve.
discount_curve = ql.YieldTermStructureHandle(
ql.FlatForward(0, ql.TARGET(), 0.04, ql.Actual360())
)
ql.CashFlows.npv(cashflows, discount_curve, False)
9625.543550654686
ql.CashFlows.bps(cashflows, discount_curve, False)
4.535150508988854
The last False
parameter in the two calls above specifies that, if one
of the cash flows were paid on the evaluation date, it should not be
included.
Of course, these functions can also be used on a single cash flow by passing them a list with a single element; below, they are used to augment the analysis we performed earlier.
def npv(c):
return ql.CashFlows.npv([c], discount_curve, False)
def bps(c):
return ql.CashFlows.bps([c], discount_curve, False)
data = []
for cf in cashflows:
c = ql.as_coupon(cf)
if c is not None:
data.append(
(
c.date(),
c.nominal(),
c.rate(),
c.amount(),
npv(c),
bps(c),
)
)
else:
data.append(
(cf.date(), None, None, cf.amount(), npv(cf), None)
)
pd.DataFrame(
data,
columns=[
"date",
"nominal",
"rate",
"amount",
"NPV",
"BPS",
],
).style.format(
{
"nominal": "{:.0f}",
"rate": "{:.1%}",
"amount": "{:.2f}",
"NPV": "{:.2f}",
"BPS": "{:.2f}",
}
)
date | nominal | rate | amount | NPV | BPS |
---|---|---|---|---|---|
October 8th, 2025 | 10000 | 3.0% | 150.00 | 148.80 | 0.50 |
April 8th, 2026 | 10000 | 3.0% | 150.00 | 145.83 | 0.49 |
October 8th, 2026 | 10000 | 3.0% | 150.00 | 142.89 | 0.48 |
April 8th, 2027 | 10000 | 3.0% | 150.00 | 140.03 | 0.47 |
October 8th, 2027 | 10000 | 3.0% | 150.00 | 137.21 | 0.46 |
April 10th, 2028 | 10000 | 3.0% | 151.67 | 135.91 | 0.45 |
October 9th, 2028 | 10000 | 3.0% | 149.17 | 131.00 | 0.44 |
April 9th, 2029 | 10000 | 3.0% | 150.00 | 129.09 | 0.43 |
October 8th, 2029 | 10000 | 3.0% | 149.17 | 125.80 | 0.42 |
April 8th, 2030 | 10000 | 3.0% | 150.00 | 123.97 | 0.41 |
April 8th, 2030 | nan | nan% | 10000.00 | 8265.00 | nan |
One last note: the BPS
function could have been called also on the
redemption, since it has an internal mechanism to detect different kinds
of coupons (based on the Acyclic Visitor pattern, if you’re curious; I
explain it in Implementing QuantLib) and would have returned 0.0.
Here, I chose to call it only on coupons, resulting in a nan
being
displayed for the redemption.
Both choices would have been correct, I guess: I’m preferring the latter because I’m seeing BPS as the question “How much does the present value of the redemption changes if we increase its rate by 1 bp?” to which my answer would be “The redemption doesn’t have a rate to increase”. It would also be ok to answer “It doesn’t change” instead.
Thanks for reading. See you next time!