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()