Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Callable Bond Pricing Issue #930

Closed
xg2202 opened this issue Nov 3, 2020 · 46 comments · Fixed by #1142
Closed

Callable Bond Pricing Issue #930

xg2202 opened this issue Nov 3, 2020 · 46 comments · Fixed by #1142

Comments

@xg2202
Copy link

xg2202 commented Nov 3, 2020

Hi,

I am a big fan of Quantlib and I have been using quantlib python to price thousands of callable bonds every day (from OAS to clean price and from price to OAS). I am still learning the library as I am not so familiar with C++ and online resources on python examples are somewhat limited, so I have to explore a lot of things myself.

Now, I have an issue with callable bond pricing. I am trying to compute the clean price of a real callable bond in the credit market, namely AES 6 05/15/26. I set the coupon rate to 10% instead of original 6% just to let you see the problem clearly on a larger scale.

This bond is near the call date (next call is on 5/15/21, in roughly 0.5 years, and the call price is 103) so if the spread is near 0 and the valuation date is today (11/3/2020), I would expect the bond to be priced around 108 as it is "priced to next call". This is also confirmed by Bloomberg. However as I shocked the OAS of the bond to 1bp, the price I got was actually 113.27, well above that. What happens I guess is that the quantlib is mistakenly pricing in one more coupon payment (there is only half a year left so only 1 semi-annual coupon before the call).

To replicate the bug in an even more straightforward way, I change the next call date to 11/10/2020, which is just 7 days from now, and the "clean price" I got based on 1bp of OAS is 108.19, still well above 103, which is the call price I had expected (again, it looks like one more coupon is priced in).

Magically, If I set the next call date to 11/9/2020, the price is finally consistent with my intuition, at 103.17, meaning by just changing the call date from 11/10/2020 to 11/9/2020, the clean price dropped 5pts! This strange behavior made me wonder if the cleanPriceOAS function is in fact computing the dirty price or something else.

I have posted my code below (you can run it directly). Could you please take a look and let me know if I am using the pricer in a correct way or there is actually a bug somewhere?
Any hint or suggestion are highly appreciated here!

import numpy as np
import pandas as pd
import QuantLib as ql
from datetime import datetime, timedelta

today = datetime.today()
dayCount = ql.Thirty360()
calendar = ql.UnitedStates()
interpolation = ql.Linear()
compounding = ql.Compounded
compoundingFrequency = ql.Semiannual
tenor = ql.Period(ql.Semiannual)
bussinessConvention = ql.Unadjusted
dateGeneration = ql.DateGeneration.Backward
monthEnd = False

class CallableBond(object):        
    #a wrapper I define to hold ql objects.
    def __init__(self, issue_dt = None, maturity_dt = None, coupon = None, calldates = [], callprices = []):
        self.issue_dt = issue_dt
        self.maturity_dt = maturity_dt
        self.callprices = callprices
        self.calldates = calldates
        self.coupon = coupon
        self.today_dt = today
        self.callability_schedule = ql.CallabilitySchedule()
        for i, call_dt in enumerate(self.calldates):
            callpx = self.callprices[i]
            day, month, year = call_dt.day, call_dt.month, call_dt.year
            call_date = ql.Date(day, month, year)
            callability_price  = ql.CallabilityPrice(callpx, ql.CallabilityPrice.Clean)
            self.callability_schedule.append(ql.Callability(
                                    callability_price,
                                    ql.Callability.Call,
                                    call_date))

    def value_bond(self, a, s, grid_points):
        model = ql.HullWhite(self.spotCurveHandle, a, s)
        engine = ql.TreeCallableFixedRateBondEngine(model, grid_points, self.spotCurveHandle)
        self.model = model
        self.bond.setPricingEngine(engine)
        return self.bond

    def makebond(self, asofdate = today):
        self.maturityDate = ql.Date(self.maturity_dt.day, self.maturity_dt.month, self.maturity_dt.year)
        self.dayCount = dayCount
        self.calendar = calendar
        self.interpolation = interpolation
        self.compounding = compounding
        self.compoundingFrequency = compoundingFrequency


        AsofDate = ql.Date(asofdate.day, asofdate.month, asofdate.year)
        ql.Settings.instance().evaluationDate = AsofDate
        self.asofdate = asofdate
        self.spotRates = list(np.array([0.0811, 0.0811, 0.0864, 0.0938, 0.1167, 0.1545, 0.1941, 0.3749, 0.6235, 0.8434, 1.3858, 1.6163, 1.6163])/100)
        self.spotDates = [ql.Date(3,11,2020), ql.Date(3,12,2020), ql.Date(1,2,2021), ql.Date(30,4,2021), ql.Date(3,11,2021), ql.Date(4,11,2022), ql.Date(3,11,2023), ql.Date(3,11,2025), ql.Date(4,11,2027), ql.Date(4,11,2030), ql.Date(2,11,2040), ql.Date(4,11,2050), ql.Date(3,11,2090)]
        spotCurve_asofdate = ql.ZeroCurve(self.spotDates, self.spotRates, self.dayCount, self.calendar, self.interpolation, self.compounding, self.compoundingFrequency)
        spotCurveHandle1 = ql.YieldTermStructureHandle(spotCurve_asofdate)
        self.spotCurve = spotCurve_asofdate
        self.spotCurveHandle = spotCurveHandle1

        self.issueDate = ql.Date(self.issue_dt.day, self.issue_dt.month, self.issue_dt.year)
        self.tenor = tenor
        self.bussinessConvention = bussinessConvention
        self.schedule = ql.Schedule(self.issueDate, self.maturityDate, self.tenor, self.calendar, self.bussinessConvention, self.bussinessConvention , dateGeneration, monthEnd)
        self.coupons = [self.coupon]
        self.settlementDays = 0
        self.faceValue = 100
        self.bond = ql.CallableFixedRateBond(
            self.settlementDays, self.faceValue,
            self.schedule, self.coupons, self.dayCount,
            ql.Following, self.faceValue, self.issueDate,
            self.callability_schedule)
        self.value_bond(0.03, 0.012, 80)

    def cleanPriceOAS(self, oas = None):
        if np.isnan(oas):
            return np.nan
        px = self.bond.cleanPriceOAS(oas, self.spotCurveHandle, self.dayCount, self.compounding, self.compoundingFrequency, self.spotCurveHandle.referenceDate())
        return px

if __name__ == '__main__':
    issue_dt = datetime(2016, 5, 25)
    maturity_dt = datetime(2026, 5, 15)
    coupon = 10/100
    calldates, callprices = [datetime(2020, 11, 9), datetime(2022, 5, 15), datetime(2023, 5, 15), datetime(2024, 5, 15)], [103, 102, 101, 100]
    bond = CallableBond(issue_dt, maturity_dt, coupon, calldates, callprices)
    bond.makebond(datetime.today())
    print(bond.cleanPriceOAS(1/10000))   #computing the clean price for OAS=1bp, with call date 11/9/2020 would give 103.17
