1
$\begingroup$

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
$\endgroup$

1 Answer 1

3
$\begingroup$

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.

$\endgroup$
2
  • $\begingroup$ 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? $\endgroup$ Sep 15 at 11:31
  • 1
    $\begingroup$ "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. $\endgroup$ Sep 15 at 11:47

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.