Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Docs/UserGuide/parameterisation/ore.tex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 29 additions & 21 deletions OREAnalytics/orea/aggregation/postprocess.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -325,14 +325,24 @@ void PostProcess::updateNettingSetKVA() {
Handle<YieldTermStructure> 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
Expand All @@ -341,22 +351,20 @@ void PostProcess::updateNettingSetKVA() {

// PD from counterparty Dts, floored to avoid 0 ...
// Today changed to today+1Y to get the one-year PD
Handle<DefaultProbabilityTermStructure> cvaDts = market_->defaultCurve(cid, configuration_)->curve();
QL_REQUIRE(!cvaDts.empty(), "Default curve missing for counterparty " << cid);
Real cvaRR = market_->recoveryRate(cid, configuration_)->value();
Handle<DefaultProbabilityTermStructure> 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);

// FIXME: if flipViewXVA is sufficient, then all code for their KVA-CCR could be discarded here...
Handle<DefaultProbabilityTermStructure> 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");
Expand All @@ -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);
Expand All @@ -388,15 +396,15 @@ 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);

DLOG("Their KVA-CCR " << nettingSetId << ": PD=" << PD2);
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);

Expand Down Expand Up @@ -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 =
Expand Down
1 change: 1 addition & 0 deletions OREAnalytics/test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ set(OREAnalytics-Test_SRC aggregationscenariodata.cpp
amcbermudanswaption.cpp
cube.cpp
historicalscenariogenerator.cpp
kva.cpp
nettedexpsoure.cpp
observationmode.cpp
parsensitivityanalysis.cpp
Expand Down
266 changes: 266 additions & 0 deletions OREAnalytics/test/kva.cpp
Original file line number Diff line number Diff line change
@@ -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 <http://opensourcerisk.org>

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 <boost/test/unit_test.hpp>
#include <test/oreatoplevelfixture.hpp>

#include <orea/aggregation/postprocess.hpp>
#include <orea/cube/cubeinterpretation.hpp>
#include <orea/cube/inmemorycubeopt.hpp>
#include <orea/scenario/aggregationscenariodata.hpp>

#include <ored/marketdata/marketimpl.hpp>
#include <ored/portfolio/collateralbalance.hpp>
#include <ored/portfolio/envelope.hpp>
#include <ored/portfolio/nettingsetdefinition.hpp>
#include <ored/portfolio/nettingsetmanager.hpp>
#include <ored/portfolio/portfolio.hpp>
#include <ored/portfolio/trade.hpp>

#include <qle/termstructures/creditcurve.hpp>

#include <ql/quotes/simplequote.hpp>
#include <ql/settings.hpp>
#include <ql/termstructures/credit/flathazardrate.hpp>
#include <ql/termstructures/yield/flatforward.hpp>
#include <ql/time/daycounters/actualactual.hpp>

#include <iomanip>

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<CreditSpec>& credits) : MarketImpl(false) {
asof_ = asof;
DayCounter dc = ActualActual(ActualActual::ISDA);
Handle<YieldTermStructure> yts(QuantLib::ext::make_shared<FlatForward>(asof, flatRate, dc));
yieldCurves_[std::make_tuple(Market::defaultConfiguration, YieldCurveType::Discount, "EUR")] = yts;
for (const auto& c : credits) {
Handle<Quote> rr(QuantLib::ext::make_shared<SimpleQuote>(c.recoveryRate));
Handle<DefaultProbabilityTermStructure> dts(
QuantLib::ext::make_shared<FlatHazardRate>(asof, c.hazardRate, dc));
recoveryRates_[std::make_pair(Market::defaultConfiguration, c.name)] = rr;
defaultCurves_[std::make_pair(Market::defaultConfiguration, c.name)] =
Handle<QuantExt::CreditCurve>(QuantLib::ext::make_shared<QuantExt::CreditCurve>(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<EngineFactory>&) override {}
};

struct TradeSpec {
std::string id;
std::string counterparty;
std::string nettingSetId;
Real up;
Real down;
Real t0;
};

struct KvaSetup {
Date asof;
std::vector<Date> dates;
QuantLib::ext::shared_ptr<Market> market;
QuantLib::ext::shared_ptr<Portfolio> portfolio;
QuantLib::ext::shared_ptr<NPVCube> cube;
QuantLib::ext::shared_ptr<NettingSetManager> 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<TradeSpec>& 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<KvaTestMarket>(
s.asof, 0.02, std::vector<CreditSpec>{{"BANK", 0.005, 0.40}, {"CPTY1", 0.05, 0.25}, {"CPTY2", 0.10, 0.10}});
s.portfolio = QuantLib::ext::make_shared<Portfolio>();
s.nettingSetManager = QuantLib::ext::make_shared<NettingSetManager>();
Date maturity = s.asof + 5 * Years;
for (const auto& t : specs) {
s.portfolio->add(QuantLib::ext::make_shared<KvaTestTrade>(t.id, t.counterparty, t.nettingSetId, maturity));
if (!s.nettingSetManager->has(t.nettingSetId))
s.nettingSetManager->add(QuantLib::ext::make_shared<NettingSetDefinition>(t.nettingSetId));
}
QuantLib::ext::shared_ptr<NPVCube> cube =
QuantLib::ext::make_shared<InMemoryCubeOpt<double>>(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<Real>(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<PostProcess> runPostProcess(const KvaSetup& s, bool flipView, bool kva = true,
bool cvaSensi = false) {
std::map<std::string, bool> analytics = {{"kva", kva}, {"flipViewXVA", flipView}, {"cvaSensi", cvaSensi}};
auto cubeInterpretation =
QuantLib::ext::make_shared<CubeInterpretation>(false, false, false, nullptr, 0, flipView);
auto scenarioData = QuantLib::ext::make_shared<InMemoryAggregationScenarioData>(s.dates.size(), 2);
return QuantLib::ext::make_shared<PostProcess>(
s.portfolio, s.nettingSetManager, QuantLib::ext::make_shared<CollateralBalances>(), s.market,
Market::defaultConfiguration, s.cube, scenarioData, analytics, "EUR", "None", 1.0, 0.95, "Symmetric",
"BANK", "", "", nullptr, cubeInterpretation, false,
std::vector<Period>{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<PostProcess>& 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<Real>& a, const std::vector<Real>& 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<TradeSpec> 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()