@boring-cyborg
Copy link

boring-cyborg bot commented Nov 3, 2020

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.

@xg2202
Copy link
Author

xg2202 commented Nov 10, 2020

I feel this is likely a bug. If you have a callable bond that will be called in 7 days (with OAS=0.0001), there is no way the current clean price is almost ~$5 higher than call price.
And this ~$5 difference is not a coincidence - in fact if you change the coupon rate to 20%, this difference will enlarge to ~$10; if you change the coupon rate to 6%, this difference will be ~$3. So clearly the pricer is summing an additional semi-annual coupon flow here. (in this case, the 7 day accrued till the next call should be near 0)

setting coupon=6% will return bond price = 106, $3 higher than call price, with 7 days left.
image

setting coupon=20% will return bond price = 113, $10 higher than call price, with 7 days left.
image

@lballabio
Copy link
Owner

Agreed. We'll have to investigate what happens in the code.

I'm moving the issue to the C++ repository, since that's where the problem probably is.

@lballabio lballabio transferred this issue from lballabio/QuantLib-SWIG Nov 10, 2020
@boring-cyborg
Copy link

boring-cyborg bot commented Nov 10, 2020

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.

@xg2202
Copy link
Author

xg2202 commented Nov 10, 2020

Agreed. We'll have to investigate what happens in the code.

I'm moving the issue to the C++ repository, since that's where the problem probably is.

Thank you so much! I will be watching this post closely. If there is anything you need from me to investigate the issue, please do not hesitate to let me know.

@OleBueker
Copy link
Contributor

OleBueker commented Dec 8, 2020

This seems like a serious shortcoming.
A problem arises when a coupon date is adjusted by its business day convention and a call date is not adjusted. Unfortunately I'm not allowed to post code snippets, but the following example should be somewhat easy to implement.

For example, imagine a bond with annual coupon payments of 4% on the 24th of October, and with a call date on the 24.10.2021. Since the coupon payment is quite high for the current interest rate environment, you would expect this bond to be called on that date, so the price should be somewhere near 104.
However, note that this call date falls on a Sunday!

By entering a business day convention, the coupon date will get adjusted to the 25th of October, 2021 (depending on what business day convention you use).
However the call date is not adjusted since the generation of the callability schedule doesn't apply any business day conventions, so it will still be on the 24th.

The function bond.CleanPrice() will return a price of ~108, which includes the second coupon.
So there seems to be some underlying problem with the way the coupons are calculated.

Or, maybe it is implemented this way on purpose and one just has to be aware of it and create a workaround that checks whether the call dates fall on the right date.

@xg2202
Copy link
Author

xg2202 commented Dec 8, 2020

This seems like a serious shortcoming.
A problem arises when a coupon date is adjusted by its business day convention and a call date is not adjusted. Unfortunately I'm not allowed to post code snippets, but the following example should be somewhat easy to implement.

For example, imagine a bond with annual coupon payments of 4% on the 24th of October, and with a call date on the 24.10.2021. Since the coupon payment is quite high for the current interest rate environment, you would expect this bond to be called on that date, so the price should be somewhere near 104.
However, note that this call date falls on a Sunday!

By entering a business day convention, the coupon date will get adjusted to the 25th of October, 2021 (depending on what business day convention you use).
However the call date is not adjusted since the generation of the callability schedule doesn't apply any business day conventions, so it will still be on the 24th.

The function bond.CleanPrice() will return a price of ~108, which includes the second coupon.
So there seems to be some underlying problem with the way the coupons are calculated.

Or, maybe it is implemented this way on purpose and one just has to be aware of it and create a workaround that checks whether the call dates fall on the right date.

This is amazing! Thank you so much Ole. The information you provided is critical and very helpful!

But one thing I still do not quite understand is that even if the coupon date gets adjusted to the 25th of Oct, 2021, the clean price should not be affected that much, no? The call price is quoted in terms of clean price. If the coupon is on the 25th instead of 24th, this should have a tiny impact on the valuation, by an amount equal to the discounted one-day accrued interest. Then why does the pricer add a whole annual coupon to the clean price in this case?

To be honest, I am not so familiar with the pricing engine in C++ so I am sure there must be some logic I am missing here and I would appreciate if you could clarify a bit further.

In general, what kind of workaround do you suggest users to take in this case? Do you think users should move the call date a bit closer (like one or two business dates, to make sure it falls on Mon-Fri) to the valuationdate once this error arises, to cheat the pricing engine, although this might mean changing the basic information of a bond? Or do you think choosing a different business day convention might help?

Thank you again for the attention to this issue.

@OleBueker
Copy link
Contributor

Yes, the first solution is exactly what I would suggest as a workaround - perform some kind of check to see if the call date falls on a non-business day, compare it to the dates in the schedule of the bond and adjust it accordingly, possibly performing another check to see if the call & payment dates match after the adjustment, and if they don't, either raise an error or some kind of warning.

I am also not familiar with the C++ base code as we are working with QuantLib-Python, however I can't really share a code snippet due to work restrictions. I'm not sure if there is actually something that needs to be adjusted within QuantLib itself for my issue, hopefully somebody else can investigate the issue further and get to the root of it.

@pcaspers
Copy link
Contributor

The callable bond pricing engine makes a few assumptions one should be aware of I guess. I am talking about the tree engine here only, since you seem to be working with that, but I'd hope that the Black engine follows a similar logic (not sure though).

  • the call includes all coupon payments that take place on times strictly greater than the call time, see here
  • the call prices (if specified as dirty) includes the accruals up to the call data, see here
  • if an exercise time happens to lie shortly before a coupon time (within "one week" following a certain logic, see link to code) the exercise time is moved forward to the coupon time for the purpose of pricing, see here

@ralfkonrad
Copy link
Contributor

ralfkonrad commented Dec 12, 2020

I was about to responds something similar as @pcaspers. So let me add just one more thing:

  • In the QuantLib there is only a call date but from a back office point of view there is also a notice date (not sure if this the official term here) which is the date when you have to inform your counterparts that the bonds will be called. The assumption here is that the notice date is also equal to the call date.

This is not directly linked to this issue but afaik these notice dates can be quiet a long time before the call dates e.g. at the beginning of the coupon period. Does this have an impact on pricing these instruments? I would expect so if you have to decide 3M, 6M or even 12M in advance whether or not you want to exercise a bond. Are there models around taking this into account?

@xg2202
Copy link
Author

