-
Notifications
You must be signed in to change notification settings - Fork 1.8k
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
Comments
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. |
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. |
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. |
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. |
This seems like a serious shortcoming. 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. 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). The function bond.CleanPrice() will return a price of ~108, which includes the second coupon. 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. |
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. |
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).
|
I was about to responds something similar as @pcaspers. So let me add just one more thing:
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? |
Hi OleBueker, I am getting: 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. I am talking about clean prices here. |
Hi pcaspers,
|
@pcaspers @OleBueker @ralfkonrad
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) |
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? |
No, I am not aware of any implementation, this would require some new dev I think. |
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. |
This issue has been automatically closed as stale. |
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: 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:
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.
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:
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:
|
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. |
Confirmed in C++. No, obviously that's not the behavior we want. |
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 |
No, there's no one responsible in particular. We can have the discussion here, I guess? |
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: QuantLib/ql/experimental/callablebonds/discretizedcallablefixedratebond.cpp Lines 55 to 63 in 9777600
However, simply removing it may result in unintended consequences. We are concerned primarily with the |
I guess the reason for the Therefore I think the problem is who the cashflows are taken into account here QuantLib/ql/experimental/callablebonds/discretizedcallablefixedratebond.cpp Lines 104 to 117 in 9777600
and in the implementation of Also be aware the similar code lines exist for other instruments like swaptions as well: QuantLib/ql/pricingengines/swaption/discretizedswaption.cpp Lines 52 to 72 in 9777600
|
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.) |
@lballabio: The adjustment of the callability premium is done in the setup of the bond: QuantLib/ql/experimental/callablebonds/callablebond.cpp Lines 471 to 485 in 9777600
|
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? |
Because |
Shouldn't it be correct if we use dirty prices here to quickly check if this solves the problem? |
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. |
Perhaps we mean different dirty here? I meant a dirty callability bond price in
|
@aichao's example #930 (comment) is calculated with a clean callability price. |
@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 Why?
|
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. |
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 When we evaluate the values_[j] = std::min(arguments_.callabilityPrices[i], values_[j]); the So if the call date is a couple of days before the coupon date we don't have to change the 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 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? |
Sorry, when posted my last comment, I was in the middle of replicating @aichao's example myself. I get the following NPVs:
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);
}
} |
@ralfkonrad and @lballabio, thank you guys for looking into this. Just to clarify our understanding of the proposed fix:
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! |
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. |
@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 QuantLib/ql/experimental/callablebonds/discretizedcallablefixedratebond.cpp Lines 104 to 117 in 0d95b09
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. I am not sure if we have to
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. |
@aichao, @lballabio: I have updated the test case to compare the result of the callable bond with the corresponding I think the differences can be explained by the missing discounting of the callability price by e.g. seven days 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 |
@aichao, @lballabio: In #1142 I have taken into account
when we have to snap the call dates and I can fully reproduce the prices of the
|
@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
adjusted_callability_date = coupon_date
adjusted_callability_dirty_price = dirty_call_price_at_original_call_date - coupon_amount
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.
Note here that 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:
Call Option Out of the MoneyThis 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:
Note, however, that the Call Option at the MoneyThis 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. Here, we see that outside of that week period prior to the coupon date, the Approximate American OptionIn 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 MoneyHere, the coupon is 5% and the yield curve is flat at 2%. We note that with the approximate American option deep in the money:
At the MoneyHere, the coupon and the discount rates are at 5%. We note that with the approximate American option at the money:
SummaryIn 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. |
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 |
@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. |
@aichao , @ralfkonrad , thanks for your patience. I also tried something equivalent to Thanks to you both for the effort! |
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!
The text was updated successfully, but these errors were encountered: