From 5ed0849bc4b35bf203c601454e8ef45a779011c5 Mon Sep 17 00:00:00 2001 From: LeonxLJX <51880185+LeonxLJX@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:12:14 +0800 Subject: [PATCH 1/2] Fix convertible-bond dividend drop between evaluation and settlement dates In BinomialConvertibleEngine::calculate(), future dividends are subtracted from s0 using the risk-free rate's referenceDate (the evaluation date) as cutoff, but the tree and DiscretizedConvertible anchor all dividend/coupon/callability times at arguments_.settlementDate (evaluation date + settlementDays). A dividend falling strictly between the evaluation date and the settlement date was subtracted from s0 but treated as 'already occurred' inside the tree and never added back, so its value silently vanished from the price. Roll the dividend-adjusted spot forward to the settlement date by dividing by the settlement-date discount factor, so the tree starts from the correct settlement-date forward price and the in-lag dividend is properly priced. Adds a regression test that asserts an in-lag dividend reduces the convertible bond price by an economically meaningful amount. Closes #2701 (Issue B) --- .../bond/binomialconvertibleengine.hpp | 10 ++ test-suite/convertiblebonds.cpp | 91 +++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/ql/pricingengines/bond/binomialconvertibleengine.hpp b/ql/pricingengines/bond/binomialconvertibleengine.hpp index ff7acbd530e..e0f7d5fe8e5 100644 --- a/ql/pricingengines/bond/binomialconvertibleengine.hpp +++ b/ql/pricingengines/bond/binomialconvertibleengine.hpp @@ -100,6 +100,16 @@ namespace QuantLib { QL_REQUIRE(s0 > 0.0, "negative value after subtracting dividends"); + // Roll the dividend-adjusted spot forward to the settlement date. + // The tree and DiscretizedConvertible anchor all dividend/coupon/ + // callability times at arguments_.settlementDate (see issue #2701), + // so a dividend falling between referenceDate and settlementDate is + // subtracted from s0 above but treated as "already occurred" inside + // the tree and never added back. Dividing by the settlement-date + // discount factor rolls the (ex-dividend) spot forward so the tree + // starts from the correct settlement-date forward price. + s0 /= process_->riskFreeRate()->discount(arguments_.settlementDate); + // binomial trees with constant coefficient Handle underlying(ext::make_shared(s0)); Handle flatRiskFree(ext::make_shared(referenceDate, riskFreeRate, rfdc)); diff --git a/test-suite/convertiblebonds.cpp b/test-suite/convertiblebonds.cpp index 9f4e4b83586..1cb8ca07bcb 100644 --- a/test-suite/convertiblebonds.cpp +++ b/test-suite/convertiblebonds.cpp @@ -386,6 +386,97 @@ BOOST_AUTO_TEST_CASE(testDividendsSpanningSettlementDate) { BOOST_CHECK_CLOSE(convertible.dividendValues()[0], expected, 1.0e-12); } +BOOST_AUTO_TEST_CASE(testDividendBetweenEvaluationAndSettlement) { + BOOST_TEST_MESSAGE( + "Testing that a dividend between evaluation and settlement date " + "is not silently dropped from the convertible bond price (issue #2701)..."); + + // Evaluation date with a non-zero settlement lag so that a dividend + // can fall strictly between the evaluation date and the settlement date. + Date today = Date(15, January, 2024); + Settings::instance().evaluationDate() = today; + + Calendar calendar = TARGET(); + DayCounter dayCounter = Actual360(); + Natural settlementDays = 3; + Date settlementDate = calendar.advance(today, settlementDays, Days); + + // A dividend strictly between the evaluation date and settlement date. + Date exDivDate = calendar.advance(today, 1, Days); + QL_REQUIRE(exDivDate > today && exDivDate < settlementDate, + "test setup: ex-div date must fall in the settlement lag"); + Real dividendAmount = 2.0; + + // Build a simple zero-coupon convertible bond. + Date maturityDate = calendar.advance(today, 2, Years); + Real faceAmount = 100.0; + Real redemption = 100.0; + Real conversionRatio = 1.0; + Real spot = 50.0; + + Handle underlying(ext::make_shared(spot)); + Handle dividendYield( + ext::make_shared(today, 0.0, dayCounter)); + Handle riskFreeRate( + ext::make_shared(today, 0.05, dayCounter)); + Handle volatility( + ext::make_shared(today, calendar, 0.20, dayCounter)); + auto process = ext::make_shared( + underlying, dividendYield, riskFreeRate, volatility); + + Handle creditSpread(ext::make_shared(0.0)); + + Schedule schedule(maturityDate - 1*Years, maturityDate, + Period(Annual), calendar, + Unadjusted, Unadjusted, + DateGeneration::Backward, false); + + auto exercise = ext::make_shared(maturityDate); + ConvertibleZeroCouponBond bond(exercise, conversionRatio, + CallabilitySchedule(), + today, settlementDays, + dayCounter, schedule, + redemption); + + DividendSchedule dividendsWith = { + ext::make_shared(dividendAmount, exDivDate)}; + DividendSchedule dividendsEmpty; + + auto engineWith = ext::make_shared >( + process, 500, creditSpread, dividendsWith); + auto engineWithout = ext::make_shared >( + process, 500, creditSpread, dividendsEmpty); + + bond.setPricingEngine(engineWith); + Real priceWithDividend = bond.NPV(); + + bond.setPricingEngine(engineWithout); + Real priceWithoutDividend = bond.NPV(); + + // The in-lag dividend must reduce the price: before the fix it was + // subtracted from s0 but never added back inside the tree (which is + // anchored at settlementDate), so its value silently vanished. + if (priceWithDividend >= priceWithoutDividend) { + BOOST_ERROR("in-lag dividend did not reduce the convertible bond price:" + << "\n with dividend: " << priceWithDividend + << "\n without dividend: " << priceWithoutDividend + << "\n expected: with < without (dividend value must not vanish)"); + } + + // The price drop should be economically meaningful: roughly the dividend + // amount rolled forward to the settlement date (zero rates here, so + // approximately the dividend amount itself). We use a wide tolerance + // because the convertible's optionality dampens the spot sensitivity. + Real expectedDrop = dividendAmount * conversionRatio; + Real actualDrop = priceWithoutDividend - priceWithDividend; + if (actualDrop < 0.5 * expectedDrop) { + BOOST_ERROR("in-lag dividend price drop is too small:" + << "\n actual drop: " << actualDrop + << "\n expected (~): " << expectedDrop + << "\n the dividend value is likely still being silently dropped"); + } +} + BOOST_AUTO_TEST_CASE(testRegression) { BOOST_TEST_MESSAGE( From a56dfa46faecc297ac5d60ed3b80297f75153958 Mon Sep 17 00:00:00 2001 From: LeonxLJX <51880185+LeonxLJX@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:47:43 +0800 Subject: [PATCH 2/2] docs(binomialconvertibleengine): clarify spot-forward rationale Per lballabio's review on #2752: drop the misleading framing that motivates the discount-factor roll-forward by the dividend schedule. The real reason is that the tree and DiscretizedConvertible anchor everything at arguments_.settlementDate, so the engine must hand the lattice the settlement-date forward price of the underlying rather than the evaluation-date spot. Keep the dividend-subtraction comment as well: a dividend falling strictly between evaluation and settlement is subtracted from s0 above and never re-added, because by settlement the tree sees it as already paid. --- .../bond/binomialconvertibleengine.hpp | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/ql/pricingengines/bond/binomialconvertibleengine.hpp b/ql/pricingengines/bond/binomialconvertibleengine.hpp index e0f7d5fe8e5..f31177946e4 100644 --- a/ql/pricingengines/bond/binomialconvertibleengine.hpp +++ b/ql/pricingengines/bond/binomialconvertibleengine.hpp @@ -90,7 +90,12 @@ namespace QuantLib { maturityDate, divdc, Continuous, NoFrequency); Date referenceDate = process_->riskFreeRate()->referenceDate(); - // subtract dividends + // Subtract every dividend that has not yet occurred as of the + // evaluation date. Note that a dividend falling strictly between the + // evaluation date and the settlement date is subtracted here but never + // added back: the tree prices the bond at the settlement date, where + // the underlying already reflects that dividend as already paid, so + // dropping it on the spot is the correct thing to do. Size i; for (i=0; idate() >= referenceDate) @@ -100,14 +105,13 @@ namespace QuantLib { QL_REQUIRE(s0 > 0.0, "negative value after subtracting dividends"); - // Roll the dividend-adjusted spot forward to the settlement date. - // The tree and DiscretizedConvertible anchor all dividend/coupon/ - // callability times at arguments_.settlementDate (see issue #2701), - // so a dividend falling between referenceDate and settlementDate is - // subtracted from s0 above but treated as "already occurred" inside - // the tree and never added back. Dividing by the settlement-date - // discount factor rolls the (ex-dividend) spot forward so the tree - // starts from the correct settlement-date forward price. + // Roll the spot forward from the evaluation date to the settlement + // date by dividing out the discount factor between the two. This is + // correct even in the absence of any dividends: the tree and + // DiscretizedConvertible anchor all dividend/coupon/callability times + // at arguments_.settlementDate (see issue #2701), so the spot the + // tree prices from must be the settlement-date forward price, not the + // evaluation-date spot. s0 /= process_->riskFreeRate()->discount(arguments_.settlementDate); // binomial trees with constant coefficient