Skip to content

Commit a385759

Browse files
authored
Fix call price accrued during ex-coupon period (#2236) (#2455)
2 parents d73e757 + c72ca15 commit a385759

File tree

3 files changed

+118
-36
lines changed

3 files changed

+118
-36
lines changed

ql/experimental/callablebonds/callablebond.cpp

Lines changed: 23 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -445,12 +445,29 @@ namespace QuantLib {
445445
arguments->callabilityPrices.push_back(i->price().amount());
446446

447447
if (i->price().type() == Bond::Price::Clean) {
448-
/* calling accrued() forces accrued interest to be zero
449-
if future option date is also coupon date, so that dirty
450-
price = clean price. Use here because callability is
451-
always applied before coupon in the tree engine.
452-
*/
453-
arguments->callabilityPrices.back() += this->accrued(i->date());
448+
/* Convert clean call price to dirty using accrued interest
449+
at the call date. We ignore ex-coupon conventions here
450+
because the call is an issuer action governed by the
451+
indenture: the holder receives the call price plus accrued
452+
from the last payment date. Using market (ex-coupon) accrued
453+
would create an inconsistency with the tree's continuation
454+
value, which includes future coupons filtered at the
455+
settlement date (see GitHub issue #2236). */
456+
Date callDate = i->date();
457+
Real callAccrued = 0.0;
458+
for (const auto& cf : cashflows_) {
459+
if (!cf->hasOccurred(callDate, false)) {
460+
auto coupon = ext::dynamic_pointer_cast<Coupon>(cf);
461+
if (coupon != nullptr) {
462+
Real acc = coupon->accruedAmount(callDate);
463+
if (coupon->tradingExCoupon(callDate))
464+
acc = coupon->amount() + acc;
465+
callAccrued = acc / notional(callDate) * 100.0;
466+
}
467+
break;
468+
}
469+
}
470+
arguments->callabilityPrices.back() += callAccrued;
454471
}
455472
}
456473
}
@@ -459,27 +476,6 @@ namespace QuantLib {
459476
}
460477

461478

