From e7bdd5bf86cab26b89c796cacae5741cf325e458 Mon Sep 17 00:00:00 2001 From: adaminoff Date: Thu, 13 Aug 2026 17:24:42 -0400 Subject: [PATCH] Implement SA-CCR credit asset-class add-on with rating-based supervisory factors The Credit branch of SaccrCalculator's add-on aggregation and the credit sub-asset-class classification in SaccrTradeData::Impl::getBucket were both left as `// TODO`, so any credit trade (e.g. BondTRS/BondRepo) produced a zero credit add-on and therefore did not contribute to EAD. This implements the Basel CRE52 credit add-on: * SaccrCalculator: aggregate reference-entity effective notionals within the credit hedging set via the systematic/idiosyncratic decomposition AddOn = sqrt( (sum_k rho_k SF_k EN_k)^2 + sum_k (1-rho_k^2)(SF_k EN_k)^2 ) with correlation rho = 0.50 (single name) / 0.80 (index). The supervisory factor SF is keyed by the reference-entity rating via a CRE52.44 lookup table (AAA/AA 0.38%, A 0.42%, BBB 0.54%, BB 1.06%, B 1.60%, CCC 6.00%; Index-IG 0.38%, Index-SG 1.06%), with an investment-grade default. * getBucket: derive the credit rating sub-asset class from the SIMM bucket mapper (RiskType CreditQ) when a mapping is configured for the reference entity, falling back to IG / Index-IG. * SimmBucketMapperBase::fromXML: SA-CCR credit rating buckets are non-numeric labels (e.g. "AA"); relax the positive-integer bucket validation so it applies only to numeric SIMM buckets and accepts rating labels as-is. Verified end-to-end on a BondTRS: the credit add-on now scales with the reference-entity rating (AA -> 0.38%) and flows through to PFE and EAD. Co-Authored-By: Claude Opus 4.8 (1M context) --- OREAnalytics/orea/engine/saccrcalculator.cpp | 49 ++++++++++++++++++- OREAnalytics/orea/engine/saccrcalculator.hpp | 2 + OREAnalytics/orea/engine/saccrtradedata.cpp | 30 ++++++++---- .../orea/simm/simmbucketmapperbase.cpp | 11 ++++- 4 files changed, 80 insertions(+), 12 deletions(-) diff --git a/OREAnalytics/orea/engine/saccrcalculator.cpp b/OREAnalytics/orea/engine/saccrcalculator.cpp index 0d9c4ab79e..b49763e181 100644 --- a/OREAnalytics/orea/engine/saccrcalculator.cpp +++ b/OREAnalytics/orea/engine/saccrcalculator.cpp @@ -86,6 +86,24 @@ SaccrTradeData::AssetClass riskTypeToAssetClass(const RiskType& rt) { } } +// Basel CRE52.44 credit supervisory factors. Single-name factors are keyed by +// the reference-entity rating; if no rating is available the investment-grade +// default (BBB, the weakest IG rating) is used. Index factors distinguish +// investment grade from speculative grade. +Real creditSupervisoryFactor(const std::string& ratingBucket, bool isIndex) { + static const std::map singleName = { + {"AAA", 0.0038}, {"AA", 0.0038}, {"A", 0.0042}, {"BBB", 0.0054}, + {"BB", 0.0106}, {"B", 0.0160}, {"CCC", 0.0600}, + {"IG", 0.0054}, {"SG", 0.0106}}; + if (isIndex) { + // ratingBucket is "Index-IG" / "Index-SG" (or a raw rating as fallback) + static const std::set sg = {"Index-SG", "SG", "BB", "B", "CCC"}; + return sg.count(ratingBucket) ? 0.0106 : 0.0038; + } + auto it = singleName.find(ratingBucket); + return it != singleName.end() ? it->second : 0.0054; // default: IG (BBB) +} + } // namespace namespace ore { @@ -107,6 +125,7 @@ void SaccrCalculator::clear() { addOnHedgingSet_.clear(); nettingSets_.clear(); isIndex_.clear(); + creditQuality_.clear(); basisHedgingSets_.clear(); volatilityHedgingSets_.clear(); } @@ -218,6 +237,10 @@ void SaccrCalculator::processCrifRecord(const CrifRecord& record) { else if (record.riskType == RiskType::EQ_SN || record.riskType == RiskType::CR_SN) isIndex_[record.qualifier] = false; + // capture the credit rating sub-asset class (bucket) for the credit SF + if (record.riskType == RiskType::CR_SN || record.riskType == RiskType::CR_IX) + creditQuality_[record.qualifier] = record.bucket; + tradeAssetClasses_[record.tradeId] = assetClass; assetClasses_[record.nettingSetDetails].insert(assetClass); @@ -335,7 +358,31 @@ void SaccrCalculator::aggregate() { } addOnHedgingSet_[it->first] = std::sqrt(addonType * addonType + addonTypeSquared); } else if (assetClass == AssetClass::Credit) { - // TODO + // Basel SA-CCR credit add-on (CRE52.42-52.51). The single credit + // hedging set aggregates reference-entity effective notionals via a + // systematic/idiosyncratic decomposition: + // AddOn = sqrt( (sum_k rho_k SF_k EN_k)^2 + // + sum_k (1 - rho_k^2) (SF_k EN_k)^2 ) + // with the supervisory factor SF keyed by the reference-entity + // rating (single name) or IG/SG (index), and correlation rho = + // 0.50 (single name) / 0.80 (index). + Real systematic = 0; + Real idiosyncratic = 0; + for (const auto& [hedgingSubsetKey, effectiveNotional] : subsetEffectiveNotional_) { + string hedgingSubset = std::get<3>(hedgingSubsetKey); + HedgingSetKey hedgingSetKey(std::get<0>(hedgingSubsetKey), + std::get<1>(hedgingSubsetKey), + std::get<2>(hedgingSubsetKey)); + if (hedgingSetKey != it->first) + continue; + bool isCreditIndex = isIndex_[hedgingSubset]; + Real supervisoryFactor = creditSupervisoryFactor(creditQuality_[hedgingSubset], isCreditIndex); + Real corr = isCreditIndex ? 0.8 : 0.5; + Real tmp = supervisoryFactor * effectiveNotional; + systematic += corr * tmp; + idiosyncratic += (1 - corr * corr) * tmp * tmp; + } + addOnHedgingSet_[it->first] = std::sqrt(systematic * systematic + idiosyncratic); } else QL_FAIL("asset class " << assetClass << " not covered"); diff --git a/OREAnalytics/orea/engine/saccrcalculator.hpp b/OREAnalytics/orea/engine/saccrcalculator.hpp index ed1efe3c32..7d30f05329 100644 --- a/OREAnalytics/orea/engine/saccrcalculator.hpp +++ b/OREAnalytics/orea/engine/saccrcalculator.hpp @@ -148,6 +148,8 @@ class SaccrCalculator { map effectiveNotional_; map subsetEffectiveNotional_; map isIndex_; + // reference-entity credit rating bucket per qualifier (SA-CCR credit SF) + map creditQuality_; vector nettingSetDetails_; map> assetClasses_; diff --git a/OREAnalytics/orea/engine/saccrtradedata.cpp b/OREAnalytics/orea/engine/saccrtradedata.cpp index 5400afeb19..0cf22c952a 100644 --- a/OREAnalytics/orea/engine/saccrtradedata.cpp +++ b/OREAnalytics/orea/engine/saccrtradedata.cpp @@ -1654,17 +1654,29 @@ string SaccrTradeData::Impl::getBucket(const Contribution& contribution) const { bucket = ""; } else if (assetClass == AssetClass::Credit) { - // For single name: SubAsset Class for Credit Single Name, e.g., AAA, AA...IG - // For index: Concatenation of �Index -� and SubAsset Class, e.g., Index - IG, Index - SG - // TODO - /// SNRFOR -> IG, AAA-A - // PREFT1 -> IG, A, BBB - // SECDOM -> SG, BB, B - // SUBL2 -> SG, B-CCC + // Credit sub-asset class drives the supervisory factor (Basel CRE52.44): + // single name -> rating (AAA, AA, A, BBB, BB, B, CCC) + // index -> "Index-IG" or "Index-SG" + // The rating is sourced from the SIMM bucket mapper (RiskType CreditQ) + // if a mapping is configured for the reference-entity qualifier, else we + // fall back to the investment-grade default. + string quality; + auto td = tradeData_.lock(); + if (td && td->bucketMapper()) { + try { + if (td->bucketMapper()->hasBuckets(RiskType::CreditQ)) + quality = td->bucketMapper()->bucket(RiskType::CreditQ, + contribution.underlyingData.qualifier); + } catch (...) { + quality = ""; + } + } if (contribution.underlyingData.isIndex) { - bucket = "Index-IG"; // "Index-SG" + // investment grade unless the mapped quality is a speculative rating + static const std::set sg = {"BB", "B", "CCC", "SG"}; + bucket = sg.count(quality) ? "Index-SG" : "Index-IG"; } else { - bucket = "IG"; + bucket = quality.empty() ? "IG" : quality; } } else if (assetClass == AssetClass::Commodity) { // TODO: SubAsset Class for Commodity - same as hedgingSet in many cases, but not always, e.g. HS=Energy, diff --git a/OREAnalytics/orea/simm/simmbucketmapperbase.cpp b/OREAnalytics/orea/simm/simmbucketmapperbase.cpp index 9b506ba4aa..2eb249e1c8 100644 --- a/OREAnalytics/orea/simm/simmbucketmapperbase.cpp +++ b/OREAnalytics/orea/simm/simmbucketmapperbase.cpp @@ -286,8 +286,15 @@ void SimmBucketMapperBase::fromXML(XMLNode* node) { bucketMapping_[riskType][qualifier].insert(mapping); TLOG("Added SIMM bucket mapping: {" << riskType << ": {" << qualifier << ", " << bucket << ", " << validFrom << ", " << validTo << ", " << fallback << "}}"); if (bucket != "Residual") { - int bucketInt = ore::data::parseInteger(bucket); - QL_REQUIRE(bucketInt >= 1, "found bucket " << bucket << ", expected >= 1"); + // SIMM buckets are positive integers. SA-CCR credit rating + // buckets (e.g. AAA, AA, BBB) are non-numeric labels and are + // accepted as-is; the >= 1 check applies only to numeric buckets. + try { + int bucketInt = ore::data::parseInteger(bucket); + QL_REQUIRE(bucketInt >= 1, "found bucket " << bucket << ", expected >= 1"); + } catch (const std::exception&) { + // non-integer bucket label (e.g. a credit rating) - allowed + } } } }