import QuantLib as ql
import pandas as pd
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.
= ql.Date(28, ql.July, 2025)
today = today ql.Settings.instance().evaluationDate
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.
classDiagram class CashFlow { date()* amount()* hasOccurred() } class SimpleCashFlow CashFlow <|-- SimpleCashFlow class Redemption SimpleCashFlow <|-- Redemption class Coupon { date() rate()* nominal() accrualPeriod() dayCounter()* accruedAmount(date)* } CashFlow <|-- Coupon class FixedRateCoupon { rate() amount() dayCounter() accruedAmount(date) } Coupon <|-- FixedRateCoupon class FloatingRateCoupon { rate() amount() dayCounter() accruedAmount(date) index() fixingDate() } Coupon <|-- FloatingRateCoupon class IborCoupon FloatingRateCoupon <|-- IborCoupon class OvernightIndexedCoupon { fixingDates() averagingMethod() } FloatingRateCoupon <|-- OvernightIndexedCoupon class InflationCoupon { rate() amount() dayCounter() accruedAmount(date) observationLag() } Coupon <|-- InflationCoupon class CPICoupon { baseDate() observationInterpolation() } InflationCoupon <|-- CPICoupon class IndexedCashFlow { notional() baseDate() fixingDate() index() } CashFlow <|-- IndexedCashFlow class ZeroInflationCashFlow { observationInterpolation() } IndexedCashFlow <|-- ZeroInflationCashFlow
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.
= ql.MakeSchedule(
schedule =ql.Date(8, ql.April, 2025),
effectiveDate=ql.Date(8, ql.April, 2030),
terminationDate=ql.Semiannual,
frequency=ql.TARGET(),
calendar=ql.Following,
convention=True,
backwards
)
= 3
settlement_days = 10_000
face_amount = [0.03]
coupon_rates
= ql.FixedRateBond(
bond =3,
settlementDays=10_000,
faceAmount=schedule,
schedule=[0.03],
coupons=ql.Thirty360(ql.Thirty360.BondBasis),
paymentDayCounter )
We can extract its cash flows and use the CashFlow
interface to display their dates and amounts:
= bond.cashflows()
cashflows
= []
data for cf in cashflows:
data.append((cf.date(), cf.amount()))
=["date", "amount"]).style.format(
pd.DataFrame(data, columns"amount": "{:.2f}"}
{ )
date | amount | |
---|---|---|
0 | October 8th, 2025 | 150.00 |
1 | April 8th, 2026 | 150.00 |
2 | October 8th, 2026 | 150.00 |
3 | April 8th, 2027 | 150.00 |
4 | October 8th, 2027 | 150.00 |
5 | April 10th, 2028 | 151.67 |
6 | October 9th, 2028 | 149.17 |
7 | April 9th, 2029 | 150.00 |
8 | October 8th, 2029 | 149.17 |
9 | April 8th, 2030 | 150.00 |
10 | 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.
0] cashflows[
<QuantLib.QuantLib.CashFlow; proxy of <Swig Object of type 'ext::shared_ptr< CashFlow > *' at 0x114a989f0> >
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
::shared_ptr<Coupon> as_coupon(ext::shared_ptr<CashFlow> cf) {
extreturn 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:
= ql.as_coupon(cashflows[0])
c 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:
= ql.as_coupon(cf)
c if c is not None:
data.append(
(
c.date(),
c.nominal(),
c.rate(),
c.accrualPeriod(),
c.amount(),
)
)else:
None, None, None, cf.amount()))
data.append((cf.date(),
pd.DataFrame(=["date", "nominal", "rate", "accrual period", "amount"]
data, columnsformat(
).style.
{"nominal": "{:.0f}",
"rate": "{:.1%}",
"accrual period": "{:.4f}",
"amount": "{:.2f}",
} )
date | nominal | rate | accrual period | amount | |
---|---|---|---|---|---|
0 | October 8th, 2025 | 10000 | 3.0% | 0.5000 | 150.00 |
1 | April 8th, 2026 | 10000 | 3.0% | 0.5000 | 150.00 |
2 | October 8th, 2026 | 10000 | 3.0% | 0.5000 | 150.00 |
3 | April 8th, 2027 | 10000 | 3.0% | 0.5000 | 150.00 |
4 | October 8th, 2027 | 10000 | 3.0% | 0.5000 | 150.00 |
5 | April 10th, 2028 | 10000 | 3.0% | 0.5056 | 151.67 |
6 | October 9th, 2028 | 10000 | 3.0% | 0.4972 | 149.17 |
7 | April 9th, 2029 | 10000 | 3.0% | 0.5000 | 150.00 |
8 | October 8th, 2029 | 10000 | 3.0% | 0.4972 | 149.17 |
9 | April 8th, 2030 | 10000 | 3.0% | 0.5000 | 150.00 |
10 | 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.
= ql.YieldTermStructureHandle(
discount_curve 0, ql.TARGET(), 0.04, ql.Actual360())
ql.FlatForward( )
False) ql.CashFlows.npv(cashflows, discount_curve,
9625.543550654686
False) ql.CashFlows.bps(cashflows, discount_curve,
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:
= ql.as_coupon(cf)
c if c is not None:
data.append(
(
c.date(),
c.nominal(),
c.rate(),
c.accrualPeriod(),
c.amount(),
npv(c),
bps(c),
)
)else:
data.append(None, None, None, cf.amount(), npv(cf), None)
(cf.date(),
)
pd.DataFrame(
data,=[
columns"date",
"nominal",
"rate",
"accrual period",
"amount",
"NPV",
"BPS",
],format(
).style.
{"nominal": "{:.0f}",
"rate": "{:.1%}",
"accrual period": "{:.4f}",
"amount": "{:.2f}",
"NPV": "{:.2f}",
"BPS": "{:.2f}",
} )
date | nominal | rate | accrual period | amount | NPV | BPS | |
---|---|---|---|---|---|---|---|
0 | October 8th, 2025 | 10000 | 3.0% | 0.5000 | 150.00 | 148.80 | 0.50 |
1 | April 8th, 2026 | 10000 | 3.0% | 0.5000 | 150.00 | 145.83 | 0.49 |
2 | October 8th, 2026 | 10000 | 3.0% | 0.5000 | 150.00 | 142.89 | 0.48 |
3 | April 8th, 2027 | 10000 | 3.0% | 0.5000 | 150.00 | 140.03 | 0.47 |
4 | October 8th, 2027 | 10000 | 3.0% | 0.5000 | 150.00 | 137.21 | 0.46 |
5 | April 10th, 2028 | 10000 | 3.0% | 0.5056 | 151.67 | 135.91 | 0.45 |
6 | October 9th, 2028 | 10000 | 3.0% | 0.4972 | 149.17 | 131.00 | 0.44 |
7 | April 9th, 2029 | 10000 | 3.0% | 0.5000 | 150.00 | 129.09 | 0.43 |
8 | October 8th, 2029 | 10000 | 3.0% | 0.4972 | 149.17 | 125.80 | 0.42 |
9 | April 8th, 2030 | 10000 | 3.0% | 0.5000 | 150.00 | 123.97 | 0.41 |
10 | April 8th, 2030 | nan | 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.