From 06246d53e30ae7d8d35c841a6dfcd13f6fd43e93 Mon Sep 17 00:00:00 2001 From: Kiril1Shevtsov Date: Wed, 11 Nov 2026 22:15:33 +0300 Subject: [PATCH] Include daily-compounded spreads in forecast overnight coupons --- ql/cashflows/overnightindexedcoupon.cpp | 5 + ql/cashflows/overnightindexedcouponpricer.cpp | 3 +- test-suite/overnightindexedcoupon.cpp | 97 +++++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/ql/cashflows/overnightindexedcoupon.cpp b/ql/cashflows/overnightindexedcoupon.cpp index 2c7c4fa205b..207fb5957fa 100644 --- a/ql/cashflows/overnightindexedcoupon.cpp +++ b/ql/cashflows/overnightindexedcoupon.cpp @@ -103,6 +103,11 @@ const std::optional& roundingPrecision) QL_REQUIRE(canApplyTelescopicFormula() || !telescopicValueDates, "Telescopic formula cannot be applied for a coupon with lookback."); + // A discount-factor ratio omits a daily compounded spread. + // Keep every overnight period for the exact product in the pricer. + if (averagingMethod_ == RateAveraging::Compound && compoundSpreadDaily_ && spread != 0.0) + telescopicValueDates = false; + const auto& fixingCal = overnightIndex->fixingCalendar(); if (telescopicValueDates) { // build optimised value dates schedule: front stub goes from rateCalcStartDate diff --git a/ql/cashflows/overnightindexedcouponpricer.cpp b/ql/cashflows/overnightindexedcouponpricer.cpp index c5290f1c07a..775d9279bfd 100644 --- a/ql/cashflows/overnightindexedcouponpricer.cpp +++ b/ql/cashflows/overnightindexedcouponpricer.cpp @@ -174,7 +174,8 @@ namespace QuantLib { const auto projGrowthFactor = [&](Size i) { return growthFactor(index->fixing(fixingDates[i]), i); }; - if (coupon_->canApplyTelescopicFormula()) { + if (coupon_->canApplyTelescopicFormula() && + (!compoundSpreadDaily || couponSpread == 0.0)) { // handle partial accrual of first fixing if the first interestDate lands on a fixing holiday const Size telescopicStartIdx = i == 0 && !applyObservationShift && (valueDates.front() < interestDates.front()) ? 1 : i; diff --git a/test-suite/overnightindexedcoupon.cpp b/test-suite/overnightindexedcoupon.cpp index 78fa9150e62..2396292302f 100644 --- a/test-suite/overnightindexedcoupon.cpp +++ b/test-suite/overnightindexedcoupon.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -37,6 +38,7 @@ #include #include #include +#include #include using namespace QuantLib; @@ -1019,6 +1021,101 @@ BOOST_AUTO_TEST_CASE(testOvernightLegWithGearingsAndSpreads) { } } } + +BOOST_AUTO_TEST_CASE(testDailyCompoundedSpreadForecast) { + BOOST_TEST_MESSAGE("Testing daily compounded spreads against independent overnight " + "products..."); + + const Date start(1, October, 2026), end(20, November, 2026); + const WeekendsOnly calendar; + const Actual360 dc; + const Real nominal = 10000.0; + auto curve = ext::make_shared( + std::vector{Date(1, September, 2026), start, Date(15, October, 2026), + Date(5, November, 2026), end, Date(1, December, 2027)}, + std::vector{1.0, 0.9975, 0.9960, 0.9945, 0.9935, 0.9620}, dc); + const Handle handle(curve); + + // Enumerate contractual business-day periods, independently of coupon dates. + std::vector dates(1, start); + while (dates.back() < end) + dates.push_back(calendar.advance(dates.back(), 1, Days)); + const Size n = dates.size() - 1; + + for (const Date& today : std::vector{Date(1, September, 2026), + Date(21, October, 2026), + Date(1, December, 2026)}) { + Settings::instance().evaluationDate() = today; + for (Natural lookback : {0, 2}) { + const bool shift = lookback != 0; + auto index = ext::make_shared( + "DailySpreadProduct" + std::to_string(today.serialNumber()) + + "_" + std::to_string(lookback), + 0, USDCurrency(), calendar, dc, handle); + std::vector observed; + for (const Date& d : dates) + observed.push_back(calendar.advance(d, -Integer(lookback), Days)); + std::vector rates(n); + for (Size i = 0; i < n; ++i) { + if (observed[i] <= today) { + rates[i] = 0.021 + 0.00007 * Real(i); + index->addFixing(observed[i], rates[i]); + } else { + const Date next = calendar.advance(observed[i], 1, Days); + rates[i] = (curve->discount(observed[i]) / curve->discount(next) - 1.0) / + dc.yearFraction(observed[i], next); + } + } + const auto& accrualDates = shift ? observed : dates; + for (Natural lockout : {0, 3}) { + for (Spread spread : std::vector{0.005, -0.005}) { + long double product = 1.0L, indexProduct = 1.0L; + for (Size i = 0; i < n; ++i) { + const Size fixing = i >= n - lockout ? n - lockout - 1 : i; + const Time weight = dc.yearFraction(accrualDates[i], accrualDates[i + 1]); + product *= 1.0L + + (static_cast(rates[fixing]) + spread) * weight; + indexProduct *= 1.0L + static_cast(rates[fixing]) * weight; + } + const Time tau = dc.yearFraction(accrualDates.front(), accrualDates.back()); + const Rate expected = Real((product - 1.0L) / tau); + const Rate expectedIndex = Real((indexProduct - 1.0L) / tau); + for (bool telescopic : {false, true}) { + OvernightIndexedCoupon coupon(end, nominal, start, end, index, 1.0, spread, + Date(), Date(), dc, telescopic, RateAveraging::Compound, + lookback, lockout, shift, true); + QL_CHECK_SMALL(coupon.rate() - expected, 2e-11); + QL_CHECK_SMALL(coupon.effectiveIndexFixing() - expectedIndex, 2e-11); + QL_CHECK_SMALL(coupon.effectiveSpread() - (expected - expectedIndex), + 2e-11); + QL_CHECK_SMALL( + coupon.amount() - nominal * dc.yearFraction(start, end) * expected, + 2e-7); + + // Unshifted partial periods include only elapsed calendar days, + // including a cutoff within a Friday-to-Monday deposit. + if (!shift) { + for (const Date& cutoff : std::vector{Date(6, October, 2026), + Date(18, October, 2026)}) { + long double partial = 1.0L; + for (Size i = 0; i < n && dates[i] < cutoff; ++i) { + const Size fixing = i >= n - lockout ? n - lockout - 1 : i; + const Time weight = dc.yearFraction(dates[i], + std::min(dates[i + 1], cutoff)); + partial *= 1.0L + + (static_cast(rates[fixing]) + spread) * weight; + } + QL_CHECK_SMALL(coupon.accruedAmount(cutoff) - + nominal * Real(partial - 1.0L), 2e-7); + } + } + } + } + } + } + } +} + BOOST_AUTO_TEST_CASE(testOvernightLegNPV) { BOOST_TEST_MESSAGE("Testing overnight leg NPV...");