Skip to content

KVA under flipViewXVA: CVA risk weights and PD floors not swapped with the parties, dvaName_ overwritten per netting set #359

Description

@dazivo

Summary

With flipViewXVA=Y, the netting-set KVA numbers (OurKVACCR, TheirKVACCR, OurKVACVA, TheirKVACVA) are wrong in three independent ways, all in PostProcess::updateNettingSetKVA() (OREAnalytics/orea/aggregation/postprocess.cpp, line numbers as of 3b62ba248):

  1. KVA-CVA risk weights are not swapped with the parties. kvaTheirCvaRiskWeight always feeds OurKVACVA and kvaOurCvaRiskWeight always feeds TheirKVACVA, flipped or not (lines 494-495).
  2. KVA PD floors are not swapped with the parties. kvaTheirPdFloor always floors the PD behind OurKVACCR, kvaOurPdFloor the one behind TheirKVACCR (lines 378-379).
  3. dvaName_ is overwritten inside the netting-set loop with the current counterparty and never restored (line 353), while the next iteration reads it as the flipped counterparty (line 330). From the second netting set on, "our" KVA is therefore computed against the previous netting set's counterparty curve instead of the bank's, so results depend on which other netting sets are in the run and on their alphabetical order. The overwritten member is then also read by updateNettingSetCvaSensitivity() (line 530), which runs right after, so with cvaSensi=Y every netting set's CVA spread sensitivities end up on the last netting set's counterparty curve.

None of this is visible with the defaults, because both floors default to 0.03 and both risk weights to 0.05, and no shipped example switches kva on. The user guide's own parameter listing (kvaOurCvaRiskWeight 0.005 vs kvaTheirCvaRiskWeight 0.05) gives a factor 10 on KVA-CVA under flip view.

Expected semantics

