diff --git a/Docs/UserGuide/parameterisation/ore.tex b/Docs/UserGuide/parameterisation/ore.tex index aa4262be87..23c224f286 100644 --- a/Docs/UserGuide/parameterisation/ore.tex +++ b/Docs/UserGuide/parameterisation/ore.tex @@ -770,7 +770,7 @@ \subsubsection{Value Adjustments} %basis point is 0.0001) assuming the day count convention of the collateral rate. \item {\tt dynamicCredit:} Flag to enable using pathwise survival probabilities when calculating CVA, DVA, FVA and MVA increments from exposures. If set to N the survival probabilities are extracted from T0 curves. \item {\tt kva:} Flag to enable setting the kva ccr parameters. -\item {\tt kvaCapitalDiscountRate, kvaAlpha, kvaRegAdjustment, kvaCapitalHurdle, kvaOurPdFloor, kvaTheirPdFloor kvaOurCvaRiskWeight, kvaTheirCvaRiskWeight:} the kva CCR parameters (see \cite{methods}). +\item {\tt kvaCapitalDiscountRate, kvaAlpha, kvaRegAdjustment, kvaCapitalHurdle, kvaOurPdFloor, kvaTheirPdFloor kvaOurCvaRiskWeight, kvaTheirCvaRiskWeight:} the kva CCR parameters (see \cite{methods}). The {\tt Our} parameters refer to the party given by {\tt dvaName}, the {\tt Their} parameters to the counterparty of the netting set, irrespective of {\tt flipViewXVA}; when the view is flipped they are swapped together with the credit curves, so that the {\tt OurKVA*} columns then report the counterparty's perspective. \item {\tt dimQuantile:} Quantile for Dynamic Initial Margin (DIM) calculation \item {\tt dimHorizonCalendarDays:} Horizon for DIM calculation, 14 calendar days for 2 weeks, etc. \item {\tt dimRegressionOrder:} Order of the regression polynomial (netting set clean NPV move over the simulation diff --git a/OREAnalytics/orea/aggregation/postprocess.cpp b/OREAnalytics/orea/aggregation/postprocess.cpp index 1a6b434e12..89ec3afd7f 100644 --- a/OREAnalytics/orea/aggregation/postprocess.cpp +++ b/OREAnalytics/orea/aggregation/postprocess.cpp @@ -325,14 +325,24 @@ void PostProcess::updateNettingSetKVA() { Handle discountCurve = market_->discountCurve(baseCurrency_, configuration_); DayCounter dc = ActualActual(ActualActual::ISDA); + struct KvaParty { + string name; + Real pdFloor; + Real cvaRiskWeight; + }; + // dvaName and the kvaOur* / kvaTheir* parameters are given from the bank's perspective; under flipViewXVA the + // counterparty takes the bank's role and vice versa, so every party specific input has to move along + const bool flipView = analytics_["flipViewXVA"]; + QL_REQUIRE(!flipView || !dvaName_.empty(), + "PostProcess::updateNettingSetKVA(): dvaName is required when flipViewXVA is set"); + const KvaParty bank{dvaName_, kvaOurPdFloor_, kvaOurCvaRiskWeight_}; + // Loop over all netting sets for (const auto& [nettingSetId, pos] : nettingSetIds()) { - string cid; - if (analytics_["flipViewXVA"]) { - cid = dvaName_; - } else { - cid = nettedExposureCalculator_->counterparty(nettingSetId); - } + const KvaParty cpty{nettedExposureCalculator_->counterparty(nettingSetId), kvaTheirPdFloor_, + kvaTheirCvaRiskWeight_}; + const KvaParty& activeOwnParty = flipView ? cpty : bank; + const KvaParty& activeCounterparty = flipView ? bank : cpty; LOG("KVA for netting set " << nettingSetId); // Main input are the EPE and ENE profiles, previously computed @@ -341,9 +351,10 @@ void PostProcess::updateNettingSetKVA() { // PD from counterparty Dts, floored to avoid 0 ... // Today changed to today+1Y to get the one-year PD - Handle cvaDts = market_->defaultCurve(cid, configuration_)->curve(); - QL_REQUIRE(!cvaDts.empty(), "Default curve missing for counterparty " << cid); - Real cvaRR = market_->recoveryRate(cid, configuration_)->value(); + Handle cvaDts = + market_->defaultCurve(activeCounterparty.name, configuration_)->curve(); + QL_REQUIRE(!cvaDts.empty(), "Default curve missing for counterparty " << activeCounterparty.name); + Real cvaRR = market_->recoveryRate(activeCounterparty.name, configuration_)->value(); Real PD1 = std::max(cvaDts->defaultProbability(today + 1 * Years), 0.000000000001); Real LGD1 = (1 - cvaRR); @@ -351,12 +362,9 @@ void PostProcess::updateNettingSetKVA() { Handle dvaDts; Real dvaRR = 0.0; Real PD2 = 0; - if (analytics_["flipViewXVA"]) { - dvaName_ = nettedExposureCalculator_->counterparty(nettingSetId); - } - if (dvaName_ != "") { - dvaDts = market_->defaultCurve(dvaName_, configuration_)->curve(); - dvaRR = market_->recoveryRate(dvaName_, configuration_)->value(); + if (activeOwnParty.name != "") { + dvaDts = market_->defaultCurve(activeOwnParty.name, configuration_)->curve(); + dvaRR = market_->recoveryRate(activeOwnParty.name, configuration_)->value(); PD2 = std::max(dvaDts->defaultProbability(today + 1 * Years), 0.000000000001); } else { ALOG("dvaName not specified, own PD set to zero for their KVA calculation"); @@ -377,8 +385,8 @@ void PostProcess::updateNettingSetKVA() { Real PD99_2 = cnd((icn(PD2) + std::sqrt(rho2) * icn(0.999)) / (std::sqrt(1 - rho2))) - PD2; // KVA regulatory PD, worst case PD, floored at 0.03 for corporates and banks, not floored for sovereigns - Real kva99PD1 = std::max(PD99_1, kvaTheirPdFloor_); - Real kva99PD2 = std::max(PD99_2, kvaOurPdFloor_); + Real kva99PD1 = std::max(PD99_1, activeCounterparty.pdFloor); + Real kva99PD2 = std::max(PD99_2, activeOwnParty.pdFloor); // Factor B(PD) for the maturity adjustment factor, B(PD) = (0.11852 - 0.05478 * ln(PD)) ^ 2 Real kvaMatAdjB1 = std::pow((0.11852 - 0.05478 * std::log(PD1)), 2.0); @@ -388,7 +396,7 @@ void PostProcess::updateNettingSetKVA() { DLOG("Our KVA-CCR " << nettingSetId << ": LGD=" << LGD1); DLOG("Our KVA-CCR " << nettingSetId << ": rho=" << rho1); DLOG("Our KVA-CCR " << nettingSetId << ": PD99=" << PD99_1); - DLOG("Our KVA-CCR " << nettingSetId << ": PD Floor=" << kvaTheirPdFloor_); + DLOG("Our KVA-CCR " << nettingSetId << ": PD Floor=" << activeCounterparty.pdFloor); DLOG("Our KVA-CCR " << nettingSetId << ": Floored PD99=" << kva99PD1); DLOG("Our KVA-CCR " << nettingSetId << ": B(PD)=" << kvaMatAdjB1); @@ -396,7 +404,7 @@ void PostProcess::updateNettingSetKVA() { DLOG("Their KVA-CCR " << nettingSetId << ": LGD=" << LGD2); DLOG("Their KVA-CCR " << nettingSetId << ": rho=" << rho2); DLOG("Their KVA-CCR " << nettingSetId << ": PD99=" << PD99_2); - DLOG("Their KVA-CCR " << nettingSetId << ": PD Floor=" << kvaOurPdFloor_); + DLOG("Their KVA-CCR " << nettingSetId << ": PD Floor=" << activeOwnParty.pdFloor); DLOG("Their KVA-CCR " << nettingSetId << ": Floored PD99=" << kva99PD2); DLOG("Their KVA-CCR " << nettingSetId << ": B(PD)=" << kvaMatAdjB2); @@ -493,8 +501,8 @@ void PostProcess::updateNettingSetKVA() { // TODO: Set MA in CCR capital calculation to 1 Real kvaCvaMaturity1 = 1.0 + (effMatDenom1 == 0.0 ? 0.0 : effMatNumer1 / effMatDenom1); Real kvaCvaMaturity2 = 1.0 + (effMatDenom2 == 0.0 ? 0.0 : effMatNumer2 / effMatDenom2); - Real scva1 = kvaTheirCvaRiskWeight_ * kvaCvaMaturity1 * eepe_kva_1; - Real scva2 = kvaOurCvaRiskWeight_ * kvaCvaMaturity2 * eepe_kva_2; + Real scva1 = activeCounterparty.cvaRiskWeight * kvaCvaMaturity1 * eepe_kva_1; + Real scva2 = activeOwnParty.cvaRiskWeight * kvaCvaMaturity2 * eepe_kva_2; Real kvaCVAIncrement1 = scva1 * kvaCapitalDiscount * dc.yearFraction(d0, d1) * kvaCapitalHurdle_ * kvaRegAdjustment_; Real kvaCVAIncrement2 = diff --git a/OREAnalytics/test/CMakeLists.txt b/OREAnalytics/test/CMakeLists.txt index 354d3cf848..cede06ba88 100644 --- a/OREAnalytics/test/CMakeLists.txt +++ b/OREAnalytics/test/CMakeLists.txt @@ -4,6 +4,7 @@ set(OREAnalytics-Test_SRC aggregationscenariodata.cpp amcbermudanswaption.cpp cube.cpp historicalscenariogenerator.cpp +kva.cpp nettedexpsoure.cpp observationmode.cpp parsensitivityanalysis.cpp diff --git a/OREAnalytics/test/kva.cpp b/OREAnalytics/test/kva.cpp new file mode 100644 index 0000000000..d50fb2a736 --- /dev/null +++ b/OREAnalytics/test/kva.cpp @@ -0,0 +1,266 @@ +/* + Copyright (C) 2026 Quaternion Risk Management Ltd + All rights reserved. + + This file is part of ORE, a free-software/open-source library + for transparent pricing and risk analysis - http://opensourcerisk.org + + ORE is free software: you can redistribute it and/or modify it + under the terms of the Modified BSD License. You should have received a + copy of the license along with this program. + The license is also available online at + + This program is distributed on the basis that it will form a useful + contribution to risk analytics and model standardisation, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + FITNESS FOR A PARTICULAR PURPOSE. See the license for more details. +*/ + +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +#include + +using namespace QuantLib; +using namespace ore::data; +using namespace ore::analytics; +using namespace boost::unit_test_framework; + +namespace { + +struct CreditSpec { + std::string name; + Real hazardRate; + Real recoveryRate; +}; + +class KvaTestMarket : public MarketImpl { +public: + KvaTestMarket(const Date& asof, Real flatRate, const std::vector& credits) : MarketImpl(false) { + asof_ = asof; + DayCounter dc = ActualActual(ActualActual::ISDA); + Handle yts(QuantLib::ext::make_shared(asof, flatRate, dc)); + yieldCurves_[std::make_tuple(Market::defaultConfiguration, YieldCurveType::Discount, "EUR")] = yts; + for (const auto& c : credits) { + Handle rr(QuantLib::ext::make_shared(c.recoveryRate)); + Handle dts( + QuantLib::ext::make_shared(asof, c.hazardRate, dc)); + recoveryRates_[std::make_pair(Market::defaultConfiguration, c.name)] = rr; + defaultCurves_[std::make_pair(Market::defaultConfiguration, c.name)] = + Handle(QuantLib::ext::make_shared(dts, yts, rr)); + } + } +}; + +// Only the envelope and the maturity are read on the exposure / KVA path, no pricing takes place +class KvaTestTrade : public Trade { +public: + KvaTestTrade(const std::string& id, const std::string& counterparty, const std::string& nettingSetId, + const Date& maturity) + : Trade("KvaTestTrade", Envelope(counterparty, nettingSetId)) { + this->id() = id; + maturity_ = maturity; + npvCurrency_ = "EUR"; + } + void build(const QuantLib::ext::shared_ptr&) override {} +}; + +struct TradeSpec { + std::string id; + std::string counterparty; + std::string nettingSetId; + Real up; + Real down; + Real t0; +}; + +struct KvaSetup { + Date asof; + std::vector dates; + QuantLib::ext::shared_ptr market; + QuantLib::ext::shared_ptr portfolio; + QuantLib::ext::shared_ptr cube; + QuantLib::ext::shared_ptr nettingSetManager; +}; + +// Two-sample cube: sample 0 carries +up, sample 1 carries -down, both decaying linearly, so that EPE != ENE +// and both are strictly positive on every date. Uncollateralised netting sets, so flipping the view negates +// the exposure exactly and EPE(flipped) == ENE(normal) bit for bit. +KvaSetup makeSetup(const std::vector& specs) { + KvaSetup s; + s.asof = Date(15, June, 2022); + Settings::instance().evaluationDate() = s.asof; + for (Integer i = 1; i <= 12; ++i) + s.dates.push_back(s.asof + Period(3 * i, Months)); + s.market = QuantLib::ext::make_shared( + s.asof, 0.02, std::vector{{"BANK", 0.005, 0.40}, {"CPTY1", 0.05, 0.25}, {"CPTY2", 0.10, 0.10}}); + s.portfolio = QuantLib::ext::make_shared(); + s.nettingSetManager = QuantLib::ext::make_shared(); + Date maturity = s.asof + 5 * Years; + for (const auto& t : specs) { + s.portfolio->add(QuantLib::ext::make_shared(t.id, t.counterparty, t.nettingSetId, maturity)); + if (!s.nettingSetManager->has(t.nettingSetId)) + s.nettingSetManager->add(QuantLib::ext::make_shared(t.nettingSetId)); + } + QuantLib::ext::shared_ptr cube = + QuantLib::ext::make_shared>(s.asof, s.portfolio->ids(), s.dates, 2); + for (const auto& t : specs) { + cube->setT0(t.t0, t.id); + for (Size i = 0; i < s.dates.size(); ++i) { + Real decay = 1.0 - 0.05 * static_cast(i + 1); + cube->set(t.up * decay, t.id, s.dates[i], 0); + cube->set(-t.down * decay, t.id, s.dates[i], 1); + } + } + s.cube = cube; + return s; +} + +// Deliberately asymmetric own / counterparty KVA parameters: the own PD floor binds for BANK (worst case PD +// ~0.09), neither floor binds for the counterparties, and the CVA risk weights differ by a factor 2.5. +QuantLib::ext::shared_ptr runPostProcess(const KvaSetup& s, bool flipView, bool kva = true, + bool cvaSensi = false) { + std::map analytics = {{"kva", kva}, {"flipViewXVA", flipView}, {"cvaSensi", cvaSensi}}; + auto cubeInterpretation = + QuantLib::ext::make_shared(false, false, false, nullptr, 0, flipView); + auto scenarioData = QuantLib::ext::make_shared(s.dates.size(), 2); + return QuantLib::ext::make_shared( + s.portfolio, s.nettingSetManager, QuantLib::ext::make_shared(), s.market, + Market::defaultConfiguration, s.cube, scenarioData, analytics, "EUR", "None", 1.0, 0.95, "Symmetric", + "BANK", "", "", nullptr, cubeInterpretation, false, + std::vector{6 * Months, 1 * Years, 3 * Years, 5 * Years, 10 * Years}, 0.0001, 0.10, 1.4, 12.5, + 0.012, 0.15, 0.03, 0.02, 0.05); +} + +struct KvaResult { + Real ourCcr; + Real theirCcr; + Real ourCva; + Real theirCva; +}; + +KvaResult kvaOf(const QuantLib::ext::shared_ptr& pp, const std::string& nettingSetId) { + return {pp->nettingSetOurKVACCR(nettingSetId), pp->nettingSetTheirKVACCR(nettingSetId), + pp->nettingSetOurKVACVA(nettingSetId), pp->nettingSetTheirKVACVA(nettingSetId)}; +} + +void checkEqual(const KvaResult& a, const KvaResult& b, const std::string& what) { + BOOST_TEST_CONTEXT(what) { + BOOST_CHECK_CLOSE(a.ourCcr, b.ourCcr, 1e-10); + BOOST_CHECK_CLOSE(a.theirCcr, b.theirCcr, 1e-10); + BOOST_CHECK_CLOSE(a.ourCva, b.ourCva, 1e-10); + BOOST_CHECK_CLOSE(a.theirCva, b.theirCva, 1e-10); + } +} + +void checkVectorsEqual(const std::vector& a, const std::vector& b, const std::string& what) { + BOOST_TEST_CONTEXT(what) { + BOOST_REQUIRE(!b.empty()); + BOOST_REQUIRE_EQUAL(a.size(), b.size()); + for (Size i = 0; i < a.size(); ++i) + BOOST_CHECK_CLOSE(a[i], b[i], 1e-10); + } +} + +const TradeSpec tradeC1a{"T_C1_a", "CPTY1", "NS_C1", 400.0, 160.0, 120.0}; +const TradeSpec tradeC1b{"T_C1_b", "CPTY1", "NS_C1", -50.0, -30.0, -10.0}; +const TradeSpec tradeC2a{"T_C2_a", "CPTY2", "NS_C2", -250.0, -90.0, -80.0}; + +} // namespace + +BOOST_FIXTURE_TEST_SUITE(OREAnalyticsTestSuite, ore::test::OreaTopLevelFixture) + +BOOST_AUTO_TEST_SUITE(KvaTest) + +BOOST_AUTO_TEST_CASE(testFlipViewMirrorsOurAndTheirKva) { + BOOST_TEST_MESSAGE("Testing that flipViewXVA swaps our and their KVA for a single counterparty"); + + KvaSetup s = makeSetup({tradeC1a, tradeC1b}); + KvaResult normal = kvaOf(runPostProcess(s, false), "NS_C1"); + KvaResult flipped = kvaOf(runPostProcess(s, true), "NS_C1"); + + BOOST_TEST_MESSAGE(std::setprecision(17) + << "normal view NS_C1: OurKVACCR=" << normal.ourCcr << " TheirKVACCR=" << normal.theirCcr + << " OurKVACVA=" << normal.ourCva << " TheirKVACVA=" << normal.theirCva); + + BOOST_CHECK(normal.ourCcr > 0.0); + BOOST_CHECK(normal.theirCcr > 0.0); + BOOST_CHECK(normal.ourCva > 0.0); + BOOST_CHECK(normal.theirCva > 0.0); + + BOOST_CHECK_CLOSE(flipped.ourCcr, normal.theirCcr, 1e-10); + BOOST_CHECK_CLOSE(flipped.theirCcr, normal.ourCcr, 1e-10); + BOOST_CHECK_CLOSE(flipped.ourCva, normal.theirCva, 1e-10); + BOOST_CHECK_CLOSE(flipped.theirCva, normal.ourCva, 1e-10); + + // normal view results are not affected by the flip view handling and must stay as they were + BOOST_CHECK_CLOSE(normal.ourCcr, 11.965905547224787, 1e-6); + BOOST_CHECK_CLOSE(normal.theirCcr, 2.4404430807946147, 1e-6); + BOOST_CHECK_CLOSE(normal.ourCva, 3.5171207035532817, 1e-6); + BOOST_CHECK_CLOSE(normal.theirCva, 0.52254364738505887, 1e-6); +} + +BOOST_AUTO_TEST_CASE(testFlipViewKvaIndependentOfOtherNettingSets) { + BOOST_TEST_MESSAGE("Testing that flipped KVA of a netting set does not depend on other netting sets"); + + KvaResult alone = kvaOf(runPostProcess(makeSetup({tradeC2a}), true), "NS_C2"); + KvaResult combined = kvaOf(runPostProcess(makeSetup({tradeC1a, tradeC1b, tradeC2a}), true), "NS_C2"); + + checkEqual(combined, alone, "NS_C2 alone vs. together with NS_C1"); +} + +BOOST_AUTO_TEST_CASE(testFlipViewKvaIndependentOfNettingSetOrder) { + BOOST_TEST_MESSAGE("Testing that flipped KVA does not depend on the netting set processing order"); + + auto specs = [](const std::string& ns1, const std::string& ns2) { + std::vector v{tradeC1a, tradeC1b, tradeC2a}; + v[0].nettingSetId = v[1].nettingSetId = ns1; + v[2].nettingSetId = ns2; + return v; + }; + auto a = runPostProcess(makeSetup(specs("NS_1", "NS_2")), true); + auto b = runPostProcess(makeSetup(specs("NS_2", "NS_1")), true); + + checkEqual(kvaOf(a, "NS_1"), kvaOf(b, "NS_2"), "CPTY1 processed first vs. second"); + checkEqual(kvaOf(a, "NS_2"), kvaOf(b, "NS_1"), "CPTY2 processed second vs. first"); +} + +BOOST_AUTO_TEST_CASE(testFlipViewCvaSensitivityUnaffectedByKva) { + BOOST_TEST_MESSAGE("Testing that flipped CVA sensitivities are the same with and without the KVA analytic"); + + KvaSetup s = makeSetup({tradeC1a, tradeC1b, tradeC2a}); + auto withKva = runPostProcess(s, true, true, true); + auto withoutKva = runPostProcess(s, true, false, true); + + for (const std::string& ns : {"NS_C1", "NS_C2"}) { + checkVectorsEqual(withKva->netCvaHazardRateSensitivity(ns), withoutKva->netCvaHazardRateSensitivity(ns), + ns + " hazard rate sensitivity"); + checkVectorsEqual(withKva->netCvaSpreadSensitivity(ns), withoutKva->netCvaSpreadSensitivity(ns), + ns + " spread sensitivity"); + } +} + +BOOST_AUTO_TEST_SUITE_END() + +BOOST_AUTO_TEST_SUITE_END()