xg2202 commented Dec 12, 2020

Yes, the first solution is exactly what I would suggest as a workaround - perform some kind of check to see if the call date falls on a non-business day, compare it to the dates in the schedule of the bond and adjust it accordingly, possibly performing another check to see if the call & payment dates match after the adjustment, and if they don't, either raise an error or some kind of warning.

I am also not familiar with the C++ base code as we are working with QuantLib-Python, however I can't really share a code snippet due to work restrictions. I'm not sure if there is actually something that needs to be adjusted within QuantLib itself for my issue, hopefully somebody else can investigate the issue further and get to the root of it.

Hi OleBueker,
@OleBueker thank you again for the previous comments. Unfortunately, I think what you pointed out is in fact a separate issue which I just replicated. I had not noticed the issue you mentioned before I posted my issue so your comment was still very helpful.
I understand you can not share your code snippets due to compliance restrictions for now. However can you take a look at this callable bond for me?
UBER 8 11/01/2026. (ISIN:US90353TAC45)
coupon = 0.08, issue date = 11/7/2018, maturity = 11/1/2026
call dates and call prices are respectively
11/1/2021, 11/1/2022, 11/1/2023,11/1/2024
106, 104, 102, 100
Please could you price this bond as of 10/29/2021 (Fri) and as of 10/30/2021 (Sat) respectively and let me know what price you get?

I am getting:
109.85 for value date on 10/29/2021 (which I believe is suspicious, and the correct price should be around 106)
111.59 for value date on 10/30/2021.

Your comment is saying the jump from 109.85 to 111.59 is due to the fact that 10/30/2021 is a Saturday so some weird business day convention is applied and the pricer probably treats 10/30/2021 as if it stood behind 11/1/2021. This would explain only the jump from 10/29 to 10/30.
However, this does not explain why I am getting 109.85 on 10/29/2021. Notice the next call is a Monday and 10/29/2021 is a Friday. There should not be any business day adjustment issue. Still, I got 109.85, which is probably 106 (call price)+3.85 (semi-annual coupon). This implied that the pricer is still pricing in an extra semi-annual coupon cashflow, which will not happen if the bond gets called on 11/1/2021.

I am talking about clean prices here.

@pcaspers @ralfkonrad

callable_bug

@xg2202
Copy link
Author

xg2202 commented Dec 12, 2020

The callable bond pricing engine makes a few assumptions one should be aware of I guess. I am talking about the tree engine here only, since you seem to be working with that, but I'd hope that the Black engine follows a similar logic (not sure though).

  • the call includes all coupon payments that take place on times strictly greater than the call time, see here
  • the call prices (if specified as dirty) includes the accruals up to the call data, see here
  • if an exercise time happens to lie shortly before a coupon time (within "one week" following a certain logic, see link to code) the exercise time is moved forward to the coupon time for the purpose of pricing, see here

Hi pcaspers,
Thank you for listing the assumptions here which I believe will help us understand the behavior of the base code better.

  • You are saying all coupon payments that take place before the call time, but after the valuation time (obviously you do not count the cashflows before valuation date) will be included for calculating the clean price of the bond. This is consistent with my understanding.
  • You are saying if we provide dirty price to construct the call schedule for the bond, then the accruals up to call date will be added to the clean price. Well in my example I am always using clean prices so I think this is not the cause of my issue but I agree with this logic.
  • You are saying in case exercise time (which I believe is the call date?) happens to lie shortly before the coupon time, the exercise time (call date) will be moved forward to the coupon time. I am not sure if this is closely related to my issue. But in the example I gave, UBER 8 11/01/2026, the next call date is 11/1/2021, and the coupon date is 11/1/2021 (the same time). However if you were to price this bond with 1bp OAS, as of 10/29/2021, you would get 109.85, which I believe is a result of 106 (next call price) + 3.85 (discounted semi-annual coupon). Since there are only 2 days away from the call time, and I am doing everything in clean price terms, I would expect the correct price to be around 106...
    Also the risk free curve I am using does not matter here because the rate is very very low nowadays.

image002
image001

@xg2202
Copy link
Author

xg2202 commented Dec 13, 2020

@pcaspers @OleBueker @ralfkonrad
Thank all of you for the attention to this matter!
I have some new updates after playing with the C++ base code and the python code. I did more experiments.

  1. I probably provided an inappropriate risk free curve for the bond which seemed to be part of the cause. If I changed my risk free curve from a zero curve to a flat forward curve, then UBER 8 2026 (US90353TAC45) can be correctly priced on 10/29/2021 using OAS = 0.0001 (the pricer gave 106.066 which is close to 106). The issue seemed to be fixed. I am happy.
    However the problem with AES 6 2026 (US00130HBX26) STILL EXITS, even with the flat forward rate. Pricing the bond with the following set up would give 108.97, which seemed to price in an extra coupon:
    issue_date = 5/25/2016,
    maturity_date = 5/15/2026,
    coupon = 0.06
    calldates = 5/15/2021, 5/15/2022, 5/15/2023, 5/15/2024
    callprices = 103, 102, 101, 100
    OAS = 0.0001
    evaluationDate = 11/13/2020
    Again, I would expect the price close to 106 (103+3), given there is roughly 0.5yrs to the next call from the valuation date.

  2. Magically, in C++ code, I implemented the above two examples, and I am able to generate correct prices for both! It means that there seemed to be nothing wrong with C++ base code!

  3. My conclusion is, there must be some weird thing going on either caused by my improper construction of the curve in python or how the curve gets attached to the bond. Can anyone point out if there is anything wrong by setting up the curve in the following way? What is your intuition for what could be wrong?

self.spotRates = list(np.array([0.0811, 0.0811, 0.0864, 0.0938, 0.1167, 0.1545, 0.1941, 0.3749, 0.6235, 0.8434, 1.3858, 1.6163, 1.6163])/100)
self.spotDates = [ql.Date(3,11,2020), ql.Date(3,12,2020), ql.Date(1,2,2021), ql.Date(30,4,2021), ql.Date(3,11,2021), ql.Date(4,11,2022), ql.Date(3,11,2023), ql.Date(3,11,2025), ql.Date(4,11,2027), ql.Date(4,11,2030), ql.Date(2,11,2040), ql.Date(4,11,2050), ql.Date(3,11,2090)]
spotCurve = ql.ZeroCurve(self.spotDates, self.spotRates, self.dayCount, self.calendar, self.interpolation, self.compounding, self.compoundingFrequency)
spotCurveHandle = ql.YieldTermStructureHandle(spotCurve)
model = ql.HullWhite(spotCurveHandle, a, s)
...

@pcaspers
Copy link
Contributor

