lballabio / QuantLib

The QuantLib C++ library
http://quantlib.org
Other
5.32k stars 1.79k forks source link

Effective duration of a callable fixed bond - can value be larger than modified duration? #1882

Closed eduzea closed 5 months ago

eduzea commented 9 months ago

I'm using the CallableFixedRateBond to compute effective duration. I'm finding a few issues/questions. My code is at the end (a link with runnable code in google Colab: here

  1. The documentation describes the calculation as "Calculate the effective duration, i.e., the first differential of the dirty price w.r.t. a parallel shift of the yield term structure divided by current dirty price". But in the code the calculation is using the output of function "cleanPriceOAS" to compute the prices (P, Ppp, and Pmm). The results are not the same if you use clean vs. dirty price. I think we should be using the dirty price in the calculation; would you agree?

  2. I think for a fixed bond, adding a bump to the OAS should be equivalent to applying a parallel shock to the curve. Is that correct? If so, we would expect to get the same result with the two approaches, but that's not the case.

  3. Finally, with the call schedule and the price given the bond is very unlikely to get called. Effective duration should be close to the modified duration, certainly it should not be larger. In all calculation variations it comes out bigger. Can you help me explain this?

To summarize, I am getting 3 possible values and I am not sure which one is correct:

Using QL effective duration function (uses clean price) Eff Dur = 4.4171

Bumping OAS manually, using dirty price Eff Dur = 4.3836

Bumping curve Eff Dur = 4.4703

Mod Dur = 4.3656

My code below:

# Define the payment schedule
effective_date = ql.Date("2021-05-20","yyyy-mm-dd")
maturity_date = ql.Date("2029-06-01","yyyy-mm-dd")
first_coupon_date = ql.Date("2021-12-01","yyyy-mm-dd")
end_of_month = True
frequency = ql.Period('6M')
calendar = ql.UnitedStates(ql.UnitedStates.GovernmentBond)
holiday_convention = ql.Unadjusted
date_generation_rule = ql.DateGeneration.Backward
schedule = ql.Schedule(effective_date, maturity_date, frequency, calendar, holiday_convention, holiday_convention, date_generation_rule, end_of_month, first_coupon_date)

# Define call schedule
call_schedule = ql.CallabilitySchedule()
calls = [
    ("2024-06-01", 102.438),
    ("2025-06-01", 101.219),
    ("2026-06-01", 100),
    ("2029-06-01", 100)
    ]
for entry in calls:
  call_schedule.append(ql.Callability(ql.BondPrice(entry[1], ql.BondPrice.Clean),ql.Callability.Call, ql.Date(entry[0],"yyyy-mm-dd")))

# Create the bond
settlement_days = 2
face_amount = 100
coupons = [0.04875]
daycount = ql.Thirty360(ql.Thirty360.ISDA)
redemption = 100
callable_bond = ql.CallableFixedRateBond(settlement_days, face_amount, schedule, coupons, daycount, holiday_convention, redemption, effective_date, call_schedule)

# Create a flat curve, prep for bumping
base_curve = ql.FlatForward(2, ql.UnitedStates(ql.UnitedStates.GovernmentBond), 0.05, ql.Thirty360(ql.Thirty360.ISDA))
bump = ql.RelinkableQuoteHandle(ql.SimpleQuote(0.0))
curve = ql.ZeroSpreadedTermStructure(ql.YieldTermStructureHandle(base_curve), bump)
curve = ql.YieldTermStructureHandle(curve)

# Create HW model
alpha = 0.03
sigma = 0.012
hw0 = ql.HullWhite(curve, alpha, sigma)

# Create Pricing Engine
settlement_date = ql.Date("2023-11-30","yyyy-mm-dd")
grid_steps = int((maturity_date - settlement_date) / 30)
engine = ql.TreeCallableFixedRateBondEngine(hw0, grid_steps)
callable_bond.setPricingEngine(engine);

# Compute OAS
clean_price = 70.926
oas = callable_bond.OAS(clean_price, curve, daycount, ql.Compounded, ql.Semiannual, settlement_date)
print(f"OAS = {oas * 10_000:,.2f} bp")

# Compute Effective Duration using ql function - this adds bumps to OAS
spread = 0.001
eff_dur = callable_bond.effectiveDuration(oas, curve, daycount, ql.Compounded, ql.Semiannual, spread)
print("\n*** Using QL function ***\n")
print(f"Eff Dur = {eff_dur:,.4f}")

# Manually compute Eff Dur
accrued = callable_bond.accruedAmount(settlement_date)
base_price = callable_bond.cleanPriceOAS(oas, curve, daycount, ql.Compounded, ql.Semiannual, settlement_date) + accrued
up_price = callable_bond.cleanPriceOAS(oas + spread, curve, daycount, ql.Compounded, ql.Semiannual, settlement_date) + accrued
down_price = callable_bond.cleanPriceOAS(oas - spread, curve, daycount, ql.Compounded, ql.Semiannual, settlement_date) + accrued
eff_dur = (down_price - up_price) / (2 * base_price * spread )
print("\n***Bumping OAS manually***\n")
print(f"Eff Dur = {eff_dur:,.4f}")

# Bumping the curve
bump.linkTo(ql.SimpleQuote(spread))
up_price = callable_bond.cleanPriceOAS(oas, curve, daycount, ql.Compounded, ql.Semiannual, settlement_date) + accrued

bump.linkTo(ql.SimpleQuote(-spread))
down_price = callable_bond.cleanPriceOAS(oas, curve, daycount, ql.Compounded, ql.Semiannual, settlement_date) + accrued

eff_dur = (down_price - up_price) / (2 * base_price * spread )
print("\nBumping curve\n")
print(f"Eff Dur = {eff_dur:,.4f}")

# Compute mod duration
bond_yield = callable_bond.bondYield(clean_price, daycount, ql.Compounded, ql.Semiannual, settlement_date)
ir = ql.InterestRate(bond_yield, daycount, ql.Compounded, ql.Semiannual)
mod_dur = ql.BondFunctions.duration(callable_bond, ir, ql.Duration.Modified )
print(f"Mod Dur = {mod_dur:,.4f}")
boring-cyborg[bot] commented 9 months ago

Thanks for posting! It might take a while before we look at your issue, so don't worry if there seems to be no feedback. We'll get to it.

lballabio commented 8 months ago

Yes, this does seem strange. We'll need to investigate what happens inside the calls.

pcoelho00 commented 8 months ago

Hi @eduzea, There was some problems with your original code.

  1. The first one was the absence of an evaluation date, this is needed for two main reasons: the yield curve which will use it to interpolate the curve and the effective duration formula that doesn't have a settlement date argument (although it should). If you don't specify an evaluation date QuantLib will default to today's date.

  2. Second, your curve and your yield calculations had different compounding and frequency defaults, a 0.001 bump to a continuous curve is not the same as a 0.001 to a Semiannual Compounded one.

With this fixes we can see that the effectiveDuration formula should be calculated using dirty prices and not clean ones as you mentioned. I hope this helps.


import QuantLib as ql

# Define the payment schedule
effective_date = ql.Date("2021-05-20", "yyyy-mm-dd")
maturity_date = ql.Date("2029-06-01", "yyyy-mm-dd")
first_coupon_date = ql.Date("2021-12-01", "yyyy-mm-dd")
end_of_month = True
frequency = ql.Period("6M")
calendar = ql.UnitedStates(ql.UnitedStates.GovernmentBond)
holiday_convention = ql.Unadjusted
date_generation_rule = ql.DateGeneration.Backward
schedule = ql.Schedule(
    effective_date,
    maturity_date,
    frequency,
    calendar,
    holiday_convention,
    holiday_convention,
    date_generation_rule,
    end_of_month,
    first_coupon_date,
)

# Define call schedule
call_schedule = ql.CallabilitySchedule()
calls = [
    ("2024-06-01", 102.438),
    ("2025-06-01", 101.219),
    ("2026-06-01", 100),
    ("2029-06-01", 100),
]
for entry in calls:
    call_schedule.append(
        ql.Callability(
            ql.BondPrice(entry[1], ql.BondPrice.Clean),
            ql.Callability.Call,
            ql.Date(entry[0], "yyyy-mm-dd"),
        )
    )

# Create the bond
settlement_days = 2
face_amount = 100
coupons = [0.04875]
daycount = ql.Thirty360(ql.Thirty360.ISDA)
redemption = 100
callable_bond = ql.CallableFixedRateBond(
    settlement_days,
    face_amount,
    schedule,
    coupons,
    daycount,
    holiday_convention,
    redemption,
    effective_date,
    call_schedule,
)

# Create a flat curve, prep for bumping
base_curve = ql.FlatForward(
    2,
    ql.UnitedStates(ql.UnitedStates.GovernmentBond),
    0.05,
    ql.Thirty360(ql.Thirty360.ISDA),
)

spread_quote = ql.SimpleQuote(0.0)
bump = ql.RelinkableQuoteHandle(spread_quote)
curve = ql.ZeroSpreadedTermStructure(
    ql.YieldTermStructureHandle(base_curve), bump, ql.Compounded, ql.Semiannual
)
curve = ql.YieldTermStructureHandle(curve)

# Create HW model
alpha = 0.03
sigma = 0.012
hw0 = ql.HullWhite(curve, alpha, sigma)

# Create Pricing Engine
settlement_date = ql.Date("2023-11-30", "yyyy-mm-dd")
grid_steps = int((maturity_date - settlement_date) / 30)
engine = ql.TreeCallableFixedRateBondEngine(hw0, grid_steps)
callable_bond.setPricingEngine(engine)

# Always set the Evaluation Date
ql.Settings.instance().evaluationDate = ql.Date(28, 11, 2023)

# Compute OAS
clean_price = 70.926
oas = callable_bond.OAS(
    clean_price, curve, daycount, ql.Compounded, ql.Semiannual, settlement_date
)
print(f"OAS = {oas * 10_000:,.2f} bp")

oas_price = callable_bond.cleanPriceOAS(
    oas, curve, daycount, ql.Compounded, ql.Semiannual, settlement_date
)
print(f"OAS Price = {oas_price:,.4f}")

# Compute Effective Duration using ql function - this adds bumps to OAS
# you can't pass a settle date to effective Duration so it uses the evaluation date
spread = 0.001
eff_dur = callable_bond.effectiveDuration(
    oas, curve, daycount, ql.Compounded, ql.Semiannual, spread
)
print("\n*** Using QL function ***\n")
print(f"Eff Dur = {eff_dur:,.4f}")

# Manually compute Eff Dur
print("\n***Bumping OAS manually***\n")
accrued = callable_bond.accruedAmount(settlement_date)
base_price = callable_bond.cleanPriceOAS(
    oas, curve, daycount, ql.Compounded, ql.Semiannual, settlement_date
)
up_price = callable_bond.cleanPriceOAS(
    oas + spread, curve, daycount, ql.Compounded, ql.Semiannual, settlement_date
)
down_price = callable_bond.cleanPriceOAS(
    oas - spread, curve, daycount, ql.Compounded, ql.Semiannual, settlement_date
)

eff_dur = (down_price - up_price) / (2 * base_price * spread)
print(f"Eff Dur with Clean Prices = {eff_dur:,.4f}")

down_price += accrued
up_price += accrued
base_price += accrued
eff_dur = (down_price - up_price) / (2 * base_price * spread)
print(f"Eff Dur with Dirty Prices = {eff_dur:,.4f}")
# Compute mod duration
bond_yield = callable_bond.bondYield(
    clean_price, daycount, ql.Compounded, ql.Semiannual, settlement_date
)
mod_dur = ql.BondFunctions.duration(
    callable_bond,
    bond_yield,
    daycount,
    ql.Compounded,
    ql.Semiannual,
    ql.Duration.Modified,
    settlement_date,
)
print(f"Mod Dur = {mod_dur:,.4f}")

# Bumping the curve
spread_quote.setValue(0.0)
base_price = callable_bond.cleanPriceOAS(
    oas, curve, daycount, ql.Compounded, ql.Semiannual, settlement_date
)

spread_quote.setValue(spread)
up_price = callable_bond.cleanPriceOAS(
    oas, curve, daycount, ql.Compounded, ql.Semiannual, settlement_date
)

spread_quote.setValue(-spread)
down_price = callable_bond.cleanPriceOAS(
    oas, curve, daycount, ql.Compounded, ql.Semiannual, settlement_date
)

eff_dur = (down_price - up_price) / (2 * base_price * spread)
print("\nBumping curve\n")
print(f"Eff Dur Clean Price = {eff_dur:,.4f}")

down_price += accrued
up_price += accrued
base_price += accrued
eff_dur = (down_price - up_price) / (2 * base_price * spread)
print(f"Eff Dur Dirty Price = {eff_dur:,.4f}")```
eduzea commented 7 months ago

Thank you @pcoelho00, this clarifies my issue. I'm wondering if the CallableBond.effectiveDuration function in QuantLib should be adjusted to use the dirty price instead of the clean price? So that it matches the modified duration when the callable bond is not getting called. Is this a valid suggestion for a change?

lballabio commented 7 months ago

Yes, that's a good suggestion, thanks.

github-actions[bot] commented 5 months ago

This issue was automatically marked as stale because it has been open 60 days with no activity. Remove stale label or comment, or this will be closed in two weeks.

github-actions[bot] commented 5 months ago

This issue was automatically closed because it has been stalled for two weeks with no further activity.