# 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


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.

• I've seen discountFactor(val_date, d[i]) it is actually the same as discountFactor(d[0],d[1])*discountFactor(d[1],d[2])... What actually changes is discountFactor(d_0, d_1) with discountFactor(d_0, d_1, d_0, d_1) why is that? Sep 15 at 11:31
• "act/act ISMA... requires the start and end of the corresponding reference period" in a sense that not passing required parameters results in incorrect results, rather than an exception. Sep 15 at 11:47