This is not directly linked to this issue but afaik these notice dates can be quiet a long time before the call dates e.g. at the beginning of the coupon period. Does this have an impact on pricing these instruments? I would expect so if you have to decide 3M, 6M or even 12M in advance whether or not you want to exercise a bond. Are there models around taking this into account?

It does have an impact I guess. In fact, the notice date determines the time on which the exercise decision has to be taken while the call date determines which coupons are included in the bond and (with a settlement delay) on which date the redemption has to be paid. The later the notice date the higher the value of the call, because the investor has more information. Neither the notice lag nor the settlement delay for the redemption payment is taken into account in the callable tree engine.

@ralfkonrad
Copy link
Contributor

It does have an impact I guess. In fact, the notice date determines the time on which the exercise decision has to be taken while the call date determines which coupons are included in the bond and (with a settlement delay) on which date the redemption has to be paid. The later the notice date the higher the value of the call, because the investor has more information. Neither the notice lag nor the settlement delay for the redemption payment is taken into account in the callable tree engine.

@pcaspers: Are you (or anybody else of course) aware of any implementation taken this into account?

@pcaspers
Copy link
Contributor

No, I am not aware of any implementation, this would require some new dev I think.

@stale
Copy link

stale bot commented Mar 19, 2021

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

@stale stale bot added the stale label Mar 19, 2021
@stale
Copy link

stale bot commented Apr 18, 2021

This issue has been automatically closed as stale.

@stale stale bot closed this as completed Apr 18, 2021
@aichao
Copy link

aichao commented Jun 3, 2021

To the QuantLib community:

Given the conversation above, it would appear that this issue is not truly resolved except for the fact that the OP (@xg2202) managed to convince himself that he no longer had a problem with the two bonds that he had. Consider the following example where we have a simple European call option at par on a 5% semiannual coupon bond and a flat 2% yield curve, so that the curve data itself does not matter, and the bond is sure to be called at its call date. In this simple example, we vary the call date for that call option in a range within +/- 10 days from a coupon payment (cash flow) date. Examining the NPV as a function of the call date in terms of days from the cash flow date (negative days means prior to the cash flow date, and positive after), we have:

Unknown

As the above figure shows, there is a jump in the NPV if the call date is within the 7 day period just prior to the cash flow date. The questions now are:

  1. Is this expected behavior?

  2. Is this behavior the result of the C++ code pointed to in @pcasper's comment on the 12th of December 2020?

    if an exercise time happens to lie shortly before a coupon time (within "one week" following a certain logic, see link to code) the exercise time is moved forward to the coupon time for the purpose of pricing, see here

    In particular, what is the basis for the certain logic given by @pcaspers? It is clear from @pcaspers that it has nothing to do with a notification period for the call. Even if it did, why is this not a parameter in constructing the bond or the callability schedule? Why does QuantLib make the assumption for the user that moving the exercise time forward to the coupon time within this period is the correct thing to do?

A hypothesis here is that moving the exercise time forward to the coupon time when the coupon time is 7 or less days forward from the exercise time mistakenly adds an extra coupon payment to the NPV, resulting in the jump of nearly 2.5 in the NPV over this time period in this example. A discussion of this seemingly odd behavior here would be greatly appreciated. Thank you for your attention to this matter.

P.S.: For full disclosure, the following is a minimal, reproducible example illustrating this in python. It would appear that the issue is not with python nor the SWIG wrapper to QuantLib, but in the C++ implementation itself.

import QuantLib as ql

# Set pricing date
pricing_date = ql.Date(18, 5, 2021)
# The settle date is two business days from the pricing date.
settle_date = ql.Date(20, 5, 2021)
ql.Settings.instance().evaluationDate = pricing_date

# Build a flat yield curve at 2%
dates = [settle_date + i * 100 for i in range(150)]
spot_rates = [0.02]*len(dates)
curve = ql.YieldTermStructureHandle(ql.ZeroCurve(dates, spot_rates, ql.Actual365Fixed()))

# Set Parameters for the bond
coupon=.05
face_amount = 100
accrual_daycount = ql.Thirty360()
coupons = [coupon]
maturity_date = ql.Date(14, 2, 2026)
issue_date = settle_date - 2 * 366
tenor = ql.Period(ql.Semiannual)
business_convention = ql.Unadjusted
date_generation = ql.DateGeneration.Backward
month_end = False
# Build the accrual schedule
calendar = ql.UnitedStates(ql.UnitedStates.FederalReserve)
schedule = ql.Schedule(issue_date, maturity_date, tenor, calendar, business_convention, 
                       business_convention, date_generation, month_end)

# Set up the call date on a cash flow date with a clean price of 100
# Then perturb the call date and see how the bond NPV changes
call_date = ql.Date(14, 2, 2022)
call_price = 100
# Build the CallabilitySchedule.  Here have a single date in the call schedule
callability_schedule = ql.CallabilitySchedule()
callability_price = ql.CallabilityPrice(call_price, ql.CallabilityPrice.Clean)
callability_schedule.append(ql.Callability(callability_price, ql.Callability.Call, call_date))
# Build the callable bond
callable_bond = ql.CallableFixedRateBond(2, face_amount, schedule, coupons, ql.Thirty360(), ql.Following,
                                         face_amount, issue_date, callability_schedule)
# Set the model (~zero mean reversion, 30 bps volatility) and the pricing engine
model = ql.HullWhite(curve, 1e-12, .003)
engine = ql.TreeCallableFixedRateBondEngine(model, 40)
callable_bond.setPricingEngine(engine)

Note that the flat yield curve is purposely set at a yield below the coupon of the bond so that the bond will be called at its call date. At this point, the cash flow and NPV of the callable bond can be verified:

>>> print(f'\nBond Cash Flows')
>>> for cash in callable_bond.cashflows():
....    print(f'date: {cash.date()}, amount: {cash.amount()}')
Bond Cash Flows
date: August 14th, 2019, amount: 1.1805555555555625
date: February 14th, 2020, amount: 2.499999999999991
date: August 14th, 2020, amount: 2.499999999999991
date: February 16th, 2021, amount: 2.499999999999991
date: August 16th, 2021, amount: 2.499999999999991
date: February 14th, 2022, amount: 2.499999999999991
date: August 15th, 2022, amount: 2.499999999999991
date: February 14th, 2023, amount: 2.499999999999991
date: August 14th, 2023, amount: 2.499999999999991
date: February 14th, 2024, amount: 2.499999999999991
date: August 14th, 2024, amount: 2.499999999999991
date: February 14th, 2025, amount: 2.499999999999991
date: August 14th, 2025, amount: 2.499999999999991
date: February 17th, 2026, amount: 2.499999999999991
date: February 17th, 2026, amount: 100.0

