From f5aba9a74fe7ac61c3c1ecdce7e3ed70743b417c Mon Sep 17 00:00:00 2001 From: "excal.rs" Date: Thu, 9 Jul 2026 23:21:35 +0100 Subject: [PATCH] Calculate spot dates from the actual evaluation date Settlement and fixing days are counted from the actual evaluation date, without first rolling it to a business day: Calendar::advance already skips non-business days, so the pre-adjustment added an extra business day whenever the evaluation date was a holiday or weekend. The adjustment is removed from MakeVanillaSwap and MakeOIS (when explicit settlement days are given) and from DepositRateHelper, FraRateHelper and BMASwapRateHelper. MakeVanillaSwap and MakeOIS also gain an optional settlement calendar, defaulting to the float and overnight calendar respectively, since the calendar used for the settlement-day advance is not always the index calendar (e.g. USD SOFR swaps). --- ql/instruments/makeois.cpp | 14 ++++-- ql/instruments/makeois.hpp | 2 + ql/instruments/makevanillaswap.cpp | 14 ++++-- ql/instruments/makevanillaswap.hpp | 2 + ql/termstructures/yield/ratehelpers.cpp | 26 ++++------ test-suite/overnightindexedswap.cpp | 65 ++++++++++++++++++++++++- test-suite/piecewiseyieldcurve.cpp | 51 +++++++++++++++++++ test-suite/swap.cpp | 61 +++++++++++++++++++++++ 8 files changed, 209 insertions(+), 26 deletions(-) diff --git a/ql/instruments/makeois.cpp b/ql/instruments/makeois.cpp index c499463a401..791f188f1ad 100644 --- a/ql/instruments/makeois.cpp +++ b/ql/instruments/makeois.cpp @@ -69,10 +69,11 @@ namespace QuantLib { } Date refDate = Settings::instance().evaluationDate(); - // if the evaluation date is not a business day - // then move to the next business day - refDate = overnightCalendar_.adjust(refDate); - Date spotDate = overnightCalendar_.advance(refDate, + // settlement days are counted from the actual evaluation + // date, even when it is not a business day (see issue #753) + const Calendar& settlementCalendar = + settlementCalendar_.empty() ? overnightCalendar_ : settlementCalendar_; + Date spotDate = settlementCalendar.advance(refDate, settlementDays*Days); startDate = spotDate+forwardStart_; if (forwardStart_.length()<0) @@ -202,6 +203,11 @@ namespace QuantLib { return *this; } + MakeOIS& MakeOIS::withSettlementCalendar(const Calendar& cal) { + settlementCalendar_ = cal; + return *this; + } + MakeOIS& MakeOIS::withEffectiveDate(const Date& effectiveDate) { effectiveDate_ = effectiveDate; return *this; diff --git a/ql/instruments/makeois.hpp b/ql/instruments/makeois.hpp index 05c4ba402bf..d9a63e6b1f7 100644 --- a/ql/instruments/makeois.hpp +++ b/ql/instruments/makeois.hpp @@ -51,6 +51,7 @@ namespace QuantLib { MakeOIS& withNominal(Real n); MakeOIS& withSettlementDays(Natural settlementDays); + MakeOIS& withSettlementCalendar(const Calendar& cal); MakeOIS& withEffectiveDate(const Date&); MakeOIS& withTerminationDate(const Date&); MakeOIS& withRule(DateGeneration::Rule r); @@ -104,6 +105,7 @@ namespace QuantLib { Natural settlementDays_ = Null(); Date effectiveDate_, terminationDate_; Calendar fixedCalendar_, overnightCalendar_; + Calendar settlementCalendar_; Frequency fixedPaymentFrequency_ = Annual; Frequency overnightPaymentFrequency_ = Annual; diff --git a/ql/instruments/makevanillaswap.cpp b/ql/instruments/makevanillaswap.cpp index 23a62e9397c..3a25b8eb62c 100644 --- a/ql/instruments/makevanillaswap.cpp +++ b/ql/instruments/makevanillaswap.cpp @@ -76,10 +76,11 @@ namespace QuantLib { refDate = iborIndex_->fixingCalendar().adjust(refDate); spotDate = iborIndex_->valueDate(refDate); } else { - // an explicit settlement-day count is advanced on the - // float/payment calendar, so adjust the reference date there. - refDate = floatCalendar_.adjust(refDate); - spotDate = floatCalendar_.advance(refDate, settlementDays_ * Days); + // settlement days are counted from the actual evaluation + // date, even when it is not a business day (see issue #753) + const Calendar& settlementCalendar = + settlementCalendar_.empty() ? floatCalendar_ : settlementCalendar_; + spotDate = settlementCalendar.advance(refDate, settlementDays_ * Days); } startDate = spotDate+forwardStart_; if (forwardStart_.length()<0) @@ -221,6 +222,11 @@ namespace QuantLib { return *this; } + MakeVanillaSwap& MakeVanillaSwap::withSettlementCalendar(const Calendar& cal) { + settlementCalendar_ = cal; + return *this; + } + MakeVanillaSwap& MakeVanillaSwap::withEffectiveDate(const Date& effectiveDate) { effectiveDate_ = effectiveDate; diff --git a/ql/instruments/makevanillaswap.hpp b/ql/instruments/makevanillaswap.hpp index 4de6432b244..f64ff455b91 100644 --- a/ql/instruments/makevanillaswap.hpp +++ b/ql/instruments/makevanillaswap.hpp @@ -51,6 +51,7 @@ namespace QuantLib { MakeVanillaSwap& withNominal(Real n); MakeVanillaSwap& withSettlementDays(Natural settlementDays); + MakeVanillaSwap& withSettlementCalendar(const Calendar& cal); MakeVanillaSwap& withEffectiveDate(const Date&); MakeVanillaSwap& withTerminationDate(const Date&); MakeVanillaSwap& withRule(DateGeneration::Rule r); @@ -95,6 +96,7 @@ namespace QuantLib { Natural settlementDays_ = Null(); Date effectiveDate_, terminationDate_; Calendar fixedCalendar_, floatCalendar_; + Calendar settlementCalendar_; Swap::Type type_ = Swap::Payer; Real nominal_ = 1.0; diff --git a/ql/termstructures/yield/ratehelpers.cpp b/ql/termstructures/yield/ratehelpers.cpp index acfcc30bf29..5dedee1c98e 100644 --- a/ql/termstructures/yield/ratehelpers.cpp +++ b/ql/termstructures/yield/ratehelpers.cpp @@ -227,11 +227,10 @@ namespace QuantLib { void DepositRateHelper::initializeDates() { if (updateDates_) { - // if the evaluation date is not a business day - // then move to the next business day - Date referenceDate = - iborIndex_->fixingCalendar().adjust(evaluationDate_); - earliestDate_ = iborIndex_->valueDate(referenceDate); + // fixing days are counted from the actual evaluation date, + // even when it is not a business day (see issue #753) + earliestDate_ = iborIndex_->fixingCalendar().advance( + evaluationDate_, iborIndex_->fixingDays() * Days); fixingDate_ = iborIndex_->fixingDate(earliestDate_); } else { earliestDate_ = iborIndex_->valueDate(fixingDate_); @@ -392,12 +391,10 @@ namespace QuantLib { void FraRateHelper::initializeDates() { if (updateDates_) { - // if the evaluation date is not a business day - // then move to the next business day - Date referenceDate = - iborIndex_->fixingCalendar().adjust(evaluationDate_); + // fixing days are counted from the actual evaluation date, + // even when it is not a business day (see issue #753) Date spotDate = iborIndex_->fixingCalendar().advance( - referenceDate, iborIndex_->fixingDays()*Days); + evaluationDate_, iborIndex_->fixingDays()*Days); if (periodToStart_) { // NOLINT(readability-implicit-bool-conversion) earliestDate_ = iborIndex_->fixingCalendar().advance( spotDate, *periodToStart_, iborIndex_->businessDayConvention(), @@ -673,13 +670,10 @@ namespace QuantLib { } void BMASwapRateHelper::initializeDates() { - // if the evaluation date is not a business day - // then move to the next business day - JointCalendar jc(calendar_, - iborIndex_->fixingCalendar()); - Date referenceDate = jc.adjust(evaluationDate_); + // settlement days are counted from the actual evaluation date, + // even when it is not a business day (see issue #753) earliestDate_ = - calendar_.advance(referenceDate, settlementDays_ * Days, Following); + calendar_.advance(evaluationDate_, settlementDays_ * Days, Following); Date maturity = earliestDate_ + tenor_; diff --git a/test-suite/overnightindexedswap.cpp b/test-suite/overnightindexedswap.cpp index a065295afe9..dabe889e9ed 100644 --- a/test-suite/overnightindexedswap.cpp +++ b/test-suite/overnightindexedswap.cpp @@ -44,6 +44,7 @@ #include #include #include +#include #include #include #include @@ -1012,14 +1013,14 @@ BOOST_AUTO_TEST_CASE(testMakeOISDefaultSettlementDays) { // Test 1-day settlement index on weekend { OvernightIndexedSwap swap = MakeOIS(6 * Months, indices[1].second, 0.01); // CORRA - Date expected(13, May, 2025); // Tuesday + Date expected(12, May, 2025); // Monday: T+1 from the actual trade date BOOST_CHECK_EQUAL(swap.startDate(), expected); } // Test 2-day settlement index on weekend { OvernightIndexedSwap swap = MakeOIS(6 * Months, indices[2].second, 0.01); // EONIA - Date expected(14, May, 2025); // Wednesday + Date expected(13, May, 2025); // Tuesday: T+2 from the actual trade date BOOST_CHECK_EQUAL(swap.startDate(), expected); } } @@ -1093,6 +1094,66 @@ BOOST_AUTO_TEST_CASE(testSettlementDaysEffectiveDateConflict) { BOOST_CHECK(swap3->startDate() != Date()); } +BOOST_AUTO_TEST_CASE(testSpotDateFromNonBusinessEvaluationDate) { + + BOOST_TEST_MESSAGE("Testing that the OIS spot date is calculated from " + "the actual evaluation date when the latter is not a " + "business day..."); + + // Saturday + Date today(20, June, 2026); + Settings::instance().evaluationDate() = today; + + auto index = ext::make_shared(); + Calendar calendar = index->fixingCalendar(); + + // settlement days are counted from the actual trade date, + // not from the next business day + Date expectedStart = calendar.advance(today, 2 * Days); + + ext::shared_ptr swap = + MakeOIS(1 * Years, index, 0.03).withSettlementDays(2); + + if (swap->startDate() != expectedStart) + BOOST_FAIL("OIS start date not calculated from the actual " + "evaluation date:\n" + " expected: " << expectedStart << "\n" + " obtained: " << swap->startDate()); +} + +BOOST_AUTO_TEST_CASE(testSettlementCalendar) { + + BOOST_TEST_MESSAGE("Testing that the OIS spot date can be calculated " + "on an explicit settlement calendar..."); + + // 3 July 2026 is a TARGET business day, but a US holiday + // (Independence Day observed), so the settlement calendar and the + // index fixing calendar diverge between here and the spot date. + Date today(2, July, 2026); + Settings::instance().evaluationDate() = today; + + auto index = ext::make_shared(); + Calendar settlementCalendar = + JointCalendar(TARGET(), UnitedStates(UnitedStates::Settlement)); + + Date expectedStart = settlementCalendar.advance(today, 2 * Days); + + ext::shared_ptr swap = + MakeOIS(1 * Years, index, 0.03) + .withSettlementDays(2) + .withSettlementCalendar(settlementCalendar); + + if (swap->startDate() != expectedStart) + BOOST_FAIL("OIS start date not calculated on the settlement " + "calendar:\n" + " expected: " << expectedStart << "\n" + " obtained: " << swap->startDate()); + + // sanity check: the two calendars must actually diverge here + BOOST_CHECK(expectedStart != + index->fixingCalendar().advance(today, 2 * Days)); +} + BOOST_AUTO_TEST_SUITE_END() BOOST_AUTO_TEST_SUITE_END() diff --git a/test-suite/piecewiseyieldcurve.cpp b/test-suite/piecewiseyieldcurve.cpp index e0b6412d9c8..e13f7cbaf19 100644 --- a/test-suite/piecewiseyieldcurve.cpp +++ b/test-suite/piecewiseyieldcurve.cpp @@ -2337,6 +2337,57 @@ BOOST_AUTO_TEST_CASE(testSwapRateHelperWithCouponPricer) { } } +BOOST_AUTO_TEST_CASE(testHelperDatesFromNonBusinessEvaluationDate) { + + BOOST_TEST_MESSAGE("Testing that rate-helper dates are calculated from " + "the actual evaluation date when the latter is not a " + "business day..."); + + // Saturday + Date today(20, June, 2026); + Settings::instance().evaluationDate() = today; + + auto euribor6m = ext::make_shared(); + Calendar calendar = euribor6m->fixingCalendar(); + // fixing days are counted from the actual evaluation date, + // not from the next business day + Date expectedSpot = calendar.advance(today, euribor6m->fixingDays() * Days); + + auto depo = ext::make_shared( + 0.03, 6 * Months, euribor6m->fixingDays(), calendar, + euribor6m->businessDayConvention(), euribor6m->endOfMonth(), + euribor6m->dayCounter()); + if (depo->earliestDate() != expectedSpot) + BOOST_ERROR("deposit helper earliest date not calculated from the " + "actual evaluation date:\n" + " expected: " << expectedSpot << "\n" + " obtained: " << depo->earliestDate()); + + auto fra = ext::make_shared(0.03, 3, euribor6m); + Date expectedFraStart = + calendar.advance(expectedSpot, 3 * Months, + euribor6m->businessDayConvention(), + euribor6m->endOfMonth()); + if (fra->earliestDate() != expectedFraStart) + BOOST_ERROR("FRA helper earliest date not calculated from the " + "actual evaluation date:\n" + " expected: " << expectedFraStart << "\n" + " obtained: " << fra->earliestDate()); + + Calendar bmaCalendar = JointCalendar(BMAIndex().fixingCalendar(), + USDLibor(3 * Months).fixingCalendar()); + auto bmaHelper = ext::make_shared( + Handle(ext::make_shared(0.75)), + 5 * Years, 3, bmaCalendar, Period(Quarterly), Following, Actual360(), + ext::make_shared(), ext::make_shared(3 * Months)); + Date expectedBmaStart = bmaCalendar.advance(today, 3 * Days, Following); + if (bmaHelper->earliestDate() != expectedBmaStart) + BOOST_ERROR("BMA helper earliest date not calculated from the " + "actual evaluation date:\n" + " expected: " << expectedBmaStart << "\n" + " obtained: " << bmaHelper->earliestDate()); +} + BOOST_AUTO_TEST_SUITE_END() BOOST_AUTO_TEST_SUITE_END() diff --git a/test-suite/swap.cpp b/test-suite/swap.cpp index d53a8642c35..bbe0df781d6 100644 --- a/test-suite/swap.cpp +++ b/test-suite/swap.cpp @@ -41,6 +41,7 @@ #include #include #include +#include #include #include @@ -600,6 +601,66 @@ BOOST_AUTO_TEST_CASE(testSpotDateUsesFixingCalendar) { BOOST_CHECK(buggyStart != expectedStart); } +BOOST_AUTO_TEST_CASE(testSpotDateFromNonBusinessEvaluationDate) { + + BOOST_TEST_MESSAGE("Testing that the swap spot date is calculated from " + "the actual evaluation date when the latter is not a " + "business day..."); + + // Saturday + Date today(20, June, 2026); + Settings::instance().evaluationDate() = today; + + auto index = ext::make_shared(); + Calendar calendar = index->fixingCalendar(); + + // settlement days are counted from the actual trade date, + // not from the next business day + Date expectedStart = calendar.advance(today, 2 * Days); + + ext::shared_ptr swap = + MakeVanillaSwap(5 * Years, index, 0.03).withSettlementDays(2); + + if (swap->startDate() != expectedStart) + BOOST_FAIL("swap start date not calculated from the actual " + "evaluation date:\n" + " expected: " << expectedStart << "\n" + " obtained: " << swap->startDate()); +} + +BOOST_AUTO_TEST_CASE(testSettlementCalendar) { + + BOOST_TEST_MESSAGE("Testing that the swap spot date can be calculated " + "on an explicit settlement calendar..."); + + // 3 July 2026 is a TARGET business day, but a US holiday + // (Independence Day observed), so the settlement calendar and the + // index fixing calendar diverge between here and the spot date. + Date today(2, July, 2026); + Settings::instance().evaluationDate() = today; + + auto index = ext::make_shared(); + Calendar settlementCalendar = + JointCalendar(TARGET(), UnitedStates(UnitedStates::Settlement)); + + Date expectedStart = settlementCalendar.advance(today, 2 * Days); + + ext::shared_ptr swap = + MakeVanillaSwap(5 * Years, index, 0.03) + .withSettlementDays(2) + .withSettlementCalendar(settlementCalendar); + + if (swap->startDate() != expectedStart) + BOOST_FAIL("swap start date not calculated on the settlement " + "calendar:\n" + " expected: " << expectedStart << "\n" + " obtained: " << swap->startDate()); + + // sanity check: the two calendars must actually diverge here + BOOST_CHECK(expectedStart != + index->fixingCalendar().advance(today, 2 * Days)); +} + BOOST_AUTO_TEST_SUITE_END() BOOST_AUTO_TEST_SUITE_END()