From f723be0b944c11f46e91741d8331dbb95ec908d0 Mon Sep 17 00:00:00 2001 From: Danijel Zivoi Date: Thu, 10 Sep 2026 16:27:47 +0200 Subject: [PATCH] Fix KVA party mapping under flipViewXVA (#359) Under flipViewXVA PostProcess::updateNettingSetKVA() swapped the PD/LGD sources of the two KVA branches by overwriting dvaName_ per netting set, while the PD floors and the KVA-CVA risk weights stayed attached to the output column. Make the party identity explicit per netting set, derive PD, LGD, floor and risk weight of the "our" branch from the active counterparty and of the "their" branch from the active own party, and never write dvaName_, which also repairs the flipped CVA sensitivities. Normal view results are unchanged. Add KvaTest covering the flip view mirror, multi-counterparty and netting set order invariance and the CVA sensitivities, and clarify the kva parameter semantics in the user guide. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Q5n2ExoAFe9qtbybzMxnsQ --- Docs/UserGuide/parameterisation/ore.tex | 2 +- OREAnalytics/orea/aggregation/postprocess.cpp | 50 ++-- OREAnalytics/test/CMakeLists.txt | 1 + OREAnalytics/test/kva.cpp | 266 ++++++++++++++++++ 4 files changed, 297 insertions(+), 22 deletions(-) create mode 100644 OREAnalytics/test/kva.cpp 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()