>>> print(f'\nBond NPV with original call date= {callable_bond.NPV()}')
Bond NPV with original call date= 103.48269824035756

Note that the call date of February 14th, 2022 is indeed a cash flow date. Now, the odd behavior occurs when the call date is perturbed from this cash flow date:

orig_call_date = ql.Date(14, 2, 2022)
delta_call_days = [d for d in range(-10, 10)]
call_dates = []
npv = []
for delta_call_day in delta_call_days:
    # Perturb the call date from the original call date at the cash flow date
    call_date = orig_call_date + delta_call_day
    call_dates.append(call_date)
    # Rebuild the CallabilitySchedule and the CallableFixedRateBond the with the modified call date
    callability_schedule = ql.CallabilitySchedule()
    # The clean call price remains at 100
    call_price = 100
    callability_price = ql.CallabilityPrice(call_price, ql.CallabilityPrice.Clean)
    callability_schedule.append(ql.Callability(callability_price, ql.Callability.Call, call_date))
    callable_bond = ql.CallableFixedRateBond(2, face_amount, schedule, coupons, ql.Thirty360(), ql.Following,
                                             face_amount, issue_date, callability_schedule)
    callable_bond.setPricingEngine(engine)
    # Compute the NPV
    npv.append(callable_bond.NPV())

import matplotlib.pyplot as plt

fig, ax = plt.subplots()
npv_line, = ax.plot(delta_call_days, np.array(npv), 'r.')
plt.axis([-12, 12, 103, 106])
ax.set(xlabel='Call Date Relative to Cash Flow Date [Days]', ylabel ='NPV', 
       title=f'NPV vs Call Date Relative to Cash Flow Date')
plt.show()

@lballabio lballabio reopened this Jun 3, 2021
@github-actions github-actions bot removed the stale label Jun 4, 2021
@aichao
Copy link

aichao commented Jun 24, 2021

Hi @pcaspers, @lballabio, et. al., I know that everyone is busy, but can we at least verify this behavior in the C++ code and have a discussion here concerning whether this behavior is as you intend? Thank you very much.

@lballabio
Copy link
Owner

Confirmed in C++. No, obviously that's not the behavior we want.

@aichao
Copy link

aichao commented Jun 28, 2021

Hi Luigi, thank you for the quick response and for the wonderful library that is QuantLib. We are very interested in the resolution of this issue. I noticed the help wanted label. Is there currently a contributor that is responsible for that part of the library with whom we can start a discussion?

@lballabio
Copy link
Owner

No, there's no one responsible in particular. We can have the discussion here, I guess?

@aichao
Copy link

aichao commented Jun 29, 2021

Yes, having the discussion here is appropriate. We are interested in contributing a fix to this issue, but obviously would need some guidance in understanding the original requirements/design/intent from which the relevant code is written (yes, we have read your developer intro and guidelines). Help in navigating the code base will also be appreciated as this is a complex library. As a starting point, the following code snippet, referred to by @pcaspers, would appear to contribute to the issue:

// To avoid mispricing, we snap exercise dates to the closest coupon date.
for (double& exerciseTime : callabilityTimes_) {
for (double couponTime : couponTimes_) {
if (withinNextWeek(exerciseTime, couponTime)) {
exerciseTime = couponTime;
break;
}
}
}

However, simply removing it may result in unintended consequences. We are concerned primarily with the TreeCallableFixedRateBondEngine with a HullWhite model. Also any pointers to existing test code or examples relevant to this issue are appreciated.

@ralfkonrad
Copy link
Contributor

I guess the reason for the withinNextWeek(exerciseTime, couponTime) is not the bond itself but the math behind the tree or finite differences implementation that is typically used with HullWhite or other models: E.g. if you have a time grid with timesteps every 3 month (something we typically use for our bermudan instruments), an extra very small time step of less than (arbitrary) seven days might "disbalance" a TrinomialTree and lead the the mentioned mispricing.

Therefore I think the problem is who the cashflows are taken into account here

void DiscretizedCallableFixedRateBond::postAdjustValuesImpl() {
for (Size i=0; i<callabilityTimes_.size(); i++) {
Time t = callabilityTimes_[i];
if (t >= 0.0 && isOnTime(t)) {
applyCallability(i);
}
}
for (Size i=0; i<couponTimes_.size(); i++) {
Time t = couponTimes_[i];
if (t >= 0.0 && isOnTime(t)) {
addCoupon(i);
}
}
}

and in the implementation of DiscretizedCallableFixedRateBond::applyCallability(Size i) and DiscretizedCallableFixedRateBond::addCoupon(Size i).

Also be aware the similar code lines exist for other instruments like swaptions as well:

// Date adjustments can get time vectors out of synch.
// Here, we try and collapse similar dates which could cause
// a mispricing.
for (Size i=0; i<arguments_.exercise->dates().size(); i++) {
Date exerciseDate = arguments_.exercise->date(i);
for (Size j=0; j<arguments_.fixedPayDates.size(); j++) {
if (withinNextWeek(exerciseDate,
arguments_.fixedPayDates[j])
// coupons in the future are dealt with below
&& arguments_.fixedResetDates[j] < referenceDate)
arguments_.fixedPayDates[j] = exerciseDate;
}
for (auto& fixedResetDate : arguments_.fixedResetDates) {
if (withinPreviousWeek(exerciseDate, fixedResetDate))
fixedResetDate = exerciseDate;
}
for (auto& floatingResetDate : arguments_.floatingResetDates) {
if (withinPreviousWeek(exerciseDate, floatingResetDate))
floatingResetDate = exerciseDate;
}
}

@lballabio
Copy link
Owner

Ralf's comment on numerical instability might be correct. We'd have to experiment and see what happens.