462-
Real CallableBond::accrued(Date settlement) const {
463-
464-
if (settlement == Date()) settlement = settlementDate();
465-
466-
const bool IncludeToday = false;
467-
for (const auto& cashflow : cashflows_) {
468-
// the first coupon paying after d is the one we're after
469-
if (!cashflow->hasOccurred(settlement, IncludeToday)) {
470-
ext::shared_ptr<Coupon> coupon = ext::dynamic_pointer_cast<Coupon>(cashflow);
471-
if (coupon != nullptr)
472-
// !!!
473-
return coupon->accruedAmount(settlement) /
474-
notional(settlement) * 100.0;
475-
else
476-
return 0.0;
477-
}
478-
}
479-
return 0.0;
480-
}
481-
482-
483479
CallableFixedRateBond::CallableFixedRateBond(
484480
Natural settlementDays,
485481
Real faceAmount,

ql/experimental/callablebonds/callablebond.hpp

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -146,15 +146,6 @@ namespace QuantLib {
146146
// helper class for option adjusted spread calculations
147147
class NPVSpreadHelper;
148148

149-
private:
150-
/* Used internally.
151-
same as Bond::accruedAmount() but with enable early
152-
payments true. Forces accrued to be calculated in a
153-
consistent way for future put/ call dates, which can be
154-
problematic in lattice engines when option dates are also
155-
coupon dates.
156-
*/
157-
Real accrued(Date settlement) const;
158149
};
159150

160151
class CallableBond::arguments : public Bond::arguments {

test-suite/callablebonds.cpp

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -950,6 +950,101 @@ BOOST_AUTO_TEST_CASE(testCallableBondOasWithDifferentNotinals) {
950950
<< " clean price with notional 25.0: " << cleanPrice25 << "\n");
951951
}
952952

953+
BOOST_AUTO_TEST_CASE(testOasContinuityThroughExCouponWindow) {
954+
955+
BOOST_TEST_MESSAGE("Testing OAS continuity when call date crosses ex-coupon period...");
956+
957+
/* This is a test case inspired by
958+
* https://github.com/lballabio/QuantLib/issues/2236
959+
*
960+
* When a call date falls between the ex-coupon date and the payment date,
961+
* OAS should vary smoothly (within tree discretization noise). Before the
962+
* fix, OAS jumped from ~+154 bps to ~-513 bps at the ex-coupon boundary
963+
* due to an inconsistency between how the tree continuation value includes
964+
* coupons and how the call price uses negative accrued during ex-coupon. */
965+
966+
auto today = Date(31, January, 2024);
967+
Settings::instance().evaluationDate() = today;
968+
969+
Natural settlementDays = 0;
970+
auto calendar = UnitedStates(UnitedStates::NYSE);
971+
auto dc = Thirty360(Thirty360::BondBasis);
972+
auto bdc = Unadjusted;
973+
auto frequency = Quarterly;
974+
Period exCouponPeriod(14, Days);
975+
976+
auto issueDate = today;
977+
auto maturityDate = Date(31, January, 2029);
978+
Real faceAmount = 100.0;
979+
std::vector<Rate> coupons = {0.06};
980+
Real redemption = 100.0;
981+
982+
Handle<YieldTermStructure> termStructure(
983+
ext::make_shared<FlatForward>(today, 0.04, dc));
984+
auto model = ext::make_shared<HullWhite>(termStructure);
985+
986+
Schedule schedule = MakeSchedule()
987+
.from(issueDate)
988+
.to(maturityDate)
989+
.withFrequency(frequency)
990+
.withCalendar(calendar)
991+
.withConvention(bdc)
992+
.withTerminationDateConvention(bdc)
993+
.backwards()
994+
.endOfMonth(true);
995+
996+
// First coupon payment date after today
997+
Date firstPaymentDate = schedule[1];
998+
Date exCouponDate = firstPaymentDate - exCouponPeriod;
999+
1000+
// Sweep call date from 1 week before ex-coupon to 1 week after payment
1001+
Date sweepStart = exCouponDate - 7 * Days;
1002+
Date sweepEnd = firstPaymentDate + 7 * Days;
1003+
1004+
Real cleanPrice = 100.0;
1005+
Compounding compounding = Compounded;
1006+
1007+
Real maxOas = -QL_MAX_REAL;
1008+
Real minOas = QL_MAX_REAL;
1009+
1010+
for (Date callDate = sweepStart; callDate <= sweepEnd; callDate++) {
1011+
CallabilitySchedule callSchedule;
1012+
callSchedule.push_back(ext::make_shared<Callability>(
1013+
Bond::Price(redemption, Bond::Price::Clean),
1014+
Callability::Call,
1015+
callDate));
1016+
1017+
CallableFixedRateBond bond(
1018+
settlementDays, faceAmount, schedule, coupons, dc,
1019+
bdc, redemption, issueDate, callSchedule,
1020+
exCouponPeriod, NullCalendar());
1021+
bond.setPricingEngine(
1022+
ext::make_shared<TreeCallableFixedRateBondEngine>(
1023+
model, 100, termStructure));
1024+
1025+
Real oas = bond.OAS(cleanPrice, termStructure, dc,
1026+
compounding, frequency) * 10000.0;
1027+
maxOas = std::max(maxOas, oas);
1028+
minOas = std::min(minOas, oas);
1029+
}
1030+
1031+
Real oasRange = maxOas - minOas;
1032+
1033+
// OAS should be reasonably continuous through the ex-coupon window.
1034+
// A range of 50 bps allows for tree discretization noise; the bug
1035+
// produced a range of ~667 bps.
1036+
Real tolerance = 50.0;
1037+
if (oasRange > tolerance)
1038+
BOOST_ERROR("OAS discontinuity across ex-coupon window:\n"
1039+
<< std::setprecision(2) << std::fixed
1040+
<< " min OAS: " << minOas << " bps\n"
1041+
<< " max OAS: " << maxOas << " bps\n"
1042+
<< " range: " << oasRange << " bps\n"
1043+
<< " tolerance: " << tolerance << " bps\n"
1044+
<< " (sweep from " << io::iso_date(sweepStart)
1045+
<< " to " << io::iso_date(sweepEnd) << ")");
1046+
}
1047+
9531048
BOOST_AUTO_TEST_SUITE_END()
9541049

9551050
BOOST_AUTO_TEST_SUITE_END()

0 commit comments

Comments
 (0)