diff --git a/ql/instruments/creditdefaultswap.cpp b/ql/instruments/creditdefaultswap.cpp index 9dbee7500c1..043dc649448 100644 --- a/ql/instruments/creditdefaultswap.cpp +++ b/ql/instruments/creditdefaultswap.cpp @@ -84,8 +84,8 @@ namespace QuantLib { } void CreditDefaultSwap::init(const Schedule& schedule, BusinessDayConvention paymentConvention, - const DayCounter& dayCounter, const DayCounter& lastPeriodDayCounter, - bool rebatesAccrual, const Date& upfrontDate) { + const DayCounter& dayCounter, const DayCounter& lastPeriodDayCounter, + bool rebatesAccrual, const Date& upfrontDate) { QL_REQUIRE(!schedule.empty(), "CreditDefaultSwap needs a non-empty schedule."); @@ -120,8 +120,8 @@ namespace QuantLib { effectiveUpfrontDate = schedule.calendar().advance(tradeDate_, cashSettlementDays_, Days, paymentConvention); } - QL_REQUIRE(effectiveUpfrontDate >= protectionStart_, "The cash settlement date must not " << - "be before the protection start date."); + QL_REQUIRE(effectiveUpfrontDate >= protectionStart_, + "The cash settlement date must not be before the protection start date."); // Create the upfront payment, if one is provided. Real upfrontAmount = 0.0; @@ -142,20 +142,25 @@ namespace QuantLib { if (tradeDate_ >= schedule.dates().front()) { for (Size i = 0; i < leg_.size(); ++i) { const ext::shared_ptr& cf = leg_[i]; - if (refDate < cf->date()) { - // Calculate the accrual. The most likely scenario. - ext::shared_ptr frc = ext::dynamic_pointer_cast(cf); - rebateAmount = frc->accruedAmount(refDate); - break; - } else if (refDate == cf->date() && i < leg_.size() - 1) { - // If not the last coupon and trade date + 1 is the next coupon payment date, - // the accrual is 0 so do nothing. + if (refDate > cf->date()) { + // This coupon is in the past; check the next one + continue; + } else if (refDate == cf->date()) { + // This coupon pays at the reference date. + // If it's not the last coupon, the accrual is 0 so do nothing. + if (i < leg_.size() - 1) + rebateAmount = 0.0; + else { + // On last coupon + ext::shared_ptr frc = ext::dynamic_pointer_cast(cf); + rebateAmount = frc->amount(); + } break; } else { - // Must have trade date + 1 >= last coupon's payment date. '>' here probably does not make - // sense - should possibly have an exception above if trade date >= last coupon's date. + // This coupon pays in the future, and is the first coupon to do so (since they're sorted). + // Calculate the accrual and skip further coupons ext::shared_ptr frc = ext::dynamic_pointer_cast(cf); - rebateAmount = frc->amount(); + rebateAmount = frc->accruedAmount(refDate); break; } } diff --git a/ql/instruments/makecds.cpp b/ql/instruments/makecds.cpp index fb92e99d7a0..65ebe04d898 100644 --- a/ql/instruments/makecds.cpp +++ b/ql/instruments/makecds.cpp @@ -46,7 +46,7 @@ namespace QuantLib { MakeCreditDefaultSwap::operator ext::shared_ptr() const { - Date tradeDate = Settings::instance().evaluationDate(); + Date tradeDate = (tradeDate_ != Null()) ? tradeDate_ : Settings::instance().evaluationDate(); Date upfrontDate = WeekendsOnly().advance(tradeDate, cashSettlementDays_, Days); Date protectionStart; @@ -131,4 +131,10 @@ namespace QuantLib { engine_ = engine; return *this; } + + MakeCreditDefaultSwap& MakeCreditDefaultSwap::withTradeDate(const Date& tradeDate) { + tradeDate_ = tradeDate; + return *this; + } + } diff --git a/ql/instruments/makecds.hpp b/ql/instruments/makecds.hpp index 99156aeed3f..ee4e2b6e3cb 100644 --- a/ql/instruments/makecds.hpp +++ b/ql/instruments/makecds.hpp @@ -53,6 +53,8 @@ namespace QuantLib { MakeCreditDefaultSwap& withPricingEngine(const ext::shared_ptr&); + MakeCreditDefaultSwap& withTradeDate(const Date& tradeDate); + private: Protection::Side side_; Real nominal_; @@ -65,6 +67,7 @@ namespace QuantLib { DayCounter lastPeriodDayCounter_; DateGeneration::Rule rule_; Natural cashSettlementDays_; + Date tradeDate_; ext::shared_ptr engine_; }; diff --git a/ql/pricingengines/credit/isdacdsengine.cpp b/ql/pricingengines/credit/isdacdsengine.cpp index dc4a67bf243..f325ce46977 100644 --- a/ql/pricingengines/credit/isdacdsengine.cpp +++ b/ql/pricingengines/credit/isdacdsengine.cpp @@ -307,14 +307,19 @@ namespace QuantLib { arguments_.accrualRebate->amount(); } - Real upfrontSign = Protection::Seller != 0U ? 1.0 : -1.0; - - if (arguments_.side == Protection::Seller) { + Real upfrontSign = 1.0; + switch (arguments_.side) { + case Protection::Seller: results_.defaultLegNPV *= -1.0; results_.accrualRebateNPV *= -1.0; - } else { + break; + case Protection::Buyer: results_.couponLegNPV *= -1.0; - results_.upfrontNPV *= -1.0; + results_.upfrontNPV *= -1.0; + upfrontSign = -1.0; + break; + default: + QL_FAIL("unknown protection side"); } results_.value = results_.defaultLegNPV + results_.couponLegNPV + diff --git a/test-suite/creditdefaultswap.cpp b/test-suite/creditdefaultswap.cpp index 5ab076d0838..54db27c96e1 100644 --- a/test-suite/creditdefaultswap.cpp +++ b/test-suite/creditdefaultswap.cpp @@ -37,6 +37,7 @@ #include #include #include +#include #include #include #include @@ -647,26 +648,29 @@ void CreditDefaultSwapTest::testIsdaEngine() { Rate spreads[] = {0.001, 0.1}; Rate recoveries[] = {0.2, 0.4}; - double markitValues[] = {97798.29358, //0.001 - 97776.11889, //0.001 - -914971.5977, //0.1 - -894985.6298, //0.1 - 186921.3594, //0.001 - 186839.8148, //0.001 - -1646623.672, //0.1 - -1579803.626, //0.1 - 274298.9203, - 274122.4725, - -2279730.93, - -2147972.527, - 592420.2297, - 591571.2294, - -3993550.206, - -3545843.418, - 797501.1422, - 795915.9787, - -4702034.688, - -4042340.999}; + double markitValues[] = { + -97798.29358, //0.001 + -97776.11889, //0.001 + 914971.5977, //0.1 + 894985.6298, //0.1 + -186921.3594, //0.001 + -186839.8148, //0.001 + 1646623.672, //0.1 + 1579803.626, //0.1 + -274298.9203, + -274122.4725, + 2279730.93, + 2147972.527, + -592420.2297, + -591571.2294, + 3993550.206, + 3545843.418, + -797501.1422, + -795915.9787, + 4702034.688, + 4042340.999 + }; + /* When using indexes coupons, the risk-free curve is a bit off. We might skip the tests altogether and rely on running them with indexed coupons disabled, but leaving them can be useful anyway. */ @@ -676,19 +680,19 @@ void CreditDefaultSwapTest::testIsdaEngine() { for (auto termDate : termDates) { for (Real spread : spreads) { - for (Real& recoverie : recoveries) { + for (Real& recovery : recoveries) { ext::shared_ptr quotedTrade = MakeCreditDefaultSwap(termDate, spread).withNominal(10000000.); Rate h = quotedTrade->impliedHazardRate(0., discountCurve, Actual365Fixed(), - recoverie, 1e-10, CreditDefaultSwap::ISDA); + recovery, 1e-10, CreditDefaultSwap::ISDA); probabilityCurve.linkTo( ext::make_shared(0, WeekendsOnly(), h, Actual365Fixed())); ext::shared_ptr engine = ext::make_shared( - probabilityCurve, recoverie, discountCurve, boost::none, IsdaCdsEngine::Taylor, + probabilityCurve, recovery, discountCurve, boost::none, IsdaCdsEngine::Taylor, IsdaCdsEngine::HalfDayBias, IsdaCdsEngine::Piecewise); ext::shared_ptr conventionalTrade = @@ -697,7 +701,27 @@ void CreditDefaultSwapTest::testIsdaEngine() { .withPricingEngine(engine); QL_CHECK_CLOSE(conventionalTrade->notional() * conventionalTrade->fairUpfront(), - markitValues[l], tolerance); + markitValues[l], tolerance); + + // Now testing that with the calculated fair-upfront, both Buyer and Seller sides + // price close to zero + ext::shared_ptr conventionalTradeBuy = + MakeCreditDefaultSwap(termDate, 0.01) + .withNominal(10000000.) + .withUpfrontRate(conventionalTrade->fairUpfront()) + .withSide(Protection::Buyer) + .withPricingEngine(engine); + + BOOST_CHECK_SMALL(conventionalTradeBuy->NPV(), tolerance); + + ext::shared_ptr conventionalTradeSell = + MakeCreditDefaultSwap(termDate, 0.01) + .withNominal(10000000.) + .withUpfrontRate(conventionalTrade->fairUpfront()) + .withSide(Protection::Seller) + .withPricingEngine(engine); + + BOOST_CHECK_SMALL(conventionalTradeSell->NPV(), tolerance); l++; } @@ -742,16 +766,222 @@ void CreditDefaultSwapTest::testAccrualRebateAmounts() { } } +void CreditDefaultSwapTest::testIsdaCalculatorReconcileSingleQuote () +{ + BOOST_TEST_MESSAGE( + "Testing ISDA engine calculations for a single credit-default swap record (reconciliation)..."); + + SavedSettings backup; + + Date tradeDate(26, July, 2021); + Settings::instance().evaluationDate() = tradeDate; + + //build an ISDA compliant yield curve + //data comes from Markit published rates + std::vector > isdaRateHelpers; + int dep_tenors[] = {1, 3, 6, 12}; + double dep_quotes[] = {-0.0056,-0.005440,-0.005190,-0.004930}; + + for(size_t i = 0; i < sizeof(dep_tenors) / sizeof(int); i++) { + isdaRateHelpers.push_back(ext::make_shared( + dep_quotes[i], dep_tenors[i] * Months, 2, + WeekendsOnly(), ModifiedFollowing, + false, Actual360() + ) + ); + } + int swap_tenors[] = {2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 15, 20, 30}; + double swap_quotes[] = {-0.004820, + -0.004420, + -0.003990, + -0.003520, + -0.002970, + -0.002370, + -0.001760, + -0.001140, + -0.000540, + 0.000570, + 0.001880, + 0.002940, + 0.002820}; + + ext::shared_ptr isda_ibor = ext::make_shared( + "IsdaIbor", 6 * Months, 2, EURCurrency(), WeekendsOnly(), + ModifiedFollowing, false, Actual360()); + for(size_t i = 0; i < sizeof(swap_tenors) / sizeof(int); i++) { + isdaRateHelpers.push_back(ext::make_shared( + swap_quotes[i], swap_tenors[i] * Years, + WeekendsOnly(), + Annual, + ModifiedFollowing, Thirty360(Thirty360::BondBasis), isda_ibor + ) + ); + } + + RelinkableHandle discountCurve; + discountCurve.linkTo( + ext::make_shared >( + 0, WeekendsOnly(), isdaRateHelpers, Actual365Fixed()) + ); + + RelinkableHandle probabilityCurve; + Date instrumentMaturity = Date(20, June, 2026); + Rate coupon = 0.01, conventionalSpread = 0.006713, recovery = 0.4; + double nominal = 1e6, markitValue = -16070.7, expected_accrual = 1000, tolerance = 1.0e-3; + + ext::shared_ptr quotedTrade = + MakeCreditDefaultSwap(instrumentMaturity, conventionalSpread).withNominal(nominal); + + Rate h = quotedTrade->impliedHazardRate(0., discountCurve, Actual365Fixed(), + recovery, 1e-10, CreditDefaultSwap::ISDA); + + probabilityCurve.linkTo( + ext::make_shared(0, WeekendsOnly(), h, Actual365Fixed())); + + ext::shared_ptr engine = ext::make_shared( + probabilityCurve, recovery, discountCurve, boost::none, IsdaCdsEngine::Taylor, + IsdaCdsEngine::HalfDayBias, IsdaCdsEngine::Piecewise); + + ext::shared_ptr conventionalTrade = + MakeCreditDefaultSwap(instrumentMaturity, coupon) + .withNominal(nominal) + .withPricingEngine(engine); + + + double npv = conventionalTrade->NPV(); + double calculated_upfront = conventionalTrade->notional() * conventionalTrade->fairUpfront(); + double df = calculated_upfront / npv; //to take into account of the discount to cash settlement + double derived_accrual = df * (npv - + conventionalTrade->defaultLegNPV() - + conventionalTrade->couponLegNPV()); + + double calculated_accrual = conventionalTrade->accrualRebate()->amount(); + + auto settlement_date = conventionalTrade->accrualRebate()->date(); + + BOOST_CHECK_CLOSE(npv, markitValue, tolerance); + + BOOST_CHECK_CLOSE(calculated_upfront, df*markitValue, tolerance); + + BOOST_CHECK_CLOSE(derived_accrual, expected_accrual, tolerance); + + BOOST_CHECK_CLOSE(calculated_accrual, expected_accrual, tolerance); + + BOOST_CHECK_EQUAL(settlement_date, WeekendsOnly().advance(tradeDate,3, TimeUnit::Days)); + +} + +void CreditDefaultSwapTest::testIsdaCalculatorReconcileSingleWithIssueDateInThePast () +{ + BOOST_TEST_MESSAGE( + "Testing ISDA engine calculations for a single credit-default swap with issue date in the past..."); + + SavedSettings backup; + + Date valueDate(26, July, 2021); + Settings::instance().evaluationDate() = valueDate; + + //this is not IMM date but the settlement date is in the past so the accrual rebate + //should not be part of the NPV + Date tradeDate(20, July, 2019); + + //build an ISDA compliant yield curve + //data comes from Markit published rates + std::vector > isdaRateHelpers; + int dep_tenors[] = {1, 3, 6, 12}; + double dep_quotes[] = {-0.0056,-0.005440,-0.005190,-0.004930}; + + for(size_t i = 0; i < sizeof(dep_tenors) / sizeof(int); i++) { + isdaRateHelpers.push_back(ext::make_shared( + dep_quotes[i], dep_tenors[i] * Months, 2, + WeekendsOnly(), ModifiedFollowing, + false, Actual360() + ) + ); + } + int swap_tenors[] = {2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 15, 20, 30}; + double swap_quotes[] = {-0.004820, + -0.004420, + -0.003990, + -0.003520, + -0.002970, + -0.002370, + -0.001760, + -0.001140, + -0.000540, + 0.000570, + 0.001880, + 0.002940, + 0.002820}; + + ext::shared_ptr isda_ibor = ext::make_shared( + "IsdaIbor", 6 * Months, 2, EURCurrency(), WeekendsOnly(), + ModifiedFollowing, false, Actual360()); + for(size_t i = 0; i < sizeof(swap_tenors) / sizeof(int); i++) { + isdaRateHelpers.push_back(ext::make_shared( + swap_quotes[i], swap_tenors[i] * Years, + WeekendsOnly(), + Annual, + ModifiedFollowing, Thirty360(Thirty360::BondBasis), isda_ibor + ) + ); + } + + RelinkableHandle discountCurve; + discountCurve.linkTo( + ext::make_shared >( + 0, WeekendsOnly(), isdaRateHelpers, Actual365Fixed()) + ); + + RelinkableHandle probabilityCurve; + Date instrumentMaturity = Date(20, June, 2026); + Rate coupon = 0.01, conventionalSpread = 0.006713, recovery = 0.4; + + //because there is no accrual involved, the markit value is decreased as compared to the + //previous test (old_markit_value - old_accrual or -16070.7 - 1000) + double nominal = 1e6, markitValue = -17070.77, expected_accrual = 0, tolerance = 1.0e-3; + + ext::shared_ptr quotedTrade = + MakeCreditDefaultSwap(instrumentMaturity, conventionalSpread) + .withNominal(nominal); + + Rate h = quotedTrade->impliedHazardRate(0., discountCurve, Actual365Fixed(), + recovery, 1e-10, CreditDefaultSwap::ISDA); + + probabilityCurve.linkTo( + ext::make_shared(0, WeekendsOnly(), h, Actual365Fixed())); + + ext::shared_ptr engine = ext::make_shared( + probabilityCurve, recovery, discountCurve, boost::none, IsdaCdsEngine::Taylor, + IsdaCdsEngine::HalfDayBias, IsdaCdsEngine::Piecewise); + + ext::shared_ptr conventionalTrade = + MakeCreditDefaultSwap(instrumentMaturity, coupon) + .withNominal(nominal) + .withPricingEngine(engine) + .withTradeDate(tradeDate); + + + double npv = conventionalTrade->NPV(); + double calculated_accrual = npv - + conventionalTrade->defaultLegNPV() - + conventionalTrade->couponLegNPV(); + + BOOST_CHECK_CLOSE(npv, markitValue, tolerance); + + BOOST_CHECK_CLOSE(calculated_accrual, expected_accrual, tolerance); +} + test_suite* CreditDefaultSwapTest::suite() { auto* suite = BOOST_TEST_SUITE("Credit-default swap tests"); suite->add(QUANTLIB_TEST_CASE(&CreditDefaultSwapTest::testCachedValue)); - suite->add(QUANTLIB_TEST_CASE( - &CreditDefaultSwapTest::testCachedMarketValue)); - suite->add(QUANTLIB_TEST_CASE( - &CreditDefaultSwapTest::testImpliedHazardRate)); + suite->add(QUANTLIB_TEST_CASE(&CreditDefaultSwapTest::testCachedMarketValue)); + suite->add(QUANTLIB_TEST_CASE(&CreditDefaultSwapTest::testImpliedHazardRate)); suite->add(QUANTLIB_TEST_CASE(&CreditDefaultSwapTest::testFairSpread)); suite->add(QUANTLIB_TEST_CASE(&CreditDefaultSwapTest::testFairUpfront)); suite->add(QUANTLIB_TEST_CASE(&CreditDefaultSwapTest::testIsdaEngine)); suite->add(QUANTLIB_TEST_CASE(&CreditDefaultSwapTest::testAccrualRebateAmounts)); + suite->add(QUANTLIB_TEST_CASE(&CreditDefaultSwapTest::testIsdaCalculatorReconcileSingleQuote)); + suite->add(QUANTLIB_TEST_CASE(&CreditDefaultSwapTest::testIsdaCalculatorReconcileSingleWithIssueDateInThePast)); return suite; } diff --git a/test-suite/creditdefaultswap.hpp b/test-suite/creditdefaultswap.hpp index abf7027d342..5b1ec477335 100644 --- a/test-suite/creditdefaultswap.hpp +++ b/test-suite/creditdefaultswap.hpp @@ -34,6 +34,8 @@ class CreditDefaultSwapTest { static void testFairUpfront(); static void testIsdaEngine(); static void testAccrualRebateAmounts(); + static void testIsdaCalculatorReconcileSingleQuote(); + static void testIsdaCalculatorReconcileSingleWithIssueDateInThePast(); static boost::unit_test_framework::test_suite* suite(); };