Matching QuantLib Bond PV to Dirty Price with Accrued Reference Periods
Summary
The document explains why a manually discounted cash-flow sum in QuantLib can differ from a bond’s dirty price, even when the same yield is used. The example uses a semiannual fixed-rate bond with an Actual/Actual ISMA day-count convention. The discrepancy arises because the manual calculation discounts directly from the valuation date to each payment date, while the bond pricing routine compounds discount factors period by period from one coupon date to the next.
Actual/Actual ISMA also needs the coupon reference period to calculate the stub interval from valuation date to the first coupon. Supplying the relevant reference-period dates and building the discount factor cumulatively reproduces the dirty price in the example. The result illustrates a convention and implementation detail, not a general failure of present-value pricing. Correct reproduction depends on matching the bond’s schedule and day-count conventions; the example does not compare other conventions or QuantLib versions.
Key ideas
- QuantLib bond pricing compounds discount factors across coupon periods instead of discounting each payment directly from the valuation date.
- Actual/Actual ISMA calculations require the relevant coupon reference-period dates.
- The first stub period can produce a different discount factor when its reference period is omitted.
- A cumulative discount calculation using the schedule periods matches the example’s dirty price.
- The method depends on using conventions consistent with the bond schedule.
Tags
Full text
# PV different from Dirty Price in QuantLib
# PV different from Dirty Price in QuantLib
As far as I understand, dirty price is the sum of clean price and accrued amount and should be equal to the Present Value (PV) of a bond at a certain yield rate. However, I can't replicate this behaviour in QuantLib-python (1.31.1) as values are different using the same yield.
Code:
```
from datetime import date
import QuantLib as ql
# Define bond parameters
face_value = 1000
issue_date = ql.Date(27, 6, 2023)
coupon_rate = 0.05 # Annual coupon rate (5%)
frequency = ql.Semiannual
day_count = ql.ActualActual(ql.ActualActual.ISMA)
# Create the bond schedule
dates = [issue_date.to_date(), date(2023, 12, 27), date(2024, 6, 27), date(2024, 12, 27), date(2025, 6, 27), date(2025, 12, 27), date(2026, 6, 27),
date(2026, 12, 27), date(2027, 6, 27), date(2027, 12, 27), date(2028, 6, 27), date(2028, 12, 27), date(2029, 6, 27)]
dates = [ql.Date(d.day, d.month, d.year) for d in dates]
schedule = ql.Schedule(dates)
# Create the bond
bond = ql.FixedRateBond(0, face_value, schedule, [coupon_rate], day_count)
# Valuations
val_date = ql.Date(14,9,2023)
yield_rate_value = 0.05
yield_rate = ql.InterestRate(yield_rate_value, day_count, ql.Compounded, frequency)
pv = sum([c.amount()*yield_rate.discountFactor(val_date, c.date()) for c in bond.cashflows()])
clean_price = ql.BondFunctions.cleanPrice(bond, yield_rate, val_date)
accrued_amount = ql.BondFunctions.accruedAmount(bond, val_date)
dirty_price = clean_price + accrued_amount
print('Clean price:', clean_price)
print('Accrued days:', ql.BondFunctions.accruedDays(bond, val_date))
print('Accrued period:', ql.BondFunctions.accruedPeriod(bond, val_date))
print('Accrual days:', ql.BondFunctions.accrualDays(bond, val_date))
print('Accrual period:', ql.BondFunctions.accrualPeriod(bond, val_date))
print('Accrued amount:', accrued_amount)
print('Dirty price:', dirty_price)
print('Present value:', pv*100/face_value)
import pandas as pd
print(pd.DataFrame([(c.date().to_date().isoformat(), c.amount(), yield_rate.discountFactor(val_date, c.date()), c.amount()*yield_rate.discountFactor(val_date, c.date()))
for c in bond.cashflows()]))
```
Terminal output:
```
Clean price: 99.99243192098663
Accrued days: 79
Accrued period: 0.21584699453551912
Accrual days: 183
Accrual period: 0.5
Accrued amount: 1.0792349726775896
Dirty price: 101.07166689366423
Present value: 101.24228365658294
0 1 2 3
0 2023-12-27 25.0 0.987730 24.693240
1 2024-06-27 25.0 0.963639 24.090966
2 2024-12-27 25.0 0.940135 23.503381
3 2025-06-27 25.0 0.917205 22.930128
4 2025-12-27 25.0 0.894834 22.370857
5 2026-06-27 25.0 0.873009 21.825226
6 2026-12-27 25.0 0.851716 21.292903
7 2027-06-27 25.0 0.830943 20.773564
8 2027-12-27 25.0 0.810676 20.266892
9 2028-06-27 25.0 0.790903 19.772578
10 2028-12-27 25.0 0.771613 19.290320
11 2029-06-27 25.0 0.752793 18.819824
12 2029-06-27 1000.0 0.752793 752.792958
```
## Answer by Luigi Ballabio (score 3)
https://quant.stackexchange.com/a/76683
It's the combination of two things.
First: when passed an interest rate `y` and a series of coupons paying at dates `d[1], d[2], ..., d[n]`, the `BondFunctions.cleanPrice` doesn't calculate the discount factor at date `d[i]` as `y.discountFactor(val_date, d[i])` as you do when calculating the PV, but as `y.discountFactor(val_date, d[1]) * y.discountFactor(d[1], d[2]) * ... * y.discountFactor(d[i-1], d[i])`; that is, it loops over the coupons and compounds the factors as it goes.
Second: act/act ISMA is a tricky day count convention. Besides the start and end date, it also requires the start and end of the corresponding reference period; this makes a difference for `y.discountFactor(val_date, d[1])`, which is not a complete period.
Putting the two things together, you can reproduce the price this way:
```
pv = 0.0
B = 1.0
cs = bond.cashflows()
for i in range(len(cs)):
if i == 0:
B *= yield_rate.discountFactor(val_date, cs[0].date(), dates[0], dates[1])
elif cs[i].date() != cs[i-1].date():
B *= yield_rate.discountFactor(cs[i-1].date(), cs[i].date(), dates[i], dates[i+1])
pv += cs[i].amount() * B
print(pv*100/face_value)
```
which gives me 101.07166689366423, same as the dirty price.Shown in full with attribution under the source's licence. Licence: CC BY-SA 4.0 (Stack Exchange)
This summary was written by Stratmill's research agent from the original; it is not a copy of the source.