Per Docs/UserGuide/methods/xva.tex ("our" KVA is driven by EPE and the counterparty's default risk / risk weight, "their" KVA by ENE and our default risk / risk weight), and given that dvaName, kvaOur* and kvaTheir* are all specified from the bank's point of view, flipping the view should swap every party-specific input together with the exposure:

OurKVA* uses TheirKVA* uses
normal counterparty PD/LGD, kvaTheirPdFloor, kvaTheirCvaRiskWeight dvaName PD/LGD, kvaOurPdFloor, kvaOurCvaRiskWeight
flipped dvaName PD/LGD, kvaOurPdFloor, kvaOurCvaRiskWeight counterparty PD/LGD, kvaTheirPdFloor, kvaTheirCvaRiskWeight

For an uncollateralised netting set the flip is an exact sign change of the netted NPV (CubeInterpretation::getGenericValue), so EPE^flip == ENE^normal bit for bit and one expects exactly OurKVACCR^flip == TheirKVACCR^normal, TheirKVACCR^flip == OurKVACCR^normal, and the same for KVA-CVA. Today the PD/LGD swap is done (via cid = dvaName_ / dvaName_ = counterparty), but the floors and risk weights stay attached to the output column.

Code

// postprocess.cpp:329-335
string cid;
if (analytics_["flipViewXVA"]) {
    cid = dvaName_;                                            // 2nd netting set onwards: previous counterparty
} else {
    cid = nettedExposureCalculator_->counterparty(nettingSetId);
}
...
// postprocess.cpp:352-354
if (analytics_["flipViewXVA"]) {
    dvaName_ = nettedExposureCalculator_->counterparty(nettingSetId);   // member, never restored
}
...
// postprocess.cpp:378-379
Real kva99PD1 = std::max(PD99_1, kvaTheirPdFloor_);   // PD1 is dvaName's PD under flip
Real kva99PD2 = std::max(PD99_2, kvaOurPdFloor_);
...
// postprocess.cpp:494-495
Real scva1 = kvaTheirCvaRiskWeight_ * kvaCvaMaturity1 * eepe_kva_1;   // feeds OurKVACVA, flipped or not
Real scva2 = kvaOurCvaRiskWeight_ * kvaCvaMaturity2 * eepe_kva_2;

Reproduction

Built from master (3b62ba248) on macOS arm64. Inputs are the shipped Examples/Exposure/Input/ore_flipview.xml with: kva=Y, fva=N, dva=N, uncollateralised netting sets (ActiveCSAFlag=false) so the mirror is exact, BANK CDS spread moved to 3 % (counterparties stay at 1 %) so the bank and the counterparties are distinguishable, and the user-guide parameter values kvaOurPdFloor=0.20, kvaTheirPdFloor=0.03, kvaOurCvaRiskWeight=0.005, kvaTheirCvaRiskWeight=0.05. Only flipViewXVA and the portfolio differ between runs.

Single counterparty (netting set CPTY_A, 2 swaps), normal vs flipped:

netting set CPTY_A normal flipped expected flipped flipped / expected
OurKVACCR 221,555.68 453,083.61 453,083.61 (= TheirKVACCR normal) 1.000000
TheirKVACCR 453,083.61 276,666.90 221,555.68 (= OurKVACCR normal) 1.248747
OurKVACVA 421,831.43 748,495.50 74,849.55 (= TheirKVACVA normal) 10.000000
TheirKVACVA 74,849.55 42,183.14 421,831.43 (= OurKVACVA normal) 0.100000

The first row shows that the exposure side of the flip is exact. The KVA-CVA rows are off by exactly kvaTheirCvaRiskWeight / kvaOurCvaRiskWeight = 10 (defect 1). The TheirKVACCR row is off by 0.20 / PD99(CPTY_A) = 1.2487: under flip that branch carries CPTY_A's PD but still gets kvaOurPdFloor = 0.20, which then binds (defect 2); BANK's worst-case PD is above both floors, which is why the OurKVACCR row happens to match.

Second counterparty leaks into the first (flipped view): netting set CPTY_B (1 swap vs CPTY_B, CDS 1 %) run alone and together with CPTY_A. Same trade, same scenarios, same netting set:

netting set CPTY_B, flipped CPTY_B alone CPTY_A + CPTY_B in one run ratio
OurKVACCR 315,163.77 246,309.60 0.781529
TheirKVACCR 231,541.20 231,541.20 1.000000
OurKVACVA 596,432.04 596,432.04 1.000000
TheirKVACVA 41,239.11 41,239.11 1.000000

CPTY_A sorts before CPTY_B, so when CPTY_B is processed dvaName_ already holds "CPTY_A" and "our" KVA-CCR for CPTY_B is computed against CPTY_A's 1 % curve instead of BANK's 3 % curve (defect 3). KVA-CVA has no PD input, which is why only the CCR row moves. In normal view the CPTY_B numbers are identical alone and combined.

Proposed fix

Make the party identity explicit per netting set instead of mutating dvaName_: build {name, pdFloor, cvaRiskWeight} for the bank (dvaName_, kvaOurPdFloor_, kvaOurCvaRiskWeight_) and for the counterparty (counterparty(nettingSetId), kvaTheirPdFloor_, kvaTheirCvaRiskWeight_), pick activeOwnParty = flip ? cpty : bank and activeCounterparty = flip ? bank : cpty, and derive PD, LGD, floor and risk weight of branch 1 from activeCounterparty and of branch 2 from activeOwnParty. The normal-view arithmetic is untouched, dvaName_ is only read, and updateNettingSetCvaSensitivity() is correct again without changes. PR with the fix and unit tests (single-counterparty mirror, multi-counterparty invariance, netting-set order invariance, CVA sensitivities with and without kva) to follow.

Notes: ValueAdjustmentCalculator::build() already snapshots origDvaName before its loops, so CVA/DVA/FVA are not affected. For collateralised netting sets the mirror is only approximate anyway (initial balances, DIM quantiles and MPoR flow modes are not sign-symmetric), but the parameter mapping above is wrong independently of that.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions