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):
- 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).
- KVA PD floors are not swapped with the parties.
kvaTheirPdFloor always floors the PD behind OurKVACCR, kvaOurPdFloor the one behind TheirKVACCR (lines 378-379).
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.
Summary
With
flipViewXVA=Y, the netting-set KVA numbers (OurKVACCR,TheirKVACCR,OurKVACVA,TheirKVACVA) are wrong in three independent ways, all inPostProcess::updateNettingSetKVA()(OREAnalytics/orea/aggregation/postprocess.cpp, line numbers as of3b62ba248):kvaTheirCvaRiskWeightalways feedsOurKVACVAandkvaOurCvaRiskWeightalways feedsTheirKVACVA, flipped or not (lines 494-495).kvaTheirPdFlooralways floors the PD behindOurKVACCR,kvaOurPdFloorthe one behindTheirKVACCR(lines 378-379).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 byupdateNettingSetCvaSensitivity()(line 530), which runs right after, so withcvaSensi=Yevery 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
kvaon. The user guide's own parameter listing (kvaOurCvaRiskWeight0.005 vskvaTheirCvaRiskWeight0.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 thatdvaName,kvaOur*andkvaTheir*are all specified from the bank's point of view, flipping the view should swap every party-specific input together with the exposure:OurKVA*usesTheirKVA*useskvaTheirPdFloor,kvaTheirCvaRiskWeightdvaNamePD/LGD,kvaOurPdFloor,kvaOurCvaRiskWeightdvaNamePD/LGD,kvaOurPdFloor,kvaOurCvaRiskWeightkvaTheirPdFloor,kvaTheirCvaRiskWeightFor an uncollateralised netting set the flip is an exact sign change of the netted NPV (
CubeInterpretation::getGenericValue), soEPE^flip == ENE^normalbit for bit and one expects exactlyOurKVACCR^flip == TheirKVACCR^normal,TheirKVACCR^flip == OurKVACCR^normal, and the same for KVA-CVA. Today the PD/LGD swap is done (viacid = dvaName_/dvaName_ = counterparty), but the floors and risk weights stay attached to the output column.Code
Reproduction
Built from
master(3b62ba248) on macOS arm64. Inputs are the shippedExamples/Exposure/Input/ore_flipview.xmlwith: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 valueskvaOurPdFloor=0.20,kvaTheirPdFloor=0.03,kvaOurCvaRiskWeight=0.005,kvaTheirCvaRiskWeight=0.05. OnlyflipViewXVAand the portfolio differ between runs.Single counterparty (netting set CPTY_A, 2 swaps), normal vs flipped:
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). TheTheirKVACCRrow is off by0.20 / PD99(CPTY_A) = 1.2487: under flip that branch carries CPTY_A's PD but still getskvaOurPdFloor = 0.20, which then binds (defect 2); BANK's worst-case PD is above both floors, which is why theOurKVACCRrow 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:
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_), pickactiveOwnParty = flip ? cpty : bankandactiveCounterparty = flip ? bank : cpty, and derive PD, LGD, floor and risk weight of branch 1 fromactiveCounterpartyand of branch 2 fromactiveOwnParty. The normal-view arithmetic is untouched,dvaName_is only read, andupdateNettingSetCvaSensitivity()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 withoutkva) to follow.Notes:
ValueAdjustmentCalculator::build()already snapshotsorigDvaNamebefore 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.