Also, I think the idea of snapping the exercise date to the closest coupon date was to avoid having to take the accrued amount into account, too (because it would be 0 at that date). If the exercise date was earlier, we might have to adjust the callability premium (assuming it's expressed as a clean price? Not sure about that, though.)

@ralfkonrad
Copy link
Contributor

@lballabio: The adjustment of the callability premium is done in the setup of the bond:

for (const auto& i : putCallSchedule_) {
if (!i->hasOccurred(settlement, false)) {
arguments->callabilityDates.push_back(i->date());
arguments->callabilityPrices.push_back(i->price().amount());
if (i->price().type() == Bond::Price::Clean) {
/* calling accrued() forces accrued interest to be zero
if future option date is also coupon date, so that dirty
price = clean price. Use here because callability is
always applied before coupon in the tree engine.
*/
arguments->callabilityPrices.back() += this->accrued(i->date());
}
}
}

@lballabio
Copy link
Owner

Hmm — this might be the source of the error then? First the callability premium is adjusted for the accrual at the actual exercise date, and then in the engine the exercise is snapped to a date in which the accrual would be 0?

@ralfkonrad
Copy link
Contributor

Because this->accrued(i->date()) is almost the full accrual then?!

@ralfkonrad
Copy link
Contributor

Shouldn't it be correct if we use dirty prices here to quickly check if this solves the problem?

@lballabio
Copy link
Owner

NPV or dirty price is the same — the problem is that the premium is overvalued, so we can't account for that.

One possible check (not conclusive, but it might be another hint) is to try different values of the coupon rate. If this is the cause, the jump should be proportional to the value of the coupon.

@ralfkonrad
Copy link
Contributor

Perhaps we mean different dirty here?

I meant a dirty callability bond price in

Callability(const Bond::Price& price, Type type, const Date& date)

@ralfkonrad
Copy link
Contributor

@aichao's example #930 (comment) is calculated with a clean callability price.

@ralfkonrad
Copy link
Contributor

Confirmed in C++. No, obviously that's not the behavior we want.

@lballabio: Do you still have your C++ code available? Can you check, if this quickhack solves the issue?

I have added the coupons in the preAdjustValuesImpl() except when the exercise date matches the coupon date.

Why?

  • when the coupon date does not match any exercise date, it does not matter, if do we pre- or post-adjust,
  • when the dates match, this replicates the current behaviour and
  • when the exercise date got adjusted but is before coupon date, in applyCallability(i) values_[j] already contain the coupon and will be correctly compared to arguments_.callabilityPrices[i]

@lballabio
Copy link
Owner

I'll try it, but frankly, I'd try to find a way to use the correct callability premium if we change the call date instead of compensating for the "wrong" premium.

@ralfkonrad
Copy link
Contributor

I'd try to find a way to use the correct callability premium if we change the call date instead of compensating for the "wrong" premium.

I am not sure if we do anything "wrong" here. We are using the "true" callability premium but we have to ensure that the coupons are taken into account at the right time before or after applyCallability(i).

When we evaluate the Callability::Call case (or Put with the std::max)

values_[j] = std::min(arguments_.callabilityPrices[i], values_[j]); 

the values_[j] need to contain what has happened after the call date as we rolling through the tree backwards in time.

So if the call date is a couple of days before the coupon date we don't have to change the arguments_.callabilityPrices[i] if values_[j] contains the coupons.

If the call and coupon date matches we know for certain that we will receive the coupon. Therefore they do not have an impact on the callability decision and should be added after we evaluate the callability. Or to put it another way: only the remaining bond after the call/coupon date should be in values_[j].

We might change the call time for numerical reasons but I think that's okay as long as we keep the right order of events rolling backwards through the tree.

Also I guess changing the callability premium will be a lot more difficult.

What do you think?

@ralfkonrad
Copy link
Contributor

Sorry, when posted my last comment, I was in the middle of replicating @aichao's example myself.

I get the following NPVs:

            Currently    Fixed Adjustment
2022-02-04:    103.39              103.39
2022-02-05:    103.40              103.40
2022-02-06:    103.41              103.41
2022-02-07:    105.84              103.38
2022-02-08:    105.85              103.39
2022-02-09:    105.87              103.40
2022-02-10:    105.88              103.42
2022-02-11:    105.89              103.43
2022-02-12:    105.91              103.44
2022-02-13:    105.92              103.46
2022-02-14:    103.47              103.47
2022-02-15:    103.48              103.48
2022-02-16:    103.49              103.49
2022-02-17:    103.50              103.50
2022-02-18:    103.50              103.50
2022-02-19:    103.51              103.51
2022-02-20:    103.52              103.52
2022-02-21:    103.53              103.53
2022-02-22:    103.54              103.54
2022-02-23:    103.55              103.55
2022-02-24:    103.55              103.55

So the jump in the NPVs seven days before 2022-02-14 has gone.

Here is my test code:

void CallableBondTest::testIssue930() {

    BOOST_TEST_MESSAGE("Testing issue 930...");

    SavedSettings backup;
    Settings::instance().evaluationDate() = Date(18, May, 2021);

    auto makeCallableBond = [](Date callDate) {
        RelinkableHandle<YieldTermStructure> termStructure;
        termStructure.linkTo(ext::make_shared<FlatForward>(Settings::instance().evaluationDate(),
                                                           0.02, Actual365Fixed()));

        Date pricingDate = Settings::instance().evaluationDate();
        auto settlementDays = 2;
        auto settlementDate = Date(20, May, 2021);
        auto coupon = 0.05;
        auto faceAmount = 100.00;
        auto redemption = faceAmount;
        auto paymentConvention = BusinessDayConvention::Following;
        auto accrualDCC = Thirty360(Thirty360::Convention::USA);
        auto maturityDate = Date(14, Feb, 2026);
        auto issueDate = settlementDate - 2 * 366 * Days;
        auto calendar = UnitedStates(UnitedStates::FederalReserve);
        Schedule schedule = MakeSchedule()
                                .from(issueDate)
                                .to(maturityDate)
                                .withFrequency(Semiannual)
                                .withCalendar(calendar)
                                .withConvention(Unadjusted)
                                .withTerminationDateConvention(Unadjusted)
                                .backwards()
                                .endOfMonth(false);
        std::vector<Rate> coupons = std::vector<Rate>(schedule.size(), coupon);

        CallabilitySchedule callabilitySchedule;
        callabilitySchedule.push_back(ext::make_shared<Callability>(
            Bond::Price(faceAmount, Bond::Price::Clean), Callability::Type::Call, callDate));

        auto callableBond = ext::make_shared<CallableFixedRateBond>(
            settlementDays, faceAmount, schedule, coupons, accrualDCC,
            BusinessDayConvention::Following, redemption, issueDate, callabilitySchedule);
        auto model = ext::make_shared<HullWhite>(termStructure, 1e-12, 0.003);
        auto engine = ext::make_shared<TreeCallableFixedRateBondEngine>(model, 40);

        callableBond->setPricingEngine(engine);

        return callableBond;
    };

    auto initialCallDate = Date(14, Feb, 2022);

    for (int i = -10; i < 11; i++) {
        auto callDate = initialCallDate + i * Days;
        auto callableBond = makeCallableBond(callDate);
        auto npv = callableBond->NPV();
        BOOST_TEST_MESSAGE(io::iso_date(callDate) << ": " << std::setprecision(5) << npv);
    }
}

@aichao
Copy link

aichao commented Jul 7, 2021

@ralfkonrad and @lballabio, thank you guys for looking into this. Just to clarify our understanding of the proposed fix:

  1. QuantLib will continue to adjust callability dates to cash flow dates when they are within a week in order to avoid any numerical issues in pricing.
  2. QuantLib will be modified to remove the addition of the accrued interest (at the original callability date) when that callability date is snapped to a cash flow date.

Please let us know if our understanding is correct or incorrect.

When can we expect a new version of QuantLib that contains this fix? Again, thank you for attending to this!

@lballabio
Copy link
Owner

Yes, I would go in that direction. It's late for this to go into the 1.23 release (for which I started the release process already) so it would be in version 1.24, probably in three months or so. Of course it will be available from GitHub earlier than that, as soon as it's merged into master.

@ralfkonrad
Copy link
Contributor

@lballabio, @aichao: #1142 might solve the main issue which IMO is the wrong order of events when we roll back through the tree and have snapped the exercise time:

In here

void DiscretizedCallableFixedRateBond::postAdjustValuesImpl() {
for (Size i=0; i<callabilityTimes_.size(); i++) {
Time t = callabilityTimes_[i];
if (t >= 0.0 && isOnTime(t)) {
applyCallability(i);
}
}
for (Size i=0; i<couponTimes_.size(); i++) {
Time t = couponTimes_[i];
if (t >= 0.0 && isOnTime(t)) {
addCoupon(i);
}
}
}

we calculate the exercise feature and then add the coupon. Rolling back and having exercise date snapped before the coupon it needs to be the other way around.

There is a test case for the snapping reproducing @aichao's example above which works fine.

https://github.com/ralfkonrad/QuantLib/blob/55eea24f3f0ef8553652528b1085c024fb75dc08/test-suite/callablebonds.cpp#L573

I am not sure if we have to

  1. QuantLib will be modified to remove the addition of the accrued interest (at the original callability date) when that callability date is snapped to a cash flow date.

IMO it is more or less correct the way we do it now. E.g. it might produce a (small) jump when the exercise date is eight and seven days before the coupon date. What is not taken into account is the discounting of the coupon between the exercise and coupon date but I think that is negligible here. But I leave this up to Luigi.

Currently #1142 is still draft, I will same documentation to it later today.

@ralfkonrad
Copy link
Contributor

ralfkonrad commented Jul 10, 2021

@aichao, @lballabio: I have updated the test case to compare the result of the callable bond with the corresponding FixedRateBond maturing at the call date.

I think the differences can be explained by the missing discounting of the callability price by e.g. seven days 102.40 * 2% * 7 / 365 = 0.0393 compared to 0.0387 as we have snapped the call date seven days into the future.

Call date     Fixed Rate        Callable           Diff
2022-02-04      103.3898        103.3898        +0.0000
2022-02-07      103.4143        103.3756        -0.0387
2022-02-08      103.4224        103.3893        -0.0332
2022-02-09      103.4306        103.4029        -0.0277
2022-02-10      103.4387        103.4166        -0.0221
2022-02-11      103.4469        103.4303        -0.0166
2022-02-14      103.4714        103.4714        -0.0000
2022-02-15      103.4796        103.4796        -0.0000
2022-02-16      103.4879        103.4879        -0.0000
2022-02-17      103.4962        103.4962        -0.0000
2022-02-18      103.5045        103.5045        -0.0000
2022-02-22      103.5376        103.5376        -0.0000
2022-02-23      103.5459        103.5459        -0.0000
2022-02-24      103.5541        103.5541        -0.0000

@ralfkonrad
Copy link
Contributor

@aichao, @lballabio: In #1142 I have taken into account

the missing discounting of the callability price

when we have to snap the call dates and I can fully reproduce the prices of the FixedRateBond maturing at the call date.

Call date     Fixed Rate        Callable           Diff
2022-02-04      103.3898        103.3898        -1.42e-14
2022-02-07      103.4143        103.4143        +2.84e-14
2022-02-08      103.4224        103.4224        +0.00e+00
2022-02-09      103.4306        103.4306        +1.42e-14
2022-02-10      103.4387        103.4387        +2.84e-14
2022-02-11      103.4469        103.4469        -1.42e-14
2022-02-14      103.4714        103.4714        +2.84e-14
2022-02-15      103.4796        103.4796        +1.42e-14
2022-02-16      103.4879        103.4879        +1.42e-14
2022-02-17      103.4962        103.4962        +1.42e-14
2022-02-18      103.5045        103.5045        +2.84e-14
2022-02-22      103.5376        103.5376        +1.42e-14
2022-02-23      103.5459        103.5459        +1.42e-14
2022-02-24      103.5541        103.5541        +4.26e-14

@aichao
Copy link

aichao commented Jul 13, 2021

@ralfkonrad and @lballabio, first we would like to thank @ralfkonrad for his work in addressing this issue. We have independently replicated the fixes made by @ralfkonrad by appropriately adjusting the callability date and callability dirty price in constructing the CallabilitySchedule for the CallableFixedRateBond in Python when the original call date is within a week prior to a coupon date (i.e., withinNextWeek(exerciseTime, couponTime) is true). We did this separately for the two stages of fixes, which we identify as:

  1. InitFix: appropriate reordering of the addCoupon and applyCallability events when the exercise time is snapped to the coupon time. We replicated this by subtracting the coupon amount from the original call date's dirty call price (clean call price + accrued amount at the original call date) to get the adjusted callability dirty price at the snapped to callability date that is the coupon date. That is:
adjusted_callability_date = coupon_date
adjusted_callability_dirty_price = dirty_call_price_at_original_call_date - coupon_amount
  1. UpdateFix: discounting of the callability price. We replicated this by discounting the callability dirty price in a manner consistent with the adjustment made by @ralfkonrad yesterday, so that:
adjusted_callability_date = coupon_date
adjusted_callability_dirty_price = dirty_call_price_at_original_call_date * 
                                   curve.discount(original_call_date) / curve.discount(coupon_date) - 
                                   coupon_amount

Doing this, we replicate @ralfkonrad's results comparing to a fixed rate bond maturing at the call date for the test case in question where the call option is deep in the money (i.e., coupon rate greater than the discount rate) so that the option is exercised.

Call Date Original Fixed Rate InitFix UpdateFix InitDiff UpdateDiff
February 4th, 2022 103.3898 103.3898 103.3898 103.3898 -0.0000 -5.258016e-13
February 7th, 2022 105.8782 103.4143 103.3756 103.4143 -0.0387 -9.094947e-13
February 8th, 2022 105.8863 103.4224 103.3893 103.4224 -0.0332 -1.008971e-12
February 9th, 2022 105.8943 103.4306 103.4029 103.4306 -0.0277 -9.805490e-13
February 10th, 2022 105.9023 103.4387 103.4166 103.4387 -0.0221 -9.663381e-13
February 11th, 2022 105.9103 103.4469 103.4303 103.4469 -0.0166 -1.037392e-12
February 14th, 2022 103.4714 103.4714 103.4714 103.4714 -0.0000 -1.222134e-12
February 15th, 2022 103.4796 103.4796 103.4796 103.4796 -0.0000 -1.477929e-12
February 16th, 2022 103.4879 103.4879 103.4879 103.4879 -0.0000 -1.577405e-12
February 17th, 2022 103.4962 103.4962 103.4962 103.4962 -0.0000 -1.634248e-12
February 18th, 2022 103.5045 103.5045 103.5045 103.5045 -0.0000 -1.776357e-12
February 22nd, 2022 103.5376 103.5376 103.5376 103.5376 -0.0000 -2.430056e-12
February 23rd, 2022 103.5459 103.5459 103.5459 103.5459 -0.0000 -2.700062e-12
February 24th, 2022 103.5541 103.5541 103.5541 103.5541 -0.0000 -3.211653e-12

Note here that InitDiff and UpdateDiff are the differences InitFix - Fixed Rate and UpdateFix - Fixed Rate, respectively.

We should note that we present this not as a substitute for @ralfkonrad's fixes because the correct course of action is to fix this in the C++ code as in his PR. At this point one may wonder why we bother to do this when @ralfkonrad has appropriately addressed the issue in his PR. Our motivations are as follows:

  1. We want to align our understanding of the underlying issue and the fixes with @ralfkonrad. For this we welcome any comments from him and @lballabio.
  2. We offer this as a potential workaround for other QuantLib users (especially using the Python wrapper) in the interim before the release of QuantLib that will contain these fixes. This avoids having to compile QuantLib from source (after the merge of the PR to master), which may be a barrier for some users who are strictly in Python. For brevity, we chose not to post our code here, but can readily do so upon request.
  3. This allows us to explore other test cases for the fixes, which we outline in the following sections.

Call Option Out of the Money

This test case is identical to that above except that the yield curve is flat at 10% compared to the 5% coupon. Here, we would expect that the call will not be exercised such that the fixes will have no effect on the NPV at the call dates compared to a fixed rate bond with the same maturity date as the callable bond. Our results verify this:

Call Date Original Fixed Rate InitFix UpdateFix InitDiff UpdateDiff
February 4th, 2022 81.8301 81.8301 81.8301 81.8301 0.0 7.105427e-14
February 7th, 2022 81.8346 81.8301 81.8301 81.8301 0.0 4.263256e-14
February 8th, 2022 81.8339 81.8301 81.8301 81.8301 0.0 4.263256e-14
February 9th, 2022 81.8333 81.8301 81.8301 81.8301 0.0 4.263256e-14
February 10th, 2022 81.8327 81.8301 81.8301 81.8301 0.0 4.263256e-14
February 11th, 2022 81.8320 81.8301 81.8301 81.8301 0.0 4.263256e-14
February 14th, 2022 81.8301 81.8301 81.8301 81.8301 0.0 4.263256e-14
February 15th, 2022 81.8301 81.8301 81.8301 81.8301 0.0 4.263256e-14
February 16th, 2022 81.8301 81.8301 81.8301 81.8301 -0.0 -2.842171e-14
February 17th, 2022 81.8301 81.8301 81.8301 81.8301 0.0 9.947598e-14
February 18th, 2022 81.8301 81.8301 81.8301 81.8301 0.0 1.278977e-13
February 22nd, 2022 81.8301 81.8301 81.8301 81.8301 0.0 7.105427e-14
February 23rd, 2022 81.8301 81.8301 81.8301 81.8301 0.0 1.278977e-13
February 24th, 2022 81.8301 81.8301 81.8301 81.8301 0.0 4.263256e-14

Note, however, that the Original implementation (no fixes applied) still has a small blip in the period one week prior to the coupon date. We suggest that @ralfkonrad add this test case to the mix to verify the fixes.

Call Option at the Money

This test case is identical to that above except now the discount and coupon rates are the same at 5%. Here, we plot the NPV as a function of the difference between the call date and the coupon date in Days.

Unknown

Here, we see that outside of that week period prior to the coupon date, the Original, InitFix, and UpdateFix NPVs are identical. Within the week period prior to the coupon date, we feel that the UpdateFix NPV is reasonable and more accurate than that from the InitFix, as expected.

Approximate American Option

In the following test cases, we approximate an American option by inserting additional callability dates at coupon dates following the coupon date to which the call date is snapped. These additional callability dates all have the same callability clean price. Only the call date is varied as before around the coupon date to which it is snapped (if within the week period). We examine both "deep in the money" and "at the money" cases.

Deep in the Money

Here, the coupon is 5% and the yield curve is flat at 2%.

Unknown-4

We note that with the approximate American option deep in the money:

  1. Outside of that week period prior to the coupon date, the Original, InitFix, and UpdateFix NPVs are identical.
  2. Within the week period prior to the coupon date, the Original NPV jumps to around the value at the coupon date. This is less severe of a jump in NPV compared to the similar European option.
  3. Within the week period prior to the coupon date, the UpdateFix NPV matches that of the equivalent Fixed Rate Bond maturing at the call date, as expected.

At the Money

Here, the coupon and the discount rates are at 5%.

Unknown-2

We note that with the approximate American option at the money:

  1. Outside of that week period prior to the coupon date, the Original, InitFix, and UpdateFix NPVs are again identical.
  2. Within the week period prior to the coupon date, the UpdateFix NPV is reasonable and in our judgment better than the Original NPV; whereas the InitFix NPV appears to be significantly poorer.

Summary

In summary, we feel confident regarding the fixes, and we would like to again thank @ralfkonrad for his efforts. In closing, we do suggest that the above test cases be added to the callable bond test suite if that is not too much trouble.

@ralfkonrad
Copy link
Contributor

again thank ralfkonrad for his efforts.

You are welcome.

I have my own interest in this issue anyhow as we implemented a HullWhite tree model based on this one and the TreeSwaptionEngine (see issue #1143) and probably need to sort out similar issues now.

@ralfkonrad
Copy link
Contributor

@aichao, regarding the extra test cases: Unfortunately I will not be able to provide them the next two or three weeks.

@lballabio might have a different option but IMO the test case "Call option out of the money" is not a new one as it tests the same snapping of callability times as the current one.

And the other examples are definitely very interesting to illustrate the problem but not really test cases in the sense that we have true prices to test against.

@lballabio
Copy link
Owner

@aichao , @ralfkonrad , thanks for your patience.

I also tried something equivalent to UpdateFix above in C++, but after taking pen and paper to understand why it worked I'm sold on Ralf's original approach. I'll be merging his PR shortly.

Thanks to you both for the effort!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

6 participants