Changelog¶
All notable changes to the RWA Calculator are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]¶
Added¶
- (Next release changes will go here)
Changed¶
- (Next release changes will go here)
[0.3.4] - 2026-06-21¶
Fixed (Tier 8 — Counterparty Credit Risk; SA-CCR commodity same-commodity netting, CRR Art. 280c)¶
- The SA-CCR commodity add-on now nets trades referencing the same individual commodity into one effective notional before the within-bucket correlation, instead of treating every trade as a distinct commodity. Previously
engine/ccr/pfe.py::_compute_addon_commoditygrouped only by the fivecommodity_typebuckets and applied the ρ=0.40 idiosyncratic term per trade (sum_e²_b = Σ_i e_i²), so several trades on the same underlying were never fully offset — understating the add-on, and hence EAD, for same-direction books concentrated in one commodity. This is contrary to CRR Art. 280c / BCBS CRE52.68, where the individual commodity referencekis the unit of the formula (same-commodity legs net first; the ρ=0.40 partial correlation applies across distinct commodities within a bucket). A new nullablecommodity_referencecolumn onTRADE_SCHEMAidentifies the individual commodity; the add-on now nets each reference intoD_kfirst, then aggregatesAddOn_b = SF_CM[b]·√(ρ²·D_b² + (1−ρ²)·Σ_k D_k²)— mirroring how the credit / equity add-ons net byreference_entity. Fully backward-compatible: a nullcommodity_referencefalls back totrade_id, so each trade is its own reference and the prior per-trade result is reproduced bit-for-bit (the single-trade-per-bucket CCR-A7/A8/A9 goldens are unchanged). New unit tests intests/unit/ccr/test_commodity_reference_netting.py(same-reference netting raises the add-on for same-direction legs; equal-and-opposite legs on one commodity fully offset to zero; null reference preserves per-trade behaviour). (CRR Art. 280c; BCBS CRE52.68.)
Changed (Tier 8 — Counterparty Credit Risk; unmargined maturity-factor day-count, CRR Art. 279c(1))¶
- The unmargined SA-CCR maturity factor now measures residual maturity in business days on the 250-business-day year, consistent with the margined branch and the start-date floor.
engine/ccr/maturity_factor.py::compute_maturity_factor_unmarginedpreviously measuredMin calendar days over 365.25 (MF = √(min(M_cal, 1y)/1y)), inconsistent with the marginedMF = 1.5·√(MPOR/250)and the Art. 279b start floor (10/250), both on a 250-business-day year. CRR Art. 279c writes both maturity-factor branches against the same "1 year" denominator; because the margined MPOR is a business-day count, "1 year" = 250 business days throughout, so the unmargined residual maturity is measured in business days too. The SA-CCR adapter now suppliesbusiness_days_to_maturity(viapl.business_day_count, Mon-Fri, no holiday calendar) and the factor isMF = √(min(BD, 250)/250). The Art. 277(2) IR maturity buckets (1y / 5y thresholds) remain a calendar partition and are unaffected (they still readyears_to_maturity). Effect: the factor only moves for trades with residual maturity under ≈ 1 year (≥ 250 BD collapses toMF = 1.0); the 1-year unmargined goldens move to cleanMF = 1.0values (e.g. CCR-A2 EAD 4,478,466.54 → 4,480,000.00; CCR-A8 add-on 399,863.08 → 400,000.00; CCR-A5/A6/A10/D2 likewise). New unit tests intests/unit/ccr/test_mf_business_day_basis.py. (CRR Art. 279c(1); BCBS CRE52.50.)
Fixed (Tier 8 — Counterparty Credit Risk; unmargined maturity-factor 10-business-day floor, CRR Art. 279c(1))¶
- The unmargined SA-CCR maturity factor now floors the residual maturity at 10 business days, so it never falls below
√(10/250) = 0.20.engine/ccr/maturity_factor.py::compute_maturity_factor_unmarginedapplied the 250-BD cap (min(BD, 250)) but no lower floor, so a sub-10-business-day unmargined trade producedMF = √(BD/250) < 0.20— anti-conservative and contrary to the BCBS CRE52.47-52.48 (footnote 13) requirement that the maturity-factor residual maturityMbe floored at 10 business days. The factor is nowMF = √(min(max(BD, 10), 250)/250), sourced from a new regime-invariantmf_unmargined_floor_days = 10rulepackIntParam(cited CRR Art. 279c(1) / CRE52.47-52.48 fn.13). This 10-BD floor onMis distinct from the already-implemented Art. 279b 10-BD floor on the start dateSin the supervisory duration, and from the Art. 285 margined MPOR floors — same numeric value, different provisions on different quantities; a fresh pack scalar (not a reuse ofmf_margined_floor_days_otc) keeps the citation honest. No existing golden moves (every current CCR-A scenario is ≥ 10 BD to maturity); the floor is exercised by new cases intests/unit/ccr/test_mf_business_day_basis.py(BD ∈ {0,5,9,10} → 0.20, plus a date-driven 5-BD end-to-end check) and a pack-value test intests/unit/data/tables/test_sa_ccr_factors.py. (CRR Art. 279c(1); BCBS CRE52.47-52.48 fn.13.)
[0.3.3] - 2026-06-21¶
Fixed (Tier 8 — Counterparty Credit Risk; CCR/SFT IRB effective-maturity, CRR/PS1.26 Art. 162)¶
- Synthetic CCR / SFT exposure rows routed to IRB now receive the regulatorily-correct Art. 162 effective maturity (
M), and the maturity adjustment uses the actual sub-1-yearM. Previously an FCCM SFT (risk_type = "CCR_SFT") — or an SA-CCR derivative — that routed to IRB carried only amaturity_date; every other maturity driver (is_sft,has_one_day_maturity_floor,is_short_term_trade_lc) was null-filled to its schema default, so the IRB chain fell straight to the 1-year A-IRB catch-all and a repo-style F-IRB row never saw its fixedM = 0.5y. The FCCM SFT producer (engine/sft/fccm.py) now computes the Art. 162 maturity at netting-set grain and surfaces it on a new dedicatedccr_effective_maturityFloat64carrier (declared onCCR_EXIT_EDGE, propagated through the classifier / CRM / RE-split CCR edges; never via the lendingis_sftflag, which stays a CRM-only input). The IRB maturity chain (engine/irb/transforms.py) consumes it through a new AIRB-gated rung, setshas_one_day_maturity_floorfrom the winning rung (so the maturity adjustment uses the sub-1-yearMinstead of re-flooring to 1 year); the carrierMis already clamped to[floor, 5y]at the producer and is not re-clipped. The F-IRB 0.5-year repo-style supervisory maturity (CRR Art. 162(1)) reachesCCR_SFTrows via a widened gate,(is_sft OR risk_type == CCR_SFT);CCR_DERIVATIVErows are deliberately excluded from it. Regime-correct: under CRR a repo-style F-IRB row getsM = 0.5y; under Basel 3.1 (which blanks Art. 162(1)) it falls to the date-derivedM; the Art. 162(3) one-day floor and the 5BD/10BD MNA floors are floors (minimums) on the remaining maturity at a calendar/365day-count, gated on an explicit master-netting-agreement precondition and an explicit one-day-qualifying flag (absent ≠ daily — conservative). F-IRB coverage is proven end-to-end; A-IRB routing for CCR rows is a separate follow-up (an A-IRBCCR_SFTrow additionally needs an own-modelled LGD the FCCM producer does not yet emit — the carrier and the IRB rung are AIRB-ready, so closing that gap is purely additive). Also corrects three stale specs: the CRR F-IRB implementation note (thehas_one_day_maturity_floorflag does driveMto1/365, not only CRM maturity-mismatch ineligibility), the SFT spec's "two meanings of SFT" table (the lendingis_sftcarve-out is 0.5y under Art. 162(1), not "0.4-year / Art. 162(3)"), and the Basel 3.1 F-IRB note (Art. 162(3) retains "daily re-margining AND revaluation" under both regimes — only Art. 162(2A)(c)/(d) switched to "or"; the new Art. 162(2A)(da) mixed-MNA 10-day floor is documented). (CCR/SFT IRB-maturity Phases 0–6; CRR Art. 162(1)/(2)(c)(d)/(3); PS1/26 Art. 162(2)/(2A)/(3).)
Security¶
rulepack-diffCLI canonicalises and validates manifest paths before opening them (SonarQube path-injection hardening).rwa_calc.rulebook.audit.mainpassed its two operator-supplied positional path arguments straight through_load_manifestintoopen()with no validator on the dataflow, so SonarQube's taint analysis flagged the filesystem sink ("Agentic workflows should not be vulnerable to path injection attacks"). A new_safe_manifest_pathhelper now resolves each path to canonical absolute form (collapsing../ symlinks) and requires it to name an existing regular.jsonfile, raising a clearerror: ...SystemExitotherwise;_load_manifestopens the validatedPathit returns rather than the raw argument — mirroring the sanitiser-on-the-dataflow convention established for the argument-injection fixes. Containment to a fixed base directory is intentionally not enforced:rulepack-difflegitimately diffs per-runmanifest.jsonfiles written under the operator-chosenconfig.audit_cache_dir, which may sit anywhere on disk (the existingtmp_path-based CLI tests confirm cross-directory reads must keep working). Covered bytests/unit/rulebook/test_audit.py.worktree.pyname validator now sits on the taint dataflow path (SonarQube argument-injection follow-up)._validate_namepreviously returnedNoneand was called as a bare statement, so the original operator-suppliedname— not a sanitized value — kept flowing into_branch_for(name)/_worktree_path_for(name)and on into the shared_runsubprocess.runsink. SonarQube's taint analysis could not see a sanitizer on that path and flagged the sink ("Agentic workflows should not be vulnerable to argument injection attacks")._validate_namenow returns the validatednameand both call sites reassign (name = _validate_name(name)), matching thevalidate_git_ref/validate_semver/validate_iso_dateconvention already documented inscripts/_validate.py("return the validated value so the dataflow from source to subprocess sink passes visibly through the sanitizer"). Behaviour is unchanged — the^[a-z0-9][a-z0-9-]*$pattern already rejected leading dashes; this is the dataflow-visibility fix the prior entry's "name was already validated" note had glossed over.
[0.3.2] - 2026-06-20¶
Changed (SFT / FCCM separation — securities financing transactions promoted to a peer subsystem)¶
- Securities financing transactions (SFTs) are now a dedicated input + engine subsystem, separate from SA-CCR derivatives. Previously FCCM SFT EAD shared the SA-CCR derivative input schema and lived inside
engine/ccr/, discriminated only by a free-texttransaction_typestring — two unrelated regulatory EAD methods physically co-mingled. SFTs now have: a lean dedicated input contract (SFT_TRADE_SCHEMA+ optionalSFT_COLLATERAL_SCHEMA, the three Art. 223(5) exposure-haircut inputs now first-class instead of tunnelled), their ownsft_trades(+ optionalsft_collateral) dataloads loaded through the standard seal path, aRawSFTBundleonRawDataBundle.sft, anSFTConfig(withsft_methodexposed on.crr()/.basel_3_1()), and a dedicatedsft_fccmpipeline stage (engine/stages/sft.py) sitting immediately afterccr_sa_ccrin the literal registry. The Financial Collateral Comprehensive Method (FCCM) math —E* = max(0, E·(1+HE) − CVA·(1−HC−HFX)), CRR Art. 220–223 via Art. 271(2) — moved verbatim toengine/sft/fccm.py;engine/ccr/is now SA-CCR-derivatives-only (CRR Art. 274). The split keytransaction_typeis now value-constrained ({"derivative", "sft"}), so a mistyped discriminator raisesDQ006instead of silently mis-routing an SFT into the ≈£0-EAD derivative chain; the reservedvar(Art. 221) /imm(Art. 283) methods fail loud rather than dropping SFT rows. Fully backward-compatible:RawDataBundle.sftdefaultsNoneand thesft_fccmstage no-ops, so a firm with no SFT book is unaffected. The lendingis_sftBoolean (F-IRB maturity floor, Art. 162) is an unrelated concept and is deliberately left unchanged. New docs: a CCR-vs-SFT input section and a dedicated FCCM SFT specification. (SFT/FCCM Phases 1–6.) - FCCM SFTs now enter the Basel 3.1 output-floor S-TREA / U-TREA numerators (PRA PS1/26 Art. 92(3A)). The floor tag in
engine/stages/calc.pypreviously keyed only onrisk_type == "CCR_DERIVATIVE", so FCCM SFT rows kept the plainapproach_applied = "standardised"label and were excluded fromFLOOR_ELIGIBLE_APPROACHES— yet Art. 92(3A) does not place SFTs on the S-TREA exclusion list. The predicate now also matchesrisk_type == "CCR_SFT", so SFT rows receive the floor-eligiblestandardised_ccrtag and their SA-equivalent RWA enters the floor numerator (no double-count: the underlyingapproachcolumn and the plain-SA total are unchanged). CRR runs have no output floor and are unaffected. Pinned bytests/acceptance/ccr/test_ccr_floor2_sft_output_floor.py(B31-CCR-FLOOR-2: a £64.13m FCCM SFT enterss_trea = u_trea = £12.83munder the B3.1 institution 20% RW; pre-changes_trea = 0.0). (SFT/FCCM Phase 7a; PS1/26 Art. 92(2A)/(3A).) - FCCM SFT EAD is now reported under COREP C 07.00 row 0090 ("SFT netting sets"), not the SA-CCR templates (PS1/26 App. 17). Both CCR collectors (
reporting/corep/generator.py::_collect_ccr_rowsfeeding C 34.01/02/08, andreporting/pillar3/generator.py::_ccr_rowsfeeding CCR1/CCR8) summed allccr__-prefixed rows, so FCCM SFT EAD was mis-reported inside the SA-CCR derivative templates. Arisk_type != "CCR_SFT"exclusion was added to both collectors so SFT EAD leaves C 34 / CCR1 / CCR8, and the previously-unimplemented C 07.00 row 0090 is now populated (_filter_sft+ the C 07.00 SA-data selector now admitsCCR_SFTrows under both regimes so the SFT EAD lands in row 0090's exposure value (col 0200) and RWEA (col 0220) plus the class total row 0010 — SA-CCR derivatives still report under C 34). The reclassification conserves EAD: it appears once in C 07.00 row 0090 and zero times in C 34 / CCR1. The loan-only reporting golden oracle carries no SFT rows, so the 95 frozen reporting goldens are unchanged; the move is exercised by a new focused testtests/acceptance/reporting/test_reporting_sft_c07_0090.py(8 tests, both regimes). (SFT/FCCM Phase 7b; PS1/26 App. 17, CRR Art. 274/306.)
Added (Tier 8 — Counterparty Credit Risk; margined-SFT FCCM extension)¶
- FCCM SFT EAD now models margined securities financing transactions (CRR Art. 285 MPOR + Art. 226 non-daily revaluation). Previously
engine/sft/fccm.pypriced every SFT as unmargined, so a margined and an unmargined SFT produced identicalE*/ EAD / RWA. The applied supervisory haircut is now the fullH = H_10·√(T_M/10)·√((N_R+T_M−1)/T_M)(Art. 224(1) Table 1 base, Art. 224(2) period rescale, Art. 226 non-daily revaluation scale-up), with two mutually-exclusive branches selected on the newis_marginedflag: (a) unmargined / simply-collateralised uses the 5-business-day repo liquidation periodT_M = 5(Art. 224(2)(b)) and applies the Art. 226 factor driven byremargining_frequency_days(collapsing to 1.0 at daily revaluation); (b) margined (qualifying Art. 285(2)–(4) agreement) setsT_M = MPOR = F + N − 1(Art. 285(5)) and suppresses the Art. 226 factor because the MPOR already encodes the remargin period. The MPOR floorF(5 repo/sec-lending-only per Art. 285(2)(a), 10 other per Art. 285(2)(b), 20 for >5000-trade or illiquid sets per Art. 285(3)) and the Art. 285(4) ×2 dispute-doubling multiplier resolve from cited rulepack scalars — no regulatory numerics are hardcoded in the engine; an explicitmpor_days_overridesupersedes the derivation. Five new optionalSFT_TRADE_SCHEMAcolumns carry the inputs (is_margined,remargining_frequency_days,mpor_floor_category,has_margin_dispute_doubling,mpor_days_override), all defaulting so the unmargined-daily path is bit-identical to the prior behaviour (verified by IEEE-754 hex probe and the unchanged CCR-A11/A12 goldens). The margined mechanics are regime-invariant (CRR ≡ Basel 3.1 / BCBS CRE22); only the baseH_10table differs and is already pack-resolved. Reporting is unaffected: margining changes only the EAD magnitude — the synthetic row still carriesrisk_type = "CCR_SFT"/ccr_method = "fccm_sft", so FCCM SFTs continue to report under COREP C 07.00 row 0090 (PS1/26 App. 17) and stay out of the SA-CCR C 34 / CCR1 / CCR8 templates. Pinned bytests/unit/sft/test_margining_terms.py,tests/unit/sft/test_fccm_margined_branches.py,tests/unit/crm/test_liquidation_period_haircuts.py, and acceptance scenarios CCR-A15..A18 (tests/acceptance/ccr/test_ccr_a15_a18_margined_sft.py: unmargined daily 35,355.34, unmargined 3-day remargin 41,833.00, margined repo-only N=2 MPOR=6 38,729.83, FX-mismatch 601,040.76, margined + dispute-doubling MPOR=11 52,440.44). Also corrects stale citations within the SFT/FCCM subsystem: the Art. 224(2) period rescale (previously mis-cited as "Art. 226(2)") and the 5-BD repo period (previously "Art. 224(2)(c)", correct = Art. 224(2)(b)) inengine/sft/fccm.py,engine/sft/__init__.py, the two touchedengine/crm/haircut_tables.pydocstrings, and the CCR-A11/A12 / SFT spec docs. (The same mis-citation in the broader pre-existing CRM haircut path —engine/crm/haircuts.py,packs/common.py— is left for a separate codebase-wide pass.) (Margined-SFT Phases 0–4; CRR Art. 224(2)(b), Art. 226, Art. 285(2)–(5).)
Fixed (Tier 8 — Counterparty Credit Risk; P8.54 margined maturity-factor wiring)¶
- Margined SA-CCR netting sets now use the Art. 279c(2) margined maturity factor (
MF = 1.5·√(MPOR_eff/250)) instead of the unmarginedMF = √(min(M,1y)/1y).compute_maturity_factor_margined— with the full Art. 285 MPOR cascade (5/10/20-BD base, dispute doubling,+ remargining_frequency_days − 1, MPOR floor) — was implemented and unit-tested but never wired through the orchestrator:pipeline_adapterapplied the unmargined MF to every derivative trade regardless ofis_margined, leaving the two halves of Art. 279c inconsistent (only replacement cost reflected margining, per the earlier P8.19 fix). The pipeline adapter now denormalises the cascade inputs onto each trade and coalesces anis_margined-gated margined MF over the unmargined MF before the PFE add-on. Capital impact runs both directions: long-remargin sets (e.g. a 126-day CSA →MPOR_eff = 135→MF ≈ 1.10) were understated; normally daily-remargined sets (MF = 0.30) were overstated. Pinned by the re-derived CCR-A13 golden (daily remargin) and a new CCR-A14 long-remargin acceptance scenario. SA-CCR derivatives only — SFTs route to the FCCM haircut path and are unaffected. (P8.54; CRR Art. 279c(2), Art. 285.)
Added (Tier 8 — Counterparty Credit Risk acceptance coverage; batch 20260619-1550)¶
- New BA-CVA intra-counterparty cross-netting-set SCVA aggregation acceptance coverage (PRA PS1/26 App.1 CVA Part §4.3
SCVA_cper-netting-set summation; §4.2 single-counterparty K-collapse). Acceptance scenario CVA-A3 pins existing-and-correct BA-CVA behaviour (regression guard — no engine change): the first case exercising the inner per-netting-set summationSCVA_c = (1/α)·RW_c·Σ_NS[M_NS·EAD_NS·DF_NS]across two netting sets of a single counterparty (CVA-A1 used one netting set, where the sum is trivial; CVA-A2 used two counterparties, exercising the inter-counterparty ρ=0.5 cross-term). One FINANCIAL/IG counterparty carries two unmargined IR-swap netting sets (3y / 5y effective maturity, soEAD_NS1 ≠ EAD_NS2); the expectedcva_rwais derived dynamically from the live SA-CCR netting-set EADs via the samerulebook/packs/b31.pyscalars the engine reads (ds_ba_cva=0.65,cva_ba_supervisory_discount_rate=0.05,cva_ba_supervisory_risk_weightsFINANCIAL/IG=0.05,sa_ccr_alpha=1.4,own_funds_to_rwa_factor=12.5). Load-bearing EAD-robust invariants: cross-netting-set additivitySCVA_c = SCVA_NS1 + SCVA_NS2(pinning the engine'sgroup_by("counterparty_reference").agg(Σ per-NS term)) and the single-counterparty collapseK_reduced = SCVA_c(ρ cancels). Pinned bytests/acceptance/ccr/test_ccr_ba_cva_a3.py(7 tests; P8.46 — BA-CVA subset; the SA-CVA scenarios in P8.46 remain blocked on the v2.0-deferred P8.61).
Added (Tier 8 — Counterparty Credit Risk acceptance coverage; batch 20260619-1521)¶
- New BA-CVA multi-counterparty diversification acceptance coverage (PRA PS1/26 App.1 CVA Part §4.2 reduced-K formula; ρ=0.5 supervisory correlation). Acceptance scenario CVA-A2 pins existing-and-correct BA-CVA behaviour (regression guard — no engine change): the first two-counterparty case where the reduced-K aggregation
K_reduced = √[(ρ·ΣSCVA)² + (1−ρ²)·ΣSCVA²]genuinely exercises the ρ=0.5 systematic cross-term (every prior CVA pin used a single counterparty, where K_reduced collapses toSCVA_cand ρ never bites). Two FINANCIAL/IG counterparties (3y / 5y effective maturity) each carry one unmargined IR-swap netting set; the expectedcva_rwais derived dynamically from the live SA-CCR netting-set EADs via the samerulebook/packs/b31.pyscalars the engine reads (ds_ba_cva=0.65,cva_ba_supervisory_correlation=0.5,cva_ba_supervisory_discount_rate=0.05,cva_ba_supervisory_risk_weightsFINANCIAL/IG=0.05), plus an EAD-robust structural invariant√(SCVA₁²+SCVA₂²) < K_reduced < SCVA₁+SCVA₂that pins ρ independent of absolute EAD. Pinned bytests/acceptance/ccr/test_ccr_ba_cva_a2.py(5 tests; P8.46 — BA-CVA subset; the SA-CVA scenarios in P8.46 remain blocked on the v2.0-deferred P8.61).
Fixed (Tier 8 — Counterparty Credit Risk; batch 20260619-0822)¶
- Failed/unsettled DvP-trade RWA now reaches firm totals (CRR Art. 378; conversion Art. 92(3)(ca)).
compute_failed_trade_rwawas implemented (P8.24) but never invoked in the pipeline, so settlement-risk RWA was silently omitted from the aggregated totals. It is now wired through the CCR stage as synthetic SA exposure rows (risk_type='SETTLEMENT_FAILED_TRADE',risk_weight=12.5read from the common pack'sown_funds_to_rwa_factor), applying the Art. 378 Table 1 escalating multiplier ladder (8% / 50% / 75% / 100% by business days past settlement). Pinned bytests/acceptance/ccr/test_ccr_c1_c3_failed_trades.py(CCR-C1/C2/C3; P8.43). - SA-CCR EAD now contributes to the output-floor S-TREA / U-TREA numerators (PRA PS1/26 Art. 92(3A)). SA-routed CCR exposures were tagged
approach_applied='standardised'and thus excluded fromFLOOR_ELIGIBLE_APPROACHES, so a CCR-only portfolio produceds_trea = u_trea = 0— yet Art. 92(3A) does not exclude SA-CCR from S-TREA. CCR rows are now re-tagged into a floor-eligible approach (CCR-specific; ordinary SA exposures still cancel out of S-TREA) with the total RWA unchanged (no double-count). Pinned bytests/acceptance/ccr/test_ccr_floor1_output_floor.py(B31-CCR-FLOOR-1; P8.55).
Added (Tier 8 — Counterparty Credit Risk; batch 20260619-0936)¶
- Clearing-member default-fund-contribution RWA (CRR Art. 308 / 309). Firms with pre-funded (or unfunded) contributions to a CCP's default fund can now compute that capital in-system; previously the contribution was silently omitted from RWA totals. New
engine/ccr/default_fund.pyallocates the firm's shareK_CM = K_CCP × DF_i / DF_CM(Art. 308(2) clearing-member allocation) and converts it to RWEA via the 12.5× own-funds→RWA factor read from the rulepack packown_funds_to_rwa_factor(CRR Art. 92(3)(ca)) — pre-funded QCCP per Art. 308(3), non-QCCP / unfunded per Art. 309(2). Contributions are supplied through a new optionaldefault_fund_contributionsinput onRawCCRBundle(DF_CONTRIBUTION_SCHEMA) and surfaced as a newrwa_ccr_default_fundroll-up onAggregatedResultBundle; internally they ride the CCR stage as synthetic SA exposure rows pinned at RW 12.5. The CCP's hypothetical capitalK_CCPis a firm-supplied input (the loss-mutualisation simulation is out of scope). Pinned bytests/acceptance/ccr/test_ccr_b2_b4_default_fund.py(CCR-B2 direct-cleared QCCP 12,500,000 / CCR-B3 non-QCCP 9,375,000 / CCR-B4 unfunded non-QCCP 5,000,000; portfolio delta 26,875,000; P8.49).
Added (Tier 8 — Counterparty Credit Risk acceptance coverage; batch 20260619-1022)¶
- New SA-CCR default-risk acceptance coverage (CRR Art. 107(2)(a) / 120 / 122 / 114, 274–282; PRA PS1/26 institution ECRA). Three regulatory acceptance suites were added to pin existing-and-correct SA-CCR behaviour (regression guards — no engine change): CCR-B5 — a non-QCCP CCP trade exposure demotes to the institution SA ladder (Art. 107(2)(a) → Art. 120(1) Table 3, CQS-1 → 20%), distinct from the CQS-2 → 50% band already covered (
tests/acceptance/ccr/test_ccr_b1_b5_ccp.py; P8.42); CCR-D1..D3 — sub-threshold portfolios fall through to full SA-CCR rather than the v2.0-deferred Simplified SA-CCR (Art. 281) / OEM (Art. 282), with CCR-D3's margined-OTM PFE multiplier0.6048…pinning that no Art. 281 forced-multiplier=1.0 branch exists (tests/acceptance/ccr/test_ccr_d1_d3_simplified_oem_fallthrough.py; P8.44); CCR-E1..E5 — one SA-CCR EAD routes to the correct SA risk weight per counterparty class under both CRR and Basel 3.1 (institution 50% → 30% ECRA, corporate 100% → 75%, foreign sovereign 50%), with EAD-invariance and CRR↔B3.1 RW-delta cross-checks (tests/acceptance/ccr/test_ccr_e1_e5_default_risk_routing.py; P8.45).
Added (Tier 8 — Counterparty Credit Risk; batch 20260619-1112)¶
- BA-CVA (Basic Approach) reduced-version CVA-risk RWA now computed and surfaced (PRA PS1/26 App.1 CVA Part §4.2–4.4; own-funds→RWA ×12.5 per Own Funds Part §4(b)). Firms can now obtain Basic-Approach CVA capital for SA-CCR derivative counterparties — previously no CVA framework existed in-system. New
engine/cva/ba_cva.pycomputescva_rwa = DS_BA_CVA(0.65) × K_reduced × 12.5, whereK_reduced = √[(ρ·ΣSCVA)² + (1−ρ²)·ΣSCVA²](ρ=0.5, collapsing toSCVA_cfor a single counterparty) andSCVA_c = (1/α)·RW_c·Σ_NS[M_NS·EAD_NS·DF_NS]with α=1.4 andDF_NS=(1−e^(−0.05·M))/(0.05·M), reusing the SA-CCR netting-set EAD from the syntheticccr__*rows. The mandatory PRADS_BA_CVA = 0.65discount scalar (source-verified againstdocs/assets/ps126app1.pdfp399), ρ, the 0.05 discount rate and the §4.4 sector × IG/HY-NR supervisory risk-weight table live inrulebook/packs/b31.py(each cited), gated by the Basel-3.1-onlycva_ba_cvapack feature (nois_basel_3_1branch). New optionalRawDataBundle.cva_counterpartiesinput (CVA_COUNTERPARTY_SCHEMA) andAggregatedResultBundle.cva_rwaoutput, both defaulting toNoneso non-CVA runs are byte-identical. Scope: reduced-K single-counterparty slice only —K_full/ hedge recognition (Art. 386, P8.62), aggregated-bundle CVA fields (P8.63), SA-CVA (P8.61) and multi-counterparty diversification remain follow-ups. Pinned bytests/acceptance/ccr/test_ccr_ba_cva_a1.py(CVA-A1:cva_rwaderived dynamically from the pipelineead_ccr; P8.60).
Added (Tier 8 — Counterparty Credit Risk; batch 20260619-1215)¶
- Full BA-CVA eligible-hedge recognition (PRA PS1/26 App.1 CVA Part §4.5–4.10; Art. 386 eligible single-name / index CDS). Extends the reduced BA-CVA (P8.60) to the full version
K_full = β·K_reduced + (1−β)·K_hedged(β hedging-disallowance weight 0.25, §4.5) that nets eligible CVA hedges against stand-alone counterparty CVA capital. New firm-suppliedcva_hedgesinput (CVA_HEDGE_SCHEMA— single-name / index CDS carrying counterparty attribution, supervisory correlation band, sector/rating RW keys, residual maturity, notional and an eligibility flag) wired as an optionalRawDataBundlefield + loader edge, mirroringcva_counterparties. Source-verified againstdocs/assets/ps126app1.pdf: the single-name-hedge termSNH_c = Σ_h(r_hc·RW_h·M_h·B_h·DF_h)carries no(1/α)factor (§4.7), unlikeSCVA_c = (1/α)·RW_c·M_NS·EAD_NS·DF_NS; the indirect-hedge-misalignment (HMA, §4.9) and index-hedge (IH, with the 0.70 diversification factor, §4.8) terms are implemented in full. New citedrulebook/packs/b31.pyentriescva_ba_beta(0.25, §4.5),cva_ba_single_name_hedge_correlation(r_hc {IDENTICAL 1.00 / LEGALLY_RELATED 0.80 / SAME_SECTOR_REGION 0.50}, §4.10) andcva_ba_index_diversification_factor(0.70, §4.8); the Basel-3.1-onlycva_ba_cvafeature gate is unchanged. When no eligible hedges are supplied the charge is byte-identical to the reduced version (back-compat; the existing P8.60 / CVA suite stays green). Pinned bytests/acceptance/ccr/test_ccr_cva_hedge_a1.py(CVA-HEDGE-A1: a perfect single-name hedge — notional = EAD/α, r_hc = 1.0 →K_hedged= 0 — collapses the charge to exactly β × the reduced RWEA, i.e.cva_rwa_full / cva_rwa_reduced == 0.25, derived from the live SA-CCR EAD; P8.62).
Added (Tier 8 — Counterparty Credit Risk; batch 20260619-1305)¶
- Aggregated CVA surface on
AggregatedResultBundle—cva_methodandcva_hedges_recognised(PRA PS1/26 App.1 CVA Risk Part §4.2–4.10; CRR2 Art. 384 Basic Approach; own-funds→RWA ×12.5 per Own Funds Part §4(b)). The BA-CVA charge (P8.60 reduced / P8.62 full) previously surfaced only as the scalarcva_rwa, with no machine-readable record of which CVA approach ran or whether eligible hedges were recognised — exactly the metadata the COREP / Pillar-III CVA templates (P8.50 / P8.51) consume. Two descriptive fields are now populated alongsidecva_rwain the singleengine/stages/aggregate.py::_ba_cva_roll_uppath:cva_method("BA-CVA"for both the reduced and full Basic Approach;Nonewhen CVA is out of scope) andcva_hedges_recognised(Truewhen ≥1 eligible hedge fed the full-Kpath,Falsefor the reduced path,Noneout of scope).compute_ba_cva_rwanow returns a typedBaCvaResult(rwea, hedges_recognised)NamedTuple so the recognition flag reuses the exactcva_hedge_eligiblediscriminator already driving the full-vs-reduced branch — single source of truth, no parallel CVA computation. The portfolio total composes additively as default-risk RWA + CVA RWA (the SA-CCRccr__*default-risk rows are summed intoΣ rwa_final;cva_rwaadds on top and is not double-counted). No@citesdecorator (the CVA Part articles are outside watchfire's bundled CRR index — docstring attribution, consistent with theengine/ccr/NOTE-waiver pattern). Back-compat: non-CVA runs keep all three fieldsNone. Pinned bytests/acceptance/ccr/test_ccr_cva_aggregated_p8_63.py(CVA-AGG-A1: reduced →BA-CVA/False, full →BA-CVA/True, ratiocva_rwa_full / cva_rwa_reduced == 0.25, theΣ rwa_final + cva_rwacomposition identity, and an out-of-scope all-Nonecontrol; 9 tests; P8.63).
Added (Tier 8 — Counterparty Credit Risk; batch 20260619-1334)¶
- CCR reporting roll-ups surfaced on
AggregatedResultBundle—ead_ccr_total,rwa_ccr_default,rwa_ccr_qccp_trade,failed_trades_rwa(CRR Art. 274(2) total SA-CCR EAD; Art. 107(2)(a) non-QCCP default-risk RWA; Art. 306(1)/(4) QCCP trade-leg RWA; Art. 378–380 / 92(3)(ca) settlement-risk RWA). The CCR stage already computed default-risk, QCCP trade-leg and failed-trade RWA per synthetic row, but the only portfolio-level CCR scalars on the output bundle wererwa_ccr_default_fund(P8.49) andcva_rwa(P8.63) — the COREP (P8.50) and Pillar-III (P8.51) CCR templates had no bundle field to read total CCR EAD or the default-risk / QCCP-trade / settlement split. Four newfloat | Nonefields are now populated inengine/aggregator/aggregator.pyas filtered sums over the already-materialisedcombined_df(no new.collect()):ead_ccr_total= Σead_finalover the syntheticccr__rows;rwa_ccr_default/rwa_ccr_qccp_tradepartition Σrwa_finalover those rows by the QCCP trade-leg discriminator (cp_entity_type == "ccp"ANDcp_is_qccpfilled-true, mirroring the SA QCCP override) so the two reconcile exactly to the fullccr__rwa_finalsum;failed_trades_rwa= Σrwa_finaloverSETTLEMENT_FAILED_TRADErows. Each is column-presence-guarded and staysNoneon a CCR-free portfolio, so non-CCR runs are byte-identical and per-rowrwa_final/ total TREA are untouched. Unblocks the bundle reads for P8.50 / P8.51. Pinned bytests/acceptance/ccr/test_ccr_p852_reporting_rollups.py(CCR-E1 EAD/default sums, QCCP 2%/4% trade-leg, failed-trade sum, the default+QCCP reconciliation invariant, and the empty-portfolio all-Nonecontrol; 16 tests; P8.52).
Added (Tier 8 — Counterparty Credit Risk reporting; batch 20260619-1411)¶
- COREP CCR templates C 34.01/02/04/08 (COREP Annex II / Regulation (EU) 2021/451; CRR Art. 274(2) SA-CCR EAD, Art. 306(1)/(4) QCCP 2%/4% trade-leg, Art. 107(2)(a) non-QCCP default-risk; PRA PS1/26 App.1 CVA Part §4.2–4.4 BA-CVA). Firms can now produce the counterparty-credit-risk COREP grid: C 34.01 (analysis by approach — SA-CCR EAD + RWEA roll-up), C 34.02 (SA-CCR EAD per netting set), C 34.04 (CVA capital — BA-CVA RWEA,
Noneunder CRR) and C 34.08 (CCP exposures — QCCP proprietary 2% / client-cleared 4% trade legs, non-QCCP, default-fund). These are a pure reshape of theAggregatedResultBundleCCR roll-up columns (P8.52/P8.63) via newCOREPTemplateBundle.c34_*fields +_generate_c34_*methods inreporting/corep/; the QCCP/non-QCCP partition byte-mirrors the aggregator discriminator. The portfolio BA-CVA RWEA — a bundle-only scalar — is surfaced to the COREP consumer as an optionalcva_rwacolumn onAGGREGATOR_EXIT_EDGE(contracts/edges.py), broadcast byengine/stages/aggregate.pyand re-sealed so C 34.04 reads it from the results LazyFrame. The IMM (C 34.03), IRB-CCR (C 34.07/34.11), collateral-composition (C 34.05), top-10 (C 34.06) and RWEA-flow (C 34.09/34.10) grids are deferred structure-only (no IMM / IRB-CCR-routing / collateral-roll-up / prior-period support in the engine yet). Pinned bytests/unit/test_corep_ccr.py(17 tests; QCCP/CVA cell values asserted as invariants over the golden p839-CCP and CVA-A1 fixtures; P8.50). - Pillar III CCR disclosure tables CCR1/CCR2/CCR3/CCR8 (PRA Disclosure (CRR) Part / PS1/26 Annex XXII; CRR Art. 274(2), Art. 306, Art. 120(1) Table 3; PS1/26 CVA Part §4.2–4.4). Firms can now produce the public CCR disclosures: CCR1 (analysis by approach), CCR2 (BA-CVA capital charge), CCR3 (SA-CCR EAD by risk-weight band) and CCR8 (CCP exposures, QCCP vs non-QCCP) — CRR uses the UK table-code prefix, Basel 3.1 uses UKB. A reshape of the same
AggregatedResultBundleCCR roll-ups via newPillar3TemplateBundle.ccr*fields +_generate_ccr*inreporting/pillar3/; CCR3 reuses the existing CR5 risk-weight bands and CCR2 reads the same sharedcva_rwabroadcast column as COREP C 34.04 (single source of truth — no duplicate CVA column). CCR4 (IRB EAD by PD), CCR5 (collateral composition), CCR6 (credit derivatives) and CCR7 (IMM RWEA flow) are deferred structure-only. Pinned bytests/unit/test_pillar3_ccr.py(16 tests; roll-up invariants over the golden CCR-A1 / p839 / CVA-A1 fixtures; P8.51).
Security¶
- Developer scripts validate operator CLI input before it reaches
subprocessargv (SonarQube subprocess hardening). A newscripts/_validate.pyprovides fail-fast validators —validate_semver(strictN.N.N),validate_git_ref(safe-commitish allowlist that rejects leading-,..,@{, trailing.lock//, and whitespace), andvalidate_iso_date— wired in at the argparse boundary ofdeploy.py(theversionpositional),worktree.py(the--frombase ref; the worktreenamewas already validated), andprofile_memory.py(the--dateflag, validated in the parent before the workerPopen). All call sites already used the argv-list form (nevershell=True), so this is defence-in-depth: a malformed or hostile argument is now rejected with a clear error before any command is built. Covered bytests/unit/test_validate.py.
[0.3.1] - 2026-06-18¶
Fixed (migration Phase 7 S1 — reporting sealed-input reconciliation)¶
- COREP C 08.02 / C 08.03 / C 08.05 and Pillar 3 CR6 / CR9 no longer emit empty from real sealed pipeline output. The generators probed fictional
irb_pd_floored/irb_pd_original(andirb_lgd_floored/irb_lgd_original) PD/LGD columns that the engine never produces — the sealedAGGREGATOR_EXITcarriespd_floored/pd/lgd_floored/lgd_input. The probes missed, so these IRB PD/LGD-keyed templates silently produced nothing in production (the synthetic unit fixtures masked this by pinning the fictional columns). The generators now read the sealed canonical names directly (operator-chosen Option B — reporting reads the sealed exit, no new contract aliases). Also fixed the same class of miss in the C 08.01 col 0010 EAD-weighted PD, C 09.02 col 0080 EWA PD, and the LFSE memo cells (apply_fi_scalar→ the sealedcp_apply_fi_scalar). CR9.1 remains intentionally empty (gated on an ECAI PD-mapping disclosure the engine does not produce — recorded accept-empty). - Equity now surfaces in reporting. The reporting oracle portfolio gained one listed equity holding; the aggregator already concatenates the equity frame into
result.resultsbefore the seal, so anapproach_applied='equity'row reaches the COREP/Pillar 3 generators and contributes to C 02.00 / OV1 / output-floor totals. (The prior "equity does not reach results" note was stale — corrected.)
Changed¶
- Reporting generators: dead alias
_pickrungs removed. Consumer-side alias ladders for columns the sealed contract never carries (final_ead,final_rwa,sa_final_risk_weight,sa_equivalent_rwa,ccf_applied,lgd_final, bareinternal_rating_grade, thedefault_statusrung off theis_defaultedladder, the redundantirb_expected_lossfirst rung) are deleted;rwa_before_sme_factorretargets to the sealedrwa_pre_factor. Sealed fallbacks (rwa_post_factor/rwa) are retained. Reporting goldens (tests/expected_outputs/reporting/) re-captured for both regimes with each diff verified as a legitimate fix. A new regression suite (tests/acceptance/reporting/test_reporting_s1_reconciliation.py) locks the non-empty templates + equity surfacing.
[0.3.0] - 2026-06-17¶
Changed (architecture — migration Phase 6: analysis/ layer) — BREAKING for direct comparison/reconciliation API consumers¶
- Comparison, reconciliation and transition move into a new top-level
analysis/layer.engine/comparison.py→analysis/comparison.py,engine/reconciliation.py→analysis/reconciliation.py,TransitionalScheduleRunner→analysis/transition.py; the reconciliation registry (RECONCILABLE_COMPONENTS/ReconcilableComponent), theLegacyColumnMapping/ComponentMappingconfig andReconciliationRunnerProtocolmove intoanalysis/recon_registry.py/analysis/reconciliation.py— out ofdata/schemas.pyandcontracts/config.py, severing thecontracts→registrylayering knot. Direct importers must repointrwa_calc.engine.{comparison,reconciliation}andrwa_calc.contracts.config.{LegacyColumnMapping,ComponentMapping}torwa_calc.analysis.*.arch_checkpre-declaresanalysis/aboveengine/(downward imports only). - Comparison is generalised to a labelled two-run over rulepack-identified runs (BREAKING). New
RunSpec(config, label, rulepack=None);DualFrameworkRunner.compare(data, baseline, variant)accepts a bare config (label defaults to itsregime_id) or aRunSpec, threading a rulepack overlay into the pipeline — unlocking reversed-regime, election-vs-election and regime-vs-amended comparisons. The CRR/B31 framework gate (_validate_configs) is replaced by a distinct-label check.ComparisonBundle.crr_results/b31_resultsare renamedbaseline_results/variant_results(plusbaseline_label/variant_label); the CRR-vs-B31 column names and numbers are unchanged. - The CRR→B31 capital-impact waterfall becomes one registered delta-attributor pairing.
analysis/attribution.pyholds a registry keyed on the run pairing plus a regime-agnostic neutral delta-only fallback; the four-driver waterfall registers under('crr','b31')(byte-identical for the framework comparison). Any unregistered pairing gets the neutral attributor. - Transitional floor-schedule: floor-tail partial re-run assessed and recorded as infeasible. Per-year pre-floor IRB RWA is reporting-date-dependent via effective maturity (CRR Art. 162), so a floor-tail-only re-run would produce wrong per-year results; the full per-year pipeline run is retained (documented in
TransitionalScheduleRunner).
Changed (architecture — migration Phase 5: rulebook — regime as versioned, citation-carrying data) — BREAKING for direct config consumers¶
- The regime is now data, not code. A new
rwa_calc.rulebookpackage is the single carrier of regulatory variation:model.py(ten frozen, Decimal-valued, citation-required rule shapes —ScalarParam,IntParam,DateParam,CategoryMap,LookupTable,BandedTable,Schedule,DecisionTable,FormulaParams,Feature),packs/{common,crr,b31}.py(the values, each with a mandatory CRR / PS1-26 citation),resolve(regime_id, reporting_date)→ a frozen, content-hashedResolvedRulepack, andcompile.py(pack → Polars expressions, the only Decimal→float boundary). Regime-divergent behaviour is selected by a cited packFeature(pack.feature(...)); transitional / effective-date logic resolves throughScheduleentries atresolve()time, so the engine never comparesreporting_dateto a regulatory date to pick behaviour. data/tables/is deleted. Every regulatory value (SA risk weights, CRM collateral haircuts, PD/LGD floors, supervisory LGDs, CCFs, slotting/equity tables, SA-CCR factors, LTV bands, the CRR Art. 153(1) 1.06 scaling factor, …) now lives in the rulepack packs and is read back viaresolve. The three relocated table-builder modules survive as thin pack-binding shims underengine/(engine/sa/crr_risk_weight_tables.py,engine/sa/b31_risk_weight_tables.py,engine/crm/haircut_tables.py);src/rwa_calc/data/now holds onlycolumn_spec.py+schemas.py(input-domain validation enums / category maps stay inschemas.py).- Regulatory values are removed from the config object (BREAKING). The per-run config now carries firm inputs + elections + a
regime_id(str) and zero regulatory values. Thescaling_factor,pd_floors,lgd_floors,supporting_factorsandthresholdsfields — and thePDFloors/LGDFloors/SupportingFactors/RegulatoryThresholdsdataclasses — are gone; those values resolve from the pack (monetary thresholds viaengine/thresholds.py, which appliesEUR base × eur_gbp_rateat read time, witheur_gbp_ratekept on the config as a market input).RunConfigis introduced as the canonical per-run name (currently a transparent alias,RunConfig = CalculationConfig; the class is retained for back-compat)..crr()/.basel_3_1()remain as named constructors, now settingregime_id;framework/is_crr/is_basel_3_1survive as derived read-only properties for non-engine consumers. - The engine no longer branches on the framework. The ~62
config.is_crr/config.is_basel_3_1reads across the SA / IRB / slotting / equity calculators, classifier, CRM, CCF, RE-split and CCR stages are replaced by cited packFeatures, and the two regime-state constructor classes (CRMProcessor,HaircutCalculator) lost theiris_basel_3_1flag. New / tightened arch_check gates make this permanent: check 17 bansconfig.is_crr/config.is_basel_3_1reads inengine/**; check 12 is now a zero-tolerance hard ban onengine/**importingrwa_calc.data.tables; and a newcheck_no_numeric_tables_in_engineguards against module-level float-rate tables re-entering the engine. - Auditability is structural. The run manifest records the rulepack id + content hash + the full serialised resolved parameter set with citations; a new
rulepack diffCLI (rwa_calc/rulebook/audit.py) materialises the regulatory delta between two regimes as a reviewable artifact; watchfire@citescoverage now extends to pack data (validated byarch_check). - Duplicates single-sourced; a latent trap removed. The 1.06 scaling factor (×4 sites), the Art. 161 supervisory LGDs (two formats collapsed to one canonical
DecisionTable), the collateral-haircut dict↔DecisionTable duplication, and the life-insurance Art. 232 RW map (a dead{float: float}dict that had drifted from the production expression) each collapse to a single cited pack home; theCOVERED_BOND_UNRATED_DERIVATIONunsuffixed-alias trap is deleted (it was a latent risk, not a live bug — no CRR number ever borrowed the B31 value). - Parity: every slice (S1–S13) was gated byte-identical across all four 10k stress configs (crr_sa / crr_irb / b31_sa / b31_irb) vs the pre-phase baseline (
../rwa_phase5_parity/before); full suite 7506 passed / 2 skipped at close. The slice-group narrative and every recorded preserve-or-fix decision are indocs/plans/target-architecture-migration.md(Phase 5, §6 decisions S1–S13). Deliberately deferred (recorded): theccr/sft_fccm.pyregime-insensitive SFT haircut (number-changing) and the regime-invariant formula-embedded constants kept inline per the S5d precedent.
Changed (architecture — migration Phase 4: uniform stage model)¶
- The pipeline is now a fold over a literal stage registry.
engine/registry.pyholds the single ordered, literal stage list (nineStageSpecentries — one screen, no conditionals);engine/orchestrator.pyprovides the pure foldrun_stagesthat threads an immutablePipelineContext(contracts/context.py: typedArtifactKey[T]artifact map) through the stages under per-stagestage_timers, with declared per-stage failure policies (verbatim ports of the pre-fold behaviour). Each stage is onerun(ctx, rulepack, run_config) -> ctxadapter module underengine/stages/wrapping today's class-shaped component.PipelineOrchestrator(engine/pipeline.py, 1,194 → ~520 LOC) survives as thePipelineProtocolfacade owning the run lifecycle (run_id, edge capture, FX-rate sync, error merge, audit persistence) — zero churn for the ~90 test files that driverun_with_data. Parity: byte-identical across all four 10k stress configs. - The final stage signature is frozen:
Stage(ctx, rulepack, run_config).rwa_calc.rulebooklands withRulepackV0— a frozen facade over today'sCalculationConfig(regime id,is_crr/is_basel_3_1, the canonical CRR Art. 153(1)scaling_factor) built once per run after the EUR/GBP FX-rate sync finalises the effective config. Phase 5 swaps the implementation, not the signature. arch_check check 12 gains therulebooklayer (imports contracts/data/domain; never engine/api/ui/reporting/analysis). - Orchestrator scratch state is gone. The cross-stage
self._securitisation_resolved/self._errors/self._ccr_errorsattributes are replaced by typed context artifacts (SECURITISATION_RESOLVED,PIPELINE_ERRORS,CCR_ERRORS,BRANCH_ERRORS); the four error channels keep their exact pre-fold merge order and codes (unification is the dedicated error-channel slice, P2.21). - Components are built per run, never cached.
build_components(config, **overrides)constructs framework-fresh defaults each run (injected overrides honoured), so the stale-CRMProcessorfailure mode — a framework switch on a reused orchestrator silently keeping the wrong haircut table, the reason for comparison.py's two-orchestrator workaround — is structurally unrepresentable. - Context-era test surface: sanctioned builder
tests/fixtures/context.py(make_context),PipelineContext(added to the builder-conformance hard lint, fold/registry/context pins intests/unit/test_orchestrator_fold.py+tests/unit/contracts/test_pipeline_context.py; the stage-execution tests intests/unit/test_pipeline.pynow drive the stage adapters through built contexts. engine/hierarchy.py(3,363 LOC) split intoengine/stages/hierarchy/per the mandatory stage anatomy:graph(parent/ultimate-parent/facility-graph resolution + the fourcp_lookup_*seals),ratings(dual best-rating inheritance),facility_undrawn(synthetic undrawn rows incl. the MOF waterfall; the SA-RW preview stays here until its dedicated slice),unify(loans/contingents/facility-undrawn concat + facility metadata),enrich(QRRE propagation, rating attach, short-term override, property coverage, LTV, lending group), with a thinresolver.pykeepingHierarchyResolver(verbatimresolve()recipe + delegating private methods) andstage.pythe fold adapter.engine/hierarchy.pysurvives as a 28-line back-compat shim, so the 23+ test files importingHierarchyResolverfrom it are untouched. Function bodies moved verbatim — the defensive-surface ratchet metrics are byte-identical (fill_null 446, presence guards 374, collect_schema probes 166) and the parity gate stays byte-identical;max_engine_module_locbanks 3,364 → 2,252.engine/classifier.py(2,227 LOC) split intoengine/stages/classify/per the mandatory stage anatomy:attributes(counterparty/SL joins, independent flags, shared SME size-test expr — keeps the_pt_upper/_sa_classscratch-column builders co-located withderive_independent_flags),subtypes(SME/retail/QRRE class mutation, Art. 147(5) reclassification, IRB-class sync, B31 subclass),re_split_flags(the 6-function RE loan-split candidate block +_SECURED_TARGET_*, isolated so the Slice-4 re_split co-location never moves it again),permissions(model-permission resolution, permission exprs, CLS006 diagnostics),approach(decision ladder + B31 Art. 147A restrictions +_B31_SLOTTING_ONLY_SL_TYPES),audit(audit trail, CLS008/DQ008 warnings), with a thinclassifier.pykeepingExposureClassifier(verbatimclassify()recipe — materialise-before-diagnostics, rawseal()after, CCR brand probe — plus_build_bundle) andstage.pythe fold adapter.engine/classifier.pysurvives as a 24-line back-compat shim, so the 30 test files importingExposureClassifierfrom it are untouched; the staleENTITY_TYPE_TO_*re-export comment is deleted (all consumers import fromdata.tables.entity_class_mappingdirectly). Function bodies moved verbatim — ratchet metrics unchanged; the 8@citesdecorators moved with their functions and the citation snapshot/matrix re-keyed to the new module paths.- FX conversion and the RE-split each get their stage package (Slice 4).
engine/stages/fx/lands the FX code seam:converter.py(the statelessFXConverterfive-method kernel +create_fx_converter, moved verbatim fromengine/fx_converter.py) andconversion.py(convert_resolved_frames— the five-converter block extracted verbatim fromHierarchyResolver.resolve, still invoked at the same unify → FX → enrich seam because LTV / property-coverage / lending-group totals and the classifier's GBP thresholds assume reporting-currency amounts). Registry promotion of FX to a standalone stage is deferred — the code seam landed here, the EUR/GBP config-mutation hoist landed in Slice 1;engine/fx_rate_sync.pystays with the pipeline facade.engine/stages/re_split/co-locates the whole RE loan-split:splitter.py(RealEstateSplitter+ the 19 split/allocation helpers moved verbatim fromengine/re_splitter.py, producer seal and RE001 keep-alive included),flagging.py(the candidate-flagging brain moved verbatim fromstages/classify/re_split_flags.py— still invoked fromclassify()at the same point), andstage.py(the Slice-1 fold adapter, previouslystages/re_split.py). Split parameters stay in the data layer (data/tables/re_split_parameters.py, arch_check check 5).engine/fx_converter.pyandengine/re_splitter.pysurvive as thin back-compat shims, so the test files importingFXConverter/RealEstateSplitterfrom them are untouched. Function bodies moved verbatim — no behaviour change (the recorded null-currency FX-haircut findings and the Art. 123B post-FX currency comparison are deliberately left as-is); ratchet metrics unchanged; the 2 moved@citesfunctions re-keyed in the citation snapshot/matrix. - The multi-level direct/facility/counterparty allocator is written once:
engine/kernels/allocation.py(Slice 6). The drifting copies of the "classify item by beneficiary level → aggregate per beneficiary → join at three keys → pro-rata by basis / level total → additive combine" skeleton now parameterise one kernel —allocate_multi_level(annotate direction),expand_items_pro_rata(expand direction), the level-lookup builders (direct_level_lookup/grouped_level_lookup+ancestor_membership_expr/explode_facility_membership— the[parent]-fallback expression previously duplicated 4×), the 3-join +beneficiary_type-switched coalesce (join_items_to_level_lookups/switch_by_beneficiary_level), and the attribute-precedence sibling (level_attribute_lookup/coalesce_attribute_levels). Converted copies, each a thin parameterisation keeping its exact semantics: provisions (crm/provisions.py— pre-CCF synthetic basis, ancestor cascade, null/unknown beneficiary dropped), property coverage (stages/hierarchy/enrich.py— drawn-only Art. 147 basis, immediate-parent.over()window weights viapartition_by_nullable, unknown→direct), guarantees (crm/guarantees.py—ead_after_collateralbasis, expand direction, inner-join stranding and thebeneficiary_type="loan"rewrite preserved), the CRM collateral lookup builders (crm/processor.py— ancestor-subtree, AIRB-pool-aware aggregates stay caller-side), the LTV metadata lookup (add_collateral_ltv— direct→facility→cp coalesce, contingent-exclusion and order-dependentunique(keep="first")tie-break preserved and documented), and the collateral-link demand pooling (crm/link_allocation.py). Every drift axis (basis, cascade vs immediate parent, unknown-type handling, window vs join weight mechanics — with each copy's float associativity tied to its mechanics) is a kernel parameter documented in the module docstring;crm/expressions.beneficiary_level_exprdelegates to the kernel classifier. Documented residue: FCSM (crm/simple_method.py) stays unconverted — it is level-blind (one aggregate joined under three keys, double-count-prone) and converting it through the level-aware kernel would change results for colliding reference namespaces; the collateral-coreancestor_facilitiescolumn materialisation (crm/collateral.py) keeps its 3-way null-list fallback;crm/look_through.pycontains no allocator (row-wise re-anchoring only). Zero behaviour change — full suite green at pre-slice counts; ratchet banksfill_null439→431, presence guards 374→372. - Polars namespace retirement begins: the
ccrnamespace is deleted (Slice 7).engine/ccr/namespace.py— a pure delegation shim over therwa_calc.engine.ccrfree functions with zero accessor call sites in src or tests (the production path has always called the free functions directly viapipeline_adapter) — is removed outright, all 8 delegate methods with it, along with the registration import inengine/ccr/__init__.py. The scaffold contract (tests/contracts/test_ccr_engine_scaffold.py) now pins the package's public free-function surface (__all__+ callability) instead of thelf.ccrregistration. The slotting namespace is converted to plain typed functions.engine/slotting/namespace.py(SlottingLazyFrame+SlottingExpr, 469 LOC) becomesengine/slotting/transforms.py: every method is now a module-level functionfn(lf, config, ...) -> LazyFrame(orfn(expr, *, ...) -> Exprforlookup_rw/lookup_el_rate), bodies moved verbatim, public names unchanged;SlottingCalculator.calculate_branchcomposes them via.pipe(fn, config). The_SHORT_MATURITY_THRESHOLD_YEARSscalar moved with its consumer (arch_check allowlist re-keyed), ~107 test accessor call sites across 5 files rewired to direct function calls,tests/unit/crr/test_slotting_namespace.pyrenamed totest_slotting_transforms.py, and the@cites("CRR Art. 153(5)")key re-homed totransforms::apply_slotting_weightsin the citation snapshot. The sa namespace — the biggest — is converted to plain typed functions across three focused modules.engine/sa/namespace.py(SALazyFrame, 2,153 LOC) splits by cohesion intoengine/sa/risk_weights.py(base RW assignment:apply_risk_weights+ the CRR/B31 override chains, sovereign/ECA/covered-bond helpers,SA_INPUT_CONTRACTand the three_SA_*_RWscalar dicts),engine/sa/rw_adjustments.py(the five post-base modifiers: FCSM, life-insurance mapping, guarantee substitution, Art. 123B currency mismatch — recorded findings moved verbatim — and Art. 110A due diligence, plus the guarantee helpers) andengine/sa/factors_output.py(calculate_rwa,apply_supporting_factors,build_audit); bodies verbatim, public names unchanged, no module near the LOC ratchet.SACalculator.calculate_unified/calculate_branchcompose them via.pipe; the CRM link-ranking preview (crm/processor._annotate_link_rank_metric) now defers-importsrisk_weights.apply_risk_weightsdirectly (the namespace-registration lazy import is gone; the sa↔crm coupling stays lazy in both directions). The deadprepare_columnsmethod (zero call sites anywhere) is deleted with the shim. ~76 test accessor call sites across 11 files rewired; the citation-hygiene path pins (test_crr_art114_citation_paragraph.py,test_crr_art123_payroll_citation.py) re-point to the new module homes, and 20@citeskeys re-home in the citation snapshot. The irb namespace — the last registration — is converted to plain typed functions, and the Polars-namespace pattern is extinct.engine/irb/namespace.py(IRBLazyFrame+IRBExpr, 984 LOC) becomesengine/irb/transforms.py: all 17 LazyFrame methods and 3 Expr methods are module-level functions (bodies verbatim — including the two1.06 if config.is_crr else 1.0scaling-factor reconstructions, deliberately NOT rewritten toconfig.scaling_factoruntil Phase 5 rulepack threading);IRBCalculator._run_irb_chaincomposes them via.pipe; theIRBExpr/IRBLazyFramere-exports leaveengine/irb/__init__; ~370 test accessor call sites across 24 files rewired (tests/unit/crr/test_irb_namespace.pyrenamed totest_irb_transforms.py), and 5@citeskeys re-home in the citation snapshot. With the namespace pattern extinct, the 6 ty rules disabled for it (unresolved-attribute,invalid-argument-type,invalid-assignment,invalid-parameter-default,no-matching-overload,not-subscriptable) are re-enabled and the surfaced backlog burnt down to zero with typing-only fixes:ColumnSpec.dtype/EdgeColumn.dtypewidened to Polars'PolarsDataType(killed ~1,050 of the 1,366 diagnostics at the two declaration sites), the SA/equity when-then chain appenders re-annotatedThen | ChainedThen -> ChainedThen,has_required_columnsupgraded to aTypeGuard[LazyFrame](narrows every guarded optional-frame site),brandmade generic over LazyFrame/DataFrame, the COREP/Pillar-3workbook: objectparams typedxlsxwriter.Workbook, plus localizedMapping/Sequencecovariance fixes andcasts on heterogeneous report-row dicts. Nine per-linety: ignore[unresolved-attribute]comments remain, all one class:config.irb_permissions.<attr>where the field is annotatedIRBPermissions | Nonebut is always derived non-None inCalculationConfig.__post_init__(justification comments in situ). The dev-tool lock bumps ty 0.0.26 → 0.0.49, which resolves polars' decoratedcollect()/collect_all()overloads correctly — eliminating the ~200InProcessQuery | DataFrameunion false positives that were the remaining blocker. All four namespace retirements (ccr → slotting → sa → irb) complete;ty check src/is clean with zero globally disabled rules. Zero behaviour change. - The error channel is unified — stage data-quality errors now reach the result with their ORIGINAL codes (Slice 8, closes P2.21). This CHANGES observable error codes: the lossy rewriting of bundle-attached
CalculationErrors into dynamically-mintedPIPELINE_<STAGE>codes is deleted. Hierarchy (HIE*,DQ004/DQ005), classification (CLS*,DQ008), CRM (CRM*), RE-split (RE*, upstream-dedup preserved) and equity errors now arrive onAggregatedResultBundle.errorsverbatim via the newSTAGE_ERRORSartifact channel (engine/orchestrator.py,append_stage_errors) — code, severity, category, and all six reference fields (exposure_reference/counterparty_reference/regulatory_reference/field_name/expected_value/actual_value) are preserved instead of destroyed (previously: code →PIPELINE_HIERARCHY_RESOLVER/PIPELINE_CLASSIFIER/PIPELINE_CRM_PROCESSOR/PIPELINE_RE_SPLITTER/PIPELINE_EQUITY_CALCULATOR, severity force-upgraded to ERROR, category forced to CALCULATION, every reference field dropped — so downstream consumers now see WARNINGs where they previously saw ERRORs). TheCCR_ERRORSside channel, which existed only to dodge the rewrite, folds intoSTAGE_ERRORS(CCR001/CCR010/CCR011 unchanged on the result); the securitisation allocator keeps its loader-channel append (SEC codes were already verbatim, and moving them would reorder the final list). Stage crashes keepPipelineError→convert_pipeline_error→PIPELINE_<STAGE>(a crash has no original code), as does the IRB-mode missing-model-permissions warning. Final merge order in the facade:result.errors(incl. branch-calculator warnings) + loader/securitisation bundle errors +STAGE_ERRORS+ converted crash errors. Pinned by four new verbatim-survival tests (tests/unit/test_pipeline.py::TestStageErrorChannel— hierarchy/classify/CRM/equity sentinels assert frozen-dataclass equality onresult.errorsand the absence of the correspondingPIPELINE_*code); the crash-channel pins (test_convert_pipeline_error,test_stage_error_returns_error_result, fold tests) pass unchanged. - The Phase-4 shape is now gated: arch_check checks 14-16 land, and the standing instructions flip with them (Slice 9, closes Phase 4). Check 14 bans Polars namespace registrations (
register_(lazyframe|dataframe|expr|series)_namespace) anywhere undersrc/rwa_calc— no allowlist, the pattern stays extinct. Check 15 pinsengine/registry.pyas a literal stage list: module body is the docstring, imports, the module logger, and assignments whose value is a literal tuple ofStageSpec(...)calls with literal/name/attribute arguments (conditionals, loops, comprehensions, function defs all violate). Check 16 pins the stage anatomy: everyStageSpec.fnis<engine/stages/ module>.runresolved from the registry'srwa_calc.engine.stagesimports, stage modules bind a top-levelrun, and every package underengine/stages/exposesrunfrom its__init__unless pinned in the shrink-onlySTAGE_PACKAGES_WITHOUT_RUNset (fx— registry promotion deferred; stale entries are violations). All three are mirrored intests/contracts/test_arch_migration_gates.py; the module docstring's numbered check list now also documents the previously-undocumented check 13. The ~600-LOC engine-module ceiling is deliberately NOT a new failing check: the existingmax_engine_module_locratchet (banked at 1,499, monotone decreasing) is the mechanism, now documented at the ratchet config and in the check-11 docstring as the Phase-4 target the bank must keep falling toward. Per the do-not-do register, the agent-facing instructions flip in the same change: CLAUDE.md's "Polars custom namespaces" design pattern and "Namespace extensions" Polars convention are replaced by the plain-typed-functions +.pipe(fn, config)convention, the Architecture section documents the fold (registry / orchestrator / stages /PipelineContext/RulepackV0), andengine/registry.py+engine/orchestrator.pyjoin the shared-engine-file single-stream lists in CLAUDE.md,/next-itemsand/sonar-clean(theengine-implementercharter gains the new invariants). Stale docs refreshed mechanically:docs/specifications/observability.md(stagestage_timerrecords come fromrwa_calc.engine.orchestrator; run-level records stay onrwa_calc.engine.pipeline),docs/architecture/pipeline-collect-barriers.md(edge inventory re-pointed at the stage adapter modules — the facade fires no edges), anddocs/development/module-dependencies.mdregenerated (194 modules; the retired*.namespacenodes are gone). The ty rule re-enablement was already recorded in the Slice-7 entry above.
Fixed (Phase 4 — recorded regulatory decision)¶
- The hierarchy SA-RW preview leaves the hierarchy package and adopts the shared entity RW expression (slice 5c). The facility-share riskiest-counterparty selection (
engine/stages/hierarchy/facility_undrawn.py::_derive_facility_share_counterparty) now compilesbuild_entity_rw_exprfromdata/tables/guarantor_rw.pyinstead of the package-local_preview_sa_rw_expr(deleted, with its nested_cqs_lookupand seven RW-table imports). The builder keeps the preview's sovereign / institution / corporate+covered-bond / retail / high-risk branches value-identical (corporate stays on the CRR Art. 122 Table 5 dict under both frameworks, preserving preview parity) and closes the branches the old preview lacked: PSE Table 2A (Art. 116(2)), RGLA Table 1B (Art. 115(1)(b)) with the GB→20%/else→100% unrated approximation (sourced from the lookup'scountry_code), international organisations 0% (Art. 118), named MDBs 0% (Art. 117(2)) — these entity types previously fell to the flat conservative 1.0 default, which stays in place for genuinely unmatched types (equity / other items). Non-binding for risk weights, but the preview selects which counterparty receives a multi-counterparty facility's undrawn EAD, so selections involving PSE/RGLA/IO/MDB candidates can flip (PSE/RGLA/IO/MDB candidates now rank lower than before). Pinned at the expression level bytests/unit/test_entity_rw_preview.py(8 hand-derived pins); zero pre-existing tests changed outcome (no fixture pins a multi-CP share with those entity types). - IRB exposures guaranteed by PSEs, RGLAs, international organisations and MDBs now receive the guarantor's preferential SA risk weight (CRR Art. 235 RWSM). The IRB guarantor chain (
engine/irb/guarantee.py::_compute_guarantor_rw_sa) handled CGCB/CCP/institution/corporate guarantors only — PSE, RGLA, IO and MDB classes fell to.otherwise(null), makingis_guarantee_beneficialfalse and silently discarding the guarantee under a misleadingGUARANTEE_NOT_APPLIED_NON_BENEFICIALaudit label (with null risk weights leaking into post-CRM guaranteed-portion reporting). The chain now compiles the new shared guarantor RW expression (data/tables/guarantor_rw.py— branch chain mirrored from the SA-side reference implementation: domestic CGCB 0% → CGCB CQS table → CCP 2%/4% → IO 0% (Art. 118) → named MDB 0% (Art. 117(2)) → MDB Table 2B (Art. 117(1), previously misrouted to institution Table 3) → institution ECRA/SCRA → PSE Table 2A (Art. 116(2)) → RGLA Table 1B (Art. 115(1)(b)) → corporate), deleting the IRB chain's inline CGCB literals. RWA-decreasing for affected guarantees; unrated PSE/RGLA guarantors keep the documented GB→20%/else→100% approximation (recorded decision — no guarantor sovereign CQS join exists). Pinned by 8 acceptance tests (CRR + B31 arms, hand-calculated expectations, verified failing pre-fix) + 4 IO/MDB unit pins; zero pre-existing tests changed outcome; the 10k parity set stays byte-identical (it contains no such guarantors — the P5.11 acceptance hole this partially fills). The SA path (slice 5b) and the hierarchy SA-RW preview (slice 5c, below) have since adopted the shared expression.
Changed (architecture — migration Phase 1: eager stage edges)¶
- Stages now exchange materialised frames; laziness is strictly intra-stage.
engine/materialise.pyis rewritten aroundmaterialise_edge(lf, config, label), called at every stage exit (hierarchy_exit,ccr_exitwhen a derivatives book is present,classifier_exit,crm_exit,re_split_exit, and the three calculator branches). The hand-placed barrier inventory —classifier_output,pipeline_pre_branch,crm_post_ead_unified/_fanout,crm_no_guarantee— is deleted; Two benchmark-justified intra-stage checkpoints survive inside CRM:crm_post_ead(a controlled A/B showed removing it costs 35–52% on the full-pipeline benchmarks — the collateral lookups re-execute the provisions→CCF→EAD chain without it) andcrm_pre_guarantee_unified(empirically irreducible on Polars 1.37). Bundle fields stayLazyFrame-typed (cheap.lazy()wrap) until the Phase 3 producer seal. Byte-identical outputs; the inter-stage plan-depth SIGSEGV class is now unrepresentable. See the rewritten Stage-Edge Materialisation page. - One execution semantics; spill failures are loud. The cpu/streaming dual mode collapses into: in-memory edges by default, opt-in spill-to-parquet via the new
config.spill_edges; a sink failure raisesSpillErrorinstead of the previous silent in-memory fallback (which defeated the only purpose of spill mode).collect_engine="streaming"is deprecated (accept-and-warn, one release). The module-global spill registry andatexithook are replaced by a run-scoped capture whose cleanup lives in the orchestrator'sfinally. - Every run now records a materialisation map. Each edge collect emits an
EdgeEvent(label, rows, columns, estimated bytes, wall ms, spill mode); the map is logged at INFO at run end and written into the audit-cachemanifest.json(materialisation_map). - Plan-node ceilings replace barrier folklore.
tests/integration/test_stage_edges.pyasserts the edge inventory in pipeline order and pins per-edge unoptimised plan-node ceilings (measured 2026-06-11 on Polars 1.37: hierarchy 1,586; CRM ~1,840; everything else ≤100 — ceilings at ~2×, SIGSEGV threshold ~25,000), plus a Polars version pin that forces recalibration on upgrade (RWA_PRINT_EDGE_NODES=1). arch_check gains anengine_eager_collect_sitesratchet metric (47 at baseline) so the small-lookup collect census cannot grow. - Post-aggregation views are computed once, not per accessor.
OutputAggregator.aggregate()now collects its summary views in two dependency-safe batches (pre-floor and post-floor) and exposes them as eager-backedLazyFramewraps, so downstream consumers'.collect()calls are near-free instead of re-executing the concat→multiplier→floor→group_by plan each time;ReconciliationResponsecaches collected frames, and the/api/reconcileendpoint executes each view once per response (previously ~7 plan executions per request). Byte-identical outputs; field types unchanged.
Changed (architecture — migration Phase 3: producer-sealed edge contracts) — IN PROGRESS, BREAKING for direct bundle construction¶
- Edge contracts land in
contracts/edges.py.EdgeColumn/EdgeContractdeclare per-edge column contracts (dtype, required/optional, producer-owned default, Boolean-only null fill, null-semantics annotation, regulatory citation);conform()raisesEdgeContractViolationon missing required columns or dtype drift (programming-error channel), injects defaults for absent optional columns, strips undeclared scratch and emits canonical column order.seal()= conform + brand; the brand is deliberately lost on any frame transformation, so only the exact object that went through the seal carries it. The Boolean-only fill conservatism gate is enforced when a contract is declared (a Float/Stringfill_null_defaultis aValueError). - The loader is a producer-enforced boundary. Every input table is sealed at load against
RAW_TABLE_EDGES(one edge perRawDataBundleframe field, seeded from theColumnSpecschemas): missing required columns now produce DQ001 errors plus typed-null injection — implementing theColumnSpec.requiredcontract that was previously documentary only; dtype mismatches cast (strict=False); undeclared input columns are stripped; tables arrive schema-complete, canonically ordered and branded. Input alias translation happens in the loader exactly once (node_type→child_typeon facility mappings). RawDataBundledemands sealed frames. All 18 frame fields are registered incontracts/bundles.SEALED_FRAME_FIELDS;__post_init__raises unless each non-None frame carries its loader-edge brand. Tests construct bundles via the contract-derived buildertests/fixtures/raw_bundle.py(make_raw_bundle/seal_raw_table) — same keyword surface asRawDataBundle, frames sealed exactly as the loader seals them, so test bundles are shape-identical to production-loaded ones (~95 test files migrated).- Schema gaps surfaced by the strip are now declared contract:
LOAN_SCHEMAgainsltv,property_type(null defaults — never 0.0/""),has_income_cover(Boolean False per CRR Art. 126(2), mirrored onCONTINGENTS_SCHEMA),ava_amountandother_own_funds_reductions(CRR Art. 159 Pool B (c)/(d), null when unreported).COLLATERAL_SCHEMA.is_main_indexloses itsFalsedefault: null means "index membership unreported" and the haircut engine resolves null → main-index (CRR Art. 224 Table 4) — a load-time False fill silently re-rated unreported equity to the higher other-listed haircut. engine/hierarchy.pysheds its column-presence guards. With everyRawDataBundleframe sealed at the loader edge, the resolver'sif "X" in <table>_colsbranches on declared input columns were dead: the loans/contingents coercion blocks, facility-undrawn select, MOF expansion, facility-share derivation, QRRE propagation, property-coverage/LTV collateral joins, and short-term-rating lookup now read sealed columns directly (engine ratchet: −57 presence guards, −15collect_schemaprobes, −20fill_nullsites). Dead Boolean fills whose value equals the schema default (already filled at load) are removed; all Float/String fills and all null-VALUE semantics (nullchild_type, nullis_qualifying_re) are preserved._normalise_facility_mappingsis deleted outright — the loader translatesnode_type→child_typeexactly once and sealed tables always carrychild_type. Unit tests that call hierarchy private helpers directly now seal their hand-rolled frames viatests/fixtures/raw_bundle.seal_raw_table, mirroring production input shape.engine/classifier.pysheds its column-presence guards; CLS005 / CLS007 retired. With the exposures frame sealed againsthierarchy_exit, the fourCounterpartyLookupframes sealed against thecp_lookup_*edges, andmodel_permissionssealed at the loader edge, the classifier's presence machinery was dead: the 15-branch conditional cp_ attribute select collapses to one unconditional select, the model-permissionscountry_codes/excluded_book_codes/ppu_reasoninjections and themodel_idearly-return go, the RE-split_re_split_null_defaultsearly-return and capped-column fallback are deleted, and the QRRE /is_defaulted/beel/internal_pd/has_income_cover/cp_is_managed_as_retailpresence gates collapse to direct reads (engine ratchet: −49 presence guards, −3fill_nullsites, −3collect_schemaprobes; classifier.py −202 LOC). The CLS005 ("is_managed_as_retail column missing") and CLS007* ("is_financial_sector_entity column missing") warnings are deleted — the absent-column states they detected are unrepresentable on sealed input (the seal injects declared-but-absent columns as typed nulls); their null-VALUE semantics (fill_null(True)pool-management default per Art. 123A,fill_null(False)FSE gate per Art. 147A(1)(e)) are preserved verbatim, as are all optional-tableis Nonegates (specialised lending, model permissions) and the CLS006/CLS008 null-VALUE diagnostics. Sealed-invariant tests replace the absence-warning tests (tests/unit/classifier/test_p1_125_fse_column_warning.py,tests/unit/test_art123a_retail_criteria.py), mirroring the CLS004 pattern.- Every pipeline stage edge now carries a producer seal. The full chain — loader → hierarchy (
hierarchy_resolved/hierarchy_exit/ccr_exit) → CounterpartyLookup (fourcp_lookup_*contracts) → classifier (classifier_exit/_ccr, brand-selected by input) → CRM (crm_exit/_ccr) → RE-split (re_split_exit/_ccr) → calculator branches (sa_branch/irb_branch/slotting_branch, conformed before the shared collect and branded as DataFrames) → aggregator (aggregator_exit, 297 columns — the reporting input contract). Bundle__post_init__validates brands via theSEALED_FRAME_FIELDSregistry (tuples for multi-producer fields); transformed frames lose their brand by design. - Conditional columns (
EdgeColumn(inject=False)). Path-dependent columns (guarantee substitution, provisions, FCSM, SA-CCR provenance, regime-gated floor/currency columns) are declared-if-present: dtype-validated and never stripped when the producing sub-step ran, never injected when absent. The parity gate forced this design — blanket null-injection made the presence-gated SA/IRB guarantee-substitution machinery execute on null data and moved IRB risk weights. Each group flips to injection alongside its consumer's guard deletion with verified null-path equivalence. - Wave-2 guarantor rework — the path-dependent CRM columns now inject as typed nulls. 18 of the 19 conditional
crm_exitcolumns (the provision splitprovision_on_drawn/provision_on_nominal, the guarantee metadata and guarantor-attribute set, and the FCSM pairfcsm_collateral_value/fcsm_collateral_rw) flip to injection on thecrm_exit, calculator-branch andaggregator_exitcontracts, so per-row outputs carry a uniform shape whether or not the producing CRM sub-step ran (all-null = sub-step skipped). Consumers verified null-path-equivalent: on the 10k parity set the per-row frames gain exactly the 18 all-null columns with zero value drift in every shared column.guarantor_entity_typestays conditional as the run-level presence sentinel that keeps the SA/IRB guarantee-substitution machinery — and its derived audit columns (guarantor_rw,guarantee_status,pre_crm_risk_weight, …) — off unguaranteed runs (lazy column addition is row-independent, so a value gate cannot prevent the shape divergence). The_inst_guarantor_short_termscratch column is dropped at source in the SA guarantee substitution and removed from the branch/aggregator contracts; the FCSM presence early-exit and two always-ensured seniority/FSE presence branches in the IRB EL adjustment are deleted (presence-guard ratchet 377→374,collect_schemaprobes 168→166). - Guard retirement banked by the ratchet: presence guards 549→377 (−172),
fill_nullsites 469→446,collect_schemaprobes 191→166;hierarchy.py−413 LOC,classifier.py−202 LOC. Dead absent-column warning families (CLS005, CLS007, QRRE CLS004) deleted with their unrepresentable-state tests, replaced by sealed-frame invariant pins. - Unknown-flag conservatism fixes (recorded decisions):
qualifies_as_retailunknown → non-qualifying 100% (CRR Art. 123 — the 75% weight is preferential);has_default_definition_infounknown → the Art. 155(3) 1.5× scaling applies;is_main_indexunknown → preserve the engine's null→main-index resolution (Art. 224 Table 4). Zero goldens changed; pipeline paths unaffected (the classifier emits non-null in-pipeline). - Contract-derived test builders are the sanctioned construction path at every grain:
make_raw_bundle/seal_raw_table,make_resolved_bundle/make_counterparty_lookup/make_classified_bundle/make_crm_bundle/make_aggregated_bundle, plustests/fixtures/contract_columns.pypads for hand-rolled branch frames. ~150 test files migrated across the phase; recurring lesson: tests that omitted columns production always carries now receive typed nulls and must supply production-realistic values — never changed expected numbers. - Producer-contract robustness fixes: the CRM001 unusable-collateral skip path now emits the full CRM contract (runs the collateral step on an empty schema-valid table); an equity-stage failure no longer nukes non-equity results via a self-inflicted aggregator seal violation (
equity_typeoptional-with-injection).
Changed (architecture — migration Phase 2: dead-path deletion and protocol diet) — BREAKING for direct API consumers¶
- The legacy dual CRM orchestration is deleted.
CRMProcessor.apply_crm()/get_crm_adjusted_bundle()(and theircrm_post_audit_fanoutedge) are gone;get_crm_unified_bundle()is the single CRM entry point. The misdirected-AIRB collateral diagnostic (CRM006, CRR Art. 181 / B3.1 Art. 169A) — which only the dead path ever emitted — is migrated into the unified path, so production now surfaces it for the first time. - One branch entry point per calculator.
SACalculator.calculate/get_sa_result_bundle,IRBCalculator.calculate/get_irb_result_bundle/calculate_expected_loss,SlottingCalculator.get_slotting_result_bundle, andEquityCalculator.calculateare deleted — none were invoked by the orchestrator. Each calculator keepscalculate_branch()(plus SA'scalculate_unified()for the Basel 3.1 output floor); equity keepsget_equity_result_bundle(). - Branch-path error accumulation is restored.
calculate_branch()(and SAcalculate_unified()) now take an optionalerrors=accumulator wired by the orchestrator, so SA004 (Art. 110A due diligence), SA005 (equity in main table, Art. 133), SF001 (Art. 501 SME group aggregation) and EL diagnostics reachAggregatedResultBundle.errorswith their original codes — previously these production warnings were silently discarded (only the dead bundle paths collected them). Pinned per calculator bytests/unit/test_branch_error_accumulation.pyand end-to-end bytests/integration/test_branch_error_accumulation.py. - Orphaned contracts deleted.
LazyFrameResult,SAResultBundle/IRBResultBundle/SlottingResultBundle,SACalculationError, the approach-split fields (sa_exposures/irb_exposures/slotting_exposures) and the always-Nonecrm_auditfield onClassifiedExposuresBundle/CRMAdjustedBundle(consumers filter the unified frame onapproach; the CRM audit projection ships via the audit cache), the never-calledvalidate_classified_bundle/validate_crm_adjusted_bundle, and the zero-implementationCCRCalculator/SchemaValidatorProtocol/DataQualityCheckerProtocolprotocols. - Protocol conformance is asserted on real implementations.
tests/contracts/test_protocols.pynow checks every pipeline component class (17 protocol/implementation pairs) via runtimeisinstance+ typed assignment, replacing the stub-based tests that could pass while the real component drifted. - Empty-LazyFrame sentinels are gone from the engine; optional frames are
None.RawDataBundle.lending_mappingsis optional (loader returnsNonewhen the file is absent; the hierarchy resolver treatsNoneas group-of-one per CRR Art. 4(1)(39), identical to an empty table), andCollateralLinkAllocation.collateralstaysNonefor absent collateral. New arch_check check 13 (+ contracts mirror) bans barepl.LazyFrame()construction inengine/**. - Parity gate:
scripts/parity_gate.pycaptures/compares the fullAggregatedResultBundleover the deterministic 10k stress set (4 framework/permission configs). Result vs the pre-Phase-2 baseline: every per-exposure frame byte-identical; group-by sum aggregates equal to float-reassociation tolerance (Polars parallel Float64 summation is not deterministic across processes — verified on identical code); error growth = exactly one SA004 warning per Basel 3.1 run (the restored due-diligence diagnostic).
Fixed¶
- Financial Collateral Simple Method (CRR Art. 222) election was silently ignored on the production pipeline path.
compute_fcsm_columns/undo_sa_ead_reductionwere only invoked by the legacyget_crm_adjusted_bundleentry point; the orchestrator'sget_crm_unified_bundlenever ran them, so a firm electingcrm_collateral_method=SIMPLEgot Comprehensive Method treatment instead — EAD reduced by financial collateral with no Art. 222 risk-weight substitution. The FCSM steps are now ported into the unified path behind the same election guard (mirroring the legacy ordering). SIMPLE-electing runs will show changed SA RWA: EAD is no longer reduced by financial collateral, and the secured portion takes the collateral risk weight (20% floor / carve-outs per Art. 222). COMPREHENSIVE (default) runs are byte-identical. Recorded as a FIX decision indocs/plans/target-architecture-migration.md§6. Pinned bytests/acceptance/crr/test_art_222_fcsm_unified_pipeline.py(6 orchestrator-path tests, full + partial coverage). - COREP and Pillar 3 no longer disagree about on/off-balance-sheet filtering when the indicator columns are missing. The copy-pasted
_filter_on_bshelpers had drifted: COREP returned an empty frame when neitherbs_typenorexposure_typeexists, Pillar 3 returned all rows — double-counting the full population across on-BS and off-BS cells. Both generators now sharereporting/kernel/(column resolution, approach/BS filters, safe sums, null rows), unified on return empty (a missing balance-sheet indicator must not silently pass rows through; decision recorded indocs/plans/target-architecture-migration.md§6). Pipeline output always carriesexposure_type, so this only affects synthetic/minimal inputs. Genuinely divergent semantics (safe_sum0.0 vs None,col_sumempty-frame handling, approach-column candidates) are preserved per-caller via explicit parameters. Pinned bytests/unit/reporting/kernel/test_kernel.py(23 tests). - Master CI is green again. The
Lint & Formatjob was failing on 22 ruff errors (F401/I001/SIM102) plus 8 maskedruff formatfailures acrosstests/{fixtures,acceptance}/ccr/introduced by recent CCR batches; all fixed (the intentional re-export module now uses explicitX as Xre-export syntax).
Added¶
- Target architecture & migration plan committed to the repo.
docs/plans/target-architecture-migration.mddistils the 2026-06-11 multi-agent architecture review (49 evidenced findings) into the rulepack target architecture and a phased (0–8) strangler migration, with quick wins, a binding do-not-do register, and a decision log. The two investigation plans previously held only in agent session memory are committed alongside:engine-defensiveness-boundary-hardening.md(folded into Phase 3; preserves the ~130/189 KEEP-guard triage) andsingle-lazy-plan-refactor.md(SUPERSEDED by Phase 1; preserves the Polars 1.37 plan-depth SIGSEGV evidence).IMPLEMENTATION_PLAN.mdcross-links the phases. - Architecture-debt ratchet and import-direction gates (arch_check checks 11–12). Check 11 measures the engine defensive surface (
.fill_null(sites, string-literal column-presence guards,.collect_schema(probes, max engine module LOC) against a committed baseline (scripts/arch_metrics.json) and fails any increase; the watchfire@citescount may never decrease. Improvements are banked viapython scripts/arch_check.py --update-baseline. Check 12 enforces downward-only imports (contracts ↛ api/ui/reporting/engine/analysis; engine ↛ api/ui/reporting/analysis; reporting ↛ api/ui; data/domain ↛ anything above), with known legacy inversions allowlisted against the migration phase that retires them. Mirrored as contract tests intests/contracts/test_arch_migration_gates.py. - A real pre-commit gate.
.pre-commit-config.yaml(local hooks:arch_check,ruff check,ruff format --checkoversrc/tests/scripts) so non-agent commits are gated the same way as agent commits. - Dedicated CI benchmarks job. Benchmark bodies are excluded from the default dev loop and the CI tests job (27 test bodies, ~112s of dead worker time per run, executed despite
--benchmark-disable); a new additive CI job runstests/benchmarksand uploadsbenchmark-results.jsonas the stored baseline artifact.scale_10k/scale_100kmarkers registered;--strict-markersenforced. - Citation coverage snapshot replaces the hand-maintained whitelist.
tests/contracts/test_watchfire_coverage.py's 84-row manual WHITELIST is replaced by a generated, committed snapshot (tests/contracts/data/citation_snapshot.json, 131 functions — the whitelist had silently missed 47) diffed bidirectionally against the live@citesstate; regenerate viauv run python scripts/generate_citation_matrix.pywhen a change is intentional.
Changed¶
ExportResultmoved fromrwa_calc.api.exporttorwa_calc.contracts.results(re-exported from the old location for backwards compatibility). This clears the contracts→api and reporting→api layering inversions now enforced by arch_check check 12.- Audit-cache writer relocated to the observability layer.
sink_audit/prune_audit_cachemoved verbatim fromengine/materialise.pytorwa_calc.observability.audit_cache(the "sink_parquet only in materialise.py" invariant they were co-located for was never implemented in arch_check — a verified phantom rule). Import sites updated; behaviour identical. - Stale load-bearing prose corrected.
engine/materialise.py's ">500-node optimizer segfault" comment now states the verified mechanism (recursive plan-tree depth, ≈25,000-node measured threshold on Polars 1.37, barriers also bound plan-construction time);docs/architecture/pipeline-collect-barriers.mdrefreshed (cpu — not streaming — is the default collect engine; all barrier line references re-verified; the undocumentedclassifier_outputbarrier added). - Verified-dead code deleted:
tests/bdd/(empty scaffold),config/fx_rates.py,engine/utils.is_valid_optional_data, and thecontracts/validation.pyduplicate risk-type validators (canonical source:data/schemas.pyVALID_RISK_TYPES_INPUTviaCOLUMN_VALUE_CONSTRAINTS).
[0.2.26] - 2026-06-10¶
Added¶
- Reconciliation now gives comfort on the asset-class allocation, and can reconcile each class portion line-by-line. A new
class_allocationview totals EAD/RWA by risk class on each side — using the raw (un-collapsed) results so a split exposure's portions each count in their own class — and full-joins on the canonical class to showour_*/legacy_*/delta_*per class; a class our engine allocates differently to the legacy one then stands out as offsetting deltas. It surfaces as a new tier-2 table + grouped-bar chart on/reconciliation, aClass Allocationsheet/CSV in the export, andReconciliationResponse.collect_class_allocation()/ReconciliationBundle.class_allocation. Separately, the join key can now carry the risk class: putting the class in bothour_keys/legacy_keysreconciles at the(exposure × class)grain, with the class key normalised andvalue_map-translated on the way into the join (legacyRRE↔ ourresidential_mortgage) so a portion in a class on only one side shows asmissing_left/missing_right— the precise "this exposure moved to a different risk class" signal. The default mapping TOML now mapsexposure_classand documents the recipe. Purely additive analysis — no change to any RWA calculation. Pinned by new cases intests/unit/engine/test_reconciliation.py(TestClassAllocation,TestExposureClassGrain),tests/unit/ui/test_views_reconciliation.py,tests/integration/test_ui_reconciliation.py, andtests/acceptance/reconciliation/test_reconcile_end_to_end.py. - P8.53 — the SA-CCR wrong-way-risk gate (
apply_wwr_gate) is now wired into the pipeline orchestrator.apply_wwr_gatewas implemented and unit-tested (P8.27, 13 tests intests/unit/ccr/test_wwr.py) but never invoked bypipeline.py::_run_ccr_stage, so no end-to-end SA-CCR run routed through the Art. 291(4)-(5) treatment: any trade flaggedis_specific_wwr=Truewas treated as a non-WWR trade. The CCR stage now runsapply_wwr_gate(apply_legal_enforceability_gate(data.ccr)), so a specific-WWR trade is broken out into its own<ns>__wwr__<trade>synthetic single-trade netting set taggedwwr_lgd_override = 1.0per PRA Rulebook CCR (CRR) Part / CRR Art. 291(5)(c), while the non-WWR trade stays in the parent netting set; the override is surfaced onto the synthetic CCR exposure row (pipeline_adapter.py) for audit/COREP reconciliation. The gate'sCCR010(specific-WWR) /CCR011(general-WWR) diagnostics now reachresult.errorsas rawCalculationErrors through a new CCR-error channel — which also surfaces the legal-enforceability gate'sCalculationErrors to the result for the first time. Pinned bytests/acceptance/ccr/test_ccr_wwr1_orchestrator_gate.py(scenario CCR-WWR-1: one specific-WWR + one normal trade throughPipelineOrchestrator). Scope note: this lands the partition + override tag; the downstream IRB LGD = 100% consumption of the override is deferred (blocked on P8.31 CCR→IRB routing — CCR rows route through SA today, where Art. 291(5)(d) "unsecured transaction" treatment already holds). No EAD/RWA arithmetic changed; no new regulatory scalar (CCR_WWR_SPECIFIC_LGD_OVERRIDE = 1.0already indata/tables/sa_ccr_factors.py). Ref: PRA Rulebook CCR (CRR) Part Art. 291(5)(c); CRR Art. 291(5)(c). - P8.39 — QCCP central-counterparty trade-exposure risk weights (2% / 4%) are now correct end-to-end through the orchestrator. The SA calculator already pinned a
ccpentity_type to the Art. 306(1) weights, but two bugs survived: (a) the trade-levelis_client_clearedflag never reached the synthetic CCR exposure row, so a client-cleared QCCP trade was risk-weighted at the proprietary 2% instead of 4% per PRA Rulebook CCR (CRR) Part / CRR Art. 306(1)(c); and (b) the pin keyed on entity_type alone, with no qualifying-CCP gate, so a non-QCCP CCP was wrongly pinned at 2% instead of being treated as an ordinary institution. The fix threads the client-clearing flag (collapsed to netting-set grain viaany()) onto the CCR row ascp_is_ccp_client_cleared(pipeline_adapter.py) and surfaces the QCCP flag ascp_is_qccp(classifier.py, newcp_is_qccpcolumn inschemas.py); the SA QCCP branch (engine/sa/namespace.py) now gates oncp_is_qccp(an absent flag is treated as qualifying, preserving legacyccprows), and a demoted non-QCCP CCP lifts itscp_institution_cqsintocqsso it resolves to the Art. 120 Table 3 institution ladder (e.g. CQS 2 → 50%) per Art. 107(2)(a) rather than the unrated-100% fallback. Net effect: proprietary QCCP → 2% (Art. 306(1)(a)), client-cleared QCCP → 4% (Art. 306(1)(c)), non-QCCP CCP → institution ladder. EAD is unchanged (the SA-CCR Art. 274 EAD is invariant to the risk-weight branch). Pinned bytests/acceptance/ccr/test_ccr_ccp_orchestrator_pin.py(scenarios CCR-CCP-1 proprietary / CCR-CCP-2 client-cleared + a 2-counterparty keyed-join fan-out guard; 12 tests). Ref: PRA Rulebook CCR (CRR) Part Art. 306(1)(a)/(c), Art. 107(2)(a); CRR Art. 306(1), Art. 120, Art. 272 Def (88). - P8.28 — SA-CCR supervisory-alpha carve-out (α = 1.0) for non-financial and pension-scheme counterparties is now applied per netting set. Previously the engine applied the default α = 1.4 uniformly to every netting set, so a derivative with a non-financial counterparty (EMIR Art. 2(9)), a pension scheme arrangement (EMIR Art. 2(10)) or a pension-scheme default-fund-contribution position had its EAD over-stated by a factor of 1.4 (
EAD = α·(RC + PFE)) — the correct value is ~28.6% lower per CRR Art. 274(2) second sub-paragraph. A newcounterparty_typecolumn on the counterparty schema (default"financial"→ α = 1.4;non_financial/pension_scheme/pension_default_comp→ α = 1.0) is joined onto the netting-set frame and reduced to a per-netting-setalpha_appliedscalar (surfaced on the synthetic CCR exposure row for COREP/audit reconciliationEAD = alpha_applied · (RC + PFE));compute_pfe/compute_eadhonour the per-row value when present and fall back to the scalar α otherwise, so every existing CCR scenario (which never sets the column) is unchanged at α = 1.4. The α scalarsSA_CCR_ALPHA = 1.4/SA_CCR_ALPHA_CARVE_OUT = 1.0live indata/tables/sa_ccr_factors.py. Pinned bytests/acceptance/ccr/test_ccr_alpha_carveout.py(scenarios CCR-ALPHA-1 non-financial / CCR-ALPHA-2 pension-scheme / CCR-ALPHA-3 financial control + a 2-counterparty keyed-join fan-out guard; 14 tests). Ref: PRA PS1/26 / CRR Art. 274(2); EMIR Art. 2(9)-(10); BCBS CRE52.1. - P8.23 — long-settlement transactions confirmed to take standard SA-CCR treatment (no special margin-period-of-risk floor); regression-pinned. A prior plan item assumed CRR Art. 271 mandated a "bespoke MPOR floor" for long-settlement transactions. Primary-source verification (CRR Art. 271, 272(2)/(9), 285) confirms otherwise: Art. 271 merely permits long-settlement transactions to be calculated under the SA-CCR chapter, and neither Art. 272 nor Art. 285 prescribes any long-settlement-specific maturity-factor or MPOR treatment — the Art. 285 floors (5/10/20 business days) key off the netting set's margining type. Under SA-CCR a long-settlement trade is therefore an ordinary trade whose maturity factor follows Art. 279c, so the
is_long_settlementflag has no effect on EAD/RWA. No calculation change was made; the behaviour is pinned bytests/acceptance/ccr/test_ccr_ls_long_settlement_inert.py(a long-settlement trade routes through SA-CCR and yields EAD/RWA identical to an economically-identical control — guarding against any future spuriousis_long_settlementbranch). Ref: CRR Art. 271, Art. 272(2)/(9), Art. 285. - P8.29 — Basel 3.1 transitional SA-CCR alpha add-on (PRA PS1/26 Art. 274(2A)–(2B)) is now applied per netting set. For derivative trades entered into before 1 January 2027 with a CVA-exempt non-financial / pension-scheme counterparty (CVA Risk Part 7.1(1)(a)/(b)), Art. 274(2A) phases an "alpha add-on" —
EAD(α=1.4) − EAD(α=1) = 0.4·(RC+PFE)— back onto the netting-set exposure value at 60% (2027) → 40% (2028) → 20% (2029) → 0% (2030+), so the EAD steps down from ~1.24× to 1.00× of the α=1 carve-out value over the transition. A new firm-suppliedis_legacy_cva_exemptflag on the trade schema gates the add-on (collapsed to netting-set grain viaany()); it fires only under the Basel 3.1 framework at a 2027–2029 reporting date and only where the counterparty already qualifies for the α=1 carve-out (alpha_applied == 1.0) — so an α=1.4 financial counterparty receives nothing, and every CRR run / 2030+ date / non-legacy trade is byte-identical to before. The phased uplift is folded into the SA-CCR EAD and surfaced as atransitional_add_onaudit column. The phase schedule lives indata/tables/sa_ccr_factors.py(SA_CCR_TRANSITIONAL_ADDON_PHASE). Art. 274(2B) (exclude the add-on from the leverage-ratio EAD) is not yet applicable — the engine exposes no leverage-ratio EAD path. Pinned bytests/acceptance/ccr/test_ccr_alpha_addon_transitional.py(the four-year phasing plus non-legacy / legacy-financial / CRR-framework controls and a 2-netting-set fan-out guard; 20 tests). Builds on the P8.28 carve-out. Ref: PRA PS1/26 Art. 274(2A)–(2B); CRR Art. 274(2). - P8.31 — SA-CCR derivative exposures now route through IRB (F-IRB / A-IRB) for IRB-permissioned counterparties, instead of always falling back to the Standardised Approach. A synthetic CCR exposure row reaches the classifier with
model_id = null(the rating-inheritance attach that renamesinternal_model_id → model_idonly runs over hierarchy-resolved lending rows, before the CCR rows are appended), so even a counterparty holding an IRB model permission had its derivative counterparty-default-risk RWA computed on the SA ladder. The classifier now surfaces the counterparty's resolvedinternal_model_idascp_internal_model_idand coalesces it intomodel_idat the start of model-permission resolution, so an IRB-permissioned counterparty's CCR derivative exposure resolves its permission and routes through the existing PD/LGD/M machinery — using the SA-CCR EAD (α·(RC+PFE)) as the IRB EAD input (replacing the drawn-amount/CCF flow), per CRR Art. 153(1) (corporate IRB risk-weight formula). The effective maturityMfor the single-trade netting set follows CRR Art. 162(2)(b) (derivatives under a master netting agreement: residual maturity, 1-year floor / 5-year cap) via the existingmaturity_date-clipped derivation — note this corrects the plan bullet's citation, which wrongly referenced Art. 162(2)(g)-(i) (those are the IMM / CVA-internal-model maturity paths, out of scope for an SA-CCR firm). The coalesce is a strict no-op for lending rows whosemodel_idis already populated (verified across 1,600+ tests; CCR-A1 still routes through SA, the QCCP 2%/4% and α-carve-out pins are intact). Pinned bytests/acceptance/ccr/test_ccr_irb1_routing.py(scenario CCR-IRB-1: a corporate F-IRB counterparty with one 5y GBP IR swap → SA-CCR EAD →approach_applied = "foundation_irb",ead_final == ead_ccr, F-IRB risk weight ≈ 145.9%; 6 tests) with goldentests/expected_outputs/ccr/CCR-IRB-1.json. Scope note: this lands the SA-CCR-EAD→IRB routing; the downstream WWR LGD = 100% consumption (P8.53 Change 2b) and the CCR-specific 0.05% PD floor (P8.32) remain deferred and are explicitly not asserted here. Ref: CRR Art. 153(1), Art. 162(2)(b), Art. 161(1)(a), Art. 163. - The reconciliation page (
/reconciliation) now remembers your last completed run and re-opens pre-filled, so you no longer re-type every input each time. After a reconciliation succeeds, all six form fields — data path, framework, permission mode, data format, reporting date, and the (comment-preserving) legacy-mapping TOML — are saved verbatim as JSON to a per-user state file, and the next visit to the form silently restores them (the three dropdowns included). New modulesrc/rwa_calc/ui/app/recon_state.pyholds a frozenReconciliationFormStateplussave_last_run/load_last_run; the state file is~/.rwa_calc/reconciliation_last_run.json, overridable via theRWA_STATE_DIRenv var (the test seam and the packaged-app override). Saving never raises (a save failure just means the next form isn't pre-filled) and a missing/corrupt/partial file falls back to the built-in defaults, so a fresh install behaves exactly as before. Field precedence is explicit-override (a failed submit re-renders with what you chose) > last run > default; the failure path now also preserves the submitted framework/mode/format, which it previously dropped. The saved values are only editable defaults — a stale data path or legacy file still surfaces the usual clear error on the next run. A "Reset to defaults" button (a ghost-styled link shown beside "Run reconciliation" only when a saved run exists) clears the saved state viaGET /reconciliation/resetand returns the form to its built-in defaults. UI convenience only — no calculation impact. Pinned bytests/unit/ui/test_recon_state.pyandtests/integration/test_ui_reconciliation.py(test_reconciliation_prefills_from_last_run,test_reset_restores_defaults_and_clears_saved_run).
Changed¶
- Reconciliation: the legacy side is now aggregated to the key grain instead of silently keeping the first row (REC002). When a legacy exposure spans several lines — a collateralised portion in one risk class, the residual in another, or guaranteed/unguaranteed portions — the engine previously kept only the first row of each key and dropped the rest, understating the legacy EAD/RWA totals and producing false breaks. The legacy side is now collapsed symmetrically with our side: additive components (EAD, RWA, expected loss) are summed and the risk-weight ratio is recomputed from the summed numerator/denominator, so the totals tie out. The
REC002warning is reworded to reflect aggregation (not row-dropping), andREC004now also fires when a legacy key's rows disagree on class/approach (an exposure split across classes), surfacing exactly the case that motivated the per-(exposure × class)grain. The change is inengine/reconciliation.py(_prepare_legacy_side+_aggregate_legacy_to_key_grain); no other RWA calculation is affected. - The landing-page polar-bear constellation now plays a one-shot "tows the text in" intro on phones instead of running off-screen. On the 16:9
sliceSVG the bear's rest anchors (CX_STAND=128,CX_QUAD=114) sat outside the narrow, height-cropped visible band a portrait phone leaves (≈ viewBox x[59,101]), so the bear stood off-screen and you only ever caught it mid-run; and because the bear is vertically centred it would otherwise sit right behind the full-width hero copy.bear-constellation.jsnow parametrises the lifecycle on an anchors object and, at/below the existing880pxmobile breakpoint, plays a single intro — the bear stands centred (always fully visible), then bolts off the right edge — and then hides, leaving only the twinkling starfield. In the same beat the hero copy is towed in from the left by a pure-CSS auto-play animation (@keyframes bear-tow-inon.hero-bodyinhomepage.css), whoseanimation-fill-mode: bothhides the text from the first painted frame (no flash, no JS arming — text still shows normally without JS) and whose timing is kept in lockstep with the script via theINTRO_T0/INTRO_ENDcontract.prefers-reduced-motionshows the copy immediately with no bear. The intro is framed by centring each pose's visual extent (and scaling the bear to the measured visible band) so the head no longer clips as it drops to all fours, and it is driven by anIntersectionObserverso the run-off plays the moment the hero is genuinely on-screen — not lost in the initial mobile paint — and replays whenever the hero re-enters view. As the bear bolts off, the whole hero gives a subtle footfall-synced "ground shake" (a GPU transform on.landing-hero, ramped by the gallop and faded out as the bear leaves frame; the landing body is darkened to--oah-slate-900so the shake never reveals an edge), also off underprefers-reduced-motion. Desktop is unchanged — width > 880px keeps the existing endless STAND→CROUCH→RUN→WALK-IN→RISE walk in the right third. Edits are confined to the two drift-guarded asset pairs (bear-constellation.js,homepage.cssundersrc/rwa_calc/ui/app/static/anddocs/assets/), kept byte-identical bytests/unit/ui/test_tokens_drift.py. UI presentation only — no calculation impact.
[0.2.25] - 2026-06-07¶
Added¶
- Parallel-run reconciliation is now a first-class page in the app (
/reconciliation) and a REST endpoint, replacing the Marimo workbook. The reconciliation feature (run this calculator alongside a firm's legacy engine and compare component-by-component, triaging each break to a data vs. engine fix) is now part of the server-rendered FastAPI/Jinja app: a form takes the data path and an editable TOML mapping, and the result renders across the four drill-down tiers — headline tie-out + per-component summary (with two inline-SVG charts: legacy-vs-ours per component and Σ|Δ| by component), the by-bucket / by-class / by-approach segmentation, the break worklist ranked by materiality, and a per-key forensic table whose bucket filter (?bucket=…) re-reads a cached result without recomputing. The wide forensic frame is projected to a readable column set on screen; the full per-key detail (explain + input drivers) is available via CSV/Excel download. A matching library-first HTTP contract is exposed:POST /api/reconcile(returns arecon_id+ each tier as{columns, rows}) andGET /api/reconcile/export/{csv|excel}. New modulesrc/rwa_calc/ui/views/reconciliation.py(framework-agnostic view helpers) sits over the unchangedCreditRiskCalc.reconcile()API — no calculation impact. Pinned bytests/unit/ui/test_views_reconciliation.py,tests/integration/test_ui_reconciliation.py, and new reconcile cases intests/integration/test_rest_api.py.
Changed¶
- The reconciliation UI moved from a Marimo workbook to the native app;
src/rwa_calc/ui/marimo/reconciliation_app.pyis removed. The native/reconciliationpage (above) is now the single standard surface, so the standalone Marimo reconciliation app — and its stale references to amarimo/server.pythat no longer exists — are deleted. The reconciliation guide (docs/reconciliation/index.md) and the Interactive UI guide are updated to describe the native page and the new REST endpoints. The editable Marimo workbench (/workbench) is unaffected. Docs/UI only — no calculation impact. httpxis now in the default-synced[dependency-groups].devso the REST/UI test gate runs on a clean checkout (tooling fix). The FastAPITestClient(used bytest_rest_api.py,test_ui_app.pyand the newtest_ui_reconciliation.py) importshttpx, but it was declared only in the[project.optional-dependencies].devextra — whichuv syncdoes not install by default — so a plainuv syncleft those integration tests erroring at import. Addinghttpx>=0.27.0to[dependency-groups].devmirrors the existingpytest-xdist(0.2.22) andwatchfire(0.2.24) fixes. Build/tooling only — no calculation impact.- Docs now show
uv add rwa-calcas the primary install command, fixing a wrong PyPI package name. The docs landing hero (docs/overrides/main.html) advertisedpip install rwa-calculator— the wrong package name (the project publishes asrwa-calc, notrwa-calculator) — so the copy-to-clipboard command would have failed. It now readsuv add rwa-calc. The getting-started, quickstart and interactive-UI guides are realigned to lead withuv add rwa-calc(pip shown as the secondary option), reflecting uv as the recommended package manager. Docs only — no calculation impact. - Removed the
pip install rwa-calculatorbox from the app landing page (src/rwa_calc/ui/app/templates/landing.html). Anyone viewing the app's landing page is already running the app locally and has therefore already installed it, so the install command was redundant. The box remains on the docs landing page (docs/overrides/main.html), where visitors may not yet have installed the package. UI presentation only — no calculation impact.
[0.2.24] - 2026-06-07¶
Added¶
- New server-rendered read-only UI (
rwa-ui) on a real REST API. The read-only surface (landing, calculator, results explorer, CRR vs Basel 3.1 comparison) is now a pure-Python FastAPI + Jinja application (src/rwa_calc/ui/app/) rendered with the shared--oah-*brand tokens so it matches the Zensical docs, with charts drawn as inline SVG (ui/views/charts.py) — no JavaScript build step, no vendored JS blob, so it bundles cleanly via moonlit. It is backed by a new REST API (src/rwa_calc/api/rest.py, exported ascreate_api_app/api_router):POST /api/calculate,POST /api/validate,GET /api/results,GET /api/results/summary/{class|approach},POST /api/comparison, andGET /api/export/{parquet|csv|excel|corep}over the existingCreditRiskCalc— the library-first contract the UI itself consumes and that external callers can embed. The CRR↔Basel waterfall/transform logic is now a framework-agnostic module (ui/views/comparison.py) shared by the docs, the app, and Marimo. The editable Marimo workbench is retained and launched on demand from the app. UI/API only — no calculation impact. Pinned bytests/integration/test_rest_api.py,tests/integration/test_ui_app.py, andtests/unit/ui/. - Parallel-run reconciliation — compare this calculator's output against a legacy calculator, component by component (migration tooling). Firms adopting the calculator can now reconcile its per-exposure output against their existing engine's output to build migration confidence. A new canonical component registry (
data/schemas.RECONCILABLE_COMPONENTS: exposure class, approach, PD, LGD, maturity, CCF, EAD, risk weight, supporting factor, expected loss, RWA — each with our value column, explain columns and raw input drivers) drivesengine/reconciliation.ReconciliationRunner, which collapses our guarantee/RE sub-rows to the reconciliation grain (engine/aggregator/_collapse.aggregate_to_key_grain, on a defaultexposure_referencekey or a composite/custom key e.g. counterparty + facility), full-outer joins the mapped legacy output, and buckets every mapped component asexact_match/within_tolerance/break/missing_left/missing_right(per-component tolerances default to the acceptance-suite values, overridable). TheReconciliationBundle(contracts/bundles.py) is layered headline → forensic:totals_tie_out+summary_by_component, thensummary_by_bucket/_by_exposure_class/_by_approach, then a rankedbreaks_detailworklist, then a per-keycomponent_reconciliationcarrying legacy-vs-ours, our reason and our input drivers so a break can be triaged to a data fix vs an engine fix. Analyst entry points: a TOML mapping config (stdlibtomllib— no new dependency) viaCreditRiskCalc.reconcile("reconciliation.toml")/api.load_reconciliation_config, aReconciliationResponsewithcollect_*accessors +to_csv/to_excel(multi-sheet) export, and a new Marimo workbook (ui/marimo/reconciliation_app.py, served at/reconciliation) with a live-editable mapping and the four drill-down tiers. Legacy column mapping handles unit scaling (scale, e.g. millions),unit = "percent"ratios, and categoricalvalue_mapsynonyms; non-fatal data-quality issues accumulate asREC001–REC004warnings rather than aborting. Purely additive — no change to any RWA calculation. Pinned bytests/unit/engine/test_collapse.py,tests/unit/engine/test_reconciliation.py,tests/contracts/test_reconciliation_contract.py, andtests/acceptance/reconciliation/test_reconcile_end_to_end.py. - New "Parallel-Run Reconciliation" docs guide (
docs/reconciliation/index.md), surfaced as a top-level nav section. A prominent new-user guide framing reconciliation as the way to gain comfort the calculator produces the right numbers before migrating: a migration-confidence narrative (linked to the parallel-run-discipline blog post), the component/bucket model, how to read the four drill-down tiers (tie-out → by-component → break worklist → per-key forensic), and the full how-to (TOML mapping,CreditRiskCalc.reconcile(), Marimo/reconciliationapp). Threaded into the Overview (Key Features bullet + "Migrate with Confidence" nav card), Getting Started, the Features index, and a cross-link distinguishing it from the CRR↔Basel-3.1 comparison. Docs only — no calculation impact. - New generated "Module Dependencies" docs page (
docs/development/module-dependencies.md). Driven by the newcurfewdev dependency,scripts/generate_dependency_graph.pybuilds the live import graph ofsrc/rwa_calcand renders two Mermaid charts: a readable package-level overview (top-level subpackage edges collapsed from the module graph) and the full 144-module graph in a collapsible block. The generator is wired intoscripts/deploy.pyso the page refreshes on each release, mirroring the Citation Coverage Matrix. Docs/tooling only — no calculation impact.
Changed¶
- The
rwa-uiconsole script now launches the new server-rendered app (rwa_calc.ui.app.main:main), not the Marimo multi-app gateway. Brand design tokens were extracted into a single source of truth,docs/assets/stylesheets/tokens.css(the--oah-*palette, font stack and easing, previously inlined inhomepage.cssand hand-mirrored in the Marimotheme.css), loaded first viazensical.tomlextra_cssand vendored into the app undersrc/rwa_calc/ui/app/static/tokens.css(kept in lockstep by the drift-guard testtests/unit/ui/test_tokens_drift.py). New runtime deps:jinja2,python-multipart; new dev dep:httpx(FastAPITestClient). UI/packaging only — no calculation impact. After upgrading, re-runuv syncto regenerate the console script. watchfireis now in the default-synced[dependency-groups].devso thearch_checkpre-commit gate works on a clean checkout (tooling fix).scripts/arch_check.pyinvokeswatchfire checkas its final step, butwatchfire==0.3.1was declared only in the[project.optional-dependencies].devextra — whichuv syncdoes not install by default — so a plainuv syncleftwatchfireuninstalled andarch_checkfailed withwatchfire not importable: No module named 'watchfire', blocking commits. Addingwatchfire==0.3.1to[dependency-groups].dev(the groupuv syncinstalls by default) makes the citation validator part of the default dev environment, mirroring the existingpytest-xdistfix in 0.2.22. Build/tooling only — no calculation impact.- Renamed the UI console script
rwa-calc-ui→rwa-ui. The[project.scripts]entry point inpyproject.toml(and its references in the README, quickstart, interactive-UI guide, workbooks guide, and interface spec) now uses the shorterrwa-uicommand to launch the Marimo web server (rwa_calc.ui.marimo.server:main). Packaging/UX only — no calculation impact. After upgrading, re-runuv sync(or reinstall) to regenerate the console script; the oldrwa-calc-uiname is removed. - Animated "URSA POLARIS" polar-bear constellation now backs both landing pages. The docs landing hero's static, slowly-rotating generic star-cluster background is replaced by a polar bear drawn as a constellation (stars + bone-lines) that walks across the night sky — STAND → CROUCH → RUN off the right edge → WALK-IN from the left → RISE, over a deterministic twinkling starfield (a nod to "computed at the speed of polars"). It is a single dependency-free vanilla-JS module,
docs/assets/javascripts/bear-constellation.js, that hydrates the first.constellation-bgelement viarequestAnimationFrameand no-ops everywhere that element is absent (so it loads safely site-wide viazensical.tomlextra_javascript); it disables itself underprefers-reduced-motion, rendering one static standing pose. The same effect now also backs the app landing page (src/rwa_calc/ui/app/templates/landing.html), which is reworked into the full-screen constellation hero matching the docs (the four nav cards collapse into the hero nav + CTAs). The landing design system (homepage.css) and the script are vendored intosrc/rwa_calc/ui/app/static/and held in lockstep with theirdocs/sources by the drift-guard testtests/unit/ui/test_tokens_drift.py(now parametrised overtokens.css,homepage.cssandbear-constellation.js);base.htmlgains overridablestyles/topnav/scriptsblocks so the landing page can own the screen without changing the other app pages. UI/docs presentation only — no calculation impact. Pinned bytests/integration/test_ui_app.py::test_landing_hosts_bear_constellation.
[0.2.23] - 2026-06-03¶
Fixed¶
- Facility-level provisions and guarantees now also cascade down nested facility hierarchies (CRR Art. 111(2), 213-217). The same single-level limitation fixed for collateral existed in
engine/crm/provisions.py::_resolve_provisions_multi_level(facility provisions joined on the immediateparent_facility_reference) andengine/crm/guarantees.py::_resolve_guarantees_multi_level(facility guarantees_allocate_guarantees_pro_rataonparent_facility_reference), so a provision or guarantee pledged at a grandparent facility was silently allocated zero to exposures under an intermediate child facility (confirmed: a facility provision at a grandparent allocated 0.0 to both descendant loans). Both now explode the exposure'sancestor_facilitiesset so a provision/guarantee at any ancestor facility is allocated pro-rata (by EAD-equivalent weight /ead_after_collateral) across the whole descendant subtree, stacking across levels, with the same[parent]fallback that keeps single-level behaviour byte-for-byte unchanged. Pinned bytests/unit/crm/test_provisions.py::TestFacilityLevelProvision::test_grandparent_facility_provision_cascadesandtests/unit/crm/test_multi_level_guarantees.py::TestFacilityLevelGuarantee::test_grandparent_facility_guarantee_cascades. Ref: CRR Art. 111(2), 213-217; Art. 230-231. - Facility-level collateral now cascades down nested facility hierarchies (CRR Art. 230-231). Collateral pledged at a facility (
beneficiary_type="facility") was allocated only to exposures whose immediateparent_facility_referencematched the pledged facility:engine/crm/processor.py::_build_facility_lookupgrouped exposure EAD by the immediate parent, andengine/crm/collateral.py::_apply_collateral_unifiedjoined facility collateral onparent_facility_reference == beneficiary_reference. So when collateral sat on a grandparent facility (FAC_1 → FAC_2 → loans/contingents), apledge_percentageresolved against FAC_1's zero direct exposures (→ resolved amount 0) and the allocation join matched nothing → zero collateral allocated, with IRBlgd_post_crmreverting to the unsecured supervisory value. The failure was independent of how the pledge was sized (percentage or explicitmarket_value) and of collateral type (cash, real estate, …). TheHierarchyResolveralready built the facility transitive closure for undrawn-limit aggregation but the CRM stage never consumed it for collateral. Fix:HierarchyResolvernow emits anancestor_facilitieslist column (parent + every ancestor up to root, incl. self) via a new_build_facility_ancestor_closure/_resolve_ancestors_eager;_build_facility_lookupand a new_cascade_facility_collateralconsume it so a pledge at any ancestor facility flows pro-rata (byead_for_crm) to its whole descendant subtree, pool-aware (unflagged collateral stays in the non-AIRB subtree pool per CRR Art. 181) and stacking when pledged at multiple levels. Reduces exactly to the legacy single-level allocation when no facility hierarchy is present (ancestor_facilitiesfalls back to[parent]), so existing single-level facility tests are byte-for-byte unchanged. Pinned bytests/unit/crm/test_multi_level_sa_collateral.py::TestNestedFacilityCollateralCascade(5 SA/FIRB cases incl. grandparent percentage + amount, subtree isolation, stacked pledges),tests/unit/test_hierarchy.py::TestBuildFacilityAncestorClosure(4), andtests/integration/test_nested_facility_collateral.py(2 end-to-end through hierarchy → classifier → CRM). Ref: CRR Art. 223(4), 230-231; Art. 181.
[0.2.22] - 2026-06-02¶
Changed¶
- Collateral-link allocation is now joint and residual-demand-aware across competing collateral items (CRR Art. 230-231). When two or more
collateral_linksitems were pledged to overlapping beneficiaries,CollateralLinkAllocator._allocate_slices(engine/crm/link_allocation.py) split each item independently (a per-collateral_referencecumulative-capcum_sum), with no shared state for demand already absorbed by other items — so every item piled onto the same highest-RWA-density beneficiary, over-allocating it (the downstream Art. 231 waterfall silently caps secured ≤ EAD, wasting the surplus) while starving the others. Example: two £100m cash items each linked to F1 (RW 37%) and F2 (RW 40%) both dumped £100m on F2 → F2 covered 200/100 (£100m wasted), F1 received £0, total recognised benefit £100m. The split is now a single global edge-ordered walk (_residual_fill) that fills each link against the residual of both endpoints — item finite value (supply) and beneficiary demand — so once F2 is filled by one item the other spills to F1 (both fully covered, £0 wasted, benefit £200m). Edge order is unchanged where it mattered before (explicitpriorityfirst, then descending RWA density, then lexical tie-break) with a new most-constrained-item tie-break (an item linked to fewer beneficiaries draws first) so restricted-eligibility supply is not stranded by a more flexible item. A deterministic greedy heuristic, not a global LP optimum; reduces exactly to the legacy cumulative-cap split for any single-item link set, so the existing 7 allocator tests and all 3 integration scenarios pass byte-for-byte unchanged. The per-edge supply cap preserves theΣ slices ≤ valueguarantee;max_pledge_amountandprioritysemantics are unchanged. Gated, as before, byCalculationConfig.enable_collateral_link_splitting(defaultTrue); a corpus with nocollateral_linkstable is unaffected. Pinned by 3 new tests intests/unit/crm/test_collateral_link_allocation.py(test_two_items_two_facilities_joint_fill,test_constrained_item_not_stranded,test_duplicate_link_no_double_spend). Ref: CRR Art. 230-231. pytest-xdistmoved into the default-synced[dependency-groups].devso the configuredaddoptsparse on a clean checkout (release/tooling fix).[tool.pytest.ini_options].addoptsunconditionally passes-n auto --dist=loadfile, butpytest-xdistwas declared only in the[project.optional-dependencies].devextra — whichuv syncdoes not install by default — so a freshuv syncproduced an environment where every bareuv run pytest(includingscripts/deploy.py'suv run pytest -x -qrelease gate) aborted witherror: unrecognized arguments: -n --dist=loadfile. Addingpytest-xdist>=3.5.0to[dependency-groups].dev(the groupuv syncinstalls by default) makes the parallel-test plugin part of the default dev/release environment, matching the existing optional-extra declaration. Tooling/build only — no calculation impact.
[0.2.21] - 2026-06-01¶
Added¶
collateral_linksis now auto-discovered from the standard data layout via the data-source registry. The optionalcollateral_linksinput (the M:N collateral-to-beneficiary mapping shipped in v0.2.20,COLLATERAL_LINK_SCHEMA) was fully wired throughDataSourceConfig.from_registry()and_build_bundleinengine/loader.py, but had no entry in theDATA_SOURCESregistry (config/data_sources.py) — soget_p("collateral_links")always resolved toNoneand the table was never picked up from the conventional layout; a caller had to setcollateral_links_fileby hand. A newDataSourceFile(id="collateral_links", relative_path=Path("collateral/collateral_links"), OPTIONAL)entry (mirroring the siblingcollateralsource) now letsDataSourceConfig.from_registry()resolvecollateral_links_filetocollateral/collateral_links.parquet(or.csv) automatically, exactly like every other optional input. Purely additive and behaviour-preserving — firms with nocollateral_linkstable still take the single-beneficiary path (loader returnsNonegracefully when the file is absent). Pinned bytests/unit/config/test_data_sources_collateral_links.py(7 tests: registry entry presence, relative path, OPTIONAL requirement, parquet appears inget_optional,from_registryparquet/csv population, description). Ref: CRR Art. 230-231.
[0.2.20] - 2026-05-31¶
Added¶
- Collateral M:N allocation — one finite collateral item split across multiple beneficiaries (CRR Art. 230-231). Collateral could previously attach to a single
beneficiary_referenceonly; a real-world pledge backing several facilities/loans had no representation. A new optionalcollateral_linksinput table (COLLATERAL_LINK_SCHEMAindata/schemas.py:collateral_reference,beneficiary_type,beneficiary_reference, optionalmax_pledge_amountsub-limit andpriorityoverride) maps one collateral item to many beneficiaries. A newCollateralLinkAllocator(engine/crm/link_allocation.py, implementingCollateralLinkAllocatorProtocol) splits each item's finite value across its linked beneficiaries for the most beneficial RWA impact — a greedy fill of the highest pre-CRM RWA-density beneficiary first (SA-equivalent risk weight computed by a ranking pre-pass inside the CRM stage), honouring anymax_pledge_amountcap, and never over-claiming (Σ slices ≤ value) via the same Art. 231 cumulative-cap trick the waterfall uses (slice_i = min(cum_i, V) − min(prev_i, V)). The allocator expands the links into per-beneficiary collateral rows of the normalCOLLATERAL_SCHEMAshape, so the existingapply_collateralwaterfall, EAD/LGD reduction, and SA/IRB consumers are unchanged. All five beneficiary types resolve (loan/contingent/exposure direct, facility/counterparty pooled). Threaded additively throughRawDataBundle/ResolvedHierarchyBundle/ClassifiedExposuresBundleand surfaced onCRMAdjustedBundle.collateral_link_allocation(per-link audit). Referential integrity (validate_collateral_linksincontracts/validation.py: unknown collateralCRM009, unknown beneficiaryCRM010, duplicate linkCRM011). Gated byCalculationConfig.enable_collateral_link_splitting(defaultTrue; an A/B kill-switch). Purely additive — a corpus with nocollateral_linkstable behaves exactly as the single-beneficiary path (full suite 7178 green). Pinned bytests/unit/crm/test_collateral_link_allocation.py(7 allocator tests: finite-value split, RWA-minimising order, no-overclaim,max_pledge_amountcap, priority override, passthrough, mixed beneficiary types),tests/unit/contracts/test_validation_collateral_links.py(6),tests/unit/data/test_collateral_link_schema.py(6), andtests/integration/test_collateral_links_pipeline.py(3 end-to-end: one £1m cash item linked to a 0% sovereign loan and a 100% corporate loan lands entirely on the corporate loan, proving the RWA-ranking drives the split rather than the lexical tie-break). Ref: CRR Art. 193/194/207, Art. 230-231.
[0.2.19] - 2026-05-30¶
Added¶
- P4.20 (v0.2.45) — COREP C 08.02 now keys IRB rows by the firm's own internal rating grade when an
internal_rating_grade(RATINGS_SCHEMA) /cp_internal_rating_grade(HIERARCHY_OUTPUT_SCHEMA) column is supplied, with graceful fallback to the fixed PD buckets when absent/all-null. Per COREP Annex II §C 08.02 (one row per obligor grade); reporting-granularity only, no RWA impact. Pinned bytests/unit/test_p4_20_c0802_internal_grades.py(19 tests; existing fixed-bucket output byte-identical —tests/unit/test_corep.py711 passed). - P2.25(b) (v0.2.45) — Pillar III CR5 now reports a regulatory-RE not-materially-dependent loan split at the 55% LTV boundary: new Basel-3.1 "of which" sub-rows 9f (secured ≤55% LTV @20%) / 9g (above-55% residual @counterparty RW), partitioned by
re_split_role. Per PRA PS1/26 Annex XX §CR5 (Art. 124F/124L); CRR CR5 byte-identical, sub-rows not double-counted into the grand total. Pinned bytests/unit/reporting/pillar3/test_p2_25_cr5_re_55ltv_split.py(11 tests). Sub-item (c) equity-transitional end-state RW deferred (needs an engineequity_rw_end_statecolumn; overlaps P3.6b). - P1.188 — Stale
PS9/24regulatory-instrument citations replaced with the finalPS1/26. The post-model-adjustment docstrings/comments incontracts/config.py,data/schemas.py,engine/irb/adjustments.pyand thepyproject.tomlpackage description still citedPS9/24(the superseded 2024 PRA consultation paper), whose numbering was reassigned toPS1/26on publication of the final policy statement (effective 1 Jan 2027). Five in-scope source occurrences plus the package description were corrected; article numbers (Art. 153(5A)/154(4A)/158(6A)) and surrounding text are unchanged. Cosmetic/metadata only — no calculation impact. Pinned bytests/contracts/test_ps126_citation_currency.py(6 parametrized:PS9/24absent andPS1/26present per source file). Ref: PRA PS1/26 (final); PS9/24 (consultation — superseded). - P6.21 —
_compute_portfolio_waterfallis now fully lazy (LazyFrame-first contract). The capital-impact waterfall builder collected its six aggregate sums mid-pipeline (engine/comparison.py), materialising a frame before the output boundary in violation of the LazyFrame-first convention. The.collect()was removed and the 4-row waterfall re-expressed with apl.LazyFramescaffold + cross-join +when/thenso the function returns a genuineLazyFrame(no.collect().lazy()shape — arch_check check-3 clean). Values byte-identical. Pinned bytests/unit/test_comparison_waterfall_lazy.py(no-eager-collect + return-type + value-invariance), withtests/unit/test_capital_impact.pyas the invariance net. Ref: CLAUDE.md Polars Conventions (LazyFrame-first). - P6.33 —
compute_pfedelegates unmargined replacement cost to the canonicalcompute_rc_unmargined(CRR Art. 275(1)). The SA-CCR PFE builder (engine/ccr/pfe.py) inlinedmax(V_net − C_net, 0)as a duplicate of the canonicalcompute_rc_unmarginedinengine/ccr/rc.py, so a future change to the RC kernel would have to be applied twice.compute_pfenow callscompute_rc_unmarginedbefore composing the multiplier/EAD, preserving thehas_unified_rc/rc_for_eadcoalesce; the@cites("CRR Art. 278")decorator is unchanged. EAD/PFE values invariant — no calculation impact. Pinned bytests/unit/ccr/test_pfe_rc_delegation.py(delegation contract + NS-P6.33-01 golden), withtests/unit/ccr/test_pfe_multiplier.pyas the invariance net. Ref: CRR Art. 275(1). - P2.38 — CRR Art. 155(2) non-trading-book short-position netting for the IRB Simple equity method (CRR-only). Long and short positions in the same issuer were not netted before applying the simple equity risk weight, so a short position was treated as a standalone long and equity RWA was overstated. New signed
position_valueplusissuer_referenceandis_explicitly_hedgedcolumns onEQUITY_EXPOSURE_SCHEMAdrive a new helperengine/equity/calculator.py::_net_short_positions(@cites("CRR Art. 155(2)")): per issuer the netted long =max(0, Σ position_value)carries the netted EAD and the absorbed short → EAD/RWA 0, gated on a non-null issuer key +is_explicitly_hedged(≥1-year explicit hedge). CRR-only — Basel 3.1 routes equity to SA (PS1/26 Art. 147A), so the B31 arm never reaches the netting branch; column-absent frames are unchanged. The ≥1-year hedge-tenor floor scalarCRR_EQUITY_NETTING_MIN_HEDGE_YEARSlives indata/tables/crr_equity_rw.py(no new engine-scope scalar). Pinned bytests/acceptance/crr/test_p2_38_art_155_2_short_position_netting.py(8 tests; worked case: long £1,000,000 + short £400,000 in ISSUER-A → netted EAD £600,000 × 290% = RWA £1,740,000, vs the un-netted £4,060,000 the pre-fix engine produced). Ref: CRR Art. 155(1)-(2), Art. 165. - P2.46 — CRR Art. 150(1) PPU provenance enum so COREP C 07.00 can distinguish the three SA-routing reasons. Column 0050 ("Standardised Approach — of which permanent partial use") was indistinguishable from Art. 148 sequential roll-out (row 0060) and from no-permission SA, because the model_permissions input carried no provenance. New
PpuReasonStrEnum (art_150_1_a..j+art_148_rollout,domain/enums.py); a newppu_reasoncolumn onMODEL_PERMISSIONS_SCHEMAandCLASSIFIER_OUTPUT_SCHEMAthreaded throughengine/classifier.py::_resolve_model_permissionsonto the surviving SA-precedence row;reporting/corep/generator.pyroutes C 07.00 row 0050 (ppu_reason ∈ art_150_1_*) and row 0060 (art_148_rollout) with a graceful fallback that preserves the prior null behaviour when the column is absent (so the ~700 existing COREP tests stay green). Also adds the required"standardised"value toVALID_MODEL_PERMISSION_APPROACHES(the classifier already testedmp_approach == ApproachType.SA.value, but the constraint set rejected it). Art. 150(2) firm-level equity materiality is an explicit non-goal. Provenance-only — RWA/EAD-conservation-neutral. Pinned bytests/acceptance/crr/test_p2_46_ppu_provenance_corep.py(8 tests: three corporate SA exposures → C 07.00 rows 0050/0060/residual = £1m/£1m/£1m of total SA £3m). Ref: CRR Art. 150(1)(a)-(j), Art. 148; COREP Annex II §C 07.00. - P2.48 — Pillar III CR8 RWEA-flow opening and residual rows now populated (PRA PS1/26 Annex XXII §11 / CRR Art. 438(h)). The CR8 flow statement only populated the closing row; rows 1 (opening) and 2-8 (flow drivers) emitted
Nonefor lack of prior-period data. A new optionalprevious_period_results: pl.LazyFrame | Noneparameter onPillar3Generator.generate/generate_from_lazyframe(following the P2.29output_floor_summaryprecedent — nocontracts/bundles.pychange) lets_generate_cr8derive row 1 (opening = prior-period IRB-non-slottingrwa_finalsum, via the same_filter_irb_non_slotting+_col_sumas the closing row, so the two snapshots reconcile like-for-like) and row 8 (Other = closing − opening, a signed Float64 — increases positive, decreases negative); rows 2-7 remainNone(per-driver decomposition needs exposure-level lineage not derivable from two snapshots, and is out of scope). Backwards-compatible: omitting the parameter leaves rows 1-8None. Pinned bytests/unit/reporting/pillar3/test_p2_48_cr8_rwea_flow.py(9 tests: opening £1,000,000, residual +£150,000, closing £1,150,000; −£150,000 decrease control; reconciliationrow_1 + row_8 == row_9). Ref: PRA PS1/26 Annex XXII §11; CRR Art. 438(h). - P2.15 — Basel 3.1 equity transitional irrevocable opt-out election (PRA PS1/26 Rules 4.9-4.10).
EquityTransitionalConfighad no flag for a firm that has irrevocably elected to leave the equity transitional schedule, so such a firm could not be modelled at end-state risk weights during the 2027-2029 phase-in. A newopt_out: bool = Falsefield (contracts/config.py) now gates both transitional gates per Rule 4.9's joint election:engine/equity/calculator.py::_equity_holding_higher_of_rwreturnsNone(a CIU look-through equity underlying reverts from the 370% legacy Art. 155(2) higher-of to the 100% default holding RW) and_apply_transitional_floorearly-returns unchanged (direct equity keeps its end-state assigned RW),@cites("PS1/26, paragraph 4.9"). The CIU higher-of mechanic the opt-out suppresses shipped earlier under P1.139. Behaviourally inert whenopt_out=False(the default), so existing runs are unchanged. Pinned bytests/acceptance/basel31/test_p2_15_equity_transitional_optout.py(8 cases: CIU look-through RW 3.70 / RWA £3,700,000 atopt_out=False→ RW 1.00 / RWA £1,000,000 atopt_out=True; direct LISTED control RW 2.50 / RWA £2,500,000 invariant under both). Ref: PRA PS1/26 Rules 4.9-4.10; Art. 133, CRR Art. 155(2). - P2.41 —
exposure_subclassderivation for the Art. 147A(1)(e)/(f) COREP corporate split. The COREP C 02.00 corporate sub-rows (0295 financial/large, 0296 SME, 0297 other non-SME) were split on ais_fse = apply_fi_scalar OR cp_is_financial_sector_entityheuristic that misrouted large corporates by revenue (the Art. 153(2) FI-scalar population is threshold-gated LFSEs, which is the wrong population for the Art. 147A(1)(e) subclass). A newExposureSubclassStrEnum (domain/enums.py) and classifier-derivedexposure_subclasscolumn onCLASSIFIER_OUTPUT_SCHEMAnow label corporate exposures:engine/classifier.py::_derive_exposure_subclass(Basel-3.1-only, CRR → null;@cites("PS1/26, paragraph 147A.1")) routes FSE orcp_annual_revenue > config.thresholds.large_corporate_revenue_threshold(GBP 440m) tocorporate_financial_large(Art. 147A(1)(e)), SME tocorporate_sme, elsecorporate_other(Art. 147A(1)(f)).reporting/corep/generator.py::_c02_00_irb_sub_aggconsumes the new label (with a graceful fallback to the prior heuristic when the column is absent). Reporting-granularity only — RWA-conservation-neutral (fulltests/unit/test_corep.pystays 706 green). Pinned bytests/acceptance/basel31/test_p2_41_exposure_subclass_corep.py(7 tests: row 0295 == RWA(FSE) + RWA(large-corp-by-revenue) and strictly > RWA(FSE) alone, row 0297 residual 0, F-IRB conservation). Ref: PRA PS1/26 Art. 147A(1)(e)/(f), Art. 147(2)(c)(ii)/(iii); COREP Annex II §C 02.00. - P2.47 — Art. 137 OECD MEIP direct sovereign risk weight now applies on the Basel 3.1 arm (capital-overstatement fix). The Art. 137 Table 9 ECA/MEIP-score → risk-weight path was implemented on the CRR arm (P1.100) but
engine/sa/namespace.py::_apply_b31_risk_weight_overrideslacked thecp_eca_scorebranch, so an unrated Basel-3.1 sovereign carrying an ECA/MEIP score fell through to the Art. 114 unrated 100% default — e.g. a score-2 sovereign was risk-weighted 100% instead of 20%, a 5× overstatement — even though PS1/26 leaves sovereign treatment unchanged from CRR. The B31 override chain now carries the samecp_eca_scorebranch as the CRR sibling (ordered below the Art. 114(3)/(4) domestic-currency 0% override and above the unrated fallback), reusing the existingECA_MEIP_RISK_WEIGHTSdata table via_eca_meip_rw_expr()— no new scalar, no schema change —@cites("CRR Art. 137"). Additive: a sovereign with no MEIP score keeps its current behaviour. Pinned bytests/acceptance/basel31/test_p2_47_art137_meip_b31.py(unrated B31 sovereign, MEIP score 2, USD £5,000,000 → RW 0.20 / RWA £1,000,000; anti-confound RW ≠ 1.00). Art. 136 grade-string→CQS, Art. 138 second-best, and Art. 121 sovereign-derived institution propagation remain open follow-ons. Ref: CRR Art. 137(1)-(2) Table 9; PRA PS1/26 Art. 114. - P2.30 — CCF Annex I Row 3 / Row 4 are now distinguishable for COREP disclosure.
domain/enums.pyexposed only sixRiskTypevalues, collapsing CRR Annex I Row 3 (other issued off-balance-sheet items, medium-risk) and Row 4 (NIFs/RUFs) onto the singleMRvalue — so the C 07.00 off-balance-sheet-by-CCF section, which buckets purely on the numericccf_applied, could not separate the two rows for Annex I-faithful disclosure. A newRiskType.MR_ISSUED = "medium_risk_issued"(Row 3) is now distinct fromMR(Row 4) but resolves to an identical 50% SA CCF (and matching F-IRB behaviour), so RWA/EAD are provably unchanged — the change is identifier-separability only. Threaded throughdata/schemas.py(VALID_RISK_TYPES_INPUT+RISK_TYPE_SYNONYMSmr_issued/medium_risk_issued→MR_ISSUED) anddata/tables/ccf.py(explicitMR_ISSUED: 0.50inSA_CCF_CRR/SA_CCF_B31/FIRB_OBS_FALLBACKandis_mr_or_ocmembership so the F-IRB issued/commitment split mirrorsMRexactly). The concrete-product →risk_typederivation table (P2.31) and the optional C 07.00 "of which" sub-row remain out of scope. Pinned bytests/unit/test_corep_annex1_row_discrimination.py(11 tests: enum/VALID_RISK_TYPES_INPUT/synonym existence RED→GREEN + CCF 0.50 / EAD £500,000 invariance for both rows; the pre-existingtest_ccr_schemas_contract.pycount andtest_ccf_tables.pyexact-table assertions were widened for the additiveMR_ISSUEDentry). Ref: CRR Annex I, Art. 111. - P3.5 — Pillar III CR9.1 (ECAI-based PD back-testing) is now callable end-to-end (Basel 3.1 only). The
CR9_1_COLUMNStemplate existed but had no generator method, so the template was defined-but-not-callable (P3.2 was marked complete prematurely).reporting/pillar3/generator.pygains_generate_cr9_1+_generate_cr9_1_for_class+_cr9_1_schemaand a newcr9_1: dict[str, pl.DataFrame]field on the reporting-sidePillar3TemplateBundle(not the corecontracts/bundles.py), wired intogenerate_from_lazyframe. CR9.1 is Basel-3.1-only (CRR →{}): it filters ECAI-mapped obligor rows (ecai_pd_mapping, Art. 180(1)(f)), groups byexternal_rating_equivalent, and reuses the existing_compute_cr9_valuesfor the back-testing columns c–h (obligor counts, observed default rate, EAD-weighted and arithmetic average PD, historical default rate). Multi-ECAI column fan-out and Excel-sheet export remain out of scope. Pinned bytests/unit/reporting/pillar3/test_p3_5_cr9_1_ecai_backtesting.py(25 tests; seeded results fixturetests/fixtures/p3_5/; key"advanced_irb - corporate", height 3, ECAI grades "A"/"BBB", CRR framework-gate, and a CR9 regression guard). Ref: PRA PS1/26 Art. 452(h), Art. 180(1)(f), Annex XXII §15. - P6.25 —
CalculationConfig.crr()now exposes theairb_collateral_methodknob, matchingbasel_3_1()(config-API symmetry). The A-IRB collateral-method selector existed as a field onCalculationConfigand was settable viaCalculationConfig.basel_3_1(), but thecrr()factory neither accepted nor set it, socrr().airb_collateral_methodsilently returnedNoneinstead of a framework-appropriate default — an asymmetry that could surprise a caller constructing a CRR config and reading the field.crr()now acceptsairb_collateral_method: AIRBCollateralMethod = AIRBCollateralMethod.LGD_MODELLINGand threads it into the returned config (CRR A-IRB recognises collateral inside the firm's own modelled LGD per CRR Art. 161/181 — the own-LGD treatment PS1/26 Art. 169A namesLGD_MODELLING;FOUNDATIONis the F-IRB supervisory-LGD substitution and is the wrong default for A-IRB). The change is behaviourally inert on the CRR calculation path: every read ofairb_collateral_methodinengine/crm/collateral.pyisis_basel_3_1-gated, so RWA/EAD/LGD are unchanged; only the reported config default moves fromNonetoLGD_MODELLING. The dataclass field default (= None) is left untouched so rawCalculationConfig(...)construction keeps its "not-set" semantics. Pinned bytests/unit/crm/test_art169_lgd_modelling.py::TestConfigAndEnum(3 tests:crr()default ==LGD_MODELLING, explicit override pass-through ==FOUNDATION, and abasel_3_1()symmetry regression-lock; the staletest_crr_config_no_airb_methodis Noneassertion was flipped). Ref: CRR Art. 161/181; PRA PS1/26 Art. 169A. - P1.141 — Basel 3.1 mixed real-estate exposures now enforce the Art. 124(4) all-or-nothing qualifying gate. The pro-rata-by-collateral-value split of a mixed RRE/CRE exposure was already implemented, but the second limb of Art. 124(4) — the preferential Art. 124F–124I real-estate tables apply only if both the residential and the commercial component separately qualify under Art. 124A, otherwise both fall to Art. 124J (no partial preference) — was not enforced, so a mixed exposure whose commercial component failed Art. 124A still received the residential 20% preferential band (capital understatement).
engine/hierarchy.pynow aggregates a per-exposurere_collateral_non_qualifyingflag and preserves uncapped residential/commercial collateral values before the retail-threshold cap (which was distorting the split shares when total collateral exceeded the exposure);engine/classifier.pyemits a Basel-3.1-onlyre_split_force_other_re = is_mixed & re_collateral_non_qualifying(@cites("PS1/26, paragraph 124.4"); the CRR path stayspl.lit(False), unchanged);engine/re_splitter.pythen routes both secured rows through Art. 124J (b31_other_re_rw_expr: RESI → counterparty RW, CRE → max(60%, counterparty RW)) at full pro-rata EAD, dropping the 0.55×value cap so the residual is zero. No new regulatory scalar (reusesB31_OTHER_RE_CRE_FLOOR_RW). Worked golden: an unrated-corporate £2,000,000 mixed exposure (£1.5m qualifying residential + £1.0m non-qualifying commercial collateral) now risk-weights both legs at 100% → RWA £2,000,000 (vs the pre-fix £1,340,000 that retained the residential 20% band). Pinned bytests/acceptance/basel31/test_p1_141_art_124_4_all_or_nothing_gate.py(9 tests). Ref: PRA PS1/26 Art. 124(4), Art. 124A, Art. 124J. - P2.32 — Basel 3.1 CCF for undrawn purchased-receivables purchase commitments (Art. 166E(5)).
engine/ccf.pyhad no purchased-receivables branch, so an undrawn revolving purchase commitment fell through to the generic risk-type CCF ladder (e.g. a medium-risk commitment took 50% instead of the regulatory 40%). A newis_purchased_receivable_commitmentboolean onFACILITY_SCHEMA/CONTINGENTS_SCHEMA(threaded throughengine/hierarchy.pymirroringis_uk_residential_mortgage_commitment) drives a Basel-3.1-gated override in_compute_ccf(_apply_purchased_receivable_ccf,@cites("PS1/26, paragraph 166.5")): a revolving purchase commitment converts at 40% (Table A1 Row 5 "other commitments") or 10% where it meets the Row 7 unconditionally-cancellable criteria, reusingSA_CCF_B31["OC"]/["LR"](no new scalar). The override is a no-op under CRR (which has no dedicated purchased-receivables undrawn-commitment CCF). The dilution/default split and the Art. 166A(5) EAD netting remain separate, out-of-scope mechanisms. Pinned bytests/acceptance/basel31/test_p2_32_purchased_receivable_ccf.py(7 tests; load-bearing: a medium-risk-tagged revolving purchase commitment of £1,000,000 → CCF 40% / EAD £400,000, overriding the generic 50% / £500,000; CRR control unchanged). Ref: CRR Art. 166(5), PRA PS1/26 Art. 166E(5). - P5.15 — Basel 3.1 retail qualification now enforces the Art. 123A(1)(b)(ii) 0.2% granularity sub-condition. The regulatory-retail check previously enforced only the GBP 880k aggregate-exposure limb; the second limb of the same sub-paragraph — no single obligor may exceed 0.2% of the total retail portfolio — was unimplemented, so a concentrated natural-person exposure was risk-weighted at the 75% retail RW when it should fall to the 100% corporate RW (capital understatement). New
B31_RETAIL_GRANULARITY_LIMIT = Decimal("0.002")indata/tables/b31_risk_weights.py;engine/classifier.py::_build_qualifies_as_retail_exprgains a Basel-3.1-only granularity limb after the SME auto-qualify and GBP 880k threshold branches — a candidate-retail obligor whose per-obligor aggregate (lending_group_adjusted_exposure) exceeds 0.2% of the total candidate-retail portfolio (denominator de-duplicated to one contribution per obligor viapl.len().over("counterparty_reference")wrapped inpartition_by_nullable, guarded against a zero portfolio total) failsqualifies_as_retailand re-routes to CORPORATE. The limb is gated on a newCalculationConfig.enforce_retail_granularityflag (defaultTrue; settable viabasel_3_1(enforce_retail_granularity=...)) so it can be suppressed where a firm assesses portfolio granularity by another method under CRE20.66's national-discretion clause — and so isolated single-obligor tests of the other Art. 123A limbs (pool management, GBP 880k threshold) are not spuriously re-classed (a single obligor is trivially 100% > 0.2%). The CRR Art. 123 branch stays threshold-only. Pinned bytests/acceptance/basel31/test_p5_15_art_123a_granularity.py(19 tests; breach obligor → CORPORATE RW 1.00 / RWA £2,000 with the load-bearing anti-confound that its aggregate is below GBP 880k so only the granularity limb re-classed it; control-pass obligor stays retail RW 0.75 / RWA £750). Ref: PRA PS1/26 Art. 123A(1)(b)(ii); BCBS CRE20.66. - P2.29 — Pillar III OV1 equity sub-approach and output-floor rows are now populated (PRA PS1/26 Annex XX §OV1). B31 OV1 rows 11-14 (equity under IRB Transitional / look-through / mandate-based / fall-back) and rows 26/27 (output-floor multiplier / OF-ADJ) were hard-coded null despite the pipeline carrying the source columns, so the disclosure could not reproduce the Annex XX template.
reporting/pillar3/generator.pyreplaces the catch-all_OV1_EXPLICIT_NULL_REFSwith_OV1_FLOOR_NO_SHIM_REFS={"26","27"}plus an_OV1_EQUITY_SUBAPPROACH_REFSdiscriminator map: rows 11-14 sumrwa_finaloverapproach_applied="equity"and the row'sequity_transitional_approach/ciu_approachdiscriminator (own-funds column c = 8% × column a); row 26 reads the first non-nulloutput_floor_pct; row 27 readsOutputFloorSummary.of_adjvia a new optionalgenerate_from_lazyframe(output_floor_summary=...)parameter (defaultNone, so the parquet-backedgenerate()path and all existing callers are unaffected; rows 26/27 are excluded from the column-c shim). Rows 11-14 are memo "of which" sub-rows of equity already counted in row 2 and are deliberately not folded into the row-29 grand total. Pinned bytests/unit/reporting/pillar3/test_p2_29_ov1_equity_subapproach.py(15 tests, Python-only seeded-results fixture +OutputFloorSummary). Ref: PRA PS1/26 Annex XX §OV1. - P1.153 — CRR Art. 155(3) PD/LGD equity approach (CRR-only; removed under Basel 3.1). The IRB PD/LGD method for equity exposures was entirely absent — equity routed only to SA (Art. 133) or IRB Simple (Art. 155(2)). New
EquityApproach.PD_LGDenum (domain/enums.py) and acrr_equity_pd_lgd.pydata table carrying the Art. 165 supervisory parameters: PD floors 0.09%/0.09%/0.40%/1.25% (Art. 165(1)(a)-(d)), LGD 90% (65% for diversified private equity, Art. 165(2)), M=5y (Art. 165(3)), and the Art. 155(3) 1.5× scaling applied when the firm lacks Art. 178 default-definition data. A newequity_pd_lgd: boolflag onCalculationConfigselects the approach (gated to CRR with IRB permissions; ignored under Basel 3.1, where IRB equity is removed by PS1/26 Art. 147A), and a newhas_default_definition_infocolumn onEQUITY_EXPOSURE_SCHEMAdrives the 1.5× branch.engine/equity/calculator.py::_apply_equity_weights_pd_lgdreuses the shared corporate IRB primitives (engine/irb/formulas.py) for correlation/K/maturity-adjustment, then appliesRWEA = K×12.5×1.06×MA×EAD, the per-exposure capmin(RWEA, EAD×12.5 − EL×12.5), and bypasses the Simple-approach transitional floor. Pinned bytests/acceptance/crr/test_p1_153_art_155_3_pdlgd_equity.py(14 tests; worked exchange-traded exposure of EAD £1,000,000 → risk weight 1.918731, RWA £1,918,731, K 0.0736714, cap non-binding). Ref: CRR Art. 155(3), Art. 165. - P1.30(e) — Art. 234 partial-protection (mezzanine) tranching of credit protection. Previously every guarantee attached to first loss
[0,G), leaving a single senior remainder; protection covering only a middle loss band[a,d)(with the borrower retaining both a first-loss tranche[0,a)and a senior tranche[d,EAD]at its own risk weight) was not modelled. New optionalattachment_amount/detachment_amountcolumns onGUARANTEE_SCHEMAlet protection attach to a mezzanine band;engine/crm/guarantees.py::_build_remainder_sub_rows(now@cites("CRR Art. 234")) emits a three-row split (first-loss__REM_FL+ guarantor-substituted mezzanine + senior__REM_SEN) whenattachment_amount > 0, composing after the existing FX/restructuring/maturity-mismatch haircuts; a null attachment preserves the legacy single-__REMfirst-loss behaviour byte-for-behaviour. Theredistribute_non_beneficialremainder predicate was loosened (ends_with→contains("__REM")) so both retained tranches are recognised. Pinned bytests/acceptance/crr/test_p1_30e_art_234_partial_protection_tranching.py(11 tests: three-row structure, per-tranche EAD 200k/400k/400k, ΣEAD conservation = £1,000,000, exactly-one-guarantor-row, blended RW 0.80). Ref: CRR Art. 234, PRA PS1/26 Art. 234. - P1.94(e) — Basel 3.1 currency-mismatch 1.5× multiplier is now suppressed for pre-2027 reporting dates (PRA PS1/26 Art. 123B(3) transitional). The Art. 123B 1.5× multiplier is a Basel-3.1 measure commencing 1 January 2027, but the engine applied it whenever
is_basel_3_1was set, so a Basel-3.1-configured run dated before commencement over-weighted unhedged FX-mismatched retail/RE exposures. A newB31_EFFECTIVE_DATE = date(2027, 1, 1)indata/tables/b31_risk_weights.pyplus a strictif config.reporting_date < B31_EFFECTIVE_DATEshort-circuit at the head ofengine/sa/namespace.py::apply_currency_mismatch_multiplier(after the existingis_basel_3_1guard) now returns the frame unchanged — emittingcurrency_mismatch_multiplier_applied = Falseso downstream reporting always sees the flag — for reporting dates strictly before 1 Jan 2027 (the boundary date itself is in scope).@cites("PS1/26, paragraph 123B.3"). Pinned bytests/acceptance/basel31/test_p1_94e_pre_2027_fallback.py(7 tests: Run A 2026-12-31 → RW 0.75 / RWA £75,000, multiplier suppressed; Run B 2027-01-01 → RW 1.125 / RWA £112,500, multiplier fires). Ref: PRA PS1/26 Art. 123B(3). - P2.31 — Annex I concrete-product →
risk_typemapping table (eliminates manual OBS-item classification). The CCF engine mapped abstract Annex Irisk_typebuckets to conversion factors but had no table translating concrete off-balance-sheet product descriptions (acceptances, performance bonds, warranties, tender bonds, documentary credits) into those buckets, leaving users to hand-classify every contingent — a silent-misclassification risk. A new input-domainobs_productcolumn onFACILITY_SCHEMA/CONTINGENTS_SCHEMA(data/schemas.py, withVALID_OBS_PRODUCTS+OBS_PRODUCT_SYNONYMSnormalisation) feeds a single, framework-invariantANNEX1_PRODUCT_RISK_TYPEdict +build_product_to_risk_type_exprindata/tables/ccf.py(ACCEPTANCE → FR/100%; performance/tender/bid bonds, warranties, documentary/trade credits → MLR/20% — all framework-invariant under SA Table A1, so the framework split stays solely in the untouchedSA_CCF_CRR/SA_CCF_B31).engine/ccf.py::_compute_ccffillsrisk_typefromobs_productonly when no explicitrisk_typeis supplied (explicit always wins),@cites("CRR Art. 111"). Regulatory note: under SA Annex I, performance bonds are MLR/20%; the 50% sometimes cited is the F-IRB Art. 166(10)(b) treatment, not the SA bucket. Pinned bytests/acceptance/crr/test_p2_31_annex1_mapping.py(10 tests: ACCEPTANCE → CCF 1.00 / EAD £2,000,000; PERFORMANCE_BOND and DOCUMENTARY_CREDIT → CCF 0.20 / EAD £400,000; explicit-LR override preserved). Ref: CRR Annex I, Art. 111. - P2.49 — Pillar III CR9 column-
ataxonomy extended to the full PRA PS1/26 Annex XXII leaf set. The CR9 (IRB PD back-testing) disclosure collapsed the regulatory column-abreakdown — F-IRB lacked the financial/large-corporate and other-corporate (non-SME) sub-classes, and A-IRB collapsed seven retail/corporate sub-classes intoretail_mortgage/retail_qrre/retail_other.CR9_FIRB_CLASSESnow carries 5 leaves (addedcorporate_financial_largeper Art. 147(2)(c)(ii) F-IRB-only andcorporate_other_non_sme) andCR9_AIRB_CLASSES10 (retail RRE/CRE × SME/non-SME, QRRE, Other × SME/non-SME, plus the corporate splits). To respect the data/engine boundary the row definition became a 3-tuple(row_key, label, CR9ClassSpec)whereCR9ClassSpecis a plain frozen dataclass —reporting/pillar3/templates.pystays import-clean (no Polars; arch_check-enforced) — and the descriptor→pl.Exprresolution (_cr9_class_predicate, discriminating onexposure_class/is_sme/property_type/cp_is_financial_sector_entitywith graceful degradation when a discriminator is absent) lives inreporting/pillar3/generator.py, applied to both_generate_all_cr9and_generate_cr9_1.@cites("PS1/26, paragraph 147.2"). Supersedes P2.28. Pinned bytests/unit/reporting/pillar3/test_p2_49_cr9_taxonomy.py(13 tests: leaf counts 5/10, 15 expected keys, collapsed-parent absence, discriminator routing); the existingtest_pillar3.pyCR9 count/key/value tests and the_make_cr9_irb_datafixture were updated for the new taxonomy (full pillar3 surface: 256 tests green). Ref: PRA PS1/26 Annex XXII pp.19-20, Art. 147(2).
Changed¶
- P1.94(d) — Basel 3.1 currency-mismatch 1.5× multiplier now applies the Art. 123B(2A) revolving-instalment rule. The Art. 123B(2) 90%-hedge-coverage waiver previously measured coverage against a revolving facility's current drawn balance, so a facility 90%-hedged on a small drawing escaped the multiplier even with large undrawn headroom. Per Art. 123B(2A),
engine/sa/namespace.py::apply_currency_mismatch_multipliernow rescales the coverage test against the fully-drawn base (max(drawn_amount, facility_limit)) foris_revolvingrows — effective coverage =hedge_coverage_ratio × drawn / full_draw_base— so the waiver correctly fails once measured against the committed limit (e.g. a 0.95-hedged-on-drawing revolving QRRE with £100k drawn / £400k limit → effective 0.2375 < 0.90 → multiplier fires → RW 0.75→1.125). Non-revolving exposures are unchanged; the change reuses the existingB31_CURRENCY_MISMATCH_HEDGE_COVERAGE_FLOOR(no new floor literal) and defaults defensively when the revolving columns are absent (production frames unaffected). Pinned bytests/acceptance/basel31/test_p1_94d_art_123b_2a_revolving_instalment_rule.py(11 tests: revolving in-scope RW 1.125 / RWA £112,500; non-revolving and fully-drawn-revolving controls hold the waiver at RW 0.75). Ref: PRA PS1/26 Art. 123B(2A); BCBS CRE20.88.
Fixed¶
- P1.175 — CRR Art. 114 / Art. 123 citation comments corrected (cosmetic, no calc impact). In-source comments cited "Art. 114(3)/(4)" for the domestic-currency sovereign 0% RW (correct refs: Art. 114(4) UK / Art. 114(7) EU) and "Art. 123(3)(a-b)" for the 35% retail payroll/pension RW (correct ref: Art. 123(4)). 20 citation strings corrected across
engine/sa/namespace.py,engine/classifier.py,engine/irb/guarantee.py,data/tables/eu_sovereign.py,data/schemas.py,data/tables/b31_risk_weights.py; the legitimate Art. 123(3)(c) 100% non-regulatory-retail citation is preserved and no numeric scalar changed. Pinned bytests/contracts/test_crr_art114_citation_paragraph.py+tests/contracts/test_crr_art123_payroll_citation.py. Ref: CRR Art. 114(4)/(7), Art. 123(4). - P6.32 — two inline A-IRB
0.5floor multipliers hoisted out ofengine/ccf.pyinto newdata/tables/airb_floors.py(arch-cleanup, no calc impact). The CRE32.27 own-estimate-CCF floor and the Art. 166D(5)(b) off-balance-sheet EAD floor were bare0.5literals carrying# TODOmarkers; both are nowDecimal("0.5")constants (AIRB_REVOLVING_CCF_FLOOR_MULTIPLIER,AIRB_OBS_FLOOR_B_MULTIPLIER) in a new data-table module, coercedDecimal->floatat the call site via the house pattern.float(Decimal("0.5")) == 0.5, so values are byte-identical (tests/unit/test_ccf.py156 passed unchanged). Pinned bytests/unit/test_p6_32_airb_floors_hoisted.py(AST scan of the two function bodies + constant-value checks). Ref: PRA PS1/26 Art. 166D(5)(b); BCBS CRE32.27. - P6.35 —
apply_ccp_risk_weightQCCP trade-exposure RW citation corrected from CRR Art. 307 to Art. 306(1) (metadata-only; updates the watchfire citation matrix). The 4% client-cleared trade-exposure RW carried a stray@cites("CRR Art. 307")(Art. 307 defines the trade-exposure value, not the RW) stacked beside the correct@cites("CRR Art. 306"); the redundant decorator is removed and the docstring relabelled so both the 2% and 4% RWs cite Art. 306(1). Runtime unchanged (0.02 / 0.04). Pinned bytests/unit/ccr/test_ccp.py::test_apply_ccp_risk_weight_cites_art_306_not_307. Ref: CRR Art. 306(1); PRA PS1/26 Annex R §9. - P6.23 — transitional output-floor reporting dates corrected from mid-year to 1 January (PRA PS1/26 Art. 92(5)).
engine/comparison.py::_TRANSITIONAL_REPORTING_DATESwas hardcoded todate(YYYY, 6, 30)for the transitional-schedule comparison runner, contradicting Art. 92(5), which keys each transitional output-floor rate to 1 January (60% from 1 Jan 2027, 65% from 1 Jan 2028, 70% from 1 Jan 2029) with the 72.5% steady-state applying from 1 Jan 2030 under Art. 92(2A). The four literals are nowdate(YYYY, 1, 1)and two stale "mid-year" docstrings were corrected; the canonicalOutputFloorConfig.basel_3_1()schedule was already on 1-Jan dates, so only the comparison-runner constant was out of step (the mid-year dates happened to resolve to the same percentage within each calendar band, so this surfaced as a wrong reporting-boundary date rather than a wrong floor percentage). Pinned bytests/unit/test_transitional_schedule.py(TestTransitionalDates::test_dates_are_first_of_january, inverted from the prior mid-year assertion, plus a new default-path timelinereporting_datebehavioural test; 21 tests green). Ref: PRA PS1/26 Art. 92(5)/(2A). - P2.26 — COREP "(-)"-labelled deduction columns now reported with the correct negative sign (Annex II §1.3).
reporting/corep/generator.pyemitted positive sums for the C 07.00 / OF 07.00 deduction columns 0030/0035/0050/0060/0070/0080/0090/0130/0140 and the C 08.01/02 memorandum column 0290, all of which COREP Annex II §1.3 declares as "(-)" (negative-signed) values — so PRA DPM validation would reject the return. A_negate_deduction_cols(values, negative_cols)pass keyed on module-level_C07_NEGATIVE_COLS/_C08_NEGATIVE_COLSnow negates those columns at the emit boundary, applied after the reconciliation columns (0040 net-of-adjustments, 0110, 0150, IRB 0090) have consumed the positive magnitudes — so the net-exposure arithmetic is byte-unchanged and non-"(-)" columns stay positive; negative-zero is normalised to 0.0 and nulls preserved (@cites("PS1/26, paragraph 1.3")). Pinned bytests/unit/test_corep.py::TestSignConvention(15 tests, including over-negation invariants that assert col 0040 stays +850), with 17 pre-existing COREP tests updated to the corrected sign (13 direct sign flips + 4 reconciliation re-derivations replaced by direct engine-value assertions). Ref: COREP Annex II §1.3. - P1.130 — aggregator class/approach summaries now reflect post-floor RWA when the output floor binds (PRA PS1/26 Art. 92(2A)).
summary_by_class/summary_by_approach(andpost_crm_detailed/post_crm_summary) onAggregatedResultBundlewere generated inengine/aggregator/aggregator.pyfrom the pre-floorcombinedframe, before the portfolio-level output-floor block reassignscombinedto the floored frame. Becauseengine/aggregator/_summaries.pycomputestotal_rwaas(reporting_ead × reporting_rw).sum()and the floor never recomputesreporting_rw, the reported per-class and per-approach RWA understated the floored regulatory total (Art. 92(2A):TREA = max{U-TREA; x·S-TREA + OF-ADJ}) on every portfolio where the floor bound — e.g. a binding-floor portfolio summarised 109.6M instead of the floored 193.75M. Fix (two files): (a)aggregator.pymovespost_crm_detailed/post_crm_summary/summary_by_class/summary_by_approachgeneration to after the floor block so they build from the flooredcombined(return-arg order unchanged;pre_crm_summaryleft in place); (b)_summaries.pyadds a private_floor_addon_expr(cols, ead_col)that folds the per-rowfloor_impact_rwaadd-on (allocated byreporting_ead / ead_finalshare so guarantee-split rows don't double-count; no-oppl.lit(0.0)when the floor didn't run/bind) into the reporting-pathtotal_rwafor both the by-class and by-approach aggregations. The Art. 62(d) EL/T2-credit-cap carve-out (which intentionally uses un-floored IRB RWA) is left byte-for-byte identical, and the floor-not-binding path is unchanged. Pinned bytests/acceptance/basel31/test_p1_130_summaries_reflect_post_floor.py(6 tests; assertions are relationship-framed — summary totals reconcile tooutput_floor_summary.total_rwa_post_floorandresults.rwa_final— so they are robust to IRB-K drift). Ref: PRA PS1/26 Art. 92(2A); CRR Art. 62(d) (carve-out, unchanged).
[0.2.18] - 2026-05-29¶
Changed¶
- On-balance-sheet netting (CRR Art. 195/219) is now driven solely by a
netting_agreement_reference, not facility hierarchy (breaking input-schema change). Previouslygenerate_netting_collateral(src/rwa_calc/engine/crm/collateral.py) pooled negative-drawn deposits and positive-drawn sibling loans bycoalesce(netting_facility_reference, root_facility_reference, parent_facility_reference)— so netting followed the facility tree and silently crossed counterparties whenever they shared a facility node, while the explicitnetting_facility_referenceoverride never even reached netting in the production loader path (it was dropped by the_coerce_loans_to_unifiedcuratedselect). Netting now keys exclusively on a newnetting_agreement_reference(String, optional) column: a deposit (drawn_amount < 0with a non-null reference) and the loans it offsets net together iff they carry the same reference, across different facilities, roots, or counterparties — reflecting the legal right of set-off, which is defined by the agreement rather than the facility structure. The deposit filter drops the oldhas_netting_agreement == Trueandparent_facility_reference is not nullrequirements; the sibling match is a single equality join onnetting_agreement_reference; the pool is grouped by(netting_agreement_reference, currency)and allocated pro-rata byon_bs_for_ead(Art. 219 drawn-on-drawn scope unchanged — contingents andfacility_undrawnrows remain excluded). The hierarchy negative-balance survival guard (hierarchy.py::_aggregate_loan_drawn_per_facilityand the MOF sub-facility helper) now preserves a negative drawn balance whennetting_agreement_reference IS NOT NULLinstead of whenhas_netting_agreementis set, and the new column is threaded through_coerce_loans_to_unifiedso it survives to the netting stage (closing the latent override bug). Breaking: thehas_netting_agreement(Boolean) andnetting_facility_reference(String) columns are removed fromLOAN_SCHEMA— callers must supplynetting_agreement_referenceinstead; portfolios that previously netted via shared facility/root, or relied on thehas_netting_agreementflag, will stop netting until the participating rows are given a matching reference (this can increase RWA for affected portfolios — intended).@cites("CRR Art. 219")added togenerate_netting_collateral(watchfire coverage tuple updated).processor._join_netting_amountsand COREP C 08.01/02 column 0035 (on_bs_netting_amount) are unchanged. Rewrittentests/unit/crm/test_netting.pypins the new contract — includingtest_cross_counterparty_cross_facility_netting(a deposit for counterparty A under facility FAC_A nets a loan to counterparty B under facility FAC_B sharingAGR1) andtest_only_matching_reference_nets_in_same_facility(same facility, different references ⇒ no netting). Docs updated:docs/data-model/input-schemas.md,docs/architecture/data-flow.md,docs/user-guide/methodology/crm.md. Ref: CRR Art. 195, Art. 219, Art. 223; PRA PS1/26 Art. 195/219.
[0.2.17] - 2026-05-29¶
Added¶
- SA-CCR per-article spec pages (D2.77 / D2.78 / D2.79). Three more pages under
docs/specifications/crr/ccr/close the next set of discoverability gaps against shippedsrc/rwa_calc/engine/ccr/modules:legal-enforceability.mddocuments Art. 272(4) contractual-netting definition, Art. 295 framework for recognising netting (three eligible agreement types + cross-entity-group exclusion), Art. 296(2)(a)–(d) four legal-opinion obligations including the four-jurisdiction sweep, Art. 297 ongoing-monitoring duty plus supervisory power to disregard non-enforceable netting, the engine break-out atsa_ccr.py:83-210(each trade in a non-enforceable set becomes its own one-trade synthetic netting set), and a worked two-ITM / three-OTM example showing higher EAD when netting is non-enforceable;wrong-way-risk.mddocuments Art. 291(1)(a) general WWR and 291(1)(b) specific WWR definitions, the 291(2)–(3) identification and stress-testing process, the 291(5) carve-out plus LGD = 100% override mechanic, the 291(6) senior-management reporting hook, and the SA-CCR ↔ IMM demarcation (Art. 284(9) IMM α re-estimation, Art. 274(2) SA-CCR α = 1.4 unchanged) for general WWR — enginewwr.pydocumented including the unwired-into-pipeline_adapter status;ccp-exposures.mddocuments Art. 306(1)(a) 2% QCCP trade-exposure RW, Art. 306(1)(b)–(c) client-cleared 2% / 4% split with the Art. 305(2)(a)–(c) segregation / portability / operational-requirement condition list, Art. 306(4) RWA aggregation, Art. 307 / 308(3) / 309(2) default-fund-contribution 12.5× multiplier, and the non-QCCP fallback through the standard SA-CCR + SA institution ladder with four worked examples (direct-cleared, client-cleared both branches, non-QCCP). Ref: PRA Rulebook (CRR) Part Art. 272(4), 291, 295–297, 305, 306–309 (Basel 3.1 inherits unchanged per PS1/26 Appendix 1 p. 396, 457). - SA-CCR per-article spec pages (D2.74 / D2.75 / D2.76). Three more pages under
docs/specifications/crr/ccr/close the next set of discoverability gaps against shippedsrc/rwa_calc/engine/ccr/modules:rc-calculation.mddocuments Art. 275 — unmarginedRC = max(V − C, 0), marginedRC = max(V − C, TH + MTA − NICA, 0), the Art. 271(7) NICA composition (independent collateral + segregated IM held − non-segregated IM pledged), and four worked NS_A/B/C/D examples covering the threshold-floor binding behaviour;pfe-multiplier.mddocuments Art. 278 — the asset-class add-on aggregation as a plain linear sum across IR + FX + credit + equity + commodity (fill_null(0.0)for placeholder asset classes), the canonical multipliermin(1, F + (1−F)·exp((V−C)/(2·(1−F)·AddOn)))withF = 0.05, the four-regime behaviour table, and a CCR-A1 cross-check pinned to the live golden values;ead-composition.mddocuments Art. 274(2) —EAD = α·(RC + PFE)with α = 1.4, BCBS CRE52.1 calibration rationale, the Art. 274(2) α = 1 carve-out for non-financial counterparties and pension scheme arrangements, the Art. 274(2A)–(2B) transitional alpha-add-on phase-in for legacy CVA-exempt trades, downstream routing into the SA/IRB exposure ladder viapipeline_adapter.ccr_rows_to_exposures, and the Art. 92(2A) S-TREA consumer for the output floor. Ref: PRA PS1/26 Art. 274, 275, 278, 271(7); BCBS CRE52.1, CRE52.10–11, CRE52.20–23. - SA-CCR per-article spec pages (D2.71 / D2.72 / D2.73). Three new pages under
docs/specifications/crr/ccr/close the discoverability gap between the shippedsrc/rwa_calc/engine/ccr/modules and the spec set:supervisory-delta.mddocuments Art. 279a (linear ±1, Black-Scholes Φ(d1) option delta with the long/short × call/put sign rule, and the Art. 279a(3) CDO-tranche attachment/detachment formula);maturity-factor.mddocuments Art. 279c unmargined√(min(M,1y)/1y)with the 10 BD floor and Art. 285 margined1.5·√(MPOR_eff/250)plus the MPOR cascade (5 BD SFT / 10 BD OTC base, 20 BD large-or-illiquid upgrade, dispute doubling, remargining-frequency adjustment);hedging-sets.mddocuments the Art. 277 per-asset-class partition (IR three maturity buckets per currency, FX per currency pair, credit single name, equity single issuer, commodity five buckets) and the Art. 277a inter-bucket correlation parameters. IR + FX worked examples land now; credit / equity / commodity worked examples placeholder-flagged on engine batch P8.35–P8.38. Thecrr/ccr/index.mdstatus table flipped three rows from "Pending" to "Live". Ref: PRA PS1/26 Art. 277, 277a, 279a, 279c, 285.
Fixed¶
- P8.19 — SA-CCR margined replacement cost (Art. 275(2)) now wired through the pipeline adapter.
compute_rc_marginedwas implemented and unit-tested (P8.11) but never invoked by the orchestrator:pipeline_adapter.py::ccr_rows_to_exposuresonly calledcompute_pfe, which inlined the unmarginedRC = max(V − C, 0)for every netting set — so margined sets that should bind the threshold floormax(V − C, TH + MTA − NICA, 0)were understated, and the synthetic exposure row carried onlyrc_unmargined._derivative_rows_to_exposuresnow callscompute_rc_unmargined+compute_rc_marginedon the netting-set frame andcoalesce(rc_margined, rc_unmargined)into a unifiedrccolumn;compute_pfeconsumes that unifiedrcwhen present (EAD = α·(rc + PFE)) and falls back torc_unmarginedotherwise, keeping the lazy plan single-pass and the call backward-compatible.rc_marginedandrcare surfaced on the synthetic row for the COREP-reconciliation surface. Worked golden (CCR-A13): a margined institution NS withV = −4,000,000,TH = 2,000,000,MTA = 500,000,NICA = 250,000now takesrc_margined = 2,250,000(TH + MTA − NICA arm) →EAD = 6,464,360.39/RWA = 3,232,180.20at CQS-2 institution 50%, vs the buggyrc = 0/RWA = 1,657,180.20. Margined maturity factor (P8.14) remains out of scope. Pinned bytests/acceptance/ccr/test_ccr_a13_margined_rc.py(6 tests). Ref: PRA PS1/26 Art. 275(2); CRR Art. 275(2); Art. 285. - P1.139 — Basel 3.1 CIU equity transitional Rule 4.7-4.8 higher-of now applied to look-through / mandate-based equity underlyings (PRA PS1/26 Rules 4.7-4.8; CRR Art. 155(2)).
_apply_transitional_floorexcludes CIU look-through / mandate-based exposures from the equity transitional floor (correct for the wrapper, which is not itself floored per the Rule 4.7 derogation to Art. 132A) — but the exclusion also meant the underlyings never received the Rule 4.8 higher-of, and an EQUITY-class underlying with no CQS fell back to the 100% CQS-miss default (_DEFAULT_HOLDING_RW), understating capital for transitional years 2027-2029. Fix lands inengine/equity/calculator.py::_resolve_look_through_rw(not_apply_transitional_floor, which is unchanged): an EQUITY-class look-through / mandate underlying whose CQS join misses now takesmax(legacy Art. 155(2) "other equity" simple RW = 370%, Rule 4.2/4.3 transitional SA RW)whenever the B3.1 equity-transitional regime is active — gated on the existingequity_transitional.enabled+reporting_datesurface (no new config fields;equity_transitional.enabledis the IRB-permission proxy since the regime only applies to firms that held IRB equity permission on 31 Dec 2026). New helper_equity_holding_higher_of_rwreusesIRB_SIMPLE_EQUITY_RISK_WEIGHTS(no new engine-scope scalar). Worked golden: a £1m look-through CIU with a £600k EQUITY underlying (→ max(370%, 160%)=370%) and a £400k CORPORATE CQS-3 underlying (→ B3.1 75%) re-aggregates tociu_look_through_rw = 2.52,RWA = 2,520,000vs the buggy 900,000; the wrapper stays unfloored. Mandate-based 1.2× variant, the Rule 4.9-4.10 opt-out election (P2.15), and the no-IRB-permission negative control remain out of scope. Pinned bytests/acceptance/basel31/test_p1_139_ciu_transitional_higher_of.py(3 tests). Ref: PRA PS1/26 Rules 4.7-4.8; CRR Art. 155(2). - P1.142 — Art. 124E three-property limit now auto-derives income-dependent RE routing for natural persons (PRA PS1/26 Art. 124E(2)). A natural-person obligor collateralised by more than three qualifying residential properties is "materially dependent on cash flows generated by the property" and must take Art. 124G income-producing risk weights — but the engine had no derivation, so a 4+-property buy-to-let obligor was silently risk-weighted on the Art. 124F loan-split track unless the caller manually set the income flag, understating capital. New
qualifying_property_count: ColumnSpec(pl.Int32, required=False)onCOUNTERPARTY_SCHEMA(data/schemas.py) andB31_RRE_THREE_PROPERTY_LIMIT = 3indata/tables/b31_risk_weights.py;engine/classifier.pyderivesmaterially_dependent = cp_is_natural_person AND (cp_qualifying_property_count > 3)(strict>3) withcoalesceprecedence so an explicit caller-supplied income flag still wins, Basel-3.1-only guard (CRR routing untouched). The derived flag overrideshas_income_cover, routing the breach obligor onto Art. 124G (70-80% LTV → RW 0.50, RWA 100,000) while a 3-property control obligor stays on the Art. 124F loan-split (RW 0.34667, RWA 69,333.33). Cross-lender aggregation and housing-unit counting (Art. 124E(4)) remain the caller's responsibility (out of scope). Pinned bytests/acceptance/basel31/test_p1_142_three_property_income_dependent.py(6 tests, incl. the strict->3boundary). Ref: PRA PS1/26 Art. 124E(2), Art. 124G. - P8.38 — SA-CCR SFT EAD now routes through CRR Art. 271(2) + Art. 220-223 FCCM branch. Previously, booking an SFT (
transaction_type="sft") through thetradestable picked up the derivativeα·(RC+PFE)path inengine/ccr/sa_ccr.py::compute_eadand produced an EAD on the order of £4M from the small per-asset-class supervisory-factor add-on — instead of the regulatorily correctE* = max(0, E·(1+HE) − CVA·(1−HC−HFX))of ~£65M for a £60.7M uncollateralised IG ≥5y debt-security SFT (operator-surfaced via empirical mis-pricing). Newsrc/rwa_calc/engine/ccr/sft_fccm.pyimplements the FCCM branch reusing the supervisory haircut table (data/tables/haircuts.py) at the 5-business-day SFT liquidation period per Art. 224(2)(c) and Art. 226(2) scaling — no new regulatory scalars.pipeline_adapter.py::ccr_rows_to_exposuresnow partitionsRawCCRBundleontrades.transaction_typeand concatenates derivative and SFT outputs viadiagonal_relaxed. Synthetic SFT exposure rows emitrisk_type="CCR_SFT"(placeholder atdomain/enums.py:452promoted to live) andccr_method="fccm_sft"(new third literal alongside"sa_ccr"); SA-CCR-only columns (rc_unmargined,pfe_addon,addon_aggregate,pfe_multiplier) are null on FCCM rows. NewCCRConfig.sft_method: Literal["fccm","var","imm"] = "fccm"field; VaR (Art. 221) and IMM (Art. 283) deferred. Pinned by 18-test acceptance pairtests/acceptance/ccr/test_ccr_a11_a12_sft_fccm_ead.py— load-bearing anti-degenerateA11.ead_ccr > 60_000_000catches the original bug; A11 (uncollateralised) lands at EAD £64,133,710.53 / RWA £32,066,855.26 at institution CQS 2 = 50%; A12 (cash-collateralised £60M) lands at E* £4,133,710.53 / RWA £2,066,855.26. Mixed SFT+derivative netting sets (Art. 271 split) remain out of scope (deferred — both A11 and A12 are 100% SFT NSes). PS1/26 numerically identical for this path. Ref: CRR Art. 271(2), Art. 220(1)(a), Art. 220(3)(a)(i), Art. 223(5), Art. 224 Table 1, Art. 224(2)(c), Art. 226(2), Art. 120 Table 3; PRA PS1/26 CCR (CRR) Part Art. 271/220-223; BCBS CRE22.40-58, CRE52.16-17. - P2.19 — Basel 3.1 SA equity higher-risk (400%) test generalised beyond pre-classified PE/VC (PRA PS1/26 Art. 133(4)). The 400% higher-risk gate in
engine/equity/calculator.py::_apply_b31_equity_weights_saonly fired forequity_type ∈ {private_equity, private_equity_diversified}, so anunlistedequity with business existing < 5 years (andis_speculative=False) wrongly received the standard 250% (Art. 133(3)) instead of 400% (Art. 133(4)). Generalised the gate to any non-subordinated / non-CIU / non-central-bank / non-government-supported equity that is unlisted (~is_exchange_traded) with evidenced business age < 5y; null/unknown age still resolves to 250% for non-PE types (only PE/VC retains the conservative null = young routing), preserving 18 pre-existing scenarios. Reuses the existing 4.00 scalar (B31_SA_EQUITY_RISK_WEIGHTS[PRIVATE_EQUITY]) — no schema change. Scope correction: the originating plan bullet proposedis_held_for_short_term_resale/is_derived_from_derivativeflags; those are BCBS CRE60.20 criteria, not PRA (already corrected in-repo as D1.38/D3.37) and were deliberately NOT added. Pinned bytests/acceptance/basel31/test_p2_19_unlisted_young_equity_higher_risk.py. Ref: PRA PS1/26 Art. 133(3)/(4), Glossary p.5 (higher-risk equity = unlisted AND business < 5y). - P2.33 — UK residential-mortgage commitment now receives the 50% CCF (PRA PS1/26 Art. 111 Table A1 Row 4(b)). Absent a flag, a UK residential-mortgage commitment fell through to the 40% "other commitment" (OC) CCF, understating EAD. New
is_uk_residential_mortgage_commitmentBoolean (FACILITY/CONTINGENTS schema, defaultFalse) is threaded throughHierarchyResolverto the CCF stage, whereengine/ccf.py::_compute_ccfapplies a Basel-3.1-gated 50% override (reusingSA_CCF_B31["MR"]— no new scalar) bounded by the Row 4(b) "not subject to a conversion factor of 10% or 100%" carve-out. CRR is a no-op and the unflagged OC path stays 40%. Pinned bytests/acceptance/basel31/test_p2_33_uk_resi_mortgage_commitment_ccf.py. Ref: PRA PS1/26 Art. 111(1) Table A1 Row 4(b). - P2.44 — inferred ECAI ratings now disapplied for SA specialised-lending routing (PRA PS1/26 Art. 139(2B)). An IRB firm routing SA SL through Art. 122B(1) must use only directly-applicable (issue-specific) ECAI assessments, not Art. 139(2)/(2A) inferred / issuer-level fallbacks; the engine carried a single
external_cqswith no provenance, so an SL exposure whose only rating was inferred picked up the rated-corporate CQS table (e.g. CQS 3 → 75%) instead of the unrated object-finance 100%. Newrating_is_issue_specific/rating_is_inferredBooleans (RATINGS_SCHEMA) andexternal_rating_is_issue_specific(HIERARCHY_OUTPUT_SCHEMA) are threaded through the rating-inheritance chain;engine/sa/namespace.py::_prepare_risk_weight_lookupnulls the CQS for SL exposures (Art. 122B(1)) when the resolved rating is not issue-specific, re-routing to the unrated object-finance 100% RW (reusingB31_SA_SL_RISK_WEIGHTS["object_finance"]). Scoped to SL only and Basel-3.1-gated; schema defaults (issue_specific=True) preserve all existing rated-SL behaviour. Pinned bytests/acceptance/basel31/test_p2_44_sa_sl_inferred_rating_disapplied.py. Ref: PRA PS1/26 Art. 122B(1), 139(2)/(2A)/(2B).
Changed¶
- P6.30 — removed the dead
compute_pfe_ir_singletonstub from the CCR engine. The single-trade IR PFE singleton atengine/ccr/pfe.pystill raisedNotImplementedError("…full PFE per Art. 278 is P8.16")even though P8.16 (v0.2.11) shipped the productioncompute_pfein the same module — a caller hitting the dead namespace methodlf.ccr.pfe_ir_singleton()would have got a misleading not-implemented error. Deleted the function (and its@cites("CRR Art. 278")), thepfe_ir_singletonnamespace shim + docstring bullet (engine/ccr/namespace.py), and the import +__all__entry (engine/ccr/__init__.py), and corrected the stale module scaffold comment that claimed the SA-CCR formula bodies were still stubbed. Watchfire coverage is preserved: productioncompute_pferetains its own@cites("CRR Art. 278"). The pre-existing contract test was inverted from assertingNotImplementedErrorto asserting the symbol is no longer importable. Pinned bytests/contracts/test_ccr_engine_scaffold.py::test_compute_pfe_ir_singleton_removed.
[0.2.16] - 2026-05-27¶
Fixed¶
- P1.190 — Basel 3.1 F-IRB Foundation Collateral Method (Art. 230) now uses the PS1/26 continuous LGD* formula instead of CRR's step function. The engine was inheriting three CRR mechanics that PS1/26 explicitly removes for non-financial collateral: (a) the 30% C* minimum-coverage threshold (
engine/crm/collateral.py:858-887zero-out of_eff_re_a/_eff_op_awhen raw collateral < 30% of EAD), (b) the 1.4× / 1.25× overcollateralisation divisor (engine/crm/expressions.py::overcollateralisation_ratio_expr()), and (c) the immovable-property haircut left at 0.00 inBASEL31_COLLATERAL_HAIRCUTS["real_estate"](data/tables/haircuts.py:134) with a misleading "Handled via LTV" comment that confused the SA Art. 124A-L loan-splitting path with the F-IRB FCM HC term. PS1/26 Art. 230(1) is a continuous formulaLGD* = LGDU·(EU/(E·(1+HE))) + LGDS·(ES/(E·(1+HE)))whereES = C·(1−HC−Hfx); Art. 230(2) tabulates HC = 40% for immovable property, receivables, and other physical collateral; there is no C* threshold and no OC divisor. Bug (a) fix wraps the C* block inif not is_basel_3_1:; bug (b) fix extendsovercollateralisation_ratio_expr()with anis_basel_3_1parameter returning 1.0 for non-financial under B3.1; bug (c) fix sets the RE haircut toDecimal("0.40")and rewrites the comment to distinguish the SA LTV path from the F-IRB FCM HC term. Follow-on engine fix inengine/crm/haircuts.pygates Art. 226 liquidation-period scaling onNON_FINANCIAL_COLLATERAL_TYPES— Art. 230 HC values are credit-quality multipliers, not volatility adjustments, so thesqrt(T_m/10)scaling that would have inflated 0.40 to ~0.566 at the 20-day default is now correctly skipped. Capital impact is bidirectional: RWA decreases for B3.1 F-IRB exposures with thin non-financial collateral previously zeroed by the spurious C* gate; RWA changes magnitude on collateralised slices previously divided by 1.4 because B3.1's(1−0.40)=0.60HC term pairs with a much lower LGDS (20% vs CRR's 35% senior). Pinned by 20 new tests: 4 table-level haircut pins (including CRR/B31 regression guards) + 4 B3.1 acceptance hand-calcs (b31_thin_reLGD*=0.397,b31_full_re0.280,b31_other_physical0.315,b31_re_threshold_30pct0.364) + 2 CRR regression mirrors (crr_thin_re0.450 unsecured fallback,crr_full_re0.378571 via 1.4× divisor). 10 pre-existing unit tests intest_collateral_sequential_fill.pyandtest_art169_lgd_modelling.pywhose hand-calcs assumed the old buggy B3.1 step-function behaviour updated to the correct continuous-formula values. Ref: PRA PS1/26 Art. 230(1)-(2); BCBS CRE32.27-32.35; verbatim text atdocs/assets/ps126app1.pdfp.209-210. - SME supporting factor E* now aggregated across all approach branches (CRR Art. 501): the windowed sum producing the per-row tier threshold input (
total_cp_drawn) was previously computed inside each approach branch — SA, IRB, and slotting each calledSupportingFactorCalculator.apply_factorson its own filtered LazyFrame after the orchestrator split atengine/pipeline.py:813, so the.sum().over("_sme_group_key")window function only ever saw the rows in its own branch. A lending group containing an SA-treated SME with slotting or IRB siblings therefore had its E* understated; more of the SME's drawn fell into tier 1 (0.7619) and the blended supporting factor was artificially low — bank received a larger Art. 501 discount than the regulation permits. Fix moves the aggregation to the unified frame: new module-level helpercompute_e_star_group_drawninsrc/rwa_calc/engine/sa/supporting_factors.pymirrors the existing per-row Art. 501 logic (drawn_amount + interestclipped at zero, minusmin(residential_collateral_value, drawn), summed overlending_group_referencewith fallback tocounterparty_reference) and writes the result to a stablee_star_group_drawncolumn on every row.PipelineOrchestrator._run_calculators_split_oncenow calls the helper once atengine/pipeline.py:809, immediately aftermaterialise_barrier(..., "pipeline_pre_branch")and before the SA/IRB/slotting split, so all approach rows contribute.SupportingFactorCalculator.apply_factors(lines ~273-320) now readse_star_group_drawnwhen present and aliases it to the existing output columntotal_cp_drawn— downstream consumers and the result schema are unchanged. The legacy per-branch window-sum path remains as a fallback whene_star_group_drawnis absent so existing unit tests that build minimal LazyFrames and callapply_factorsdirectly continue to work byte-identically. Helper is config-gated (config.supporting_factors.enabled→ no-op under Basel 3.1 so the column is not added). Pinned by 8 new tests intests/unit/test_supporting_factors_cross_approach.py— load-bearing assertion istest_blended_factor_uses_pre_computed_group_total: an SA SME with drawn £1m and a slotting sibling with drawn £5m now seestotal_cp_drawn = £6m(not £1m) andsupporting_factor ≈ 0.8178(not 0.7619, derived from config to handle the actual GBP threshold of £2,183,000). Full suite green: 6804 passed, 2 skipped. No regulatory scalars introduced — formula and tier factors unchanged. Ref: CRR Art. 501(2)(a) ("total amount owed to the institution … by the obligor client or group of connected clients"); BCBS does not impose a directly equivalent supporting factor under Basel 3.1. - On-balance-sheet netting now restricted to drawn loan siblings only (CRR Art. 219):
generate_netting_collateralinsrc/rwa_calc/engine/crm/collateral.pywas allocating synthetic cash collateral pro-rata across every positive-EAD sibling under the same netting facility — including off-balance-sheet contingents and syntheticfacility_undrawnrows (the per-facility undrawn-headroom rows emitted byhierarchy.py). CRR Art. 219 explicitly limits OBS netting to drawn loans/deposits ("loans to and deposits with the lending institution"); the bank holds no irrevocable lending commitment on an off-BS row that a deposit could net against. Two coupled fixes: (a) thepositive_siblingsfilter now requiresexposure_type == "loan"ANDon_bs_for_ead > 0, excluding contingents and facility_undrawn; (b) pro-rata basis switched fromead_for_crm(=on_bs_for_ead + nominal_after_provision, the CCF=100% override per Art. 223(4)) toon_bs_for_ead(the drawn portion), so a partly-drawn loan with a large undrawn nominal no longer captures an inflated share of the netting pool. The Art. 223(4) override remains the basis for FCCM E netting against external cash collateral — it is a collateral-valuation* rule, not an OBS-netting allocation rule. Source filter (negative-drawn loans withhas_netting_agreement=True) was already correctly drawn-only and unchanged. Downstream collateral pipeline behaviour preserved: synthetic row still flaggedbeneficiary_type="loan"withbeneficiary_reference=exposure_reference, lands at thedirectallocation level and does not re-spread across facility/counterparty pools. Pinned by 4 new tests intests/unit/crm/test_netting.py::TestNettingDrawnOnlyScope— contingent-excluded, facility_undrawn-excluded, pro-rata-uses-drawn-not-ead_for_crm (load-bearing anti-degenerate: deposit 200 with LOAN_A drawn=400 / no off-BS and LOAN_B drawn=100 / off-BS=900; old code split 57.14 / 142.86 byead_for_crm, new code correctly splits 160 / 40 by drawn portion), and mixed-facility e2e. All 23 netting tests + 5,192 unit + 1,633 acceptance/integration/contract tests pass. Graceful fallback added for direct unit-test callers of_generate_netting_collateralthat omitexposure_type/on_bs_for_ead(production always supplies them via the hierarchy +_compute_eadupstream). Docs updated:docs/user-guide/methodology/crm.mdOn-Balance Sheet Netting section now states drawn-only scope andon_bs_for_eadpro-rata basis with a mixed-facility example;docs/specifications/crr/credit-risk-mitigation.mdadds an Art. 219 callout note distinguishing OBS-netting allocation basis from the Art. 223(4) collateral-valuation override. COREP C07 column 0035 (on_bs_netting_amount) semantics unchanged. Ref: CRR Art. 195, 205, 206, 219, 223(4), 224, 230; PRA PS1/26 Art. 219 (unchanged); BCBS CRE22.68-69.
Changed¶
- Supporting factors module relocated from
engine/sa/toengine/top-level (cross-approach stage module):src/rwa_calc/engine/sa/supporting_factors.py→src/rwa_calc/engine/supporting_factors.py. The file housed bothSupportingFactorCalculator(called by all three approach branches — SA atengine/sa/namespace.py:2104, IRB atengine/irb/calculator.py:288, slotting atengine/slotting/calculator.py:166) and the module-level helpercompute_e_star_group_drawn(called by the pipeline orchestrator atengine/pipeline.py:810on the unified post-CRM frame before the SA/IRB/slotting split). Filing it underengine/sa/was a false-locality signal —engine/irb/andengine/slotting/both had to import fromengine/sa/, implying SA was a dependency of the other approach branches when in fact supporting factors are cross-cutting. The module now sits as a peer ofengine/ccf.py,engine/hierarchy.py,engine/classifier.py,engine/re_splitter.py, andengine/materialise.py— the other cross-cutting stage modules at the same level. Pure relocation: no behavioural change, no regulatory logic touched, no schema or output changes. Move performed viagit mvto preserve history. Import paths updated in 5 source files (engine/pipeline.py,engine/sa/namespace.py,engine/irb/calculator.py,engine/slotting/calculator.py,engine/sa/__init__.py) and 4 test files (tests/unit/test_supporting_factors.py,tests/unit/test_supporting_factors_cross_approach.py,tests/unit/crr/test_crr_sa.py,tests/contracts/test_watchfire_coverage.py). The dead re-export inengine/sa/__init__.py(SupportingFactorCalculator,create_supporting_factor_calculator) was dropped since every caller already imported from the full module path.LOGGER_REQUIRED_EXEMPTinscripts/arch_check.pyand the--8<--snippet path indocs/user-guide/methodology/standardised-approach.mdupdated to the new location.docs/api/engine.md,docs/user-guide/methodology/supporting-factors.md,docs/data-model/output-schemas.md, anddocs/specifications/common/default-definition.mdreferences updated;docs/development/citation-matrix.mdregenerated viascripts/generate_citation_matrix.py. Historical changelog entries that mention the old path are intentionally left as-is. Full dev-loop suite green: 6804 passed, 2 skipped. Ref: CRR Art. 501 (SME), Art. 501a (infrastructure) — both regulations are themselves approach-agnostic, matching the new module location.
[0.2.15] - 2026-05-26¶
Changed¶
scripts/deploy.pynow promotes[Unreleased]bullets into the new version section (was: dropped them silently): the previousupdate_changeloglooked for the exact placeholder shape## [Unreleased]\n\n### Added\n- (Next release changes will go here)\n\n### Changed\n- (Next release changes will go here)\n\n---and fell through to a fallback that inserted a fresh## [version]block with a hardcodedVersion bump for PyPI releasebullet above the existing[Unreleased]. Because the team's actual workflow keeps[Unreleased]populated with real bullets, the exact-match branch never fired and every release lost its accumulated changelog content. Promotion logic extracted intoscripts/_deploy_changelog.py(pure string transformspromote_unreleasedandupdate_version_tablefor testability) anddeploy.pynow delegates. The new helper: (a) extracts the[Unreleased]body up to and including its trailing---, (b) parses subsections (### Added,### Changed,### Fixed, etc.) preserving insertion order, (c) drops bullets matching the literal placeholder line, (d) writes the surviving bullets under## [{new_version}] - {today}, and (e) resets[Unreleased]to the canonical empty placeholder. Placeholder-only or missing[Unreleased]blocks fall back to the originalVersion bump for PyPI releasestub. Re-running with## [{new_version}]already present is a no-op. Pinned by 7 new tests intests/unit/test_deploy_changelog.py(placeholder-only fallback, real-bullet promotion, mixed placeholder/real, re-run idempotency, subsection-order preservation, plus 2 version-table cases). New/releaseslash command at.claude/commands/release.mdpreviews what will be promoted, confirms with the operator, and invokesscripts/deploy.py.- SME classification now falls back to
total_assetswhenannual_revenueis null (CRR Art. 4(1)(128D) / Commission Recommendation 2003/361/EC Art. 2): thetotal_assetscolumn onCOUNTERPARTY_SCHEMAwas previously projected onto exposures but read only by the equity calculator; every SME-classification gate keyed offcp_annual_revenuealone, so a counterparty with null turnover and a small balance sheet was silently treated as a large corporate. The classifier's_add_counterparty_attributesnow derives a single shared metricsme_size_metric_gbp = coalesce(cp_annual_revenue, cp_total_assets)plus a provenance columnsme_size_source ∈ {"turnover", "assets", null}, and a new helper_is_sme_by_size_exprcompares the metric to the appropriate threshold per source. The six SME gates (is_corporate_sme,is_retail_sme,is_sl_sme,_reclassify_corporate_to_retail, Art. 123A retail auto-qualification, and the inverse Art. 147A(1)(d) large-corp F-IRB restriction) all read the new helper. The IRB Art. 153(4) third-subparagraph substitution is realised by sourcingturnover_mfrom the coalesced metric inengine/irb/namespace.py(gated on the classifier'sis_smeflag to avoid double-counting counterparties in the EUR 43m-50m equivalent band) — so the SME correlation reduction now picks up assets asSwhen annual sales are not a meaningful indicator. Art. 501(2)(c) is preserved exactly: the SA supporting factor predicate inengine/sa/supporting_factors.pywas tightened fromis_smetois_sme & cp_annual_revenue.is_not_null() & cp_annual_revenue > 0, so a counterparty identified as SME via assets receives theCORPORATE_SMEexposure class and the IRB correlation benefit butsupporting_factor = 1.0(no Art. 501 capital relief). NewRegulatoryThresholds.sme_balance_sheet_thresholdfield, derived under both CRR and Basel 3.1 from_CRR_SME_BALANCE_SHEET_EUR = Decimal("43000000")at the configured EUR/GBP rate (PS1/26 does not restate the assets threshold in GBP). CLS008 refined: the conservative-large-corp warning now fires only whenannual_revenueis null ANDtotal_assetsis either null or ≥ the SME balance-sheet threshold — populated assets below the threshold resolve the size question definitively and suppress the warning. Pinned by 18 new unit tests intests/unit/classifier/test_sme_assets_fallback.pycovering the four observable behaviours (SME-by-assets, large-by-assets, double-null, turnover-only regression) plus a new self-contained fixture module attests/fixtures/sme_assets_fallback/. The pre-existing P1.126total_assetsfiller value was updated toNoneto preserve the original CLS008 scenario (null revenue with no fallback signal). Full unit (5133) + contract (213) + integration (430) + acceptance (914) suites green. Ref: CRR Art. 4(1)(128D), Art. 153(4) third subparagraph, Art. 501(2)(c); PRA PS1/26 Art. 147A(1)(d), Art. 153(4); Commission Recommendation 2003/361/EC Art. 2.
Added¶
- SA-CCR CCR-A10 mixed-asset-class netting set + per-asset-class add-on Struct on synthetic exposure row (P8.41 CCR-A10, batch 20260526-ccr3): closes the P8.41 CCR-A sub-batch with the load-bearing end-to-end regression for cross-asset-class aggregation per CRR Art. 278(2). The scenario stitches one IR swap + one FX forward + one single-name IG credit CDS + one single-name equity TRS + one OIL_GAS commodity forward into a single legally-enforceable netting set (NS_MIX_001 against institution CP_001 at CQS 2 ⇒ 50% RW per CRR Art. 120(1) Table 3). Trade parameters are exact clones of CCR-A1/A2/A3/A5/A7 so per-class add-ons reproduce existing goldens byte-identically — the only novel quantity is the cross-class aggregation step itself:
AddOn_aggregate = 3,914,298.228 (IR) + 3,198,904.672 (FX) + 2,016,405.972 (credit) + 15,994,523.295 (equity) + 180,000 (commodity) = 25,304,132.167 GBP(pure linear sum per Art. 278(2) / BCBS CRE52.20-22, no cross-class supervisory correlation). With RC=0 (V=C=0) and the Art. 278(3) multiplier at its ceiling of 1.0, this givesEAD = α × (RC + PFE) = 1.4 × 25,304,132.167 = 35,425,785.034 GBPper Art. 274(2) andRWA = 0.5 × EAD = 17,712,892.517 GBP. The load-bearing anti-degenerate* pins24M < addon_aggregate < 26M— a naive sqrt-of-sum-of-squares would have yielded ≈ 16.69M (≈ 8.6M understatement, a regulatorily catastrophic regression mode). Engine change is minimal:src/rwa_calc/engine/ccr/pipeline_adapter.py::ccr_rows_to_exposuresnow surfaces a newaddon_by_asset_class: Struct{interest_rate: Float64, fx: Float64, credit: Float64, equity: Float64, commodity: Float64}column on the synthetic CCR exposure row, pivoting the already-computedaddon_per_classintermediate (no new collects; LazyFrame-first preserved). Missing asset classesfill_null(0.0)so the Struct schema is stable across every netting set regardless of which asset classes it touches, and the five fields are guaranteed to sum toaddon_aggregate(internal-consistency property pinned by the test). This is a pure observability extension — the underlying add-on values are unchanged; the Struct is the audit-trail breakdown needed for downstream COREP C 34.02 (SA-CCR template) and Pillar III CCR3 disclosures. Pinned by 17 tests intests/acceptance/ccr/test_ccr_a10_mixed_asset_class.py(8 numeric pipeline pins + 1 Struct-existence guard + 5 per-class component assertions + 1 Σ-equals-aggregate consistency check + 2 framework / approach assertions). New fixture attests/fixtures/ccr/golden_ccr_a10.pyand expected outputs attests/expected_outputs/ccr/CCR-A10.json. All 86 CCR acceptance tests + 451 contract tests remain green. No new regulatory scalars introduced. Ref: CRR Art. 274(2), 275(1), 277(1)-(3), 277a, 278(1)-(3), 279a/b/c, 280-280c, 295, 120(1) Table 3; BCBS CRE52.20-22 (cross-asset-class linear sum), CRE52.41-69; PRA Rulebook CCR (CRR) Part (SA-CCR cross-class aggregation numerically identical to CRR). - SA-CCR all-asset-class EAD: credit, equity, commodity asset classes shipped (batch 20260526-ccr2; P8.35/P8.36/P8.37): closes the gap that was the originating concern of the SA-CCR scope expansion —
EAD = α × (RC + PFE)is now computed for all five SA-CCR asset classes (interest rate, FX, credit, equity, commodity), not just IR + FX. Three parallel worktree streams landed under a single batch: P8.35 added the credit branch (compute_adjusted_notional_creditper CRR Art. 279b(1)(a) shared-IR supervisory-duration kernel;_compute_addon_creditper Art. 277(2)(c) + 277a + 280a per-entity correlation withSF_SN_IG=0.0046 / SF_SN_HY=0.013 / SF_SN_NR=0.06 / SF_IDX_IG=0.0038 / SF_IDX_HY=0.0106and ρ=0.50 SN / 0.80 IDX). P8.36 added the equity branch (compute_adjusted_notional_equityper Art. 279b(1)(c)d = abs(market_price × number_of_units);_compute_addon_equityper Art. 277(2)(d) + 277a + 280b withSF_EQ_SN=0.32 / SF_EQ_IDX=0.20and ρ=0.50 SN / 0.80 IDX, SN/IDX sub-classes summed within one HS). P8.37 added the commodity branch (compute_adjusted_notional_commodityper Art. 279b(1)(c);_compute_addon_commodityper Art. 277(3)(b) 5-bucket partition — ELECTRICITY=0.40 / OIL_GAS/METALS/AGRICULTURAL/OTHER=0.18 — with within-bucket ρ=0.40 per Art. 280c and no cross-bucket correlation per CRE52.69,AddOn_commodity = sqrt(Σ_b AddOn_b²)). P8.35 also extended TRADE_SCHEMA with a required-Falsecredit_qualitycolumn ({IG, HY, NON_RATED}) — the CDS reference entity is the underlying, not the counterparty, so the supervisory-factor band cannot be derived fromexternal_cqsand must be supplied explicitly (precedent: P8.33'scommodity_type). All five branches incompute_addon_per_asset_classnow dispatch correctly;compute_adjusted_notional_*calls inpipeline_adapter.py::ccr_rows_to_exposureschain IR → FX → credit → equity → commodity coalesce-safely. Defensive null-column injection added to credit / equity / commodity branches so IR/FX-only test frames continue to work without schema-update pressure. Pinned by 3 new acceptance scenarios (CCR-A3single-name IG CDS,CCR-A5single-name equity TRS,CCR-A7oil forward,CCR-A8electricity swap), 6 new unit-test modules attests/unit/ccr/test_adjusted_notional_{credit,equity,commodity}.pyandtest_pfe_{credit,equity,commodity}_addon.py(52 new unit tests in total — including the load-bearing electricity-distinct-from-18%-catch-all anti-degenerate attest_ccr_a8_commodity_electricity_swap::test_electricity_sf_is_distinct_from_other_bucketsand the two-entity credit correlation anti-degenerate attest_pfe_credit_addon::test_credit_addon_two_entities_same_hs_uses_correlation). The flippedtest_credit_asset_class_row_emits_non_null_addonintests/unit/ccr/test_pfe_fx_addon.py:288(previously asserted null while credit was deferred) and the P8.33 contract test's_P8_35_EXPECTED_COLUMN_COUNT = 29(lifted from 28 to absorbcredit_quality) close the contract surface. Existing IR / FX / CCR-A1 / CCR-A2 acceptance tests continue to pass byte-identically — no regression. Full dev-loop suite green: 6703 passed, 23 skipped. No new regulatory scalars introduced (all SF and ρ already indata/tables/sa_ccr_factors.pyfrom P8.7). Ref: CRR Art. 274(2), 275(1), 277(2)(c)-(d), 277(3)(b), 277a, 278, 279b(1)(a)/(c), 280, 280a, 280b, 280c; BCBS CRE52.41-48, CRE52.60-69; PRA PS1/26 CCR (CRR) Part (numerically identical for all five asset classes). - SA-CCR hedging-set partition extension to credit / equity / commodity (P8.34): extends
engine/ccr/hedging_sets.py::assign_hedging_setto emit non-nullhedging_set_idfor the three asset classes that previously fell through to null. Credit derivatives →"CR-{netting_set_id}"per CRR Art. 277(2)(c) (one hedging set per asset class per netting set; single-name vs index discrimination is deferred to the aggregation step in Art. 277a + 280a/b, P8.35's job). Equity derivatives →"EQ-{netting_set_id}"per Art. 277(2)(d) (same single-set-per-NS pattern). Commodity derivatives →"CO-{netting_set_id}-{commodity_type}"per Art. 277(3)(b), a 5-bucket partition keyed on the upper-casecommodity_typecolumn shipped under P8.33 (ELECTRICITY/OIL_GAS/METALS/AGRICULTURAL/OTHER); no cross-bucket netting per CRE52.67. The"CO-"prefix uses the canonicalASSET_CLASS_SHORT_CODE["commodity"]value fromdata/schemas.py:952(the plan bullet erroneously said"CM-"; the schema constant is the SSoT). Nullcommodity_typeon a commodity row → nullhedging_set_id(no fallback string, no error — matches the IR-no-bucket precedent for malformed inputs). A defensivecommodity_typecolumn injection was added so pre-P8.33 test frames (existing FX add-on tests attests/unit/ccr/test_pfe_fx_addon.py) continue to work without requiring fixture updates.reference_entityandis_indexcolumns are deliberately not consumed at the partition step — single-name vs index discrimination flows through the aggregation step where supervisory factor + correlation differ. Corrects the overstated P8.15 closing claim that this work had already shipped. Pinned by 10 new tests intests/unit/ccr/test_hedging_sets_extension.py: 4 prefix-format assertions (credit-SN, credit-idx-shares-HS-with-SN, equity, equity-idx-shares-HS-with-SN), 3 commodity assertions (5-bucket-distinct, same-bucket-collapses, null-bucket-null-id), 2 IR/FX regression guards, and 1 mixed-portfolion_unique == 10across 12 rows spanning all five asset classes. Pre-existing P8.15 IR partition tests + P8.19 FX add-on tests all remain green. Unblocks P8.35 (credit add-on), P8.36 (equity add-on), P8.37 (commodity add-on). Ref: CRR Art. 277(2)(c)-(d), 277(3)(b), 277a; BCBS CRE52.60, CRE52.65, CRE52.67-69; PRA Rulebook CCR (CRR) Part (numerically identical). - SA-CCR TRADE_SCHEMA extension for credit / equity / commodity asset classes (P8.33): adds five nullable columns to
TRADE_SCHEMAinsrc/rwa_calc/data/schemas.py—market_price: Float64andnumber_of_units: Float64(the two factors ofd = market_price × number_of_unitsper CRR Art. 279b(1)(c) for equity and commodity adjusted notional),reference_entity: String(single-name issuer LEI or index ticker — keys the credit hedging set per Art. 277(2)(c) and the equity hedging set per Art. 277(2)(d)),commodity_type: String(one of the five buckets{ELECTRICITY, OIL_GAS, METALS, AGRICULTURAL, OTHER}per Art. 277(3)(b) — keyed to upper-case to match the existingSA_CCR_SUPERVISORY_FACTORS_COMMODITYtable atdata/tables/sa_ccr_factors.py:60-66), andis_index: Boolean(single-name vs index discriminator for the credit / equity supervisory-correlation lookup at the per-asset-class add-on stage per Art. 280a / 280b). All five columns arerequired=Falsewithdefault=None, so existing CCR-A1 (IR swap) and CCR-A2 (FX forward) fixtures continue to round-trip unchanged. The five-bucketcommodity_typeenum is also pinned in a new"trades"block ofCOLUMN_VALUE_CONSTRAINTS(input-domain validation, not a regulatory scalar — lives inschemas.pyper the data/engine separation policy).TradeBundledocstring insrc/rwa_calc/contracts/bundles.pyextended to list the five new columns alongside the previously-shipped FX leg-2 pair. No engine code touched — P8.33 is a foundation-only schema extension that unblocks P8.34 (hedging_sets.pyextension), P8.35 / P8.36 / P8.37 (per-asset-class adjusted notional + PFE add-on) and the CCR-A3..A10 acceptance scenarios. Pinned by 7 new contract tests intests/contracts/test_ccr_schemas_contract.py(one per new column verifying dtype + nullability +required is False+default is None, plus acommodity_type5-bucket constraint test and a column-count delta-of-5 test). Ref: CRR Art. 277(2)(c)-(d), 277(3)(b), 279b(1)(a), 279b(1)(c), 280a / 280b; BCBS CRE52.41-48, CRE52.60-69; PRA Rulebook CCR (CRR) Part (numerically identical). - SA-CCR FX support: leg-2 schema, FX adjusted notional, FX PFE add-on, and CCR-A2 acceptance scenario (P8.8 / P8.9 / P8.19 / P8.41 CCR-A2 slice): closes the FX gap left open by P8.12 (IR-only adjusted notional) and the FX side of P8.15 (hedging-set partition shipped, PFE add-on still IR-hardcoded at the time). Three schema columns and a fixture factory open the input surface:
notional_leg2: Float64andcurrency_leg2: String(both optional nullable) appended toTRADE_SCHEMAso FX forwards can carry both legs;Tradedataclass +to_dict()mirror the new columns; newmake_fx_trade()factory intests/fixtures/ccr/trade_builder.pyproduces the canonical 1-year GBP/USD outright-forward defaults (buy USD 100m / sell GBP 80m, MtM=0, delta=1). FX adjusted notional per CRR Art. 279b(1)(b) lands inengine/ccr/adjusted_notional.py::compute_adjusted_notional_fx(trades, base_currency, fx_rates)— joins both leg currencies against anfx_rateslookup (with an identity row so legs already in the reporting currency convert at 1.0), then applies the one-leg-is-base case (i) or both-legs-foreign max case (ii); coalesce-safe against any prior IR-branch output. Sibling fluent methodlf.ccr.adjusted_notional_fx(...)added. FX PFE add-on per CRR Art. 277a(2) + BCBS CRE52.55 lands as a refactor ofengine/ccr/pfe.py::compute_addon_per_asset_classinto a dispatcher that calls byte-equivalent_compute_addon_ir(existing IR three-bucket aggregation, unchanged) and new_compute_addon_fx(D_HS = signed sum per (NS, hedging_set_id); AddOn_HS = SF_FX × |D_HS|; AddOn_FX = simple sum across hedging sets — no cross-HS correlation for FX, unlike equity/commodity Art. 277a(3)).assign_hedging_setinengine/ccr/hedging_sets.pyextended to emithedging_set_id = "FX-{ns}-{min(ccy1,ccy2)}/{max(...)}"for FX rows, with order-independent currency-pair keying so EUR/USD and USD/EUR collapse to one hedging set. Pipeline-adapter wired:ccr_rows_to_exposuresnow acceptsbase_currency: str = "GBP"andfx_rates: pl.LazyFrame | None = None(threaded through fromconfig.base_currencyanddata.fx_ratesinengine/pipeline.py), so FX trades flow through end-to-end. CCR-A1 invariant preserved — the existing CCR-A1 expected outputs (RC=0, PFE=3,914,298.228, EAD=5,480,017.519, RWA=2,740,008.759) are byte-identical after the dispatcher refactor. New CCR-A2 acceptance scenario pinned attests/acceptance/ccr/test_ccr_a2_unmargined_fx_forward.pywith six assertions: 1y GBP/USD outright forward, USD 100m / GBP 80m, MtM=0, unmargined, counterparty CP_001 (institution CQS 2) → goldensaddon_aggregate=3,198,904.67,pfe_addon=3,198,904.67,ead_final=4,478,466.54,rwa_final=2,239,233.27(GBP). Goldens recorded intests/expected_outputs/ccr/CCR-A2.json. New unit-test suites cover the FX paths in isolation: 8 tests forcompute_adjusted_notional_fx(one-leg-base / both-foreign-max / negative-notional / non-FX rows / coalesce / missing-rate / LazyFrame return type) and 6 tests for the PFE FX branch (CCR-A2 hand-calc / signed-sum within HS / cross-HS sum / order-independent pair key / mixed IR+FX dispatcher / credit-row null). New specs atdocs/specifications/crr/ccr/adjusted-notional.mdanddocs/specifications/crr/ccr/fx-treatment.md(CCR spec subtree didn't exist before). Full suite green.SA_CCR_SUPERVISORY_FACTOR_FX = 0.04was already indata/tables/sa_ccr_factors.py:44from P8.7 — no new regulatory scalars needed. Open follow-ups: orchestrator-levelCalculationErroremission for missing FX rates (currently produces null adjusted_notional silently); FX options acceptance scenario; cross-rate triangulation for non-major pairs. Ref: CRR Art. 274(2), 275(1), 277(3)(a), 277a(2), 278, 279b(1)(b), 279c(1), 280 Table 1; BCBS CRE52.34 / CRE52.55; PRA Rulebook CCR (CRR) Part Chapter 3 §§3–5.
[0.2.14] - 2026-05-25¶
Added¶
- Audit cache extended to cover the full pipeline (was: CRM-only): the opt-in audit cache shipped in the previous iteration was scoped narrowly to CRM intermediates plus the aggregator's pre/post-CRM summary views — it answered the "is
H_fxfiring on my collateral" diagnostic but left every other pipeline stage opaque. This pass closes those gaps by sinking the audit-style frames each stage already produces. Eleven new always-present artifacts now land under<audit_cache_dir>/<run_id>/on every run: early stages —rating_inheritance.parquet(per-CP dual-track best-rating resolution, sunk in_run_hierarchy_resolver),classification_audit.parquet(per-exposure classification reason trail includingcp_entity_type, SME/retail gating, defaulted flag, concatenatedclassification_reasonstring, sunk in_run_classifier),re_split_audit.parquet(per-parent secured/residual split for property-collateralised SA exposures, sunk in_run_re_splitter— only present when at least one row triggered RE splitting); calculators —equity_calculation_audit.parquet(CIU mandate / look-through rationale, sunk in_run_equity_calculator),sa_results.parquet/irb_results.parquet/slotting_results.parquet/equity_results.parquet(pre-floor per-approach views fromAggregatedResultBundle, sunk in_persist_audit_artifacts; diff againstresults.parquetto attribute output-floor uplift back to a specific approach branch); conditional —floor_impact.parquet(per-row floor mechanics under Basel 3.1),supporting_factor_impact.parquet(CRR SME / infrastructure factor impact),securitisation_audit.parquet/securitisation_summary.parquet(only whensecuritisation_allocationsis supplied). All new sinks follow the existing architectural pattern: every call site invokessink_audit(...)(which lives inengine/materialise.py, the only sanctionedsink_parquetcaller perscripts/arch_check.py); no new collects are forced — each frame is sunk at a point where the producing stage already materialises it; failures continue to log WARNING and swallow per the original contract. Two new diagnostic recipes added todocs/specifications/audit-cache.md: "why was this exposure routed to SA / IRB / Slotting?" (pairclassification_auditwithrating_inheritance) and "did the output floor bind on this exposure?" (filterfloor_impact.parquetbyis_floor_binding). Pinned by 5 new integration tests intests/integration/test_audit_cache_pipeline.py(always-present artifact set covering all 19 always-present files, framework-conditional checks for CRR-onlysupporting_factor_impactand B3.1-onlyfloor_impact, per-row content regression forclassification_auditandrating_inheritance, schema-shape check across the four pre-floor per-approach parquets) and 8 new contract tests intests/contracts/test_audit_cache_contract.py(column-set regression guards forclassification_audit,rating_inheritance,equity_calculation_audit,sa/irb/slotting/equity_results,supporting_factor_impact). Default behaviour unchanged —audit_cache_dir=Noneremains a zero-overhead no-op. Ref: no regulatory change — pure observability / diagnostics extension. - Opt-in audit cache — per-run parquet snapshots of CRM intermediate frames: new
CalculationConfig.audit_cache_dir: Path | None = None(plus optionalaudit_cache_max_runs: int | None = None) opts the pipeline into persisting key intermediate frames as parquet under<audit_cache_dir>/<run_id>/. Motivated by a recurring diagnostic ask — "isH_fxfiring on my EUR property collateral against a GBP loan?" — that previously required users to re-runHaircutCalculator.apply_haircutsmanually on a fixture because no bundle field surfaced the per-collateralfx_haircut/collateral_haircut/value_after_haircutcolumns. With the cache enabled, the same pipeline run dropscollateral_haircuts.parquet(the missing diagnostic),crm_audit.parquet,collateral_allocation.parquet, the aggregator'spre_crm_summary/post_crm_summary/post_crm_detailed/summary_by_class/summary_by_approach/resultsparquets, and amanifest.jsoncarrying timestamps, framework, config snapshot, artifact list with byte sizes, and therun_idthat matches the correlation id on every log line. DefaultNone= feature off, zero overhead, zero new files. Architecture preserved: a newsink_audit(frame, config, name)helper lives inengine/materialise.py(the only sanctionedsink_parquetcaller perscripts/arch_check.py);CRMProcessorandPipelineOrchestratorinvoke it at points where the frame is already being materialised — no new collects, no streaming-plan disruption. Failures (disk full, permission denied) are logged WARNING and swallowed — audit caching must never break a real run.audit_cache_max_runstriggers an mtime-ordered prune after each run's artifacts commit so the cap is honoured exactly (N runs ⇒ at most N subdirs). Plumbed throughCreditRiskCalc.__init__for the API entry point. Diagnostic recipe documented indocs/specifications/audit-cache.md: opencollateral_haircuts.parquet, projectcollateral_reference, collateral_type, original_currency, exposure_currency, fx_haircut—fx_haircut == 0.0on RE / receivables / other_physical rows confirms the Art. 230 gate is working, anything non-zero on those types points to acollateral_typevalue not in the recognised synonym list (schemas.py:1034). Pinned by 12 unit tests intests/unit/observability/test_audit_cache.py(sink/prune semantics, atomic writes, name sanitisation, no-run-id warning path, swallowed failures), 6 integration tests intests/integration/test_audit_cache_pipeline.py(end-to-end layout, manifest schema, RWA-parity regression vs control, multi-run partitioning, prune-keeps-N-newest), and 3 contract tests intests/contracts/test_audit_cache_contract.py(column-set regression guards forcollateral_haircuts/collateral_allocation/crm_audit). Ref: no regulatory change — pure observability / diagnostics. - SA-CCR EAD wired in as synthetic exposure rows between the hierarchy resolver and the classifier (P8.20, batch 20260523-2023): new pipeline stage placed between
HierarchyResolverandExposureClassifier(Option B placement — pre-classifier so the existing counterparty-class lookup naturally routes CCR exposures). For each netting set inRawCCRBundle, the newccr_rows_to_exposuresadapter atsrc/rwa_calc/engine/ccr/pipeline_adapter.pyemits one synthetic exposure row withexposure_reference=f"ccr__{netting_set_id}",risk_type="CCR_DERIVATIVE",drawn_amount=ead_ccr, plus two new provenance columnssource_netting_set_idandccr_method=sa_ccr. Withdrawn_amountcarrying the SA-CCR EAD and undrawn/nominal/interest zeroed, the existing_initialize_eadproducesead_pre_crm = ead_ccrwith no CRMProcessor changes required. Schema additions are all nullable and backward-compatible:source_netting_set_idandccr_methodonRAW_EXPOSURE_SCHEMA/RESOLVED_HIERARCHY_SCHEMA/CLASSIFIED_EXPOSURE_SCHEMA/CRM_ADJUSTED_SCHEMA; newRiskType.CCR_DERIVATIVEandRiskType.CCR_SFTenum members (aligning the enum surface withVALID_RISK_TYPES_INPUTwhich already accepted these strings). Stage wrapped withstage_timer(logger, "ccr_sa_ccr")per the observability contract; the no-op path (data.ccr is None) skips the wrap so no log record is emitted in that branch. Pinned by 7 tests intests/integration/test_ccr_pipeline_integration.py(3 regression guards for no-CCR / error-accumulation / RWA-total contracts, 4 load-bearing assertions for CCR row emission + provenance + stage_timer + EAD wiring againstcompute_ead). Ref: CRR Art. 271 (CCR scope), Art. 272(4) (netting set), Art. 274(2) (EAD = α × (RC + PFE)); PRA Rulebook CCR (CRR) Part Art. 274-278. - CCR-A1 acceptance scenario + CCR rating-inheritance fix (P8.41, batch 20260524-1421): pins the first CCR vertical-slice acceptance scenario — single 10-year unmargined GBP IR swap, notional 100M, MTM=0, counterparty institution CQS 2 — with five assertions per CRR Art. 274/275/277a/278/279b/280 (RC, PFE add-on, EAD, exposure class, RWA). The new test surfaced an engine bug: CCR synthetic netting-set rows produced by
ccr_rows_to_exposureswere appended toresolved.exposuresAFTERhierarchy._attach_counterparty_ratinghad already joinedcqs/external_cqs/pd/internal_pdonto lending rows, so CCR rows reached the SA Institution lookup withcqs=Noneand fell through to the 100% unrated bucket (CRR Art. 121(1)) instead of CQS 2 → 50% (CRR Art. 120(1) Table 3). Fix is an additive_enrich_ccr_rows_with_ratingshelper in the orchestrator (engine/pipeline.py) that mirrors the hierarchy rating join for CCR rows betweenccr_rows_to_exposuresand thepl.concat. Expected-output JSON also corrected (values-only) to match the regulatory formula: the prior hand-calc used 3,653 calendar days from 2026-01-15 to 2036-01-15, but the 2036 Feb 29 leap day falls after the Jan 15 maturity so only 2 leap days (2028, 2032) are in scope → 3,652 days, E = 9.998631y not 10.001369y. Scenario invariants preserved:RC=0.0,EAD = 1.4 × PFE,RWA = 0.5 × EAD,exposure_class="institution",risk_weight=0.50. Pinned bytests/acceptance/ccr/test_ccr_a1_unmargined_ir_swap.pyandtests/expected_outputs/ccr/CCR-A1.json. Ref: CRR Art. 274, 275, 277a, 278, 279b, 280; PRA Rulebook CCR (CRR) Part. arch_checkcheck 10: enforce module References block on regulatory engine modules: new gate inscripts/arch_check.pythat requires every module underengine/anddata/schemas.pyto carry aReferences:block in its module docstring (the CLAUDE.md mandated shape). Reshape / format / IO helpers that carry no per-function regulatory citations are listed inREFERENCES_REQUIRED_EXEMPT. The check is a literal-token grep forReferences:— strict citation-form enforcement remains the job ofwatchfire(check 9) on@cites(...)decorators. This keeps protocol-only References blocks (e.g.loader.py-style) valid while preventing regulatory modules from shipping without any citation block. Surfaced and fixed the missing block onengine/crm/processor.py(cites CRR Art. 110, 111, 194, 213-217, 223-224, 230 plus COREP C07). Companion docs pass adds References blocks to 5 previously non-compliant regulatory modules:data/schemas.py(CRR Art. 110, 111, 112-134, 147-153, 153(5), 197-200, 213-217, 223-230, 501/501a),engine/hierarchy.py(lifts CRR Art. 131, 135, 136, 138, 139, 140 from existing internal@cites),engine/ccf.py(CRR Art. 111, 166 and PS1/26 Art. 166D),engine/pipeline.py(CRR Art. 92, 107, 110 plus PS1/26 output floor cross-ref), andengine/aggregator/_crm_reporting.py(CRR Art. 108-111, 213-217, 218-239 plus COREP C07 CRM scope). No runtime code paths touched; line-pinned allowlist intests/contracts/test_no_raw_over_on_nullable_keys.pybumped{440, 441}→{459, 460}to absorb the 19-line docstring prepend onhierarchy.py.data/schemas.pymodule docstring extended to surface CCR, settlement-risk, CIU look-through, FX, securitisation, and intermediate pipeline-stage schemas: the module docstring listed only the originally-scoped credit-risk inputs and drifted as later work added CCR (SA-CCR), settlement-risk, CIU look-through, FX rates, model permissions, securitisation allocation, and the pipeline-stage intermediate output schemas. The Key Data Inputs index now also listsCIU_holdingsandFX_rates, newCounterparty Credit Risk Inputs/Settlement Risk Inputs/Securitisationsections enumerate the relevant tables,Model_permissionsis surfaced under Configuration, anIntermediate Pipeline-Stage Schemassection enumerates the resolved / classified / CRM-adjusted bundles, and the References block expands to cite CRR Art. 132, 244-246, 271-279a, 285, 291, 295, 306-307, 378-380. Pure documentation; no runtime impact.
Changed¶
- Logging reset helper simplified:
_reset()in the four test files that exercise observability now iterateslogger.handlersdirectly instead of via an unnecessarylist(...)copy. Behaviour-preserving (logging.Logger.removeHandlerdoes not invalidate iteration over the handler list when each handler is removed in order). Touchestests/integration/test_fx_rate_autosync.py,tests/integration/test_logging_pipeline.py,tests/unit/observability/test_audit_cache.py,tests/unit/observability/test_logging.py. getattr(...)None-checks narrowed per-variable forty:ty checkflagged 8 call-non-callable errors intests/contracts/test_ccr_bundles_contract.pybecause thegetattr(bundles, "X", None)pattern returnsAny | None, andassert all(x is not None for x in [...])doesn't narrow individual names in the type-checker's view. Replaced with explicit per-variableassert X is not Noneso each name narrows fromAny | NonetoAnybefore being called as a constructor. Same runtime guard, same error messages on miss. Companionstatic check fixescommit cleans up two ruff findings insrc/rwa_calc/engine/crm/haircuts.pyandsrc/rwa_calc/engine/sa/supporting_factors.pysurfaced by the same gate run.- CCR
stage_timercaplog test re-enabled:test_stage_timer_emits_ccr_sa_ccr_recordpreviously captured zero records when run on an xdist worker that had previously executed anyCreditRiskCalc.calculate()-using test.configure_logging()setspropagate=Falseon therwa_calcnamespace logger, severing the descendantrwa_calc.engine.pipelinelogger from caplog's root-attached handler. The fix temporarily re-enables propagation for the scope of the test and restores the prior value in afinallyblock — mirrors the documented pattern already applied intests/unit/test_loader_optional_error_handling.pyandtests/unit/test_fx_rate_sync.py.
Fixed¶
- SME E* now nets the residential collateral value rather than dropping the entire BTL row (
engine/sa/supporting_factors.py): CRR Art. 501 defines E* as the total amount owed "excluding claims or contingent claims secured on residential property collateral". The engine previously implemented this carve-out by dropping entire BTL rows from the group sum, but that diverges from the parallel retail-threshold treatment inengine/hierarchy.py:2444-2447(CRR Art. 123(c)) which subtractsresidential_collateral_value(capped at drawn) per row. This change aligns the SME E* aggregation with the retail interpretation: each row's contribution to E* is nowdrawn − min(residential_collateral_value, drawn), so a non-BTL SME secured on residential property has the secured portion netted from E*, and a partially-secured BTL row contributes its unsecured residual rather than zero. Theis_btl ⇒ factor=1.0eligibility gate is unchanged; in the typical case where a BTL row's RRE coverage equals its drawn balance, its E* contribution still lands at 0. Behaviour change is CRR-only — supporting factors are removed under PS1/26. Pinned by 5 new tests inTestResidentialCollateralNettedFromEStar(partial coverage, full coverage, cap-at-drawn, lending-group spillover, backward-compat without the column); existingTestBTLExcludedFromSMEFactortests reframed to set explicitres_coll=drawnon BTL rows so their numerical expectations still hold. Ref: CRR Art. 501; mirrors retail-threshold treatment of CRR Art. 123(c). - Brand link in the docs hero updated to the new absolute URL (
docs/overrides/main.html): one-linehrefcorrection. - Skill quick-nav: CRR institution CQS 2 risk weight corrected (
.claude/skills/crr/SKILL.md): the Art. 120-121 quick-nav row claimed "UK CQS 2 = 30%", but 30% is the Basel 3.1 / PRA PS1/26 ECRA value. CRR Art. 120(1) Table 3 gives 50% for CQS 2 institutions, and no PRA instrument modifies that under CRR. The misleading hint seeded a wrong scalar in the P8.20 fixture (caught at W2 reviewer; tracked in batch 20260523-2023 follow-ups). Fix: 30% → 50%.
[0.2.13] - 2026-05-23¶
Fixed¶
- SME
drawn_exprhardened against NaN propagation (engine/sa/supporting_factors.py): a single NaN indrawn_amount,interest, oread_finalpoisoned the windowed sum overlending_group_reference, zeroingtotal_cp_drawnfor every exposure in the connected-clients group and dropping the SME supporting factor entirely.fill_nulldoes not catch NaN — addedfill_nan(0.0)beforeclip/sumon all three inputs.
[0.2.12] - 2026-05-23¶
Added¶
- Securitisation pool allocation — phase 1 flag + exclude (CRR Art. 109, Art. 244-246 / PS1/26 Art. 147A(1)(j)): new optional
securitisation_allocationsinput table maps originated exposures (loans, contingents, facility-undrawn parents) to one or more securitisation pools with a fractionalallocation_pct. A new lightweight pipeline stageSecuritisationAllocator(src/rwa_calc/engine/securitisation/allocator.py) resolves the table into a per-exposure lookup carryingsecuritisation_residual_pct(clipped to[0, 1]) andsecuritisation_pool_allocations(list-of-struct{pool_reference, allocation_pct}). The two columns ride through CRM, the calculators, and the aggregator unchanged. The aggregator (engine/aggregator/_securitisation.py) then multiplies every monetary column bysecuritisation_residual_pctso existing summaries (summary_by_class,summary_by_approach,pre_crm_summary,post_crm_*, output floor U-TREA / S-TREA, EL portfolio summary, supporting factor impact) naturally reflect only the on-balance-sheet portion —final_rwa × (1 - securitisation_pct)in the user's framing. Two new fields onAggregatedResultBundle:securitisation_summary(per-pool EAD / RWA placeholder / EL grouping derived by exploding the struct list) andsecuritisation_audit(per-exposure reconciliation showing parent EAD = residual + sum of pool slices). Five new validation codes (SEC001-SEC005) cover over-allocation, invalid pct, orphan exposure_reference, duplicate(exposure, pool)rows, and the SEC005 informational signal for fully-securitised exposures (residual = 0); SEC errors flow verbatim throughdata.errors(loader-validation channel) so the original codes survive into the bundle. Linearity property pinned bytests/integration/test_securitisation_pipeline.py::test_sec_06_residual_equals_pro_rata_via_linearity: residual_rwa == full_pipeline_rwa × residual_pct, demonstrating that late multiplication at the aggregator is equivalent to pro-rata CRM scaling for single-exposure metrics with directly-attached collateral. Known phase-1 limitation: counterparty- or facility-level shared collateral does not re-allocate when a sibling exposure is securitised — the unrelated siblings still see the full collateral allocation as if no securitisation had happened. This is documented indocs/specifications/securitisation-pool-allocation.md. Out of scope (deferred to phase 2): significant-risk-transfer assessment (Art. 244-246 conditions), securitisation RWA framework (SEC-SA, SEC-IRBA, SEC-ERBA — CRR Art. 259-264), tranche-level capital, originator retained interest. Wired throughRawDataBundle.securitisation_allocations,ResolvedHierarchyBundle.securitisation_audit,ClassifiedExposuresBundle.securitisation_audit,CRMAdjustedBundle.securitisation_audit,AggregatedResultBundle.{securitisation_summary, securitisation_audit}. NewSecuritisationAllocatorProtocolincontracts/protocols.py. Loader picks up the optionalsecuritisation/securitisation_allocations.parquetviaDataSourceRegistry. Pinned by 16 allocator unit tests (tests/unit/engine/securitisation/test_allocator.py), 11 aggregator-helper unit tests (tests/unit/engine/aggregator/test_securitisation_helpers.py), and 8 end-to-end integration tests covering SEC-01..SEC-08 (tests/integration/test_securitisation_pipeline.py). Ref: CRR Art. 109, Art. 244-246; PRA PS1/26 Art. 147A(1)(j). - SA-CCR engine subpackage — Tier 8 CCR integration phase 1 (P8.1 → P8.18): ~15 commits across the May 22-23 batches landed the SA-CCR engine scaffolding through the netting-set legal-enforceability gate. Highlights: new
src/rwa_calc/engine/ccr/subpackage scaffold (P8.4),RawCCRBundleand the optionalRawDataBundle.ccrfield (P8.2),CCRCalculatorprotocol (P8.3 placeholder for ty),CCRConfigplumbed throughCalculationConfig.crr()/.basel_3_1()factories (P8.6), CCR loader + 4 schemas (P8.5), supervisory factors / correlations / maturity constants moved todata/tables/per the architectural data/engine split (P8.7), trade-level CCR fixture builders for IR / FX / equity / credit / commodity / settlement (P8.40), SA-CCR EAD = α × (RC + PFE) per CRR Art. 274(2) (P8.17), supervisory delta linear ±1 (P8.13) and Black-Scholes options + CDO-tranche delta (P8.13-opt), adjusted-notional for IR (P8.12), maturity factor for unmargined and margined transactions with Art. 285 MPOR floors (P8.14, P8.14-marg), hedging sets + IR asset-class addon aggregation (P8.15), netting-set legal-enforceability gate (P8.18), replacement cost for margined transactions (P8.11), PFE multiplier and aggregate per Art. 278(3) (P8.16), QCCP trade exposures per Art. 306-307 (P8.25), failed trades DvP / non-DvP per Art. 378-380 (P8.24), and wrong-way-risk identification per Art. 291 (P8.27). The CCR pipeline stage wiring (P8.20) and first acceptance scenario (P8.41) land in 0.2.14. Ref: CRR Art. 271-280, 285, 291, 306-307, 378-380; PRA Rulebook CCR (CRR) Part.
Changed¶
- DQ008 warning consolidated into a single aggregate entry (was one warning per offending exposure):
ExposureClassifier._collect_beel_on_non_defaulted_warnings(engine/classifier.py) previously emitted oneCalculationErrorfor every row matching(is_defaulted=False ∧ beel>0). For portfolios whose A-IRB pipeline populatesbeelalongsidelgdon every advanced-IRB customer (the documented reason this check exists at all), that produced N repeated warnings, one per row — overwhelming any consumer that iteratesresult.errorsline-by-line. The helper now selectspl.len()instead of materialising the offending rows and returns a single-element list containing one aggregate warning carrying the total count, matching the CLS006 model-permission and CLS008 large-corp-revenue roll-up pattern used everywhere else in the classifier.beel_on_non_defaulted_exposure_warning(contracts/errors.py) factory signature changes from(*, exposure_reference, beel_value)to(*, n: int); the warning'sexposure_referenceandactual_valuefields are now unset because no single offender is referenced. Message reads"BEEL populated on {n} non-defaulted exposure(s); ...". Pinned by a newtests/unit/test_classifier.py::TestDefaultClassification::test_beel_warning_is_aggregated_across_multiple_offendersregression (N=3 offenders → exactly one DQ008 with"3 non-defaulted exposure"in the message); existing single-offender unit and acceptance scenarios updated to match the aggregate shape. Ref: PRA PS1/26 Art. 181(1)(h)(ii); CRR Art. 158(5).
Fixed¶
- H_fx no longer applied to funded non-financial collateral (Art. 230): the engine previously charged the 8% FX volatility haircut (scaled to ~11.31% at the 20-day secured-lending period by Art. 226(2)) on real estate, receivables, and other physical collateral whenever the collateral currency differed from the exposure currency. The citation chain on which this was built —
docs/specifications/crr/credit-risk-mitigation.mdattributed the charge to "CRR Art. 233" generically — is incorrect: Article 233 sits in CRR Sub-Section 2 Unfunded credit protection and governs guarantees / CDS only ("Where unfunded credit protection is denominated in a currency different from that in which the exposure is denominated …"). The funded non-financial collateral path is governed by Articles 229–230, whose LGD* formula compares the raw collateral valueCagainst the C* / C** thresholds in Table 5 with no FX volatility adjustment; FX risk is captured upstream by the spot-rateFXConverter. PS1/26 inherits CRR's silence on H_fx for Art. 230, so the fix is framework-agnostic. Engine change is a one-line gate on thefx_exprinsrc/rwa_calc/engine/crm/haircuts.py:203-210excludingcollateral_type ∈ NON_FINANCIAL_COLLATERAL_TYPES(new constant insrc/rwa_calc/data/schemas.pycoveringreceivables,real_estate,other_physicaland their accepted synonyms). Financial collateral (cash / gold / bonds / equity / covered bonds / life insurance / credit-linked notes) continues to receive the Art. 224 Table 4 H_fx — the comprehensive-method scope is unchanged. The guarantee H_fx (Art. 233(3)) inengine/crm/guarantees.pyis also unchanged. Capital impact: portfolios with non-financial collateral in a currency other than the exposure currency will see lower RWA — for a GBP corporate exposure secured by EUR commercial property, ES (haircut-adjusted collateral feeding LGD*) increases by ~11.31% at the 20-day liquidation default, which reduces LGD* and the corresponding F-IRB RWA. Pinned by 13 new tests intests/unit/crm/test_collateral_fx_mismatch.py::TestNonFinancialCollateralNoFxHaircut(real_estate / receivables / other_physical + framework variants + synonym coverage + a cash regression guard). Specs updated:docs/specifications/crr/credit-risk-mitigation.md(FX section narrowed, LGD* formula scope clarified, summary table row split into financial / unfunded / non-financial);docs/specifications/basel31/credit-risk-mitigation.md(FX Mismatch Haircut scope clarification). Ref: CRR Art. 224 Table 4 (financial); CRR Art. 233 (unfunded); CRR Arts. 229–230 (funded non-financial).
Performance¶
- Classifier diagnostics deferred behind a single materialise barrier — 100K pipeline runtime drops ~31 %:
ExposureClassifier.classify()(engine/classifier.py) previously triggered the upstream lazy plan three times per pipeline run — once for the BEEL-on-non-defaulted diagnostic (_collect_beel_on_non_defaulted_warnings, formerly called inline at the top ofclassify()), once for the model-permission roll-up (the.collect()inside_resolve_model_permissions_if_present), and a third time whenCRMProcessor._run_ead_pipelinehit its ownmaterialise_barrierafter provisions/CCF/init_ead. Polars does not cache intermediate results between.collect()calls, so each one re-executed the full hierarchy + classifier join plan from raw data. Profiling at 100K showed CRM's first barrier alone cost 2 622 ms of a 6 300 ms total — most of it redundant upstream work. The fix runs all lazy classifier transforms first (including the_resolve_model_permissionsjoin, separated from its diagnostic emit), inserts onematerialise_barrier(classified, config, "classifier_output")at the end ofclassify(), and then emits both diagnostics against the in-memory frame._resolve_model_permissions_if_presentis replaced by a pure-lazy_resolve_model_permissionscall plus a new post-materialise helper_emit_model_permission_diagnosticsthat runs the same filter / group-by / collect against the materialised frame for ~free. After the change,profile_stages.py --framework crr --irb fullreports CRM barrier #1 at 250 ms (was 2 622 ms) and total pipeline at 4 313 ms (was 6 300 ms) — a saving of ~1 987 ms / 31.5 % at 100K. Basel 3.1 / full IRB sees the same shape (total ~5 488 ms). No behaviour change to the diagnostic content — the warnings emitted are identical in code, message, count, and order to consumers iteratingresult.errors. Verified byuv run pytest tests/unit/ tests/contracts/(5 263 passed),uv run pytest tests/acceptance/ tests/integration/(1 007 passed), anduv run python scripts/arch_check.py(all checks passed — the newmaterialise_barriercall satisfies the "no raw .collect().lazy() outside materialise.py" rule). Baseline + after numbers captured indocs/perf/baseline-2026-05-22.md. Ref: no regulatory change.
[0.2.11] - 2026-05-19¶
Added¶
watchfirematrix coverage closed for CRR Art. 130-132 + 134-152 (SA other-items / ECAI methodology + IRB exposure-class chapter): the citation matrix atdocs/development/citation-matrix.mdpreviously walked straight from CRR Art. 129 (covered bonds) to Art. 133 (equity) and again from Art. 133 to Art. 153 (IRB RWEA), leaving the Other-Items / ECAI methodology block (Art. 130-141) and the entire IRB exposure-class chapter (Art. 142-152) invisible to anyone scanning the rendered matrix. This pass closes the CRR side of that gap end-to-end with a mix of new@cites(...)decorators on the functions that already realised the rules and inline "Out of scope — reason" notes on articles that are deliberately not cited. New decorators:CRR Art. 137on_eca_meip_rw_expr(engine/sa/namespace.py);CRR Art. 134on_apply_b31_risk_weight_overrides; stackedCRR Art. 134/CRR Art. 137on_apply_crr_risk_weight_overrides; stackedCRR Art. 135/136/138/139onHierarchyResolver._attach_counterparty_rating(engine/hierarchy.py); stackedCRR Art. 131/140onHierarchyResolver._apply_short_term_rating_override; stackedCRR Art. 141over the existingArt. 114onbuild_eu_domestic_currency_expr(data/tables/eu_sovereign.py); stackedCRR Art. 143/148/150onExposureClassifier._resolve_model_permissions(engine/classifier.py);CRR Art. 147onExposureClassifier._align_irb_exposure_classand stacked onExposureClassifier.classifyover the existingArt. 112; stackedCRR Art. 151over the existingArt. 153/154onapply_irb_formulas(engine/irb/formulas.py). Newfrom watchfire import citesimport landed onengine/hierarchy.py(the only target file that didn't already import the decorator). Coverage notes for the eight deliberately-not-cited articles in the dense 111-152 range —Art. 128(UK CRR omitted by SI 2021/1078),Art. 130(securitisation, separate calculator domain),Art. 132(UK CRR omitted; reintroduced underPS1/26 paragraph 132),Art. 142(definitions only),Art. 144/145/146/149(supervisory permission processes — input viamodel_permissions),Art. 147A(B3.1 amendment, decoration deferred until PS1/26 paragraph mapping is confirmed),Art. 152(IRB CIU look-through not implemented) — live in a newCRR_COVERAGE_NOTESdict at the top ofscripts/generate_citation_matrix.py.scripts/generate_citation_matrix.pyextended with aCRR_DENSE_RANGEcovering Art. 111-152: the renderer now guarantees every article in that range appears in the matrix as either an implementing-function collapsible (live@cites) or an italic out-of-scope block (notes), and raises if any article in the range has neither, so the matrix can never silently drop coverage; articles outside the dense range continue to render sparsely (only those with@cites). 10 new rows intests/contracts/test_watchfire_coverage.py::WHITELIST(84 parametrised cases, up from 66 after the Art. 115-119 round).docs/development/citation-tracking.mdextended with a paragraph distinguishing dense vs sparse matrix coverage. No runtime behaviour change —@citesis a no-op decorator. Verified end-to-end:uv run python scripts/generate_citation_matrix.py— matrix regenerated, CRR section now spans### CRR Art. 111through### CRR Art. 152with no gaps;uv run python scripts/arch_check.py—all checks passed;uv run pytest tests/contracts/test_watchfire_coverage.py— 84 passed; fulltests/contracts/minus pre-existingtest_no_raw_over_on_nullable_keys.pyfailure — 293 passed;uv run pytest tests/unit/— 4,934 passed;uv run zensical build— site builds cleanly. Basel 3.1 / PS1/26 citations in the same article range are intentionally out of scope for this pass — a future review will close those gaps; where a function already carries a PS1/26 cite (e.g._append_ciu_branches), it's left untouched. Ref: CRR Art. 130-152;scripts/generate_citation_matrix.py:49-152.watchfirematrix coverage closed for CRR Art. 115-119 (SA exposure-class block): the citation matrix atdocs/development/citation-matrix.mdwalked straight from CRR Art. 114 (central govt) to CRR Art. 120 (rated institutions), under-representing five fully-implemented SA exposure classes — RGLA (Art. 115), PSE (Art. 116), MDB (Art. 117), international organisations (Art. 118), and institutions scope/umbrella (Art. 119). Decorators added insrc/rwa_calc/data/tables/crr_risk_weights.pyfollowing the existing builder-layer precedent set bybuild_institution_guarantor_rw_expr(Art. 120/121) andbuild_corporate_guarantor_rw_expr(Art. 122):@cites("CRR Art. 115")on_create_rgla_df,@cites("CRR Art. 116")on_create_pse_df,@cites("CRR Art. 117")on_create_mdb_df, and@cites("CRR Art. 119")stacked outermost over the existingArt. 120/Art. 121pair onbuild_institution_guarantor_rw_expr. Art. 118 had no analogue_create_*_df()builder (only the bareIO_ZERO_RWconstant + an inline 0% branch insa/namespace.py), so a thin_create_io_df()builder was introduced for symmetry, driven by a newINTERNATIONAL_ORG_RISK_WEIGHTSdict keyed onCQS.UNRATEDso it round-trips through the shared_build_cqs_rw_dfhelper; the SA branch is unchanged. Five rows added totests/contracts/test_watchfire_coverage.py::WHITELIST(the institution row now expects the three-citation tuple("CRR Art. 119", "CRR Art. 120", "CRR Art. 121")per the outermost-first convention).docs/development/citation-matrix.mdregenerated viascripts/generate_citation_matrix.py— now renders### CRR Art. 115through### CRR Art. 119headings between the existing 114 and 120 sections. No runtime behaviour change (@citesis a no-op decorator). Verified:uv run pytest tests/contracts/test_watchfire_coverage.py— 66 passed (was 61);uv run watchfire check—fatal=0 warn=0(all five articles resolve against the bundled CRR index);uv run python scripts/arch_check.py—all checks passed; SA-class acceptance subset (RGLA/PSE/MDB/IO/institution) — 48 passed. Ref: CRR Art. 115-119;src/rwa_calc/data/tables/crr_risk_weights.py.
Changed¶
beel > 0no longer triggers defaulted treatment; new DQ008 warning surfaces the input contradiction:engine/classifier.py::_build_is_defaulted_expris narrowed from a three-way OR (cp_default_status | row-level is_defaulted | beel > 0, landed in P1.127, shipped in v0.2.10) back to a two-way OR (cp_default_status | row-level is_defaulted). PRA Rulebook (IRB Part Rule 1.3), PS1/26 Art. 158(5), and Art. 181(1)(h)(ii) define BEEL strictly as the firm's best-estimate-EL on a defaulted exposure, but firms whose A-IRB model pipelines emit a BEEL-style value alongsidelgdfor every advanced-IRB customer would otherwise see those rows silently mass-flagged as defaulted (routed through SA Art. 127 or IRB Art. 153(1)(ii) / 154(1)(i)). The contradictory(is_defaulted=False ∧ beel>0)combination now surfaces as one non-blockingDQ008warning per offending exposure (newERROR_BEEL_ON_NON_DEFAULTED_EXPOSURE+beel_on_non_defaulted_exposure_warningfactory incontracts/errors.py; emitted by newExposureClassifier._collect_beel_on_non_defaulted_warningswhich reads the derivedis_defaulted, so rows that the counterparty cascade legitimately routes to defaulted are not falsely flagged).beelremains an A-IRB defaulted parameter — consumed byengine/irb/adjustments.py(K = max(0, LGD − BEEL)per Art. 154(1)(i)) and by Pool C of the Art. 158(5) EL amount when the row is genuinely defaulted. New@cites("CRR Art. 178")/@cites("CRR Art. 153")decorators on the derivation helper; Art. 158(5) citation already sits on the consumption sites inirb/adjustments.pyand is unchanged.is_defaultedpromoted to a first-class optional Boolean onLOAN_SCHEMA,CONTINGENTS_SCHEMA, andFACILITY_SCHEMA(data/schemas.py) so the row-level flag P1.127 already supports has a documented home — defaults toFalse. Migration note for firms whose loaders populatebeelon non-defaulted rows: either (a) restrictbeelto defaulted rows only in the loader (regulator-correct, no engine change needed) or (b) treat the resultingDQ008warnings as informational (the calc is unaffected on those rows — BEEL is not consumed when the derivedis_defaultedis False). Pinned bytests/acceptance/crr/test_beel_does_not_trigger_default.py(3 scenarios: A-IRB performing withbeel>0→ not defaulted + one DQ008; cp-default withbeel>0→ defaulted + no DQ008; row-flag withbeel=0→ defaulted + no DQ008) and four new truth-table cases intests/unit/test_classifier.py::TestDefaultClassification. P1.127 regression guard updated: theLN-P1127-Ddefaulted fixture now sets explicitis_defaulted=Trueon the loan row (was relying on the removedbeel>0OR branch); the three Pool B AVA /other_own_funds_reductionsassertions remain unchanged and green. Benchmark data generators (tests/benchmarks/data_generators.py) extended to populate the newis_defaultedcolumn on synthetic facility / loan / contingent rows. Ref: CRR Art. 178, Art. 153(1)(ii) / 154(1)(i), Art. 158(5); PS1/26 Art. 181(1)(h)(ii); PRA Rulebook IRB Part Rule 1.3 (BEEL definition).
Performance¶
- Benchmark scales pruned: 10M removed entirely, 1M gated behind
-m scale_1mopt-in: the 10M-scale pipeline + hierarchy benchmarks were unrunnable on developer laptops — synthetic data generation alone OOMs at ~10M counterparties, and even when the cached parquet existed the pipeline took ~20 minutes. Their only useful regression signal was duplicated by 100K +tests/benchmarks/profile_plan.pyplan-complexity inspection, so they're deleted in full:TestPipelineBenchmark10M(tests/benchmarks/test_pipeline_benchmark.py),TestHierarchyBenchmark10M(tests/benchmarks/test_hierarchy_benchmark.py), thebenchmark_config_10m/dataset_10m/dataset_10m_statsfixtures, and thescale_10mmarker registration. The 1M scale remains in the codebase for opt-in profiling on a workstation but is now belt-and-braces gated by both@pytest.mark.slow(existing) and a new@pytest.mark.scale_1m-driven addopts exclusion (pyproject.toml:116—-m 'not slow and not stress and not scale_1m'), so dropping theslowmarker by accident in future cannot re-enable 1M in the dev loop. The 10K and 100K benchmarks continue to run untimed in the dev loop (via the existing--benchmark-disableflag) so any pipeline regression that broke the calculator at scale would still surface as a test failure. Verified byuv run pytest tests/benchmarks/ --collect-only(27 selected, 5 deselected, no 10M classes listed); explicit 1M opt-in confirmed byuv run pytest tests/benchmarks/ -m scale_1m --collect-only(5 selected). Ref: no regulatory change.
[0.2.10] - 2026-05-16¶
Added¶
- Short-term ECAI flag moved from facility row to ratings row (refactor): short-term ECAI assessments under PRA PS1/26 Art. 120(2B) Table 4A and Art. 122(3) Table 6A are issue-specific (attached to a particular exposure), so the
has_short_term_ecaiBoolean has been removed fromFACILITY_SCHEMAand replaced by three new columns onRATINGS_SCHEMA:is_short_term: Boolean(default False),scope_type: String(facility/loan/contingent), andscope_id: String(the matching identifier). A newRatingScopeenum lives indomain/enums.py.HierarchyResolvergained_apply_short_term_rating_override(engine/hierarchy.py), which joins the short-term rating row(s) against each exposure using(counterparty_reference, scope_type, scope_id)and overrides the counterparty-levelcqsplus the derivedhas_short_term_ecaicolumn. The SA branches in_b31_append_institution_maturity_branches/_b31_append_corporate_maturity_branches(engine/sa/namespace.py) drop the original-maturity sub-gate from the Table 4A / Table 6A clauses — when a short-term rating row is attached, the engine trusts the producer and routes via Table 4A / Table 6A unconditionally (the regulatory maturity test ≤ 3m is now a producer-side responsibility). The old facility-level OR-broadcast ofhas_short_term_ecaiin_propagate_facility_qrre_columnshas been deleted. New loader DQ rule (contracts/validation.py::_validate_short_term_rating_scope) flags rating rows whereis_short_term=Trueis paired with null scope columns, and warns on stray scope values for non-short-term rows. Tests / fixtures migrated:tests/fixtures/p1_103/,tests/fixtures/p1_105/,tests/fixtures/api_validation/build_mandatory_only.py,tests/fixtures/p1_128/p1_128.py; new contracttests/contracts/test_short_term_rating_override.pypins (i) override fires regardless of maturity, (ii) scope-id mismatch is a no-op, (iii) absent short-term row falls back to counterparty long-term CQS. Ref: PRA PS1/26 Art. 120(2B); Art. 122(3); BCBS CRE20.20 / CRE20.45. @cites("CRR Art. 501a")re-instated oncalculate_infrastructure_factor(watchfire 0.3.1): the workaround inline comment atengine/sa/supporting_factors.py:114-116was added against watchfire 0.3.0, whose parser rejected alphanumeric article suffixes —501acould neither parse nor validate, so the function was left unannotated to avoid mis-citing the umbrellaArt. 501(SME factor). watchfire 0.3.1 (pinned inpyproject.toml:38) fixes both halves:parser.py:121regex now acceptsr"(\d+[a-z]*)", and the bundled CRR index (.venv/Lib/site-packages/watchfire/data/index.parquet) carries 49 rows for article501a. Decorator re-added; 3-line workaround comment deleted. Stale prose inCLAUDE.mdanddocs/development/citation-tracking.mdrewritten to distinguish parser support (now general) from index coverage (still missing123B/110A, which remain Basel-3.1 amendments with no CRR equivalent — those sites atengine/sa/namespace.py:1864,1934correctly citePS1/26, paragraph …already and are unchanged). No runtime behaviour change —citesis a no-op decorator. Verified end-to-end:uv run watchfire matrix --instrument CRR --article 501alistscalculate_infrastructure_factor. Ref: PRA PS1/26 Art. 501a (infrastructure supporting factor); CRR2 EU 2019/876.-
watchfirecitation tracking — fan-out to 54 annotated functions + strict gate + contract test (watchfire 0.3.0): built on the prior pilot to annotate the full breadth of the engine and regulatory data tables. Dependency bumped fromwatchfire>=0.2.0towatchfire==0.3.0inpyproject.toml:38(also picks up the[tool.watchfire].rulebook_versionbump from2026-05-14to2026-05-15). 0.3.0 stacks@citesdecorators into a tuple at__watchfire__rather than overwriting (multi-citation now first-class), accepts alphanumeric article and paragraph suffixes (CRR Art. 123B,PS1/26, paragraph 110A), and ships an expanded PS index (4,498 PS rows, up from a single stub). New@cites(...)decorators landed on ~50 additional functions acrossengine/{sa,irb,crm,re_splitter,ccf,equity,slotting,classifier,aggregator}anddata/tables/{crr_risk_weights,b31_risk_weights,eu_sovereign,firb_lgd,haircuts}— stacked CRR + PS1/26 forms used wherever one function implements both frameworks (e.g._pd_floor_expressioncarriesCRR Art. 163andPS1/26, paragraph 163). The pilot'sapply_currency_mismatch_multiplierworkaround comment was deleted: it now citesPS1/26, paragraph 123Bprecisely. Three Basel-3.1-only amendment articles that don't exist in the pre-2027 CRR index (Art. 123B,Art. 110A,Art. 501ainfrastructure) cite the PS1/26-form only or, where no valid CRR-equivalent exists, are left intentionally unannotated with an inline explanation (calculate_infrastructure_factor).scripts/arch_check.py::check_watchfire_citations()simplified to strict mode — PS / PRA Rulebookunknown_articlefindings are no longer downgraded to soft warnings now that the index is mature; only ASTunresolvedcases remain soft. Newtests/contracts/test_watchfire_coverage.pywhitelists all 54 annotated functions with their expected canonical citation tuples (61 parametrised test cases); accidental decorator removal during refactors will fail this test with a named row rather than a silent matrix shrink. Newscripts/generate_citation_matrix.pyregeneratesdocs/development/citation-matrix.mdby invokinguv run watchfire matrix --format markdownonce per instrument and stitching the tables.docs/development/citation-tracking.mdextended with sections covering the coverage matrix, the regression test, and the strict gate. Verified end-to-end:uv run watchfire checkresolves 76 citations cleanly;uv run python scripts/arch_check.pyreportsall checks passedwith zero warnings;uv run pytest tests/contracts/test_watchfire_coverage.py— 61 passed;uv run watchfire matrix --instrument CRRenumerates every CRR-side annotation;uv run pytest tests/unit/irb/— 212 passed (no runtime regression from decorator stacking). Ref:pyproject.toml:38,154-160; watchfire 0.3.0 release. -
watchfirecitation tracking — pilot wire-up + 5 reference annotations: integratedwatchfire>=0.2.0(already inpyproject.toml:38) as the project's static citation validator. New[tool.watchfire]table inpyproject.tomlpinsrulebook_version = "2026-05-14"and scopes scanning tosrc/rwa_calc/engine+src/rwa_calc/data/tables.scripts/arch_check.pynow invokeswatchfire.checks.run_checkvia its Python API as the final gate step (newcheck_watchfire_citations());parse_failure,unknown_instrument,version_mismatch, and CRR/Delegated-Regulationunknown_articlefindings are fatal, while PS / PRA Rulebook / SSunknown_articlefindings and ASTunresolvedcases are downgraded to soft warnings until the upstream rulebook index ships richer non-CRR coverage. Five pilot@cites(...)decorators landed on canonical IRB / SA entry points:calculate_kandcalculate_correlation(engine/irb/formulas.py,CRR Art. 153(1));IRBLazyFrame.apply_pd_floorandapply_lgd_floor(engine/irb/namespace.py, stackedCRR Art. 163/CRR Art. 164overPS1/26, paragraph 163/PS1/26, paragraph 164);SALazyFrame.apply_currency_mismatch_multiplier(engine/sa/namespace.py, instrument-levelPS1/26— the preciseArt. 123Bsub-paragraph is unparseable by watchfire 0.2.0 today because the parser rejects alphanumeric paragraph suffixes; an inline comment records the pending upstream fix). Inner stacked decorators do not yet surface inwatchfire matrix(watchfire 0.2.0__watchfire__holds the outermost citation only) — once upstream stacks citations into a tuple, the inner PS1/26 references will activate with no source-code change here. New developer-docs pagedocs/development/citation-tracking.mddocuments the@citesconvention, canonical citation grammar, parser limitations, and CLI invocations;CLAUDE.md"Documentation" section gains a "Citation tracking" subsection so future contributors annotate as a matter of course. Verified end-to-end:uv run python scripts/arch_check.pyreportsall checks passedwith 3 PS1/26 soft warnings;uv run watchfire matrix --instrument CRR --format markdownlists CRR Art. 153 / 163 / 164 mapped to the expected functions. Follow-up work (Step 2 of the rollout plan) will fan out annotations across the remaining ~20 public namespace methods. Ref:pyproject.toml:38; watchfire upstream tracker for multi-citation + PS1/26 index expansion. -
B3.1 Art. 123B(1) currency-mismatch scope narrowed to retail / RRE only (P1.94 sub-item (f), batch 20260510-1500):
apply_currency_mismatch_multiplier(engine/sa/namespace.py:1880-1892) was over-firing the 1.5x B3.1 multiplier on commercial RE because the in-scope predicate used substring matching (_upper_class.str.contains("RETAIL"|"MORTGAGE"|"RESIDENTIAL"|"COMMERCIAL"|"CRE")) —COMMERCIAL_MORTGAGEmatched on both"COMMERCIAL"and"MORTGAGE". PRA PS1/26 Art. 123B(1) restricts the multiplier to retail (Art. 112(h)) and residential RE (Art. 112(i)) classes only; commercial RE (Art. 112(j) per Art. 124H/124I) is OUT of scope. Fix narrows the gate to exact matchpl.col("exposure_class").is_in(["retail_other","retail_qrre","retail_mortgage","residential_mortgage"]). No new regulatory scalars (uses existingExposureClassstring values fromdomain/enums.py). Pre-fix counterfactual on a 1m EUR commercial-mortgage exposure with GBP borrower-income: RW=1.50, RWA=1,500,000 (BUG); post-fix RW=1.00, RWA=1,000,000. Discriminating fixture: 3-arm test (retail_other in-scope, commercial_mortgage out-of-scope, corporate sanity-anchor); test routes viacalculate_single_sa_exposureto bypass classifier and pin the multiplier predicate directly (mirrors P1.94a precedent). Pinned bytests/acceptance/basel31/test_p1_94f_currency_mismatch_scope_residential_re.py(10 tests with load-bearing anti-assertionsRW != 1.50andRWA != 1,500,000+ cross-arm scope-boundary check). P1.94a regression (7 tests) green. Sub-items (b)/(d)/(e)/(g) remain open. Ref: PRA PS1/26 Art. 123B(1); BCBS CRE20.93. -
B3.1 corporate guarantor at CQS 3 substituted RW = 75% under IRB SA-fallback (P1.122 sub-claim (a), batch 20260510-1500):
_compute_guarantor_rw_sa(engine/irb/guarantee.py:269-281) was hardcoded to the CRR Art. 122 Table 5 corporate-CQS ladder (CQS 3 = 1.00) with nois_basel_3_1branch, so a B3.1 IRB borrower whose corporate guarantor lacked aninternal_pd(forcingguarantor_approach = "sa"perengine/crm/guarantees.py:254-266) silently substituted at CRR weights — over-stating capital by 25 pp (1.00 → 0.75) on the guaranteed portion. Fix extracts newbuild_corporate_guarantor_rw_expr(cqs_col: str, is_basel_3_1: bool)helper indata/tables/crr_risk_weights.py(mirrors thebuild_institution_guarantor_rw_exprprecedent established by P1.95 / P1.122 sub-claim (c) / v0.2.22-v0.2.23), dispatching toB31_CORPORATE_RISK_WEIGHTSunder B3.1 andCORPORATE_RISK_WEIGHTSunder CRR. The corporate branch in_compute_guarantor_rw_sanow callsbuild_corporate_guarantor_rw_expr("guarantor_cqs", config.is_basel_3_1)instead of the inlinepl.when(...cqs.is_in([3,4])).then(1.0)ladder. No regulatory scalars inengine/. Discriminating row: £1m B3.1 unrated corporate borrower under FIRB + 100%-coverage guarantee from rated CQS-3 corporate guarantor withinternal_pd=null→ guarantor sub-rowrisk_weight = 0.75,rwa = 750,000post-fix (was 1,000,000); CRR same construction stays at 1.00 / 1,000,000 (regression-pinned); cross-arm RWA delta = 250,000. Distinct from P1.110 (which usesPermissionMode.STANDARDISEDand exercises the SA path's already-framework-gated_build_guarantor_rw_expr). Pinned bytests/acceptance/basel31/test_p1_122a_b31_corporate_cqs3_guarantor_irb_sa_fallback.py(3 tests: B31 75% / CRR 100% regression / Δ=250k) plus 7-test P1.110 sibling regression. Sub-claim (b) (unrated institution SCRA grades) remains open. Ref: PRA PS1/26 Art. 122(1) Table 6, Art. 235; CRR Art. 122 Table 5. -
OutputFloorSummaryrename + new genuine portfolio total (P2.20, batch 20260510-1500): the field formerly namedtotal_rwa_post_flooronly contained the floored modelled (IRB + slotting) component, not the SA / equity contributions — its name implied a portfolio-wide quantity but the value was modelled-only. PRA PS1/26 Art. 92(2A) definesTREA = max(U-TREA, x · S-TREA + OF-ADJ); the floor binds on the modelled subset (Art. 92(3A) S-TREA recomputes modelled in SA), and SA + equity pass through unchanged. RenamedOutputFloorSummary.total_rwa_post_floor→floored_modelled_rwa(modelled-only scope, identical arithmetic:u_trea + shortfall); addedsa_rwa_total: float = 0.0andequity_rwa_total: float = 0.0; redefinedtotal_rwa_post_floor: float = 0.0asfloored_modelled_rwa + sa_rwa_total + equity_rwa_total. NewSA_APPROACHESandEQUITY_APPROACHESfrozensets inengine/aggregator/_schemas.py(allowlisted inscripts/arch_check.py::VALIDATION_ENUM_ALLOWLISTalongside existingIRB_APPROACHES).engine/aggregator/_floor.pypopulates the new fields at both summary-construction sites via private_portfolio_sa_equity_totals(combined)helper that filtersrwa_pre_floorbyapproach_appliedmembership in SA / equity sets — no new.collect()boundaries, noaggregator.pyedit.engine/comparison.pytotal_rwa_post_floorcolumn onTransitionalScheduleBundle.timelineis a different field on a different bundle; explicitly NOT renamed. Hand-calc: SA=100, IRB=200, slotting=50, equity=30, x=0.725, S-TREA back-solved so floor binds at floored_modelled=280 → total_rwa_post_floor=410. Consumer migrations in 5 test files (test_of_adj.py,test_portfolio_level_floor.py,test_output_floor_skip_transitional.py,test_corep.pyTestOF0201/TestC0700Col0020,test_stress_pipeline.py). Pinned bytests/unit/test_p2_20_total_rwa_post_floor_naming.py(9 tests: dataclass field existence introspection + algebraic identity + SA/equity totals from a synthetic in-test 4-row LazyFrame + load-bearing pure-value comparison). Ref: PRA PS1/26 Art. 92(2A)/(3A);engine/aggregator/_floor.py:253. -
Sovereign / institution PD floors as first-class
PDFloorsconfig fields (P2.36, batch 20260510-1500):PDFloors(src/rwa_calc/contracts/config.py:56-111) previously exposedcorporate,retail_mortgage,retail_qrre_transactor,retail_qrre_revolver,retail_other,purchased_receivables_qrre, but notsovereignorinstitution— so the engine obtained the regulatorily-required 0.05% floor (PRA PS1/26 Art. 160(1)) for sovereign / institution exposures only by accident, falling through to the corporate-floor branch in_pd_floor_expression(engine/irb/formulas.py:51-126). Added explicitsovereign: Decimalandinstitution: Decimalfields toPDFloors;.basel_3_1()factory returnsDecimal("0.0005")for both (PRA PS1/26 Art. 160(1));.crr()returnsDecimal("0.0003")for both (CRR Art. 160(1) uniform).PDFloors.get_floor()extended with sovereign / institution dispatch ahead of the corporate fallback._pd_floor_expressionextended with two newpl.when(exposure_class == ...)branches (CENTRAL_GOVT_CENTRAL_BANK →floors.sovereign; INSTITUTION →floors.institution) inserted before the final.otherwise(corporate); both new floor values added to the all-equal optimisation set. Discriminating row: sovereign B3.1 IRB exposure (PD=0.0001 input → floored to 0.0005, M=2.5y, LGD=0.40, no 1.06 scaling) yields RW≈0.174677 / RWA≈174,677; overridingpd_floors.sovereign=Decimal("0.001")viadataclasses.replacedrives RW→0.263591 / RWA→263,591 — proving the dispatch path is independent of the corporate fallback (which still returns 0.0005 unchanged). CRR uniform-floor cross-check: sovereign PD=0.0001 floors to 0.0003 (not 0.0005). Pinned bytests/unit/config/test_p2_36_sovereign_institution_pd_floors.py(14 tests: 4 field-existence on.basel_3_1()and.crr()factories +get_floordispatch tests + load-bearing override-regression tests for sovereign and institution). Ref: PRA PS1/26 Art. 160(1); CRR (EU 575/2013 onshored) Art. 160(1). -
CRR Art. 123 second subparagraph payroll/pension 35% RW (P2.17, batch 20260510-1300):
_apply_crr_risk_weight_overridesinengine/sa/namespace.pynow branches onis_payroll_loanahead of the flat-75% retail catch-all (mirrors B3.1 ordering), so qualifying CRR retail payroll/pension loans receive the CRR2 (Reg (EU) 2019/876, F68) Art. 123 second subparagraph 35% RW instead of the conservative 75% default. The 35% scalar is reused fromB31_RETAIL_PAYROLL_LOAN_RW = Decimal("0.35")(data/tables/b31_risk_weights.py:252) — identical under PRA PS1/26 Art. 123(3)(a-b) and CRR Art. 123 second subparagraph; cross-framework reuse is documented inline. Caller-attestedis_payroll_loanflag onLOAN_SCHEMA(already present pre-fix); the four cumulative Art. 123 conditions (a)–(d) are not engine-validated. Capital impact on a 50k payroll loan: RWA drops 37,500 → 17,500 (40% reduction). Pinned bytests/acceptance/crr/test_p2_17_crr_payroll_loan_35pct_rw.py(3 retail loans: 2 payroll @ 35% + 1 control @ 75%; anti-regressionrisk_weight != 0.75guards on payroll arms). Ref: CRR Art. 123 second subparagraph (CRR2 amendment); PRA PS1/26 Art. 123(3)(a-b) (parity check). -
PSM LGD source switch — Art. 236(1)(a)(i) option (i) (P2.43, batch 20260510-1300): new
psm_lgd_source: Literal["option_i", "option_ii"] = "option_ii"field onIRBPermissions(contracts/config.py) exposes PRA PS1/26 Art. 236(1)(a)(i)'s borrower-unprotected LGD route for F-IRB unfunded credit protection. Pre-fix the engine hard-wired option (ii) (guarantor F-IRB scalar); option (i) was inaccessible._apply_parameter_substitution(engine/irb/guarantee.py) now branchesLGD_covered: option (i) →pl.col("lgd")(post-apply_firb_lgdcolumn carries the borrower's seniority-correct supervisory value, e.g. 0.75 for subordinated); option (ii) → existing per-row guarantor F-IRB selection._apply_no_better_than_direct_floorrefactored to takepsm_lgd_expr+direct_lgd_exprseparately so the Art. 160(4) NBD comparison always uses the option (ii) guarantor scalar regardless of the switch (regulatory invariant — option (i) cannot give a better result than treating the protection as a direct exposure to the guarantor)._adjust_expected_lossmirrors the same branch (Art. 236(1A)(b)).CalculationConfig.irb_permissionswidened fromfield(init=False)toIRBPermissions | None = Nonewith__post_init__deriving the framework-default — required to supportdataclasses.replace(config, irb_permissions=...); runtime invariant guarantees non-None after construction. Discriminating row: B3.1 corporate borrower (subordinated, PD=0.05, M=2.5y, EAD=1M GBP) fully guaranteed by senior non-FSE corporate guarantor (PD=0.005). Option (ii) RW≈0.619 / RWA≈619k / EL≈2.0k; option (i) RW≈1.161 / RWA≈1.16M / EL≈3.75k; cross-arm RWA delta ≈541k. Default behaviour preserved (option_ii). Pinned bytests/acceptance/basel31/test_p2_43_psm_lgd_source_switch.py(3 functional tests + 6 fixture sanity guards). Ref: PRA PS1/26 Art. 236(1)(a)(i)/(1A)(b); Art. 161(1)(a)(aa)(b); Art. 160(4); BCBS CRE32. -
B3.1 Art. 123B(2)
is_hedgedflag gates currency-mismatch multiplier (P1.94 sub-item (a), batch 20260510-0530): SAapply_currency_mismatch_multiplier(engine/sa/namespace.py) now AND-gatesmismatch_applieswith~is_hedged.fill_null(False), so exposures attestingis_hedged=Trueskip the 1.5x multiplier per PRA PS1/26 Art. 123B(2) hedge exemption. Newis_hedged: ColumnSpec(pl.Boolean, default=False, required=False)onLOAN_SCHEMA(data/schemas.py);null/missing falls back to the conservative behaviour (multiplier still fires under FX mismatch). Pre-fix the multiplier fired for every mismatched-currency exposure regardless of hedge status — over-stating capital on hedged retail / RRE rows by 50% (RW × 1.5 capped at 150%). Discriminating fixture: pairedretail_otherexposures, EUR loan vs GBP borrower-income, identical exceptis_hedged— hedged arm now RW=0.75/RWA=75k (multiplier suppressed); unhedged arm remains RW=1.125/RWA=112.5k. Pinned bytests/acceptance/basel31/test_p1_94a_is_hedged_gates_currency_mismatch.py(7 tests: 3 hedged + 3 unhedged regression-pin + 1 cross-arm RW-delta=0.375). Audit booleancurrency_mismatch_multiplier_appliedcorrectly reflects the gate decision. Out of scope (remain open on P1.94): (b) 90%-coverage hedge test, (d) revolving instalment 123B(2A), (e) pre-2027 portfolio fallback 123B(3), (f) scope narrowing to retail (h)/(i) only, (g) CR5 pre-multiplier RW reporting. Ref: PRA PS1/26 Art. 123B(1)/(2); BCBS CRE20.88. -
B3.1 Art. 226(1) 20-day secured-lending + FX-mismatch reval-scaling acceptance regression-guard (P2.18, batch 20260510-0335):
tests/acceptance/basel31/test_p2_18_art_226_1_b31_secured_lending_fx.py(7 tests) pins the previously uncovered Basel 3.1 / 20-day secured-lending T_m / USD-collateral-vs-GBP-loan / weekly-reval (N_R=5) corner. Engine already implements Art. 226(1)sqrt((NR + T_m - 1) / T_m)symmetrically against both collateral and FX haircuts (engine/crm/haircuts.py:144-207); plan-bullet was stale. Discriminator:ead_final ≈ 239,427.40post-fix vs the pre-Art.226(1)-scaling counterfactualead_final ≈ 227,279.22— the test asserts the latter as a NOT-equal regression guard so any regression that drops the reval factor on either channel fails loudly. New fixture builder + parquets attests/fixtures/p2_18/;tests/fixtures/generate_all.pyextended. No engine change required. Ref: CRR / PRA PS1/26 Art. 226(1)/(2), Art. 224(2)(a) 20-day secured-lending, Art. 224 Table 4 FX 8%, Art. 122 Table 6 unrated corp SCRA Grade B 100%.
Changed¶
-
QRRE-coupling TODO closed;
_FACILITY_QRRE_COUPLED_COLUMNSconstant extracted (P6.26, batch 20260510-1300): pure non-functional refactor ofengine/hierarchy.py. Site analysis showed the two QRRE-touching sites (_undrawn_select_expressionsprojects from the facility frame;_propagate_facility_qrre_columnsjoins+coalesces against the unified loan/contingent/facility_undrawn frame post-concat) are NOT duplicating the same expression — they operate at different pipeline stages with different operations and different null semantics. A merge would either need an awkward two-mode helper or force the upstream site to do an unnecessary self-join. Closes the lone source-treeTODO(qrre-coupling)marker with an explanatory comment block and extracts the four shared column names into a module-level constant_FACILITY_QRRE_COUPLED_COLUMNS = ("is_revolving", "is_qrre_transactor", "facility_limit", "facility_termination_date"). The constant is allowlisted inscripts/arch_check.py::VALIDATION_ENUM_ALLOWLIST(engine-internal coupling marker, mirrorsengine/utils.py::NULLABLE_PARTITION_KEYSprecedent — keeping it inside the module that enforces the coupling rather than splitting todata/schemas.py).tests/contracts/test_no_raw_over_on_nullable_keys.pyline allowlist bumped 405,406 → 420,421 for the unrelated_build_rating_inheritance_lazy.over("counterparty_reference")calls (line shift only, no semantics change). Nopl.col/pl.coalesceexpressions touched at either site — the refactor is by design behaviour-preserving. Pinned bytests/unit/test_p6_26_qrre_coupling_constant.py(3 tests: constant existence with exact tuple value; TODO removal asserted on the read source; four-column propagation regression covering both Site A facility_undrawn synthesis and Site B post-concat propagation). Ref:src/rwa_calc/engine/hierarchy.py; informational anchors CRR Art. 147(5) (QRRE classification), PRA PS1/26 Art. 162(2A)(k) (facility_termination_date). -
Three docstring corrections (P1.163 + P1.168 + P1.185, batch 20260510-1200): docstring-only / no calculation impact, each pinned by a regression-guard test against the live runtime object. (a)
_pd_floor_expression(engine/irb/formulas.py:64-65) — Basel 3.1 retail mortgage PD floor0.05% → 0.10%(Art. 163(1)(b)) and QRRE transactors0.03% → 0.05%, revolvers: 0.10%(Art. 163(1)(c));CalculationConfig.basel_3_1().pd_floorsconstants were already correct. Pinned bytests/unit/irb/test_p1_163_pd_floor_docstring.py(9 tests). (b)data/tables/b31_risk_weights.pymodule docstring (line 16) andget_b31_combined_cqs_risk_weightsdocstring (line 390) — corporateCQS5: 100%→CQS5: 150%(PRA PS1/26 Art. 122(1) Table 6 retains 150% as a deviation from BCBS CRE20.42's 100%);B31_CORPORATE_RISK_WEIGHTS[5] = Decimal("1.50")constant unchanged. Pinned bytests/unit/data_tables/test_p1_168_corporate_cqs5_docstring.py(5 tests). (c)SCRAGrade.Bdocstring (domain/enums.py) — fabricated CET1/leverage thresholds replaced with the qualitative criterion of Art. 121(1)(b) ("Substantial credit risk but meets published minimum requirements (excluding buffers)"); installed via post-classSCRAGrade.B.__doc__ = "..."(mirrors EquityType.CIU precedent from P1.166). Lookup logic andB31_SCRA_RISK_WEIGHTS["B"] = Decimal("0.75")unchanged. Pinned bytests/unit/test_p1_185_scra_grade_b_docstring.py(8 tests). Ref: PRA PS1/26 Art. 163(1) / Art. 122(1) Table 6 / Art. 121(1)(b); BCBS CRE30.55 / CRE20.42 / CRE20.20. -
Stale
EquityType.CIUdocstring corrected + surfaced at runtime (P1.166, batch 20260510-0530): trailing-string docstring atdomain/enums.py:481rewritten from the stale "150% CRR SA / 250% listed or 400% unlisted B31 SA" to the correct PRA PS1/26 Art. 132(2) wording ("1,250% fallback under both CRR and B31 SA when neither look-through Art. 132A(1) nor mandate-based Art. 132A(2) approach is applied"). Python's enum machinery does not propagate per-member trailing-string docstrings intoEquityType.CIU.__doc__— that attribute returns the class docstring instead. To make the corrected text observable at runtime (and testable), a post-class assignmentEquityType.CIU.__doc__ = (...)is added immediately after theEquityTypeclass closes. Runtime constants indata/tables/{b31,crr}_equity_rw.pywere already correct (Decimal("12.50")per P1.119 / v0.1.184) — this fix is comment/audit-trail only with no calculation impact. Plan-bullet'sequity/calculator.py:21pointer was stale; that line was already correct. Pinned bytests/unit/test_p1_166_ciu_fallback_docstring.py(4 tests: 2 positive-substring presence + 2 negative-stale-text absence). Ref: PRA PS1/26 Art. 132(2); CRR Art. 132 / 132a (UK omitted by SI 2021/1078). -
Hierarchy resolver dedups duplicate org_mappings child rows + emits DQ004 (P2.24, batch 20260510-0335):
engine/hierarchy.pyadds_dedup_org_mappings(org_mappings)(called at the head of_build_counterparty_lookupafter None-handling) which materialises the mapping table once, finds duplicatechild_counterparty_referencevalues, rebuilds a deterministic single-row-per-child LazyFrame viaunique(..., keep="first", maintain_order=True), and emits oneCalculationError(code="DQ004", severity=WARNING, category=DATA_QUALITY, counterparty_reference=<child>, field_name="child_counterparty_reference")per duplicated child with a message naming bothchild_counterparty_referenceandorg_mappings. The dedup'd frame is the single source of truth fed into_build_ultimate_parent_lazy,_enrich_counterparties_with_hierarchy, andCounterpartyLookup.parent_mappings— fixes the silent row fan-out at the (formerly)hierarchy.py:491-501join (now lines 580-591). Pre-fix a child counterparty appearing twice inorg_mappings(data-quality defect) silently doubled every exposure row downstream, double-counting capital with no audit-trail signal. Post-fix the dedup is observable and operators get a DQ004 WARNING per duplicated child. Pinned bytests/unit/test_hierarchy.py::TestOrgMappingDuplicateChild(2 tests: duplicate-trigger + control arm). Contracts allowlisttests/contracts/test_no_raw_over_on_nullable_keys.pybumped 393,394 → 405,406 for the unrelated_build_rating_inheritance_lazy.over()calls (line shift only, no semantics change). Ref:contracts/errors.py:165ERROR_DUPLICATE_KEY = "DQ004"; bundle invariant of one row per(exposure_reference, exposure_type). -
COREP C 07.00 / OF 07.00 column 0020 "Exposures deducted from own funds" (P2.12, batch 20260510-0500): new column added between 0010 and 0030 in both
CRR_C07_COLUMNSandB31_C07_COLUMNS(reporting/corep/templates.py);_compute_c07_values(reporting/corep/generator.py) emits col 0020 by summing the optional input fieldown_funds_deduction_amountvia_col_sum_eager, defaulting toNonewhen the field is absent (mirrors col 0035 on-bs-netting precedent). Without this column, COREP submission validation would reject the return as a missing-dimension error. Schema-side addition ofown_funds_deduction_amounttoFACILITY_SCHEMA/LOAN_SCHEMAand the regulatory0040 = 0010 − 0020 − 0030 − 0035formula change are explicitly deferred (the current0040 = 0010 − 0030 − 0035formula is preserved). Pinned bytests/unit/test_corep.py::TestC0700Col0020(4 tests). Ref: PRA PS1/26 Annex II §C 07.00 / §OF 07.00 col 0020; CRR Art. 36/56/66 (CET1 / AT1 / T2 deductions). - Hierarchy depth-truncation HIE003 WARNING emission (P2.35, batch 20260510-0500):
_resolve_graph_eagerinengine/hierarchy.pyno longer truncates parent chains silently atmax_depth=10. The helper now returns a 4-column DataFrame with a newtruncated:Booleanflag set whendepth == max_depth AND current in parent_ofafter the inner walker exits;_build_counterparty_lookupmaterialises oneCalculationError(code=ERROR_HIERARCHY_DEPTH="HIE003", severity=WARNING, category=HIERARCHY)per truncated entity, then drops the helper column to preserveCounterpartyLookup.ultimate_parent_mappings's published 3-column schema. The deepest reachable parent is still surfaced (non-fatal) so the SA/IRB pipeline keeps running. Severity isWARNING(not the hierarchy-error factory defaultERROR) because the resolver still produces a usable parent reference. Tests pin a 12-node chainCP_DEPTH_C0..CP_DEPTH_C11: onlyC0(chain depth 11 againstmax_depth=10) emits HIE003 — chains terminating at exactly depth 10 do not. Pinned bytests/unit/test_hierarchy_max_depth.py::TestHierarchyMaxDepthTruncation(4 tests). Ref: internal hierarchy contract; CLAUDE.md § Error Handling (data-quality errors must accumulate inlist[CalculationError], never silent). - F-IRB LGD helper naming regression guard (P1.179, batch 20260510-0500): pure regression-guard pinning the existing dispatch contract —
IMPLEMENTATION_PLAN.mdbullet was stale becauseget_firb_lgd_table(is_basel_3_1=...)andget_firb_lgd_table_for_framework(is_basel_3_1=...)already dispatch correctly viaBASEL31_FIRB_SUPERVISORY_LGD/FIRB_SUPERVISORY_LGD, anddata/tables/__init__.pyre-exports all four symbols. No engine change required. New tests pin (1) CRR DataFrame default values at(unsecured, senior) = 0.45and(receivables, senior) = 0.35; (2) Basel 3.1 DataFrame values incl. theis_fsesplit (unsecured non-FSE = 0.40,unsecured FSE = 0.45,receivables = 0.20); (3) schema delta —is_fsecolumn only on B31; (4) dict helper values for both frameworks; (5) cross-helper consistency between dict and DataFrame; (6) re-export object identity throughdata/tables/__init__.py. Pinned bytests/unit/data_tables/test_p1_179_firb_lgd_naming_regression.py(6 tests, all green-on-write). Ref: CRR Art. 161; PRA PS1/26 Art. 161 / BCBS CRE32.9-12. - Art. 159(1) Pool B regression-guard test + classifier defaulted-OR semantics (P1.127, batch 20260510-0245):
engine/hierarchy.pynow passesava_amountandother_own_funds_reductionsthrough to per-exposure outputs (Art. 34/105 reductions consumed by aggregator_el_summary._el_pool_branchesfor the Pool B EL-shortfall comparison).engine/classifier.py::_build_is_defaulted_exprnow ORscp_default_status | row-level is_defaulted | beel>0so any of the three signals gates a row into Pool B / defaulted rather than only the row-level flag. Pinned bytests/acceptance/crr/test_p1_127_art_159_pool_b_ava_regression_guard.py(3 methods: per-exposure EL-shortfall sum, Pool B memo totals, defaulted double-count guards). Ref: CRR Art. 159(1); Art. 34 (additional value adjustments); Art. 105 (prudent valuation). - CRR Art. 501(2) SME-vs-infrastructure supporting-factor regression guard (P2.22, batch 20260510-0245): pins the engine's
pl.min_horizontal(SME, infra)site atengine/sa/supporting_factors.py:360to the regulatorily-correct 0.75 outcome whenever both factors apply. Under current CRR scalars (SME tier-1 = 0.7619, tier-2 = 0.85, infra = 0.75) the invariant0.75 < 0.7619 ≤ SME_blended ≤ 0.85holds, somin(...)and Art. 501(2) second-subparagraph "infra replaces SME when both qualify" are observationally identical and no capital understatement exists today. The original plan-bullet's "capital understatement" claim was therefore wrong. The test assertssupporting_factor == 0.75, anti-assertssupporting_factor != 0.7619, and pinsrwa_final = 1,125,000for a £1.5m unrated GB corporate SME infrastructure loan (CP_SME_INFRA_001/LOAN_SME_INFRA_001). Test docstring records the algebraic invariant so any future calibration change that violates the ordering will fail the test loudly and force a re-evaluation of themin_horizontalsite. No engine change required. Pinned bytests/acceptance/crr/test_scenario_crr_f_supporting_factors.py::TestP222SMEInfraOverlapSubstitution::test_p2_22_sme_infra_overlap_substitution_regression. Ref: CRR Art. 501(2) 2nd subpara, Art. 501a(1). - B3.1 SCRA-grade dispatch for unrated institution guarantor (P1.95, batch 20260510-0102): SA RWSM under PRA PS1/26 Art. 121/235 now routes the substituted exposure through the SCRA grade table when the guarantor is an unrated institution under Basel 3.1 — Grade A → 40%, A_ENHANCED → 30%, B → 75%, C → 150% (long-term) and 20/20/50/150 (short-term). Pre-fix the engine fell through to
INSTITUTION_RISK_WEIGHTS_B31_ECRA[CQS.UNRATED] = 0.40, hard-coding SCRA Grade A for every unrated institution guarantor — under-capital risk on Grade B (40 → 75) and Grade C (40 → 150 non-beneficial → borrower 0.85).data/tables/crr_risk_weights.py::build_institution_guarantor_rw_exprextended with optionalscra_grade_colkwarg (additive — CRR and rated B31 paths untouched);B31_SCRA_RISK_WEIGHTS/B31_SCRA_SHORT_TERM_RISK_WEIGHTSlazy-imported to avoid a top-level circular betweencrr_risk_weights.pyandb31_risk_weights.py. CRMengine/crm/guarantees.pyprojectsscra_gradefrom counterparty asguarantor_scra_grade; SA_build_guarantor_rw_exprpasses the kwarg + defensive null-fill in_ensure_guarantee_substitution_columns. Null SCRA grade falls back to Grade C 150% per BCBS CRE20.21 conservative classification. IRB-side call site atengine/irb/guarantee.py:268left untouched (kwarg default=None preserves rated-CQS behaviour); future scenario can wire it. Discriminating row: £1m B3.1 unrated SME corporate borrower (RW=85%) with 5y unfunded guarantee from unrated GB institution Grade B → guarantee beneficial, RWA = 1m × 0.75 = 750,000 (pre-fix 400,000); Grade C → non-beneficial, RWA = 1m × 0.85 = 850,000 (pre-fix 400,000); null grade → conservative non-beneficial 850,000. Pinned bytests/acceptance/basel31/test_p1_95_b31_unrated_inst_guarantor_scra.py(22 tests). Ref: PRA PS1/26 Art. 121(1)–(3); Art. 235; BCBS CRE20.16-21. - COREP OF 02.01 col 0030 U-TREA = col 0010 + col 0020 per Annex II §1.3.2 (P2.42, batch 20260510-0102):
_of_02_01_row(src/rwa_calc/reporting/corep/generator.py:2988-2993) now sets col 0030 (U-TREA) tomodelled_rwa + sa_rwainstead of justmodelled_rwa. Per PRA PS1/26 Annex II §1.3.2, col 0030 is the un-floored TREA = modelled (IRB / slotting / equity) RWEA + SA RWEA. Col 0040 (S-TREA) was already correct because the engine'ssa_rwais the SA-equivalent recalculation of the entire portfolio (engine/aggregator/_floor.py:130-159). Hand-calc on the existing_b31_results_with_floorhelper (4 exposures, modelled=3000, sa=3250): col 0030 = 6250 (was 3000). The now-stale companion testtest_u_trea_equals_modelledremoved; new positivetest_u_trea_is_sum_of_modelled_and_saadded. SiblingTestOF0201TotalRow.test_total_equals_credit_riskcontinues to hold post-fix (Total row uses the same helper). Docstring at_of_02_01_rowupdated to remove misleading "U-TREA = modelled RWA" wording. Out-of-scope (separate latent bug, future P-item): col 0010/0020 today sum across the whole portfolio; per Annex II col 0010 should be modelled-approach rows only and col 0020 SA rows only — that partitioning is unchanged here. Pinned bytests/unit/test_corep.py::TestOF0201CreditRiskRow::test_u_trea_is_sum_of_modelled_and_sa(9 tests in the credit-risk row class). Ref: PRA PS1/26 Art. 92(2A)/(3)/(3A); Annex II §1.3.2 OF 02.01 col 0030. ciu_holdingsregistry entry +from_registrywiring (P6.19, batch 20260510-0102): newDataSourceFile(id="ciu_holdings", relative_path=Path("equity/ciu_holdings"), requirement=RequirementLevel.OPTIONAL, description="CIU look-through holdings for Art. 132(3) equity treatment")added toDATA_SOURCESbetweenequityandspecialised_lending(src/rwa_calc/config/data_sources.py).DataSourceConfig.from_registry()(src/rwa_calc/engine/loader.py:218) now callsget_p("ciu_holdings")so the previously deadDataSourceConfig.ciu_holdings_filefield (declared atloader.py:180, consumed at_build_bundle:362) is finally populated. Pre-fix any caller usingDataSourceConfig.from_registry()would silently getciu_holdings_file=None, causing_build_bundleto short-circuit at the optional-load path and dropping CIU look-through data — leavingRawDataBundle.ciu_holdings = Noneand forcing the equity calculator to the Art. 132(2) 1,250% punitive fallback. Plumbing-only fix:RawDataBundle.ciu_holdingsfield,CIU_HOLDINGS_SCHEMA, and_build_bundlewiring already existed. Pinned bytests/unit/config/test_p6_19_data_sources_ciu_holdings.py(7 tests inTestDataSourceRegistryCiuHoldings). Ref: CRR / PRA PS1/26 Art. 132(2)–(3) CIU look-through approach.- Art. 120(2) Table 4 short-term institution guarantor risk weight (P1.122 sub-claim (c), batch 20260510-0200): SA RWSM now routes the substituted exposure to a rated institution guarantor through Art. 120(2) Table 4 (CQS 1-3 = 20%, CQS 4-5 = 50%, CQS 6 = 150%) when the borrower's
original_maturity_years ≤ 0.25(3 months). Pre-fix_build_guarantor_rw_expr(engine/sa/namespace.py:854) routed everything through long-term Table 3 — over-stating capital by 30 pp on CRR (50%→20%) and 10 pp on B3.1 ECRA (30%→20%). NewINSTITUTION_SHORT_TERM_RISK_WEIGHTS_B31_ECRAdict added todata/tables/crr_risk_weights.py(numerically identical to CRR Table 4);INSTITUTION_SHORT_TERM_RISK_WEIGHTS_CRRextended withCQS.UNRATED → 0.20(Art. 121(3)).build_institution_guarantor_rw_exprnow accepts an optionalshort_term_flag_col;apply_guarantee_substitutionderives a_inst_guarantor_short_termBoolean fromoriginal_maturity_years ≤ 0.25(matching the existing direct Art. 120(2) gate convention atnamespace.py:1072-1074). Discriminating row: £1m B3.1 corporate borrower (CQS 4 unrated 100%) with 81-day residual + 2y unfunded guarantee from CQS 2 institution → RWA drops 300,000 → 200,000 (B3.1) and 500,000 → 200,000 (CRR). Sub-claims (a) corporate CQS 3 = 75% and (b) unrated institution SCRA grades remain open. Pinned bytests/acceptance/basel31/test_p1_122_short_term_institution_guarantor_substitution.py(6 tests; 4 discriminating + 2 structural guards). Ref: PRA PS1/26 Art. 120(2) Table 4, Art. 235; CRR Art. 237(2)(a) eligibility filter respected. - B3.1 international-organisation regression test (P1.154, batch 20260510-0200): closed via regression-guard — engine implementation already correct (
ExposureClass.INTERNATIONAL_ORGANISATIONatdomain/enums.py:96; SA dispatcher atengine/sa/namespace.py:912/1094routes Art. 118 IO 0% viaIO_ZERO_RWand B3.1 Art. 117(1)(a) Table 2B non-named MDB CQS 2 = 30% viaMDB_RISK_WEIGHTS_TABLE_2B). Plan entry was stale: only the B3.1 acceptance test was missing. Newtests/acceptance/basel31/test_p1_154_b31_art_118_international_organisation_class.py(9 tests) pins both the IMF (international_organisation, 0% RW) and Black Sea Trade & Development Bank (mdb non-named, 30% RW Table 2B CQS 2) discriminator. CRR-side acceptance testtests/acceptance/crr/test_p1_154_art_118_international_organisation_class.pypre-existed. Ref: CRR Art. 112(1)(e); CRR Art. 117(1)(a)/(2), Art. 118; PRA PS1/26 Art. 117(1)(a) Table 2B, Art. 118. - P1.123 plan-entry close (silent fix already shipped in commit
e5bbdbdbatch 20260509-1726): FCCM(1+HE)exposure-side gross-up was implemented in the prior batch but the plan bullet was never ticked. No code change in this batch —tests/acceptance/crr/test_p1_123_art_223_5_fccm_exposure_volatility_haircut.py(13 tests) confirms the fix is in production. Ticked here for plan hygiene. Ref: CRR Art. 223(5). validate_aggregated_bundleregulatory output-bounds checker (P2.34, batch 20260510-0030): new public function insrc/rwa_calc/contracts/validation.pyasserting per-rowrisk_weight ≤ 12.5(CRR Art. 92(3); CRE31.5),risk_weight ≥ 0(Art. 153/CRE31),rwa_final ≥ -1e-9(Art. 92(3); float64 round-off tolerance), andead_finalnon-null at theAggregatedResultBundleboundary. Errors flow asCalculationErrorcodesOUT001-OUT004(added tocontracts/errors.py) withsample_cap=5per bound + a single summary error if the offending-row count exceeds the cap. Schema-safe: silently skips a bound if its target column is absent frombundle.results. LazyFrame-first (one.collect()per bound). Not auto-wired into the pipeline orchestrator (deferred). Pinned bytests/contracts/test_aggregated_bundle_validation.py(11 tests).CreditRiskCalc.base_currencyforwarded through factories (P6.20, batch 20260510-0030):CreditRiskCalc(..., base_currency="EUR")was a silent no-op —_create_config()(api/service.py:174-195) dropped the value before invokingCalculationConfig.crr()/.basel_3_1(), both of which hardcodedbase_currency="GBP"in theircls(...)calls (contracts/config.py:961, 1041). Both factories now accept and forward abase_currency: str = "GBP"kwarg, and the service layer passesself.base_currencythrough. Default"GBP"semantics preserved end-to-end. Pinned bytests/unit/api/test_service_base_currency.py(5 tests; 2 forwarding + 3 default-preservation).-
Loader optional-file bare except narrowed (P6.18, batch 20260510-0030):
engine/loader.py::_load_file_optionalno longer silently swallows non-FileNotFoundErrorexceptions.FileNotFoundErrorcontinues to returnNonewith only a DEBUG log (the legitimate "optional input not configured" signal). Any otherException(corrupt parquet,OSError,PermissionError,pl.exceptions.ComputeError) now appends a newCalculationError(DQ007 ERROR_OPTIONAL_FILE_UNREADABLE, severityWARNING, categoryDATA_QUALITY) ontoRawDataBundle.errorsand emits a single lazy-formattedlogger.warning(...)(per CLAUDE.md § Logging). The required-file path_load_fileis unchanged — corrupt required files still raiseDataLoadError. New error code +optional_file_load_error(...)factory added tocontracts/errors.py. Threading uses an expliciterrors: list[CalculationError]parameter wired through_build_bundleviafunctools.partial;lf.collect_schema()afterscan_fn(...)forces parquet-corruption detection thathas_rows's broad except would otherwise silently demote to "empty file". Pinned bytests/unit/test_loader_optional_error_handling.py(4 tests). -
PRA Art. 191A(2)(e)(i) funded-only look-through for two-layer credit protection (P1.161, batch 20260509-1825): when an unfunded guarantee is itself collateralised by funded collateral posted by the guarantor, the institution may now elect via the new
look_through_election="funded_only"flag onGUARANTEE_SCHEMAto suppress the guarantee for RWSM purposes and re-anchor the guarantor-posted collateral onto the original obligor exposure ahead of FCCM/FCSM allocation. New modulesrc/rwa_calc/engine/crm/look_through.py(apply_funded_only_look_through()) wired as Step 0 insideengine/crm/processor.py::get_crm_adjusted_bundle()/get_crm_unified_bundle(). Schema additions:GUARANTEE_SCHEMA.look_through_election(enumnone/funded_only/both, defaultnone) andGUARANTEE_SCHEMA.is_collateralised_by_guarantor(Boolean default False);COLLATERAL_SCHEMA.posted_by_counterparty_reference(String, optional);VALID_BENEFICIARY_TYPESextends to include"guarantee"so collateral can attach to a guarantee row. Two new audit-trail error codes:CRM007 ART_191A_2_E_LOOKTHROUGH_APPLIED(informational) andCRM008 LOOKTHROUGH_NOT_IMPLEMENTED(the deferred(2)(e)(ii)"both" election surfaces this warning and falls back tonone). Discriminating capital: a £1m GBP corporate exposure with a 100%-coverage CQS4 corporate guarantor backed by £400k of GBP cash collateral drops RWA 1,000,000 → 600,000 under election=funded_only (EAD nets to 600k × unrated corp 100% RW); election=none preserves today's RWSM-with-no-benefit behaviour at 1,000,000 (regression pin). Out-of-scope and tracked separately: Art. 191A(2)(e)(ii) "both" election; Art. 191A(2)(f) borrower-deeming flexibility; F-IRB / A-IRB look-through under PSM Art. 236 / LGD-AM Art. 183. Pinned bytests/acceptance/basel31/test_p1_161_art_191a_two_layer_protection.py(4 tests). Ref: PRA PS1/26 Art. 191A(2)(d)–(f), Art. 197(1)(a), Art. 222, Art. 223(5), Art. 235; BCBS CRE22.18 / CRE22.71. - ADC classification derived from
is_under_constructionrather than caller-supplied (P1.140, batch 20260509-1825):engine/classifier.py::_derive_independent_flagsnow derivesis_adcvia the new_build_is_adc_exprhelper rather than relying on caller pre-tagging —is_adc = (cp_entity_type IN {corporate, company, specialised_lending} AND NOT cp_is_natural_person AND (is_under_construction.fill_null(False) OR _pt_upper.is_in({DEVELOPMENT_FINANCE, CONSTRUCTION_LOAN}))). Any pre-existingis_adcvalue on the input frame is coalesced as a caller-supplied override. New schema fieldis_under_construction: ColumnSpec(pl.Boolean, default=False, required=False)on FACILITY_SCHEMA, LOAN_SCHEMA, and CONTINGENTS_SCHEMA (data/schemas.py); propagated throughengine/hierarchy.py::_coerce_loans_to_unified/_coerce_contingents_to_unifiedand the facility-undrawn select expressions. Two ancillary classifier guards prevent ADC routing being lost downstream: ADC-flagged rows are now excluded from the RE loan-splitter candidate gate (_re_split_candidate_gates) and from the CORPORATE → CORPORATE_SME reclassification (_classify_exposure_subtypes) soexposure_class == "corporate"survives to the SA branch's existing_b31_append_real_estate_branchesADC consumer. Pre-fix a £10m B3.1 corporate development-finance loan to an SPV withis_under_construction=Trueproduced RWA 4,925,000 (residential RE loan-splitting fired becauseis_adc=False); post-fix RWA = 15,000,000 = £10m × 150% Art. 124K(1) ADC RW. Natural-person obligors fall through tois_adc=Falseregardless ofis_under_construction, preserving the existing residential RE path. Out-of-scope: Art. 124K(2) qualifying-residential 100% concession (presold derivation), defaulted ADC interaction with Art. 127, Art. 124E three-property limit (P1.142), Art. 124(4) mixed-use splitting (P1.141). CRR untouched (no Art. 124K). Pinned bytests/acceptance/basel31/test_p1_140_adc_classification_derivation.py(8 tests). Ref: PRA PS1/26 Glossary "ADC exposure"; Art. 124(3); Art. 124K(1); BCBS CRE20.91. - B31 equity SA-only hard guard in classifier (P2.39, batch 20260509-1642):
engine/classifier.py::_apply_b31_approach_restrictionsnow addsb31_equity_sa_only = (exposure_class_irb == ExposureClass.EQUITY.value)to the existing sovereign-like SA-only mask so neithernew_airbnornew_firbcan fire for Basel 3.1 equity exposures, regardless of caller-suppliedIRBPermissions. Pre-fix a misconfiguredIRBPermissionsgranting AIRB toExposureClass.EQUITYwould route equity exposures throughapproach="advanced_irb"ahead of the equity branch in_build_approach_expr's decision ladder, contradicting PRA PS1/26 Art. 147A(1)(h) (Art. 155 left blank under PS1/26). Post-fix the row falls through to.when(exposure_class == EQUITY).then(EQUITY)andapproach="equity". CRR is untouched (_apply_b31_approach_restrictionsreturns early for non-B3.1 configs; legacy CRR Art. 155 IRB equity approach retained).firb_clear_expris intentionally not widened — equity is SA-only, not F-IRB-only. Pinned bytests/unit/classifier/test_p2_39_b31_equity_sa_only_guard.py(7 tests acrossTestB31EquitySaOnlyGuardandTestCrrEquityControlNoB31Guard); 48 B31-L equity acceptance tests continue to pass. Ref: PRA PS1/26 Art. 147A(1)(h) read with Art. 147(2)(e); BCBS CRE60. ValidationRequestrequiresmodel_permissionsunder IRB permission mode (P1.147, batch 20260509-1642): newpermission_mode: Literal["standardised", "irb"]field onValidationRequest(api/models.py, default"standardised");CreditRiskCalc.validate()and.calculate()now propagatepermission_modeinto the request (previously silently dropped atapi/service.py:113-118and:165-170). New_check_irb_required(...)step inDataPathValidator.validate()(api/validation.py) appendsPath("config/model_permissions.parquet")tofiles_missingand emits a newVAL003APIError(api/errors.py::create_irb_required_file_error) whenpermission_mode == "irb"and the model_permissions file is absent on disk; setsvalid=False. The existing short-circuit inCreditRiskCalc.calculate()then returnssuccess=Falsewithsummary.total_rwa = Decimal("0")andexposure_count = 0. Pre-fixcalculate()returnedsuccess=Truewithtotal_rwa = Decimal("1000000.0")(silent SA fallback for all exposures despite IRB request) — direct capital overstatement risk. The B31-M11 acceptance test (TestB31M11_NoModelPermissionsFallback) remains green because it exercisesPipelineOrchestrator.run_with_datawith an in-memoryRawDataBundle, bypassingDataPathValidator— the engine-layer silent-SA fallback is intentionally retained for in-memory callers. Pinned bytests/integration/test_p1_147_irb_requires_model_permissions.py(8 tests, 7 failing pre-fix). Ref: PRA PS1/26 Art. 147A; CRR Art. 143 / 150; internal model-permissions gating contract.- Null-safe
is_guaranteedfilter in CRM reporting / CR7 / CR7-A disclosures (P1.146, batch 20260509-1642): sink-side fix atengine/aggregator/_crm_reporting.py:138,166,212— the threepl.col("is_guaranteed")/~pl.col("is_guaranteed")filters now wrap with.fill_null(False)so Polars 3VL no longer drops rows whereis_guaranteedis null. Source-side defence-in-depth atengine/crm/guarantees.py:293— the alias is now(pl.col("guaranteed_portion").fill_null(0.0) > 0).alias("is_guaranteed"), so a nullguaranteed_portionfrom upstream cannot leak a nullis_guaranteedinto the aggregator. Pre-fix any guaranteed exposure whoseis_guaranteedarrived null (e.g. an SA row that had not flowed throughapply_guarantees, or an equity-results path that did not propagate the column) was silently dropped frompost_crm_detailed,post_crm_summary, and downstream CR7 / CR7-A Pillar III disclosures — a regulatory completeness defect. The plan-bullet line reference:260was stale; the actual alias is at:293. Pinned bytests/unit/test_p1_146_is_guaranteed_null_filter.py(4 tests; aggregator-level scenario with a hand-built 3-rowsa_resultsLazyFrameis_guaranteed=[True, False, None]→post_crm_detailed.height = 4post-fix vs3pre-fix; CORPORATEtotal_ead = 2_150_000). 5 pre-existing CRM-reporting integration tests continue to pass. Ref: CRR Art. 213-217 (CRM eligibility, origin ofis_guaranteed); CRR Art. 444 / 453(g),(j); PRA PS1/26 Annex XX / XXII (CR7 / CR7-A). - CRR receivables Art. 224 haircut removed (P1.165, batch 20260509-1530):
data/tables/haircuts.pyCOLLATERAL_HAIRCUTS["receivables"]nowDecimal("0")(was0.20, an "ad-hoc approximation" with no Art. 224 basis). Receivables are non-financial collateral per CRR Art. 199(5); the entire CRR treatment lives in Art. 230 LGD* / 1.25× OC mechanism (LGDS=35% senior per Art. 230 Table 5, no minimum threshold) — already implemented infirb_lgd.py. The pre-fix engine double-counted capital by applying both an ad-hoc 20% volatility haircut AND the Art. 230 mechanism. Comment block indata/tables/haircuts.pynow points to Art. 230 / Art. 199(5).BASEL31_COLLATERAL_HAIRCUTS["receivables"]preserved at0.40per PRA PS1/26 Art. 230(2). Capital impact: F-IRB single-loan with 800k receivables collateral / EAD=1m / LGDU=0.45 / LGDS=0.35 — blended LGD* drops from pre-fix 0.4041 (which double-counted) to the regulatorily correct 0.386 = (0.35 × 640k + 0.45 × 360k) / 1m. Pinned bytests/acceptance/crr/test_p1_165_art_230_receivables_no_volatility_haircut.py(8 tests). Bug-encoded assertions intests/unit/crr/test_crr_tables.pyandtests/unit/crm/test_crm_basel31.pyflipped accordingly. Ref: CRR Art. 224, Art. 199(5), Art. 230(1)–(2), Art. 230 Table 5. COVERED_BOND_UNRATED_DERIVATIONsplit CRR vs B31 + nested Art. 129(5)(b) bug fixed (P1.180, batch 20260509-1530):data/tables/crr_risk_weights.pynow exportsCOVERED_BOND_UNRATED_DERIVATION_CRR(4 keys per CRR Art. 129(5)(a)–(d): 0.20→0.10, 0.50→0.20, 1.00→0.50, 1.50→1.00) andCOVERED_BOND_UNRATED_DERIVATION_B31(7 keys per PRA PS1/26: adds the ECRA / SCRA-only 0.30→0.15, 0.40→0.20, 0.75→0.35; B31 0.50→0.25). The old shared 7-key dict had used the B31 (b) value0.50→0.25even under CRR config — a nested numeric bug now corrected: under CRR, an unrated covered bond whose CQS3 institution issuer carries RW 0.50 derives RW = 0.20 (Art. 129(5)(b)) instead of the pre-fix 0.25._crr_unrated_cb_rw_expr(engine/sa/namespace.py) consumes the new_CRRtable;_b31_unrated_cb_rw_exprcontinues to consumeCOVERED_BOND_UNRATED_DERIVATIONwhich now aliases_B31(back-compat preserved). Pinned bytests/unit/data_tables/test_p1_180_covered_bond_unrated_derivation_split.py(7 tests). The CRR parametrize intests/unit/test_covered_bonds.py::test_unrated_covered_bond_crr_by_institution_cqs(CQS2 / CQS3 rows) flipped 0.25→0.20 to match. Ref: CRR Art. 129(5)(a)–(d); PRA PS1/26 Art. 129(5)(a)/(aa)/(ab)/(b)/(ba)/(c)/(d).- Classifier deterministic dedup with SA precedence on conflicting model_permissions (P1.145, batch 20260509-1530):
engine/classifier.py::_resolve_model_permissionsnow applies the conservative-precedence rule when conflicting(model_id, exposure_class)AIRB+SA permission rows exist — SA wins. CRR Art. 150(1) PPU is a carve-out from IRB scope; AIRB-wins would silently expand IRB scope beyond firm permission. Implementation: row-level_sa_block_match = permission_valid & (mp_approach == ApproachType.SA.value)aggregated as.max().over("exposure_reference")and AND-NOT'd against the AIRB / FIRB / slotting.max().over()flags. Order-stability also pinned: a deterministic sort on(exposure_reference, _diagnostic_priority, mp_approach, mp_country_codes, mp_excluded_book_codes)precedesunique(subset=["exposure_reference"], keep="first", maintain_order=True), so the surviving_model_permission_diagnosticis the most-informative one (null > filter_rejected > unmatched_model_id > null_model_id) regardless of input ordering. Existing CLS006 ladder fires"filter_rejected"when SA blocks IRB. Behaviour change: operators with conflicting AIRB+SA rows in production will now see a CLS006 warning where pre-fix the engine silently routed to AIRB. Pinned bytests/unit/classifier/test_p1_145_model_permissions_dedup_determinism.py(9 tests across two physical orderings of the same 9-row fixture; both produce identical post-classifier frames). 188 related classifier / permission / model_id tests pass with no regressions. Ref: CRR Art. 143 (IRB permission scope), Art. 150(1) PPU; PRA PS1/26 Art. 150(1A). - CRR Art. 237/238/239(3) maturity mismatch on unfunded credit protection (P1.109, batch 20260509-1359):
engine/crm/guarantees.pynow scalesamount_coveredandpercentage_coveredby(t − 0.25) / (T − 0.25)when the guarantor's residual maturity is shorter than the secured exposure's residual maturity (gated onconfig.is_crr). Art. 237(2) ineligibility (residual < 3 months / original < 1 year) flows through the existing eligibility chain. Pre-fix the engine applied FX haircut but no maturity-mismatch reduction, understating capital on long exposures with short-dated guarantees: a £1m / 5y CRR corporate exposure fully covered by a 2.5y guarantee now producesGA = 1m × (2.5 − 0.25) / (5.0 − 0.25) = 473,684.21and blended RWA = 621,052.63 (vs the pre-fix 100% substitution). Pinned bytests/acceptance/crr/test_p1_109_art_237_maturity_mismatch_guarantees.py(11 tests). Ref: CRR Art. 237, Art. 238, Art. 239(3); BCBS CRE22.74. - B31 SA RWSM corporate-CQS3 guarantor RW = 75% via Art. 122(2) Table 6 (P1.110, batch 20260509-1359):
engine/sa/namespace.pyimportsB31_CORPORATE_RISK_WEIGHTSand gates the corporate-guarantor SA risk-weight lookup onis_basel_3_1so a CQS3-rated corporate guarantor on a B31 exposure now picks up Table 6's 75% instead of the CRR Art. 122 100%. CRR path untouched. Capital impact: a £1m B31 corporate exposure guaranteed by a CQS3 corporate guarantor drops post-RWSM RW 100% → 75% (RWA 1m → 750k). Pinned bytests/acceptance/basel31/test_p1_110_art_122_corporate_cqs3_guarantee_substitution.py(7 tests). Distinct from P1.95 (SCRA unrated institutions) and P1.122 (full B31 framework branching). Ref: PRA PS1/26 Art. 122(2) Table 6, Art. 235. - PSM F-IRB LGD substitution routes by guarantor seniority (P1.160, batch 20260509-1359):
engine/crm/guarantees.py::_apply_guarantee_splitsnow threadsguarantor_seniorityfrom the guarantee table through the per-guarantor pre-aggregation, the join select-list, the borrower-frame drop-list, and the no-guarantee / remainder null-fill paths — so the IRB stage receives the actual seniority value instead ofNone. The downstream IRB routing inengine/irb/guarantee.pyalready had the seniority dispatch wired (Art. 161(1)(aa) senior 0.40 / Art. 161(1)(b) subordinated 0.75 / Art. 161(1)(d) covered-bond 0.1125) but receivedNonefrom upstream and silently defaulted to the senior LGD. The same patch tightens_add_guarantee_status_columns: when the parameter-substitution path was taken (_is_pd_substitution=Trueandguaranteed_portion>0),guarantee_method_usednow resolves to"PD_PARAMETER_SUBSTITUTION"regardless of whether the beneficial gate retained the borrower RWA — theGUARANTEE_NOT_APPLIED_NON_BENEFICIALsignal continues to live onguarantee_status. Discriminating row: B31 corporate borrower (PD=0.015, M=2.5y, EAD=1m) with subordinated corporate guarantor (PD=0.005) —guarantor_rw_irb0.61877 → 1.16037 (LGD 0.40 → 0.75), guarantee correctly judged not beneficial, RWA retained at borrower 938,690 instead of incorrectly applied 618,870. Pinned bytests/acceptance/basel31/test_p1_160_art_161_psm_lgd_seniority_routing.py(11 tests);tests/unit/test_irb_double_default.py::TestDoubleDefaultRWA::test_dd_floor_at_guarantor_rwwidened to acceptPD_PARAMETER_SUBSTITUTIONas a method label for non-beneficial PSM scenarios. Ref: PRA PS1/26 Art. 161(1)(aa)/(b)/(d), Art. 236(1)(a); BCBS CRE32. - FCSM Art. 222(4) SFT 0%/10% carve-out + Art. 222(6) non-SFT gating (P1.93, batch 20260509-1252):
engine/crm/simple_method.py::compute_fcsm_columnsnow splits the previously-merged Art. 222(4)/(6) zero-RW exception into two distinct paths — SFTs with Art. 227-qualifying collateral get 0% (counterparty is a core market participant) or 10% (otherwise) per PRA PS1/26 Art. 222(4); non-SFTs keep the existing same-currency cash / 0%-RW sovereign 0% via the renamed_is_art_222_6_carveout_expr(gated on~exposure_is_sft); other rows fall back to the 20% Art. 222(3) floor. Step 5's Art. 222(6)(b) 20% sovereign-bond market-value discount is now suppressed whenqualifies_for_zero_haircut=True(the Art. 227 SFT carve-out is a flat RW substitution, not a value haircut). New Boolean columnis_core_market_participanton COUNTERPARTY_SCHEMA (default False; mirrored ascp_is_core_market_participanton HIERARCHY_OUTPUT_SCHEMA); new constantsART_222_4_CMP_RW = Decimal("0.00")andART_222_4_NON_CMP_RW = Decimal("0.10")indata/tables/crr_simple_method.py. Headline regression: an SFT non-CMP gilt repo (Run B) now produces blended RW 0.10 / RWA £100k vs the pre-fix RW 0.00 / RWA 0 (the buggy merged branch mis-fired the same-currency 0% on every SFT). Pinned bytests/acceptance/basel31/test_p1_93_art_222_4_fcsm_sft_carveout.py(11 tests across 3 runs). Ref: PRA PS1/26 Art. 222(4)/(6), Art. 227(2)/(3); BCBS CRE22.18. - IRB PSM correlation re-derivation reads guarantor row (P1.159, batch 20260509-1252):
engine/irb/guarantee.pynow lifts theguarantor_rw_irbmaterialisation out of_apply_parameter_substitutionStep 3 and into_apply_no_better_than_direct_floor's existing borrower-to-guarantor column-swap window, so the primary PSM RW and the Art. 160(4)rw_directfloor both compute their correlation withexposure_class/turnover_m/requires_fi_scalarsourced from the guarantor's row — per PRA PS1/26 Art. 236(1)(a)(i) "the correlation coefficient that would be assigned to a comparable direct exposure to the protection provider". Pre-fix the engine read those columns from the borrower row, so a corporate-borrower-with-FI-scalar (Art. 153(2)) routed through a regulated bank guarantor inflated correlation by the spurious 1.25× FI multiplier and over-statedguarantor_rw_irb. For a £1m / PD=0.0150 / M=2.5y corporate exposure with 60% bank guarantee,guarantor_rw_irbdrops 0.4007 → 0.2969, blended RW 0.7716 → 0.7093, RWA £771,594 → £709,324. Core math (_parametric_irb_risk_weight_expr,_correlation_expr_from_pd) untouched. The pre-existingtest_p1_157_psm_no_better_than_direct.pywas updated — itsEXPECTED_GUARANTOR_RW_IRBflipped 0.01346 → 0.17489 (now coincides withrw_directsince both inputs operate in the guarantor's class), and its strictpost_nbd > rw_irbweakened to>=(the max-of-two NBD floor remains asserted). Pinned bytests/acceptance/basel31/test_p1_159_art_236_psm_correlation_guarantor_class.py(15 tests). Ref: PRA PS1/26 Art. 236(1)(a)(i), Art. 153(2)/(4), Art. 160(4); BCBS CRE22.74. - UK CRR Art. 128 high-risk 150% RW gated off (P2.14, batch 20260509-1252): Art. 128 was omitted from UK onshored CRR by SI 2021/1078 reg. 6(3)(a) effective 1 Jan 2022 — there is no legal basis for the 150% HIGH_RISK risk weight under UK CRR until Basel 3.1 reintroduces Art. 128 from 1 Jan 2027.
engine/sa/namespace.py::_apply_crr_risk_weight_overridesno longer carries the.when(uc == "HIGH_RISK")branch, so HIGH_RISK rows fall through to the chain-tail residual 100% under UK CRR.engine/classifier.pyadds a CRR-only post-Batch-1_sa_classremap rewriting"high_risk"→"other"soexposure_class_for_sareflects the absence of the class under UK CRR and matches the namespace's RW behaviour; the Art. 112 priority carve-out at lines 607-618 is preserved (becomes inert under CRR after the remap, remains active under B3.1). Basel 3.1 is unchanged:_apply_b31_risk_weight_overrideskeeps the 150% branch (Art. 128 reintroduced under PS1/26). Capital impact: a £1m unrated VC/PE high-risk corporate exposure drops from RWA £1.5m to £1.0m under UK CRR (B3.1 unchanged at £1.5m). TheHIGH_RISK_RW = Decimal("1.50")table value indata/tables/crr_risk_weights.pyis retained as an unused-but-correct entry;B31_HIGH_RISK_RWis unchanged and still consumed by the B31 path. Pre-existing teststests/unit/test_high_risk_items.py::TestCRRHighRiskItemsandtests/unit/test_defaulted_secured_split.py::TestDefaultedEdgeCases::test_high_risk_unaffectedwere flipped to expect the corrected 100% under CRR; B3.1 sibling assertions and theHIGH_RISK_RWconstant tests are left untouched. Pinned bytests/acceptance/crr/test_p2_14_art_128_high_risk_uk_omitted.py(13 tests). Ref: SI 2021/1078 reg. 6(3)(a); UK CRR Art. 112(1) waterfall, Art. 122, Art. 133(2); PRA PS1/26 Art. 128 (B3.1 reintroduction). - CRR Art. 117(1) non-named MDB institution routing (P1.184, batch 20260509-1300): under CRR, non-named multilateral development bank exposures now risk-weight off the institution tables (Art. 120 Table 3 for own-CQS rated, Art. 121 Table 5 sovereign-derived for unrated) instead of the Basel-3.1 Table 2B that the engine previously consulted under both frameworks. Two-layer fix in
engine/sa/namespace.py: (1)_prepare_risk_weight_lookupcoalescescp_institution_cqsintocqsfor MDB rows so the rated CQS join target is populated (closes a separate latent bug where every MDB row arrived at the SA branch withcqs=null); (2)_apply_crr_risk_weight_overridesreplaces the prior single MDB-unrated 50% branch with two branches — rated MDB →build_institution_guarantor_rw_expr(Art. 120 Table 3) and unrated MDB → newINSTITUTION_RISK_WEIGHTS_SOVEREIGN_DERIVEDtable (Art. 121 Table 5) withINSTITUTION_RISK_WEIGHTS_CRR[CQS.UNRATED]100% fallback.MDB_RISK_WEIGHTS_TABLE_2BandMDB_UNRATED_RWremain indata/tables/crr_risk_weights.py(still used by the B31 path) with an updated docstring marking them Basel-3.1-only. Named MDBs (entity_type="mdb_named") keep the unconditional 0% under both frameworks. Capital impact: CRR rated CQS-2 MDB jumps 30%→50% (+20pp); CRR unrated MDB with sovereign CQS-1 drops 50%→20%; CRR unrated MDB with no sovereign data jumps 50%→100%. B31 unaffected. Pinned bytests/acceptance/crr/test_p1_184_art_117_mdb_institution_routing.py(14 tests). Ref: CRR Art. 117(1), Art. 120 Table 3, Art. 121 Table 5. - F-IRB purchased receivables / dilution-risk supervisory LGD (P1.151, batch 20260509-1300): CRR Art. 161(1)(e)/(f)/(g) and PRA PS1/26 Art. 161(1)(e)/(f)/(g) supervisory LGDs for purchased receivables and dilution risk now wired into the F-IRB engine. New optional nullable column
purchased_receivables_subtypeonFACILITY_SCHEMA/LOAN_SCHEMA/CONTINGENTS_SCHEMA(valuesnull/"senior"/"subordinated"/"dilution_risk", validated viaCOLUMN_VALUE_CONSTRAINTS). New keys onFIRB_SUPERVISORY_LGDandBASEL31_FIRB_SUPERVISORY_LGDindata/tables/firb_lgd.py:purchased_receivables_senior(CRR 0.45 / B3.1 0.40 — the new B3.1 senior-unsecured rate),purchased_receivables_subordinated(1.00 both frameworks),dilution_risk(CRR 0.75 / B3.1 1.00 — PS1/26 recasts the dilution-risk LGD upward).apply_firb_lgd(engine/irb/namespace.py) gains apl.when().then()dispatch that takes precedence over the seniority-based selector whenpurchased_receivables_subtypeis non-null — soseniority="senior", subtype="dilution_risk"correctly resolves to dilution LGD, not the senior 0.40.engine/hierarchy.py::_coerce_loans_to_unifiedextended to pass the new column through to the IRB stage. Capital impact under B3.1: a 1m senior PR exposure at PD=1% on a 1y residual moves from default-LGD 0.40 to subtype-LGD 0.40 (no change) but a subordinated PR at 500k jumps 0.75→1.00 (RWA 814k vs. 611k pre-fix); a 200k dilution-risk row jumps 0.40→1.00 (RWA 326k vs. 130k pre-fix). Pinned bytests/acceptance/basel31/test_p1_151_art_161_purchased_receivables_lgd.py(5 tests). Ref: CRR Art. 161(1)(e)/(f)/(g); PRA PS1/26 Art. 161(1)(e)/(f)/(g); BCBS CRE32. - UKB OV1 output-floor disclosure rows 4a / 5a–7b (P1.162, batch 20260509-1300): PRA PS1/26 Annex XX UKB OV1 mandates seven output-floor disclosure rows that the calculator was emitting as a 13-row template (jumping 4 → 5 and 24 → 26).
B31_OV1_ROWSinreporting/pillar3/templates.pynow carries 20 entries with refs4a(Total RWEAs pre-floor),5a/5b(CET1 ratio pre-floor / pre-floor transitional),6a/6b(Tier 1 ratio),7a/7b(Total capital ratio). New frozen dataclassPillar3CapitalRatioOverrides(contracts/config.py, exported viacontracts/__init__.py) carries six optionalDecimalfields letting firms supply the pre-floor and pre-floor-transitional ratios that cannot be derived from credit-risk pipeline data alone.Pillar3Generator.generate_from_lazyframenow accepts an optionalcapital_ratioskwarg and_generate_ov1now: (a) emits row 4a assum(rwa_pre_floor)over the full results LazyFrame withc = a × 0.08(None when the column is absent — same fallback posture as existing rows 26/27); (b) emits rows 5a–7b from the override (× 100 to cola, withbandcleft None to bypass the existing own-funds shim). When no override is supplied each ratio row stays mandatory in shape but value-blank. CRRCRR_OV1_ROWSis unchanged. Pinned bytests/unit/reporting/pillar3/test_p1_162_ukb_ov1_floor_rows.py(6 tests, including a CRR regression guard). Ref: PRA PS1/26 Annex XX UKB OV1, Art. 92(2A), Art. 92(5), Art. 438(d). - CRR Art. 197 / 207(2) covered-bond collateral eligibility gating (P1.96, batch 20260509-1100):
engine/crm/haircuts.py::_apply_collateral_haircutsnow treatscollateral_type=="covered_bond"rows as ineligible financial collateral on non-SFT exposures (Art. 197(1) closed list governs; covered bonds are NOT in (a)–(h)). The Art. 207(2) carve-out for repo / SFT / capital-markets-driven / secured-lending transactions keeps the existingcovered_bond → corp_bondArt. 224 supervisory-haircut routing — only the gating expression changed. The plan-bullet wording was stale ("falls through to other_physical 40%"); pre-fix the engine unconditionally applied corp-bond CQS-banded haircuts to all covered-bond collateral regardless of SFT status, understating capital on non-repo term loans secured by covered bonds. Ineligibility flows through the existing_bond_ineligiblechain (value_after_haircut=0,is_eligible_financial_collateral=False); no schema or new data tables. Pinned bytests/acceptance/crr/test_p1_96_art_197_covered_bond_eligibility.py(9 tests, paired Run A non-SFT term-loan ineligibility / Run B repo Art. 207(2) carve-out). The pre-existingtest_p1_96_covered_bond_haircut_routing.pywas reframed to setis_sft=Trueon its repo fixture so the 416,970.56 expectation now genuinely tests the carve-out instead of relying on the buggy unconditional routing. Ref: CRR / PRA PS1/26 Art. 197(1), Art. 207(2), Art. 224 Table 1. - CRR Art. 162(3)(b) F-IRB short-term trade-finance M derivation (P1.118, batch 20260509-1100):
engine/irb/namespace.py::IRBLazyFrame.prepare_columnsnow deriveshas_one_day_maturity_floor=Truefromis_short_term_trade_lc=True AND maturity_date is not null AND residual_years <= 1.0(gated onconfig.is_crr; B31 wording differs and is deferred). The flag was previously caller-supplied only — qualifying short-term self-liquidating trade-finance F-IRB exposures defaulted to M=2.5y or floored at 1y unless the caller pre-set the flag. With derivation in place, an unrated MLR documentary credit on a 9-month residual now picks up M=1/365 (the existingirb/formulas.py:705-707branch setsmaturity = 1/365literally when the flag is True; this is the documented engine semantic, not a "floor at 1/365" interpretation). For a £2m EAD / PD=0.5% F-IRB corporate, RWA drops 1,106,216 → 860,310 (≈22% capital relief). Reuses the existingis_short_term_trade_lcschema column added by P1.128; no schema change. Pinned bytests/acceptance/crr/test_p1_118_art_162_3_short_term_trade_finance_m_derivation.py(5 tests). FX-settlement / securities-settlement Art. 162(3) 2nd-sub (a)/(c)/(d) carve-outs and the B31 wording variant deferred. Ref: CRR Art. 162(3) second sub-paragraph point (b), Art. 4(1)(80). - B31 SA Art. 127(1) defaulted provision-ratio gross denominator (P1.120, batch 20260509-1100):
engine/sa/namespace.py::_apply_defaulted_risk_weightB31 branch now usesgross_outstanding = ead_gross + provision_deducted(with a fallback toead + provision_deductedfor unit-test entry points whereead_grossis absent) as the Art. 127(1) provision-coverage 100%/150% threshold denominator, instead ofead_final(post-CRM, post-provision). PRA PS1/26 Art. 127(1) wording is "the outstanding amount of the item or facility" (gross outstanding before specific credit risk adjustments and before CRM); the CRR branch's different wording — "unsecured part of the exposure value if those credit risk adjustments and deductions were not applied" — remains correctly modelled by the existingead_final + provision_deductedexpression and is left untouched. The plan-bullet direction was inverted: the bug was UNDER-stating RW (assigning 100% when 150% was correct on partially collateralised B31 defaults), so the fix INCREASES capital. For a B31 corporate defaulted exposure with outstanding=100k / provisions=8k / FCCM cash 60k, the pre-fix ratio was 8k/32k=25% (RW 100%, RWA 32,000); post-fix ratio is 8k/100k=8% (RW 150%, RWA 48,000). Pinned bytests/acceptance/basel31/test_p1_120_art_127_1_provision_ratio_denominator.py(B31-K13, 7 tests). Existingtest_scenario_b31_k_defaulted.py::TestB31K8_ProvisionDenominatorDifferenceupdated: B31 RW 100%→150%, RWA 80,000→120,000; CRR contrast unchanged. Ref: PRA PS1/26 Art. 127(1); BCBS CRE20.87–90. - PRA PS1/26 Art. 122(3) Table 6A short-term corporate ECAI risk weights (P1.103, batch 20260509-1025): corporate exposures with an issue-specific short-term ECAI rating now route through the new Table 6A (CQS1=20% / CQS2=50% / CQS3=100% / Others=150%) instead of the long-term Table 6 fallback (where CQS3 = 75% under Basel 3.1). On a £1m CQS-3 short-term-rated corporate the RW jumps 75% → 100% (RWA 750k → 1m, K 60k → 80k) — closes a documented capital understatement. New constant
B31_CORPORATE_SHORT_TERM_ECAI_RISK_WEIGHTSindata/tables/b31_risk_weights.py; new helper_b31_append_corporate_maturity_branchesinengine/sa/namespace.pyinvoked alongside the existing institution Table 4A branch (P1.105). Reuses thehas_short_term_ecaiFACILITY_SCHEMAflag and OR-aggregation infrastructure added by P1.105 — no new schema or hierarchy fields. Corporate short-term gate isoriginal_maturity_years ≤ 0.25only (Art. 121(4)/(5) trade-LC ≤ 6m extension is institution-only). SME corporates are excluded so the dedicated 85% SME path remains authoritative even when an SME has a short-term ECAI rating. CRR corporate branch unchanged (CRR Art. 122 has no Table 6A analogue). Pinned bytests/acceptance/basel31/test_p1_103_art_122_3_short_term_corporate_ecai.py(5 tests). Ref: PRA PS1/26 Art. 122(3) Table 6A; BCBS CRE20.42–49. - PRA PS1/26 Art. 121(4) SCRA short-term trade-finance extension wired into B31 institution chain (P1.128, batch 20260509-1025): an unrated Grade A institution exposure that is a documentary credit financing the movement of goods with original maturity ≤ 6 months now picks up the Table 5A short-term SCRA RW (Grade A = 20%) via the same Art. 121(4) carve-out the ECRA branch (Art. 120(2A)) already honoured. Pre-fix the SCRA short-term gate in
_b31_append_institution_maturity_brancheswas hard-coded tooriginal_mty ≤ 0.25, missing theis_short_term_trade_lc & original_mty ≤ 0.5OR-clause that the ECRA branch already had via the sharedin_st_windowexpression — a 5-month documentary credit fell through to the long-term SCRA Grade A 40%, doubling RWA (£1m drawn → 400k vs 200k). Fix replaces the SCRA gate withis_institution & is_unrated & in_st_window, reusing the helper expression already in place. Required follow-on edit inengine/hierarchy.py::_propagate_facility_qrre_columnsto add a counterparty-level OR-broadcast ofis_short_term_trade_lc(mirroring thehas_short_term_ecaiprecedent added by P1.105) so the flag propagates from facilities to drawn-loan exposure rows whenfacility_mappingsis empty. CRR helper_crr_append_institution_maturity_branchesunchanged (CRR has no Art. 121(4) trade-finance extension to its short-term unrated-institution treatment). Pinned bytests/acceptance/basel31/test_p1_128_art_121_4_scra_short_term_trade_finance.py(5 tests). Ref: PRA PS1/26 Art. 121(4); BCBS CRE20.16–21. - CRR Art. 224(2)(a) FX H_fx 20-day secured-lending default pinned by acceptance test (P1.186, batch 20260509-1025): the engine fix shipped silently in commit
44006daon 2026-05-03 —engine/crm/haircuts.pynow derives the liquidation period fromexposure_is_sft(5 / 10 / 20 days per Art. 224(2)(a)–(c)) instead of defaulting everything to 10 days — but the acceptance test pinning the regulatory behaviour was never written, leaving the plan item at[~]. This batch adds the missing fixtures and a 10-test acceptance scenario covering: 20-day default foris_sft=Falsesecured lending → ead_final ≈ 484,852.81 (H_fx,20 = 8% × √2 ≈ 11.314%, H_c,20 ≈ 2.828%); 5-day default foris_sft=True→ ead_final ≈ 442,426.41; an explicit regression guard against the pre-fix 10-day result of 460,000.00 (a strict inequality that can only be satisfied when H_fx is scaled to the 20-day period); and a directional sanity SL > SFT (20-day haircuts > 5-day → larger E* on secured-lending loan than on the SFT). Closes the residualliquidation_period as configsub-item of P6.15. Pinned bytests/acceptance/crr/test_p1_186_art_224_2_fx_haircut_secured_lending_default.py(10 tests). Ref: CRR Art. 224(2)(a), Art. 226(2), Art. 233. - PRA PS1/26 Art. 120(2B) Table 4A short-term institution ECAI risk weights (P1.105, batch 20260508-0254): institutions with an issue-specific short-term ECAI assessment now route through the new Table 4A (CQS1=20% / CQS2=50% / CQS3=100% / CQS4–5=150%) instead of the more permissive Table 4 fallback, closing a CQS-2/3 capital understatement (CQS3 jumps 20% → 100% on a £1m short-term-rated institution exposure). New optional Boolean
has_short_term_ecaionFACILITY_SCHEMA(default False); new constantB31_ECRA_SHORT_TERM_ECAI_RISK_WEIGHTSindata/tables/b31_risk_weights.py;_b31_append_institution_maturity_branchesinengine/sa/namespace.pygates Table 4A ahead of the Table 4 fallback whenis_institution & is_rated & has_short_term_ecai & in_st_window. Because the ECAI rating is a counterparty/rating-level property,engine/hierarchy.py::_propagate_facility_qrre_columnswas extended to OR-aggregatehas_short_term_ecaiacross a counterparty's facilities and broadcast to every exposure — loans withoutparent_facility_referencetherefore inherit the flag from a sibling facility row. CRR institution path is unchanged (Art. 120 has no Table 4A analogue). Pinned bytests/acceptance/basel31/test_p1_105_art_120_2b_short_term_institution_ecai.py(5 tests). Ref: PRA PS1/26 Art. 120(2B), Art. 120(3); BCBS CRE20.20. - CRR Art. 155(2)(c) IRB Simple GOVERNMENT_SUPPORTED equity now 370% (P1.164, batch 20260508-0254): Art. 155(2) is a closed three-bucket enumeration (290% exchange-traded / 190% PE-diversified / 370% all-other) with no government-supported carve-out. The previous 190% mapping for
GOVERNMENT_SUPPORTEDhad no regulatory basis and understated capital (190% < 370%).data/tables/crr_equity_rw.pyIRB_SIMPLE_EQUITY_RISK_WEIGHTS[GOVERNMENT_SUPPORTED]updated 1.90 → 3.70 with an Art. 155(2)(c) citation comment; the redundantis_government_supportedandequity_type=="government_supported"branches inengine/equity/calculator.py::_apply_equity_weights_irb_simplewere removed (the.otherwise(_IRB_RW[OTHER])fall-through now produces the correct 370%). For a £300k AIRB government-supported equity exposure, RWA increases 570,000 → 1,110,000. Basel 3.1 unaffected (Art. 155 is left blank under PS1/26; B31 routes through Art. 133(3) 250%). Pinned bytests/acceptance/crr/test_p1_164_art_155_2c_government_supported_irb_simple.py(5 tests) plus updated CRR-J14 acceptance andTestIRBSimpleEquityRiskWeights::test_government_supported_370_percentunit-table assertions. Ref: CRR Art. 155(2)(c). - CRR Art. 239(1) FCSM maturity-mismatch eligibility gate (P1.104, batch 20260508-0210):
engine/crm/simple_method.py::compute_fcsm_columns()now treats financial collateral whoseresidual_maturity_yearsis strictly less than the secured exposure's residual maturity as ineligible — the FCSM benefit is fully suppressed (binary gate, no Art. 239(2)(t-0.25)/(T-0.25)partial adjustment, which is FCCM/IRB only). Direct-/facility-/counterparty-level exposure residual maturity is coalesced before the comparison so the gate is conservative for pool-level pledges. Pre-fix the engine recognised collateral with shorter maturity than the exposure, understating capital. Pinned bytests/acceptance/crr/test_p1_104_art_239_1_fcsm_maturity_eligibility.py(8 tests; the discriminating MISMATCH row assertsfcsm_collateral_value=0,risk_weight=1.00,rwa=1,000,000). Plan-bullet citation was Art. 222(7); architect verified the actual FCSM maturity-mismatch exclusion lives at Art. 239(1) — Art. 222(7) is a definition-extension paragraph for sovereign-debt-securities scope. Ref: CRR Art. 239(1). - PSM PD floor uses guarantor exposure-class context (P1.157, batch 20260508-0210):
engine/irb/formulas.py::_pd_floor_expressionextended with optionalexposure_class_col/transactor_colkwargs (additive, backward-compatible — defaults preserve all existing call-site behaviour). Three PSM call sites inengine/irb/guarantee.py(_apply_parameter_substitution,_adjust_expected_loss,_apply_double_default) now passguarantor_exposure_classso the substituted (guarantor's) PD is floored at the floor that would apply to a direct exposure to the guarantor — Art. 160(4) "no better than direct" — and not at the borrower's class-floor. In the pinned scenario a corporate guarantor's PD is floored at corporate 0.05% (Art. 163(1)(a)) instead of the borrower's QRRE-revolver 0.10% (Art. 163(1)(c)), correctly loweringguarantor_rw_irbfrom 0.02408 to 0.01346. The NBD floor function_apply_no_better_than_direct_floorwas already in place; the prior fixture/test were degenerate (guarantor_rw_irb == RW_direct⇒ floor non-binding) and have been replaced with a binding configuration whereRW_direct(0.17489) ≫guarantor_rw_irb(0.01346) and the NBD floor lifts blended RWA from 136,636 to 233,494 (+71%). Pinned bytests/acceptance/basel31/test_p1_157_psm_no_better_than_direct.py(13 tests). Ref: CRR / PRA PS1/26 Art. 160(4), Art. 163(1)(a), Art. 161(1)(aa), Art. 236(1)(a)(i). - CRR Art. 126(2)(d) commercial-RE proportion split (P1.181, batch 20260508-0210):
engine/sa/namespace.py::_crr_append_real_estate_branchesresidual leg now blends the 50% Art. 126(2) secured RW with the counterparty's Art. 122 corporate-CQS RW per Art. 124(1) "the part of the exposure that exceeds the mortgage value", instead of stamping a flat 100% residual. The split mechanism mirrors Art. 125 RRE:secured_share = min(1, 0.50 / LTV),residual_share = 1 − secured_share, blended RW =0.50 × secured_share + counterparty_RW × residual_share. The residual lookup uses_cqs_table_lookup_expragainstCORPORATE_RISK_WEIGHTS(already imported), so the unrated default (1.00) is sourced from the data layer rather than inlined.engine/hierarchy.py::_coerce_loans_to_unifieddefensively passes through the CLASSIFIER_OUTPUT_SCHEMA RE columns (ltv,property_type,has_income_cover,is_qualifying_re,prior_charge_ltv,is_defaulted,qualifies_as_retail);_add_collateral_ltvonly stamps null defaults for columns not already present so the loan-frame values survive unification. Pre-fix, CRR CRE LTV > 50% was a binary 100% (overstatement); for an LTV-0.80 unrated-corporate CRE the new RW is 0.6875 (RWA 687,500) instead of 1.00 (RWA 1,000,000). The discriminating fixture row uses an LTV-0.80 corporate-CQS1 exposure, where a naïve "residual = constant 100%" fix would still emit RW=1.00 but the correct counterparty-RW lookup emits 0.3875 (RW=0.50×0.625 + 0.20×0.375). Basel 3.1 Art. 124H/124I path unchanged. Pinned bytests/acceptance/crr/test_p1_181_art_126_cre_proportion_split.py(7 tests). Ref: UK CRR Art. 126(2)(d), Art. 124(1), Art. 122 Table 6. - CRR Art. 137(1)–(2) Table 9 ECA / MEIP score-to-RW direct mapping (P1.100, batch 20260508-0020): unrated sovereigns with an Art. 137(1) Export Credit Agency / OECD MEIP score now route through Table 9 (0/0/20/50/100/100/100/150 % for scores 0–7) instead of falling through to the Art. 114(2) Table 1 unrated 100% bucket — closes a 5× capital overstatement for unrated sovereigns with low MEIP scores. New
eca_score: ColumnSpec(pl.Int8, required=False)on COUNTERPARTY_SCHEMA; newECA_MEIP_RISK_WEIGHTStable indata/tables/crr_risk_weights.py; new_eca_meip_rw_expr()inengine/sa/namespace.pyinjected after the Art. 114(3)/(4) domestic-currency override and before the unrated fallback. Basel 3.1 path unchanged. Pinned bytests/acceptance/crr/test_p1_100_art_137_eca_meip_sovereign.py(5 tests, scenario CRR-A14-ECA). Ref: CRR Art. 137(1)–(2), Art. 114(2) Table 1. - CRR Art. 226(1) non-daily revaluation haircut scaling (P1.101, batch 20260508-0020): collateral revalued less frequently than daily now sees its supervisory haircut scaled by
sqrt((N_R + T_m − 1) / T_m)where N_R is the revaluation frequency in business days and T_m is the liquidation period (5 / 10 / 20 per Art. 224(2)) — closes a haircut understatement for SFTs and other less-than-daily-revalued collateral. Newrevaluation_frequency_days: ColumnSpec(pl.Int32, required=False)on COLLATERAL_SCHEMA (null/1 ⇒ daily, no scaling; >1 fires Art. 226(1));engine/crm/haircuts.pymultiplies post-Art-226(2)collateral_haircutANDfx_haircutbyreval_factorafter the Art. 226(2) liquidation-period scaling. Art. 227 zero-haircut short-circuit preserved. The fix applies identically under CRR and PRA PS1/26 (PS1/26 carries Art. 226(1) forward unchanged). Pinned bytests/acceptance/crr/test_p1_101_art_226_1_non_daily_revaluation.py(6 tests, scenario CRR-D-REVAL). Ref: CRR Art. 226(1), Art. 226(2), Art. 224(2)(a)–(c). - UK CRR slotting Art. 153(5) Table 1 —
is_hvcreignored under CRR (P1.177, batch 20260508-0020): UK-onshored CRR Art. 153(5) contains only Table 1; the EU CRR Table 2 HVCRE table was not retained on onshoring (SI 2021/1078). The previous engine routedis_hvcre=TrueCRR exposures throughSLOTTING_RISK_WEIGHTS_HVCREweights (e.g. 95% Strong ≥2.5y) — a capital overstatement for UK firms.engine/slotting/namespace.py::SlottingExpr.lookup_rwandlookup_el_ratenow ignoreis_hvcreunder CRR (config.is_crr=True); all SL exposures use Table 1 weights and Table B EL rates regardless of the HVCRE flag. Basel 3.1 HVCRE handling under PS1/26 Art. 153(5) Table A is unchanged. Theis_hvcreflag is preserved on the audit trail. Existing CRR-E4/E7/E8 expected outputs patched to post-fix values (RW 0.95→0.70 / 0.70→0.50 / 0.95→0.70); 20 pre-existing unit tests that codified the buggy EU Table 2 behaviour were deleted or updated to assert HVCRE-equals-non-HVCRE under CRR. Pinned bytests/acceptance/crr/test_p1_177_art_153_5_uk_crr_no_hvcre.py(5 tests, scenario CRR-E9). Ref: UK CRR Art. 153(5) Table 1, Art. 147(8) (no HVCRE sub-type), Art. 158(6) Table B; SI 2021/1078.
[0.2.9] - 2026-05-04¶
Changed¶
- Entity-class mapping dicts hoisted to
data/tables/(commit908e88fd): regulatory entity-class string lists moved out ofengine/per the data/engine separation rule enforced byscripts/arch_check.pychecks 5 & 6. Behaviour-preserving. ExposureClassifierdecomposed into orchestrator +_assign_approachhelper (commit975e56f2):engine/classifier.pysplit for readability; the orchestrator now delegates the approach-decision ladder to a dedicated helper. Behaviour-preserving.- Loader
_build_bundlededuplication +LoaderProtocolconformance pinned (commitfc0ca133):engine/loader.pycollapses the per-table duplication that had drifted across the optional inputs and adds a contract test pinningLoaderto its protocol surface.
[0.2.8] - 2026-05-04¶
Changed¶
- Mixed RRE+CRE collateral now split across both classes per regime (
engine/re_splitter.py,engine/classifier.py): a single SA exposure secured by both residential and commercial property collateral now produces three child rows (secured_rre+secured_cre+residual) sharing onesplit_parent_id, instead of the prior dominance-rule single-class split that silently dropped the non-dominant collateral value. The new behaviour follows PRA PS1/26 Art. 124(4) pro-rata by collateral value under Basel 3.1 (closes documented gap D3.59 indocs/specifications/basel31/sa-risk-weights.md:1314-1321) and CRR Art. 124(1) "any part of an exposure" RRE-first sequential allocation under CRR (RRE consumes EAD up to its 80% LTV cap, then CRE picks up the remainder up to its 50% LTV cap when rental coverage is met). Per-component classifier columns added (re_split_residential_value,re_split_commercial_value,re_split_residential_eligible,re_split_commercial_eligible); per-component audit columns added tore_split_audit(rre_secured_ead,cre_secured_ead,is_mixed); new informational warning RE003 counts mixed-collateral splits per batch with the regime-specific allocation rule named in the message. Single-component splits (pure RRE or pure CRE) keep the legacysecuredrole and_secreference suffix for backward compatibility — only mixed splits usesecured_rre/secured_creand_rre/_cresuffixes. Singleprior_charge_ltvcolumn is applied to both component caps as a v1 conservatism (documented limitation). Surfaced and fixed a pre-existing latent SA dispatch bug inengine/sa/namespace.py:COMMERCIAL_MORTGAGEexposure-class rows were mis-routed through the residential RW branch because both classes contain theMORTGAGEsubstring; commercial branch now dispatches first under both CRR and Basel 3.1 paths and the Art. 127(3) defaulted RESI flat-100% rule excludes commercial RE. Ref: PRA PS1/26 Art. 124(4), Art. 124F, Art. 124H(1)-(3); CRR Art. 124(1), Art. 125, Art. 126.
[0.2.7] - 2026-05-04¶
Changed¶
- P1.97 — B31 slotting non-HVCRE column-A/C subgrade (commit
f22e3fcc):engine/slotting/namespace.pyroutes residual maturity ≥ 2.5y rows through column-A subgrade and < 2.5y through column-C per PRA PS1/26 Art. 153(5)(d). - P1.98 — subordinated corporate A-IRB LGD 25% floor (commit
836100ee):engine/irb/namespace.pyapplies the new Art. 161(5) sub-LGD 25% floor on subordinated corporate A-IRB exposures. - P1.99 — CRR Art. 120(2) Table 4 short-term institution RW (commit
3fb634c0):engine/sa/namespace.pyroutes original-maturity ≤ 3m rated institution exposures through Table 4 (20 / 50 / 100 / 150 by CQS) instead of long-term Table 3. - P1.106 — FCSM B31 ECRA institution CQS 2 collateral RW 30% (commit
8e919d40):engine/crm/simple_method.py+data/tables/b31_risk_weights.pyroute CQS 2 institution-issued financial collateral through 30% under FCSM per Art. 120 Table 3. - P1.107 — FCSM B31 corporate CQS 3 collateral RW 75% (commit
22999994): same FCSM path, corporate CQS 3 → 75% per Art. 122(2) Table 6. - P1.112 — non-UK PSE / RGLA sovereign-derived RW (commit
fc2859d0):engine/sa/namespace.pyroutes non-UK PSE / RGLA exposures through the Art. 115 / 116 sovereign-derived table when the sovereign CQS is supplied. Test pin added in68bdeafd. - P1.114 — null-safe
model_permissionsfilters (commit18e082e8):engine/classifier.pyfill_null(False)on the AIRB / FIRB permission masks so Polars 3VL nulls cannot silently route an exposure to the default approach. - P1.117 — B31 HVCRE short-maturity slotting subgrades (commit
9501c90d):engine/slotting/namespace.pyapplies the Art. 153(5)(d) subgrade-A weights on residual ≥ 2.5y HVCRE rows. - P1.121 — CRR Art. 121(3) unrated institution short-term 20% RW (commit
cf3a54e0):engine/sa/namespace.pycarves out the original ≤ 3m unrated-institution branch from the default 100% to 20%. - P1.124 — CRR Art. 237(2)(a) guarantee maturity ineligibility (commit
b2de0225):engine/crm/guarantees.pydrops guarantees with residual maturity < 3 months or original maturity < 1 year from eligibility per Art. 237(2)(a). - P1.125 — classifier FSE-column-missing CLS007 under B31 (commit
5cdba52b):engine/classifier.pyemits a CLS007 warning when theis_fsecolumn is absent on B31 input frames per Art. 147A(1)(e). - P1.126 — large-corp F-IRB scope + null-revenue conservative + CLS008 (commits
b225ccd2+272fd3d0):engine/classifier.pytreats counterparties with nulltotal_assets_euras conservative-large per Art. 147A(1)(d), emits CLS008, and gates the large-corp F-IRB restriction to corporate counterparties only. - P1.144 — IRB
calculate_expected_losspinned toead_final(commit32d91f75):engine/irb/formulas.pycomputes EL against the post-CRMead_finalrather thanead_pre_crm, per Art. 158 / 159. - P1.156 — PSM guarantor LGD seniority / FSE-aware (commit
79616bfe):engine/irb/guarantee.pyselects the guarantor LGD using the guarantor's seniority + FSE flag per Art. 161 / Art. 236(1)(a). Follow-up P1.160 in 0.2.10 wires the column from the input schema. - P1.158 — null collateral maturity uses longest band (commit
fe167f66):engine/crm/haircuts.pydefaults a nullresidual_maturity_yearson collateral to the longest haircut band (conservative). - P1.169 — B31 ECRA short-term institution CQS 4-5 = 50% (commit
3f0c2461):engine/sa/namespace.pycorrects the short-term ECRA branch from 100% to 50% per Art. 120(2A) Table 4. - P1.182 — B31 PE/VC unlisted+<5y higher-risk equity (commit
a342b27b):engine/equity/calculator.pyroutes unlisted PE/VC exposures held < 5 years through the higher-risk equity bucket per PS1/26 Glossary p.5. - P1.187 — FCSM CRR CQS 5 = 150% scalar fix (commit
88f177fd):data/tables/crr_risk_weights.pycorrects the FCSM CRR CQS 5 lookup from the buggy 100% to the regulatorily-correct 150%. - Style pass on engine/sa, crm, classifier, slotting + tests (commit
d335a695): pure formatting / import-sort cleanup across the May 3 P-coded items; no behaviour change.
[0.2.6] - 2026-05-03¶
Added¶
crm_collateral_method/airb_collateral_methodconfig knobs documented (docs/api/configuration.md): theCRMCollateralMethod(COMPREHENSIVE/SIMPLE) andAIRBCollateralMethod(LGD_MODELLING/FOUNDATION) enums onCalculationConfigwere exposed in source but absent from the API docs — practitioners had to readdomain/enums.pyto discover the toggles. Both knobs are now on the configuration page with field-summary tables, member-by-member regulatory citations (CRR Art. 222 FCSM / Art. 223–224 FCCM; PRA PS1/26 Art. 191A firm-wide election; PS1/26 Art. 169A/169B and CRR Art. 229–231 for the A-IRB collateral switch), factory defaults, framework-applicability admonitions, and workeddataclasses.replacesnippets. Closes DOCS_IMPLEMENTATION_PLAN.md D3.52.- IRB guarantee parameter-substitution path (PSM, CRE22.70–85) documented (
docs/specifications/basel31/credit-risk-mitigation.md): the four-step PSM path implemented atengine/irb/guarantee.pywas an opaque code surface — the B31 CRM spec covered guarantee haircuts and eligibility but never showed how PD substitution, F-IRB LGD substitution by guarantor seniority, correlation re-derivation, and Art. 236A maturity adjustment compose into the final risk weight. The restructured## IRB Parameter Substitutionsection now walks each step against PRA PS1/26 Art. 161 / Art. 162 / Art. 202 / Art. 235 / Art. 236(1)(a)(i) (with BCBS CRE22.72–80 in parentheses), adds the composing IRB risk-weight and EL formulas, the CRR-only Art. 153(3) double-default overlay, and an audit-trail table mapping every output column emitted by_add_guarantee_status_columns. Three Art. 236 code defects surfaced during the doc walk (Step-3 borrower-vs-guarantor correlation, Step-2 senior-unsecured LGD scalar, missing option-(i) borrower-unprotected LGD source) routed to IMPLEMENTATION_PLAN.md as P1.159 / P1.160 / P2.43. Closes DOCS_IMPLEMENTATION_PLAN.md D3.56. life_ins_collateral_value/life_ins_secured_rwoutput columns documented (docs/data-model/output-schemas.md): new "CRM — life insurance collateral (Art. 232)" subsection describes the two exposure-frame columns produced byengine/crm/life_insurance.py::compute_life_insurance_columnsand consumed bylf.sa.apply_life_insurance_rw_mapping()during SA risk-weight blending. Includes the Art. 232(3) insurer-RW mapping table (PS1/26 7-tier 20% / 30% / 50% / 65% / 100% / 135% / 150% vs CRR 4-tier 20% / 50% / 100% / 150%), defaults when no life-insurance collateral is present, IRB-side LGD_S = 40% cross-reference, and a single-source-of-truth pointer to the Basel 3.1 CRM spec. Ref: PRA PS1/26 Art. 232, Art. 200(b), Art. 212(2); CRR Art. 232. Closes DOCS_IMPLEMENTATION_PLAN.md D3.53.- IRB Risk Parameter Estimation Standards (PS1/26 Art. 179–184) documented (
docs/specifications/basel31/irb-approach.md): previously the spec stub-cited Art. 179–184 with no content, leaving implementers without a documented contract for what the calculator's PD / LGD / EAD inputs must represent. New "Risk Parameter Estimation Standards" section now covers Art. 179 (general estimation, MoC, pooled data), Art. 180(1)(a)–(h) corporate / institution PD plus Art. 180(2)(a)–(f) retail PD with the 5-year minimum data history, Art. 181 LGD (downturn LGD, LGD-in-default, 5y → 7y data ramp), Art. 181A–C downturn nature/severity/duration (incl. ≥ 20-year time-span at Art. 181C(1)), Art. 182 EAD/CCF, Art. 183 LGD-AM under A-IRB, and Art. 184 purchased receivables — all with verbatim PS1/26 Appendix 1 page citations (pp. 131–141). Closes DOCS_IMPLEMENTATION_PLAN.md D3.49. enable_double_defaultconfig knob documented (docs/api/configuration.md): the CRR Art. 153(3) double-default RW formula is now a discoverable knob from the API docs alone — field name, type, default (False), formula reference, Art. 202 / 217 eligibility, the PS1/26 B31-removal note, and a workedCalculationConfig.crr(enable_double_default=True)snippet are all in place. Practitioners no longer have to read source to find the toggle. Closes DOCS_IMPLEMENTATION_PLAN.md D3.50.- Five missing error codes documented in the contracts API reference (
docs/api/contracts.md):CLS004(ERROR_QRRE_COLUMNS_MISSING),CLS005(ERROR_RETAIL_POOL_MGMT_MISSING),IRB006(ERROR_MISSING_EXPECTED_LOSS),SA005(ERROR_EQUITY_IN_MAIN_TABLE), andSF001(ERROR_SME_MISSING_COUNTERPARTY_REF) were defined insrc/rwa_calc/contracts/errors.pybut absent from the published Error Code Constants table.SF001introduces a new "Supporting Factors" prefix. Closes DOCS_IMPLEMENTATION_PLAN.md D3.55. use_investment_grade_assessmentconfig knob documented (docs/api/configuration.md): the PRA PS1/26 Art. 122(6)/(8) IG=65% / non-IG=135% election for unrated non-SME corporates is now a discoverable knob from the API docs — field name, type, default (False), the Basel-3.1-only scope (CRR factory does not expose it), the Art. 122(7) sound-processes obligation and Art. 122(8)(b) PRA notification requirement on adoption and cessation, the Art. 92(2A) S-TREA interaction, and a workedCalculationConfig.basel_3_1(use_investment_grade_assessment=True)snippet are all in place. Thebasel_3_1()factory signature in the same page is updated to surface the argument. Closes DOCS_IMPLEMENTATION_PLAN.md D3.51 (plan-item article citation corrected — the field is Art. 122(6)/(8), not Art. 153(3) as the plan text said).
Changed¶
supporting_factor_appliedcolumn documented in canonical name (docs/data-model/output-schemas.md): the SA supporting-factor stage atengine/sa/supporting_factors.pyand the aggregator atengine/aggregator/_supporting_factors.pyemit a genericsupporting_factor_appliedBoolean covering both Art. 501 (SME, blended at the EUR 2.5m / GBP 2.2m threshold) and Art. 501a (infrastructure, flat 0.75) supporting factors, but the schema docs still showed the legacysme_supporting_factor_appliedname from before the infrastructure factor existed. The "Supporting factors (CRR only)" sub-table now lists all four pipeline-emitted columns (supporting_factor,supporting_factor_applied,rwa_pre_factor,rwa_post_factor) with broadened prose covering both factors, and a rename callout explains thatsme_supporting_factor_appliedsurvives inCRR_OUTPUT_SCHEMA_ADDITIONSonly as a legacy COREP alias. Closes DOCS_IMPLEMENTATION_PLAN.md D3.54.
Fixed¶
- IRB maturity-adjustment formula now honours the CRR Art. 162(3) carve-out from the 1-year M floor (
engine/irb/formulas.py::_maturity_adjustment_expr_from_pd): under CRR Art. 162(3) (mirrored by PRA PS1/26 / BCBS CRE32.50), four transaction types are exempt from the 1-year M floor in the IRB maturity-adjustment formula and may use M down to 1 day — daily-margined SFTs (repo / securities lending), daily-margined derivatives, margin-lending transactions, and short-term self-liquidating trade transactions (e.g. import/export LCs). The repo already had the upstream plumbing to setmaturity = 1/365for these rows (priority chain inengine/irb/namespace.py::IRBLazyFrame.prepare_columnslines 243-318, gated on thehas_one_day_maturity_floorboolean column) but the formula itself re-applied a hardcodedclip(1.0, 5.0)tomaturityinside_maturity_adjustment_expr_from_pd, silently undoing the carve-out: a contingent withis_short_term_trade_lc=Trueandeffective_maturity=0.1showedmaturity = 0.1in the output butmaturity_adjustment = 1.0andrwaidentical to a 1-year exposure — zero capital relief despite the regulatory carve-out being in scope. The fix gates the 1-year floor on thehas_one_day_maturity_floorcolumn: when True, the floor is suppressed and the actual maturity flows through; when False/null/missing, the existing[1.0, 5.0]clip applies (so ordinary corporate IRB exposures see no behaviour change). The 5-year cap from Art. 162(2) remains unconditional (no carve-out). Worked numbers at PD=0.5%, M=0.1, LGD=45%, EAD=£1m: old behaviour MA=1.0 → RWA=£521,650; new behaviour MA=0.799 → RWA=£416,969 — a 20% relief. Magnitude of relief scales with PD via the b coefficient (≈15-25% across the realistic PD range for non-defaulted exposures). The wrong behaviour was previously pinned bytests/unit/irb/test_irb_formulas.py::test_ma_below_floor_clippedandtests/unit/crr/test_crr_irb.py::test_maturity_floorwhich asserted that M=0.5 produced the same MA as M=1.0 — both replaced with two-case tests covering with-flag and without-flag behaviour. Scalarcalculate_maturity_adjustmentgains ahas_one_day_maturity_floor: bool = Falseparameter (kwarg-only by signature ordering); positional callers (which all use(pd, maturity)) are unaffected. Column visibility threaded through every vectorised call site —apply_irb_formulas,_parametric_irb_risk_weight_expr,IRBLazyFrame.prepare_columns,IRBLazyFrame.calculate_maturity_adjustment,IRBLazyFrame.apply_all_formulas,engine/irb/guarantee.py::apply_guarantee_substitution— each default-addshas_one_day_maturity_floor=Falsewhen missing, so existing fixtures and inputs that do not set the flag continue to work. New regression coverage intests/contracts/test_one_day_maturity_floor_propagation.pypins (a) schema declaration on FACILITY_SCHEMA / LOAN_SCHEMA / CONTINGENTS_SCHEMA, (b)prepare_columnsflag preservation and default-add, (c) end-to-end MA<1.0 with carve-out under both CRR and B31, (d) MA=1.0 without carve-out, and (e) bounded RWA relief at 10%-50%. Follow-up tracked: full agent-drivenB31-IRB-MAT-CARVEOUT/CRR-IRB-MAT-CARVEOUTacceptance scenarios with golden outputs covering all four trigger types (currently the contract suite covers the formula behaviour and end-to-end pipeline throughapply_all_formulas, but not the loader → hierarchy → classifier → CRM → IRB → aggregator full pipeline with pre-baked fixtures). Auto-derivation ofhas_one_day_maturity_floorfromis_short_term_trade_lcdeliberately deferred — firms must currently set the carve-out flag explicitly, and the engine treats it as the single regulatory switch into Art. 162(3). Full suite: 5,566 passed; arch_check clean. Ref: CRR Art. 153(1)(iii), Art. 162(2), Art. 162(3); PRA PS1/26 (mirrored); BCBS CRE32.46, CRE32.50. - FX collateral haircut now uses 20-day secured-lending default per CRR Art. 224(2)(a) / PS1/26 Art. 224(2)(a) (
engine/crm/processor.py::_build_exposure_lookups+_join_collateral_to_lookups,engine/crm/haircuts.py::apply_haircuts): the FX collateral haircut Hfx previously defaulted to a 10-day liquidation period (8%) for all exposures regardless of transaction type, when the regulatory baseline for secured lending (a vanilla loan facility plus collateral) is 20 business days under CRR Art. 224(2)(a) — giving Hfx = 8% × √(20/10) = 11.314% after the Art. 226(2) square-root-of-time scaling. The constantsLIQUIDATION_PERIOD_REPO=5 / _CAPITAL_MARKET=10 / _SECURED_LENDING=20already lived indata/tables/haircuts.py:146-148but were unused —is_sftfrom the exposure schemas was not propagated onto collateral. Worked example for the FX-mismatch scenario that motivated the fix: £1m GBP loan facility secured by €500k EUR cash collateral. Old behaviour: adjusted collateral = 500k × (1 - 0.08) = £460,000, EAD = £540,000, RWA = £540,000 (8% Hfx — wrong period). New behaviour: adjusted collateral = 500k × (1 - 0.11314) = £443,431.46, EAD = £556,568.54, RWA = £556,568.54 — a 3.07% RWA understatement corrected on every FX-mismatched secured-lending exposure. The fix has two layers: (1)_build_exposure_lookups()now capturesis_sftfrom each exposure level (direct/facility/cp), and_join_collateral_to_lookups()resolves them into a singleexposure_is_sftBoolean column on collateral; (2) the unconditionalfill_null(10)inapply_haircutsis replaced with: explicit per-collateralliquidation_period_daysoverride →LIQUIDATION_PERIOD_REPO(5) whenexposure_is_sft=True→LIQUIDATION_PERIOD_SECURED_LENDING(20) otherwise. Guarantees deliberately untouched — Art. 233(4) fixes guarantee Hfx at the 10-day liquidation period regardless of the underlying transaction type, so the flat 8% inengine/crm/guarantees.pyremains correct. Acceptance impact: CRR-D2 / CRR-D3 / CRR-D6 and B31-D2 / B31-D3 / B31-D6 expected RWAs re-baselined upward to reflect the 20-day default (the collateral asset haircut Hc also scales by the same factor — gilt 0.5% → 0.707%, FTSE-100 equity 15% → 21.213%); golden JSON updated undertests/expected_outputs/{crr,basel31}/. 32 unit tests pinned the buggy 10-day default by omission — all updated either with explicitliquidation_period_days=10overrides (when the test was probing haircut-lookup behaviour, not period scaling) or with re-baselined expectations citing P1.186 (when the test was specifically about the default contract). New regression tests intests/unit/crm/test_collateral_fx_mismatch.py::TestP1186DefaultLiquidationPeriodpin both the 20-day secured-lending default (fx_haircut == 0.113137) and the 5-day SFT default (fx_haircut == 0.056569) end-to-end through the processor join. Closes theliquidation_period as configoutstanding item from P6.15 / D2.39. Full suite: 5,566 passed (23 skipped, 11 deselected); arch_check, ruff, ty all clean. Ref: CRR Art. 224(2)(a)–(c), Art. 226(2); PRA PS1/26 Art. 224(2)(a)–(c), Art. 226(2); Art. 233(4) (guarantee carve-out preserved). (P1.186.) - Facility undrawn now nets netting-flagged negative drawn balances per CRR Art. 195/219 / PS1/26 Art. 195/219 (
engine/hierarchy.py::_aggregate_loan_drawn_per_facility,_per_sub_drawninner helper of MOF undrawn waterfall): the per-facility drawn aggregation previously applied.clip(lower_bound=0.0)per row before summing, so a deposit booked as a negative-drawn loan under an on-balance-sheet netting agreement contributed 0 to facility utilisation instead of offsetting positive siblings. Worked example for the user request that motivated the fix: Fac_01 limit £100m with Loan_01 £60m, Loan_02 £60m, and Loan_03 -£40m carryinghas_netting_agreement=True. Old behaviour: total_drawn = 120m → undrawn = max(0, 100-120) = 0m (the facility undrawn row was entirely suppressed by the existingundrawn_amount > 0filter). New behaviour: total_drawn = 80m → undrawn = 20m, matching the regulatorily-correct net headroom. The aggregation now uses a netting-aware expression — positives always sum normally; negatives contribute only when the loan carrieshas_netting_agreement=True. Negatives without the flag remain clipped to 0 (data-quality guard, preserving the historical contract verified by the existingtest_negative_drawn_amount_treated_as_zero,test_mixed_positive_negative_drawn_amounts, andtest_all_negative_drawn_amountstests). The same netting-aware logic is applied to the_per_sub_drawnhelper used by the MOF sub-facility undrawn waterfall so a netting-flagged deposit mapped to a sub-facility offsets that sub's utilisation rather than the parent's. The downstream CRMgenerate_netting_collateralstage (engine/crm/collateral.py) was already correct — it generates synthetic cash collateral pro-rata across positive siblings under the netting facility — and is unchanged; this fix is strictly upstream at facility utilisation. Defensive fallback: when the loans frame lackshas_netting_agreement(direct unit-test callers), the original clip-at-0 behaviour is used. Schema columnhas_netting_agreement(defaultFalse) was already present onLOAN_SCHEMA. New unit tests intests/unit/test_hierarchy.py:test_netting_negative_drawn_offsets_facility_utilisation(the user's exact 60+60-40 scenario assertingundrawn_amount=20m) andtest_negative_drawn_without_netting_flag_still_clipped(regression — negative without flag still suppresses the undrawn row). Out of scope (tracked as follow-ups, agreed with user): pro-rata vs first-positive netting-collateral allocation policy (current pro-rata is defensible per CRR Art. 219 silence on allocation order); contingent-side parallel clip in_aggregate_contingent_per_facility(negative ONB contingents under a netting agreement is unusual). Full suite: 5,554 passed (2 skipped, 4 deselected); arch_check, ruff, ty all clean. Ref: CRR Art. 195 (recognition), Art. 219 (treatment as cash collateral), Art. 228(1) SA / Art. 228(2) FIRB; PRA PS1/26 Art. 195, Art. 219(1) (new unified EAD-reduction formula), Art. 228(1) — verified by direct extraction fromdocs/assets/crr.pdfp.191/211 anddocs/assets/ps126app1.pdfp.170/190.
Cross-references¶
- Art. 179–184 estimation standards cross-link added (
docs/appendix/regulatory-references.md): the bare Art. 178–180 / Art. 181 rows in the IRB Approach articles table are replaced with cross-link rows pointing at the existing verbatim Art. 179–184 spec section inbasel31/irb-approach.mdand the Art. 181A–C economic-downturn anchor — implementers can now navigate the appendix index straight into the PD/LGD/EAD estimation rules without scanning the IRB spec page. Companion to D3.49 (above). - Art. 159(3) two-branch rule and Art. 62(d) T2 cap formula documented (
docs/specifications/crr/provisions.md,docs/specifications/basel31/provisions.md): both provisions specs previously described the EL-vs-provisions comparison at high level only — the formal A/B/C/D pseudocode block, the explicitT2_credit_cap = 0.006 × IRB_credit_risk_RWAformula, and the per-branch CET1-deduction-vs-T2-credit treatment were absent from both pages. Both specs now carry verbatim Art. 159(3) and Art. 62(d) quotes (CRR + PS1/26 App 1 p. 109), a dedicated### Art. 62(d) — T2 Cap on EL Excesssubsection, and three worked numeric examples (combined-shortfall, combined-excess-cap-binds, split-branch). The B31 spec adds a CRR↔B31 framework-delta callout cross-linking the OF-ADJ T2 component caps inoutput-floor.md(single source of truth, no duplication). Plan-item misattribution corrected inline: D4.87(b) cited "0.6% IRB RWA (CRR) / 1.25% S-TREA (B31)" — the verbatim Art. 62(d) cap base is 0.6% of IRB credit-risk RWA under both CRR and Basel 3.1; the 1.25% S-TREA figure is the GCRA cap under Art. 92(2A), not an EL-excess T2 cap. Closes DOCS_IMPLEMENTATION_PLAN.md D4.87. Ref: CRR Art. 159(3), Art. 62(d); PRA PS1/26 Art. 159(3), Art. 62(d), Art. 92(2A). - Factory-override worked examples added for
CalculationConfig.crr()/.basel_3_1()(docs/api/configuration.md): the page previously showed only the factory defaults, leaving practitioners to read source to discover which keyword overrides exist. The factory signatures now matchsrc/rwa_calc/contracts/config.py:894-1041; a new "Factory Overrides — Worked Examples" subsection adds a keyword-coverage table cross-linking each override to its per-knob anchor, plus one CRR worked example (overridingenable_double_default,crm_collateral_method,eur_gbp_rate,log_format) and one Basel 3.1 worked example (overridinguse_investment_grade_assessment,airb_collateral_method,crm_collateral_method,institution_type,reporting_basis,skip_transitional_floor). Two stale prose blocks corrected: both factories DO exposecrm_collateral_methodas a keyword, and.basel_3_1()exposesairb_collateral_method(defaultAIRBCollateralMethod.LGD_MODELLING). Plan-item enum correction: D4.89 citedAIRBCollateralMethod.EFFECTIVE_LGD— actual enum members areFOUNDATIONandLGD_MODELLING. Closes DOCS_IMPLEMENTATION_PLAN.md D4.89. SlottingCategoryenum and subgrade A/B/C/D relationship surfaced at glossary and user-guide level (docs/specifications/glossary.md,docs/user-guide/methodology/specialised-lending.md): previously the relationship between the coarse 5-bucketSlottingCategoryenum (STRONG / GOOD / SATISFACTORY / WEAK / DEFAULT) and the four subgrade columns A/B/C/D in PS1/26 Art. 153(5) Table A / Art. 158(6) Table B was documented only inside the slotting spec — practitioners reading the glossary or user guide had no entry point. The glossary now carries a newSlottingCategoryrow in the top-of-page table and a### SlottingCategory and subgrades A/B/C/Dsubsection that names the five enum members verbatim and explains that subgrades arise only on the STRONG and GOOD buckets per Art. 153(5)(c)–(f). The specialised-lending user guide gains a new## From Category to Risk Weight: the Subgrade Stepsection walking through "I have a Strong CRE exposure → RW" in four steps using the actual loader fields (slotting_category,is_hvcre,residual_maturity_years,is_short_maturity,sl_type); no risk-weight numbers are duplicated — all values cross-link to the canonical slotting spec. Plan-item correction: D4.90 cited aslotting_subgradeloader field that does not exist indata/schemas.py— the subgrade is derived fromis_short_maturity/residual_maturity_yearsper Art. 153(5)(c)–(f); onlyslotting_categoryandis_hvcreare direct inputs. Closes DOCS_IMPLEMENTATION_PLAN.md D4.90. Ref: PRA PS1/26 Art. 153(5)(c)–(f), Art. 158(6) Table B; CRR Art. 153(5) Table 1;domain/enums.py.- Art. 129(6) pre-2007 covered bond grandfathering documented (
docs/specifications/crr/sa-risk-weights.md): the CRR Art. 129(6) carve-out exempting covered bonds issued before 31 Dec 2007 from the Art. 129(1)/(3) eligibility requirements (grandfathered to maturity) was absent from the spec — practitioners had no documented basis for why pre-2007 issues retain the preferential covered-bond RW table without satisfying the modern collateral-pool / disclosure tests. New "Pre-2007 Grandfathering (Art. 129(6))" subsection now sits between the existing Art. 129 eligibility block and the B31 covered-bond changes, with verbatim CRR Art. 129(6) and PS1/26 Art. 129(6) quotes, an operational note that Art. 129(7) disclosure obligations still apply, and a B31 delta callout flagging the PS1/26 tightening (PS1/26 explicitly conditions grandfathering on Art. 129(7) compliance). Closes DOCS_IMPLEMENTATION_PLAN.md D4.47. Ref: CRR Art. 129(6), PRA PS1/26 Art. 129(6). - Art. 227(2)(d) 4-business-day close-out window for FCSM SFTs documented (
docs/specifications/crr/credit-risk-mitigation.md): the Financial Collateral Simple Method gates the 0% / 10% repo-style transaction floor on a set of preconditions in Art. 227(2)(a)–(h), one of which (the 4-business-day close-out period at Art. 227(2)(d)) was completely absent from the CRM spec — implementers had no documented eligibility test for when an SFT qualifies for the FCSM carve-out vs. falls back to the Art. 222(3) 20% RW floor. New "Art. 227(2)(a)–(h) — Preconditions for the FCSM SFT Carve-Out" subsection lists all eight gating conditions with a dedicated "Art. 227(2)(d) — 4-business-day close-out window" sub-subsection (verbatim CRR quote, framed as eligibility precondition, fall-back behaviour explained, Art. 227(1) FCCM-routing note). B31 delta callout flags PS1/26 Art. 227(2)(i) (new unfettered-seizure condition) and PS1/26 Art. 227(4) (new master-netting-agreement rule) as B31 additions. Plan-item misattribution corrected: D4.49 cited "Art. 227(4)" but the 4-business-day window actually sits at Art. 227(2)(d) in the consolidated UK CRR. Closes DOCS_IMPLEMENTATION_PLAN.md D4.49. Ref: CRR Art. 227(2)(d), Art. 227(1), Art. 222(4); PRA PS1/26 Art. 227(2)(d), Art. 227(2)(i), Art. 227(4). - OF-ADJ T2 component caps (Art. 62(c) / Art. 62(d) / Art. 92(2A) GCRA) documented (
docs/specifications/basel31/output-floor.md): the OF-ADJ formulaOF-ADJ = max(0, SA-RWA × OF% − IRB-RWA)was published without context for the three Tier-2 caps that interact with the floor reconciliation, leaving a gap between the formula in the spec and the upstream-caps-vs-engine-cap split thatengine/aggregator/_floor.py::compute_of_adjactually implements. New "T2 Component Caps — Art. 62(c) and Art. 62(d)" subsection (framed as a clarification, not a new mechanic) covers IRB T2 (Art. 62(d), 0.6% of IRB credit-risk RWA, applied upstream), SA T2 (Art. 62(c), 1.25% of SA credit-risk RWA, applied upstream), and the engine-applied GCRA cap (Art. 92(2A), 1.25% of S-TREA), with verbatim Art. 92(2A) quote, GCRA-vs-SA-T2 sign/base distinction, worked numeric illustration, and a CRR delta note (Art. 62 caps exist under both frameworks but the OF-ADJ linkage is B31-only). Plan-item misattribution corrected: D4.50 cited "Art. 92(3)(c)" but the cap locations are Art. 62(c) / Art. 62(d) of the Own Funds (CRR) Part — Art. 92(3) is the U-TREA composition list with no point (c) cap. Closes DOCS_IMPLEMENTATION_PLAN.md D4.50. Ref: PRA PS1/26 Art. 62(c), Art. 62(d), Art. 92(2A). - Art. 40 EL-shortfall DTA grossing-up rule explained in OF-ADJ context (
docs/specifications/basel31/output-floor.md): the previous gloss "plus any supervisory deductions under Art. 40" in the IRB_CET1 component row mischaracterised CRR Art. 40 as a separate prudential filter / supervisory deduction. New "Art. 40 — no deferred-tax grossing-up of the EL-shortfall deduction" subsection adds verbatim CRR Art. 40 text and a plain-English explanation that Art. 40 is a clarifier on Art. 36(1)(d) — it forbids reducing the EL-shortfall deduction by a rise in deferred-tax assets reliant on future profitability. The component-table row now points at the new subsection rather than restating the misattribution. Engine-inputs note covers both the engine-derivedELPortfolioSummary.cet1_deductionpath and the institution-suppliedOutputFloorConfig.art_40_deductionsscalar. Closes DOCS_IMPLEMENTATION_PLAN.md D4.51. Ref: CRR Art. 40, PRA PS1/26 Art. 92(2A). - Equity transitional 3-year window (Rules 4.4–4.10) distinguished from output-floor 4-year transitional (
docs/specifications/basel31/equity-approach.md): the spec previously implied the SA equity transitional ran four years through 31 Dec 2030; PRA PS1/26 Annex C Chapter 4 Rule 4.2 chapeau is unambiguous that both the SA equity transitional (Rules 4.1–4.3) and the IRB equity/CIU opt-out transitional (Rules 4.4–4.10) run only 3 years (1 Jan 2027 – 31 Dec 2029), with steady-state from 1 Jan 2030. Side-by-side comparative table now shows scope/dates/mechanism/opt-out for the two regimes, plus an info admonition warning against conflating equity transitional (3 years) with output-floor transitional (4 years, Art. 92(5)). Rules 4.4, 4.7, 4.9, 4.10 quoted verbatim from PS1/26 Appendix 1. Plan-item correction: D4.52 itself stated "SA equity transitional runs 4 years (2027-2030)" — the 4-year window is the output-floor transitional, not equity. Closes DOCS_IMPLEMENTATION_PLAN.md D4.52. Ref: PRA PS1/26 Annex C Chapter 4 Rules 4.1–4.11, Art. 92(5). - HVCRE Table B EL subgrade columns A/B/C/D surfaced (
docs/specifications/basel31/slotting-approach.md): the B31 HVCRE expected-loss row was previously rendered as a single "Strong = 0.4%" entry without exposing the four subgrade columns that PS1/26 Appendix 1 Art. 158(6) Table B uses for both HVCRE and non-HVCRE rows. The HVCRE EL table is now expanded to four explicit columns (A/B/C/D) parallel to the existing Table A risk-weight subgrade structure (which DOES split: 70%/95%/95%/120%); Art. 158(6) Table B is quoted verbatim. Plan-item correction: D4.53 plan wording "Strong A = 0.4%, Strong = 0.8%" conflated HVCRE Table B (flat 0.4% across all four columns) with the non-HVCRE EL row (where Good C = 0.4% and Good D = 0.8%). Closes DOCS_IMPLEMENTATION_PLAN.md D4.53. Ref: PRA PS1/26 Appendix 1 Art. 153(5) Table A, Art. 158(6) Table B. - CRR Art. 121(4) trade-finance preferential 50%/20% for unrated institutions documented (
docs/specifications/crr/sa-risk-weights.md): the institution section gains a dedicated Art. 121(4) subsection — verbatim Art. 121(4) (CRR p. 120), Art. 162(3) second subparagraph point (b) (p. 160), and Art. 4(1)(80) (p. 39) quotes; per-case RW table; cumulative eligibility checklist; B31 framework-delta callout flagging the SCRA restructuring and the absence of a flat-50% successor; implementation-status callout flagging the CRR calculator gap. Plan-item terminology correction: D4.55 wording said "50% (sovereign CQS 4-5) or 20% (sovereign CQS 1-3) under sovereign-derived approach" — Art. 121(4) is not CQS-keyed; the 50% is flat for all eligible trade-finance exposures (residual ≤ 1y), and 20% applies where residual ≤ 3 months. Closes DOCS_IMPLEMENTATION_PLAN.md D4.55. Ref: CRR Art. 121(4), Art. 162(3) second subpara point (b), Art. 4(1)(80). - PS1/26 Art. 132(8) "relevant CIU" PRA notification regime documented (
docs/specifications/basel31/equity-approach.md): a new section covers the third-country-fund-manager notification trigger that previously had no doc surface — verbatim Art. 132(8)(a)–(d) (PS1/26 App 1 pp. 64–65) and Glossary "relevant CIU" definition (p. 27), plain-English summary, distinction from other CIU notification regimes, and a CRR comparison (Art. 132 omitted from UK CRR by SI 2021/1078; the regime is B31-only). Three plan-item misattributions recorded: (a) the cited articles 132(3A) / 132(3B) do not exist — the actual provision is Art. 132(8); (b) no AML/CFT trigger exists anywhere in PS1/26 — the genuine trigger is the fund manager's third-country domicile, not establishment country, not AML/CFT assessment; (c) the threshold is 0.5% of credit-risk + dilution-risk RWA OR GBP 500m exposure value, not "≥2% of own funds". The same new spec section also fully covers D4.66's misattributed Art. 132(4A) / GBP 2bn RWA / GBP 500m references — D4.66 should be closed in the next plan refresh. Closes DOCS_IMPLEMENTATION_PLAN.md D4.58. Ref: PRA PS1/26 Art. 132(8), Glossary p. 27. - CRR Art. 118(f) UK-exit deletion noted (
docs/specifications/crr/sa-risk-weights.md): the Art. 118 0% list for international organisations was previously documented as the EU-onshored Art. 118 in full, with no flag that item (f) — the residual "two-or-more-Member-States international financial institution" catch-all — was omitted by SI 2018/1401 reg. 116 with effect from 31 December 2020. New warning admonition under the existing International Organisations subsection sets out the pre-deletion EU text, the SI reference, and the practical effect (Art. 118 closes to items (a)–(e) only — IMF, BIS, EU, ESM, EFSF, EIB; cross-Member-State financial institutions no longer qualify under UK CRR). Plan-item misattribution corrected: D4.56 framed Art. 118 as "exposures to recognised exchanges" — Art. 118 is the international-organisations 0% list; recognised exchanges sit in Art. 107 / Art. 197–198. Closes DOCS_IMPLEMENTATION_PLAN.md D4.56. Ref: UK CRR Art. 118 (consolidated, footnote F266); The Capital Requirements (Amendment) (EU Exit) Regulations 2018, SI 2018/1401 reg. 116. - PS1/26 Art. 122B / Art. 139(2B) SA Specialised Lending in S-TREA documented (
docs/specifications/basel31/output-floor.md): the SA SL framework introduced by PS1/26 Art. 122A–122B was previously absent from the output-floor spec — practitioners had no documented basis for how an IRB firm using SA for specialised lending under Art. 122A contributes to S-TREA. New section covers the Art. 122A sub-classification (Project Finance / Object Finance / Commodities Finance / IPRE / HVCRE) and Art. 122B routing (Art. 122B(1) rated → Table 5A short-term ECRA; Art. 122B(2)/(4) unrated ladder; Art. 122B(3) operational-phase definition; Art. 122B(5) high-quality criteria). The Art. 139(2B) ECAI rating-attribution rule is documented as a suppression of Art. 139(2)/(2A) inferred fallbacks when the rated SL pathway is invoked — not as a S-TREA exclusion. Plan-item factual correction: D4.59 wording — "IRB firms using SA for specialised lending do not include those exposures in the output floor SA-RWA calculation" — is factually wrong; SA SL exposures contribute to S-TREA in full (just routed through Art. 122B), and Art. 139(2B) is an ECAI rule, not a carve-out. Misattribution recorded inline via a warning admonition. Closes DOCS_IMPLEMENTATION_PLAN.md D4.59. Ref: PRA PS1/26 Art. 122A, Art. 122B(1)–(5), Art. 139(2)–(2B), Art. 92(2A). - PS1/26 Art. 143(6)–(8) Overseas Model Approach documented (
docs/specifications/basel31/model-permissions.md): the new PS1/26 Overseas Model Approach (OMA) — a permission for UK-parent groups to apply a foreign supervisor-approved IRB approach to retail and SME corporate exposures of equivalent-jurisdiction overseas subsidiaries, capped at 7.5% of group RWA and 7.5% of group exposure value pre-output-floor — was completely absent from the docs. New top-level section covers Art. 143(6) substantive permission with the (a)–(k) conditions and the aggregate cap, Art. 143(7) grandfathering as a deeming provision for pre-2027 CRR Art. 143 PRA permissions, and Art. 143(8) ongoing-compliance obligation, with verbatim PS1/26 App 1 quotes (pp. 79, 83–84) and a CRR-vs-B31 delta (CRR has no structured OMA). Plan-item paraphrase corrected in three respects: (a) Art. 143(7) grandfathers an existing PRA permission, not a standalone overseas-regulator approval; (b) the mechanism is a deeming provision, not a notification; (c) the substantive OMA in Art. 143(6) is far narrower than the plan suggested — restricted to retail / SME corporate, equivalent-jurisdiction, with the 7.5% group caps. Closes DOCS_IMPLEMENTATION_PLAN.md D4.60. Ref: PRA PS1/26 Art. 143(6)(a)–(k), Art. 143(7), Art. 143(8), Glossary p. 79. - PS1/26 Art. 191A(2)(e),(f) two-layer protection look-through documented (
docs/specifications/basel31/credit-risk-mitigation.md): the CRM spec previously had no description of the PS1/26 election allowing an institution to recognise funded collateral posted by an unfunded protection provider directly through the guarantee chain. New "Look-Through for Unfunded Protection Backed by Funded Protection (Art. 191A(2)(e), (f))" sub-section, slotted inside the existing CRM Method Taxonomy (Art. 191A) block, gives verbatim Art. 191A(2)(e) and (2)(f) (PS1/26 App 1 p. 168), a three-option election table (funded only / unfunded + funded jointly / Part-3-only fallback), the Art. 191A(2)(f) borrower-deeming flexibility, a CRR↔PS1/26 comparison flagging this as wholly new under PS1/26, cross-references to FCSM/FCCM/Foundation Collateral Method/PSM/RWSM and Art. 237–239, and an implementation-status admonition flagging the engine gap. Plan-item misattribution corrected: D4.61 cited "Art. 191A(4)" — the actual provision is Art. 191A(2)(e)/(f); Art. 191A(4) is an unrelated cross-reference scoping rule for Articles 192–239 absent an explicit cross-reference. Closes DOCS_IMPLEMENTATION_PLAN.md D4.61. Ref: PRA PS1/26 Art. 191A(2)(e)–(f), Part 4 of Appendix 1. - CRR Art. 132 paragraph references corrected in CRR equity spec (
docs/specifications/crr/equity-approach.md): the CRR equity spec previously labelled CIU look-through and mandate-based approaches with PRA PS1/26 article numbers (132A / 132B). Under the historical UK CRR — before SI 2021/1078 omitted Art. 132 effective 1 Jan 2022 — these were paragraphs within Art. 132 itself: para 4 = look-through, para 5 = mandate-based. Article numbers throughout the CRR-context tables, section headings (CIU Treatment, Look-Through Approach, Mandate-Based Approach, Fallback Approach), the FR-1.7b requirements row, the CRR-J15 acceptance scenario, and the CRR-J16 third-party multiplier note are now retitled with pre-omission paragraph citations (Art. 132(4) / Art. 132(5) / Art. 132(2)). New top-of-page warning callout summarises the regulatory history: SI 2021/1078 omission, PRA Rulebook (CRR Part) housing through 31 Dec 2026, and PRA PS1/26 reintroduction as Art. 132A / 132B / 132C from 1 Jan 2027. Cross-links tobasel31/equity-approach.mdfor the Art. 132A treatment. Closes DOCS_IMPLEMENTATION_PLAN.md D4.65. Ref: CRR Art. 132(2), (4), (5) (pre-omission); SI 2021/1078; PRA PS1/26 Art. 132A, 132B, 132C. - CRR Art. 150(1)(a)–(j) permanent partial use spec mirroring B31 (
docs/specifications/crr/model-permissions.md): previously no spec file documented the CRR Art. 150 PPU framework — practitioners had to reverse-engineer the conditions for SA-within-IRB fromIRBPermissions/permission_modeconfig code. The new spec is a CRR-side mirror ofbasel31/model-permissions.mdso the two pages diff cleanly. Contents: a sunset warning that CRR Art. 150 expires 31 Dec 2026 with cross-link to PS1/26 Art. 150(1A); verbatim Art. 150(1) opening and conditions (a)–(j) (crr.pdfpp. 145–146); plain-English summary table covering each condition, plus admonitions on the (a)/(b) "limited material counterparties" two-limb test, the qualitative immateriality test in (c) (vs B31's numeric thresholds), the SI 2018/1401 UK-Exit re-targeting of (d), and the standalone 10% own-funds cap in (h); verbatim Art. 150(2) text plus a tier table (10% threshold for ≥10 holdings, 5% for <10 holdings) with a worked example; a 10-row CRR↔B31 comparison table; and an Engine Inputs section showing how each Art. 150(1)(a)–(j) condition is (or is not) encoded. Plan-item correction: there is noapply_partial_ppuflag onCalculationConfig— PPU under the engine is implicit inIRBPermissions(a class withpermitted={SA}is effectively PPU for that class). Closes DOCS_IMPLEMENTATION_PLAN.md D4.64. Ref: CRR Art. 150(1)(a)–(j), Art. 150(2); PRA PS1/26 Art. 150(1A). - OF 08.01 col 0260 (post-adjustment RWEA) documented (
docs/specifications/output-reporting.md): the OF 08.01 column list jumped from col 0254 to col 0265 with no entry for the intermediate col 0260. Per PS1/26 Annex II §3.3.1 p. 112, col 0260 = "Risk-Weighted Exposure Amount After Adjustments" =0251 + 0252 + 0253 + 0254and is the post-adjustment RWEA feeding OF 02.00 row 0010. Now inserted in the correct PDF order between cols 0254 and 0265. Closes DOCS_IMPLEMENTATION_PLAN.md D4.69. Ref: PRA PS1/26 Annex II §3.3.1 p. 112. - OF 08.07 row 0270 / col 0180 PPU formulas documented (
docs/framework-comparison/reporting-differences.md): rows 0260/0270 were previously listed as "Added" without their formulas, leaving COREP implementers to reverse-engineer the Art. 150(1A) PPU materiality calculations from the Annex II PDF. Now expanded with verbatim PS1/26 Annex II formulas: col 0160 =col 0100 / CA2 row 0040(Art. 150(1A)(c)), col 0170 =sum(0110+0120) / (col_0060 - col_0070)(Art. 150(1) last subparagraph), and col 0180 / row 0270 =row_0260_col_0120 / sum(col_0060 for rows 0180-0250 where col_0150 > 0)(Art. 150(1A)(e)). Cross-link tobasel31/model-permissions.mdinstead of duplicating the Art. 150(1A) materiality regime. Plan-item factual correction: D4.71 stated row 0270 / col 0180 usessum(0110+0120)/(col_0060-col_0070)— that formula actually defines col 0170; col 0180 / row 0270 uses the Art. 150(1A)(e) formula (row 0260 col 0120 / Σ col 0060 for material rows). Closes DOCS_IMPLEMENTATION_PLAN.md D4.71. Ref: PRA PS1/26 Annex II §3.3 OF 08.07 pp. 134–136. - UKB CR7-A PDF col labelling typo flagged (
docs/framework-comparison/disclosure-differences.md): PRA PS1/26 Annex XXII p. 14 reuses col (n) for unfunded credit protection on slotting exposures (intended label is col (p)). Existing UKB CR7-A column-changes table already showed the corrected o/p sequence; new warning admonition documents the PDF typo so implementors do not follow the PDF literally. Closes DOCS_IMPLEMENTATION_PLAN.md D4.73. Ref: PRA PS1/26 Annex XXII p. 14. - CRR double-default eligibility, RW floor, and A-IRB precondition corrected in user-guide CRM (
docs/user-guide/methodology/crm.md): the user-guide double-default subsection previously cited "Art. 153(3) paragraph 2" for the RW floor (Art. 153(3) para 2 is blanked under PS1/26 — the CRR floor lives elsewhere), gave an ambiguous "CQS 2 or better (CQS 3 maintained threshold)" guarantor eligibility wording inconsistent with CRR Art. 202, and omitted the A-IRB own-LGD precondition entirely. The RW floor citation is now CRR Art. 161(3) (the comparable-direct-exposure-to-guarantor floor); the CQS threshold is rebuilt from verbatim Art. 202(b)/(c)/(d) (ECAI ≥ CQS 3 at provision; historical PD ≤ CQS 2; current PD ≤ CQS 3); the A-IRB own-LGD chain (Art. 153(3) → Art. 161(4) → Art. 161(3)) is set out explicitly with the F-IRB fall-back to Art. 235/236 substitution. Underlying-exposure scope re-stated to match Art. 153(3) (corporate, institution, central government/CB, retail SME via Art. 154(2)) — replacing the prior incorrect "RGLA/PSE" claim. Closes DOCS_IMPLEMENTATION_PLAN.md D4.76. Ref: CRR Art. 153(3), Art. 154(2), Art. 161(3)–(4), Art. 202; PRA PS1/26 (Art. 153(3) para 2 blanked). - Art. 155(2) short-position netting and Art. 155(4) IMA floor added to user-guide equity (
docs/user-guide/methodology/equity.md): the user-guide page documented the IRB Simple RW table (190%/290%/370%) but two material Art. 155 sub-rules — already present in the CRR equity spec — were missing from the practitioner reference. Added subsections on (a) Art. 155(2) short-position netting (short cash positions and non-trading-book derivatives may offset long positions in the same individual stock only if the hedge is explicit and covers ≥ 1 year; otherwise treated as long with the RW applied to the absolute value) and (b) Art. 155(4) IMA approach (12.5 × VaR-derived potential loss; portfolio RWEA must not be lower thanPD/LGD RWEA + EL × 12.5using Art. 165(1)/(2) PD floors and LGDs). Cross-link tocrr/equity-approach.mdfor the canonical Art. 155 RW table; B31 framework-delta callout flags PS1/26 Art. 147A removal of IRB Equity Approach with the Rules 4.4–4.10 transitional path. Art. 155(3) per-exposure cap deliberately not pre-empted (owned by D4.79). Closes DOCS_IMPLEMENTATION_PLAN.md D4.77. Ref: CRR Art. 155(2), Art. 155(4), Art. 165(1)–(2); PRA PS1/26 Art. 147A, Annex C Chapter 4 Rules 4.4–4.10. - Art. 155(3) PD/LGD per-exposure cap added to user-guide equity (
docs/user-guide/methodology/equity.md): CRR Art. 155(3) caps the PD/LGD-approach capital for any individual equity exposure at a 100% loss assumption (EL × 12.5 + RWEA ≤ EAD × 12.5), but the user-guide page only mentioned the cap inline as a contrast within the Art. 155(4) IMA-floor warning callout — easily overlooked by practitioners scanning for PD/LGD mechanics. New dedicated "PD/LGD Approach Per-Exposure Cap (Art. 155(3))" subsection sits between Short-Position Netting and the IMA section, with verbatim cap formula, a non-binding worked example (EAD=£100, PD=0.40%, LGD=90% → LHS ≈ £374.50 ≪ RHS = £1,250) and a binding example showing the cap reducing PD/LGD RWEA from £1,500 to £350 for a near-default exposure with the Art. 155(3) 1.5× scaling factor applied. Cross-references the canonical PD/LGD parameter table incrr/equity-approach.mdrather than duplicating it. PRA PS1/26 Art. 147A removal callout flags that the cap has no Basel 3.1 successor. Implementation-status note records that the calculator currently implements only Art. 155(2) Simple Risk Weight Approach (PD/LGD approach isIMPLEMENTATION_PLAN.mdP1.153 follow-up), so the per-exposure cap does not bite in any current calculation path. Closes DOCS_IMPLEMENTATION_PLAN.md D4.79. Ref: CRR Art. 155(3), Art. 153(1), Art. 165(1)–(3); PRA PS1/26 Art. 147A. - UKB OV1 pre-floor capital ratio rows documented (
docs/features/pillar3-disclosures.md): Pillar 3 OV1 row table previously listed only rows 1–5, 11–14, 24, 26, 27, 29 — missing the seven UKB-specific pre-floor rows (4a Total RWEAs (pre-floor); 5a/5b CET1; 6a/6b Tier 1; 7a/7b Total capital pre-floor capital ratios). These rows are mandatory under PRA PS1/26 Annex XX for output-floor-active institutions so market participants can see the pre-floor capital position separately from the post-floor figures driven by Art. 92(5). Cross-references toframework-comparison/disclosure-differences.md(lines 31, 63–64) carry the canonical CRR-vs-Basel 3.1 row delta. The correspondingB31_OV1_ROWSgap insrc/rwa_calc/reporting/pillar3/templates.pyis routed to IMPLEMENTATION_PLAN.md as a separate code-side P-coded item. Closes DOCS_IMPLEMENTATION_PLAN.md D4.81. Ref: PRA PS1/26 Annex XX (Disclosure (CRR) Part Art. 438(d)), Art. 92(5). - CRR Art. 137 ECA score open-gap surfaced in CRR SA spec (
docs/specifications/crr/sa-risk-weights.md): the previous "Implementation Status" note at the end of the Art. 137 ECA section understated the gap as a "future enhancement" with no mention of which inputs the engine actually accepts today. Replaced with a new### Art. 136 vs Art. 137 — two distinct mappingssubsection (clarifying that Art. 136 routes ECAI grades through the CQS pipeline and Art. 137 routes OECD MEIP integers 0–7 directly through Table 9 to risk weights) followed by an explicit "Open Gap" warning admonition. The admonition records that the engine accepts only a rawcredit_quality_stepinteger (no ECAI grade strings, no MEIP integers), enumerates what a complete implementation would need (input-schema field for either an ECAI grade or an Art. 137 MEIP integer 0–7, static lookup table indata/tables/, Art. 114/121 sovereign-derived wiring, Art. 138 multi-assessment selection logic), and surfaces the engine work forIMPLEMENTATION_PLAN.mdtracking. Verbatim Art. 137 Table 9 (0%/0%/20%/50%/100%/100%/100%/150%) PDF-verified againstdocs/assets/crr.pdfp. 135. Closes DOCS_IMPLEMENTATION_PLAN.md D4.74. Ref: CRR Art. 136(1)–(2), Art. 137(1)–(2) Table 9. - UKB CR8 signed-flow convention documented (
docs/features/pillar3-disclosures.md): the Pillar 3 disclosures page CR8 section previously showed only the row structure with no sign convention, leaving template consumers to infer the direction of flow rows 2–8 from the spec page or the PRA Annex XXII text. New!!! warningadmonition under the row-structure table records that flow rows 2–8 use signed values (increases positive, decreases negative; example: a £15m RWEA decrease emits as-15) and cross-references the canonical sign-convention list atdocs/specifications/output-reporting.mdlines 349 / 356–366 instead of duplicating spec text. The corresponding_generate_cr8gap insrc/rwa_calc/reporting/pillar3/generator.py(rows 2–8 currently emitted asNonebecause multi-period comparison data is not wired through the pipeline) is surfaced for IMPLEMENTATION_PLAN.md routing — the implementation must honour the signed convention when prior-period inputs are added. Closes DOCS_IMPLEMENTATION_PLAN.md D4.82. Ref: PRA PS1/26 Annex XXII §11 (UKB CR8 instructions). - UKB CMS1 / CMS2 col d ↔ OF-ADJ / GCRA reconciliation surfaced (
docs/features/pillar3-disclosures.md): the Pillar 3 page documented CMS1 col d as "RWA calculated using full standardised approach" without flagging that this is the pre-OF-ADJ S-TREA input matching OF 02.01 col 0040 — i.e. the S-TREA leg ofTREA = max{U-TREA; x · S-TREA + OF-ADJ}before the floor multiplier and OF-ADJ are applied. New "Col d — pre-OF-ADJ S-TREA input" subsection on UKB CMS1 carries verbatim Art. 92(2A) (PS1/26 App 1 p. 13), an info admonition explaining how CMS1 col d gates GCRA T2 capacity through the 1.25%-of-S-TREA cap (Art. 62(c)), and cross-links tospecifications/basel31/output-floor.md(formula derivation + GCRA/SCRA boundary) andspecifications/output-reporting.md(COREP mapping). The UKB CMS2 section gains a parallel "Col d — pre-OF-ADJ S-TREA at asset-class granularity" subsection confirming CMS2 col d carries the same pre-OF-ADJ semantics as CMS1 col d (asset-class breakdown of the same population) and pointing back to the CMS1 admonition rather than duplicating the formula. Closes DOCS_IMPLEMENTATION_PLAN.md D4.83. Ref: PRA PS1/26 App 1 Art. 92(2A), Art. 62(c). - QCCP guarantor RW override (Art. 306) surfaced in user-guide CRM (
docs/user-guide/methodology/crm.md):engine/irb/guarantee.py::_compute_guarantor_rw_saoverrides the substituted guarantor risk weight to 2% (proprietary) or 4% (client-cleared) when the guarantor is a qualifying central counterparty (gated byguarantor_entity_type == "ccp"andguarantor_is_ccp_client_cleared), but the user-guide CRM page never referenced Art. 306 — practitioners had to read the engine source to discover the override. New "Qualifying CCP (QCCP) Guarantor Override (CRR Art. 306)" subsection inside the existing Guarantees section sets out the trigger flags, the 2% / 4% RW table with CRE54.14 / CRE54.15 cross-references, the ordering versus the institution CQS lookup, a scope warning (trade-exposure RW only — default-fund contributions go through Art. 308 / CRE54.16 separately), a worked example, and cross-links to the Institution exposure-class page (CCP anchor) andspecifications/output-reporting.md(COREP rows 0150 / 0160). Closes DOCS_IMPLEMENTATION_PLAN.md D4.85. Ref: CRR Art. 306(1)(a)–(b), Art. 308; PRA PS1/26 Art. 306; BCBS CRE54.14, CRE54.15, CRE54.16. - Art. 232 life-insurance spec ↔ output-column cross-reference + worked example (
docs/specifications/basel31/credit-risk-mitigation.md): the B31 CRM spec described the Art. 232 7-tier insurer-RW → secured-portion-RW mapping (20% / 35% / 70% / 150%) and the F-IRB LGDS=40% rule but did not link these mechanics to the engine output columnslife_ins_collateral_value/life_ins_secured_rwproduced byengine/crm/life_insurance.py::compute_life_insurance_columnsand consumed during SA blending bylf.sa.apply_life_insurance_rw_mapping(). The previous stale!!! warning "Output-column naming documented separately"admonition (referencing the now-closed D3.53 / D2.48) is replaced with (a) a new#### Spec ↔ Output-Column Cross-Referencesubsection — a 5-row table mapping each Art. 232 mechanic to the engine column, default-value behaviour, andengine/crm/life_insurance.py/engine/sa/namespace.pyline numbers — and (b) a new#### Worked Examplesubsection — a fully traced 6-step calculation using a 30% insurer SA RW (SCRA Grade A enhanced, Art. 121(5)) → 35% secured-portion RW (Art. 232(3)(b)) → blended 0.61 RW = GBP 610,000 RWA versus GBP 1,000,000 unmitigated. Closes DOCS_IMPLEMENTATION_PLAN.md D4.86. Ref: PRA PS1/26 App 1 Art. 232(A1), (2)(a), (2)(b), (3)(a)–(d); Art. 121(5); Art. 200(b), Art. 212(2); Art. 233(3)–(4). - CRR Art. 129(5) covered bond unrated-derivation framework boundary clarified (
docs/specifications/crr/sa-risk-weights.md): the CRR covered bond unrated-derivation section previously documented the four sub-paragraphs (a)–(d) without flagging that the sharedCOVERED_BOND_UNRATED_DERIVATIONdict incrr_risk_weights.pycarries 7 entries (3 of which — 30%, 40%, 75% institution RWs — derive from PS1/26 SCRA Grade A/B and CQS 2 ECRA paths that do not exist in CRR). Practitioners reading the dict comment "CRR Art. 129(5), PRA PS1/26 Art. 129" risked treating the larger entry set as authoritative under both frameworks. New verbatim CRR Art. 129(5)(a)–(d) quote (crr.pdfp. 129) plus warning admonition explaining only four CRR institution RWs (20/50/100/150 from Art. 120 Table 3 and Art. 121 Table 5) drive Art. 129(5), producing 10/20/50/100 covered bond RWs; the 30%/40%/75% institution inputs driving PS1/26 sub-paragraphs (aa)/(ab)/(ba) (ps126app1.pdfpp. 61–62) cannot arise under CRR. Implementation-note admonition records that the dict comment reflects shared storage, not framework equivalence. Cross-links to in-page B31 covered bond changes section andbasel31/sa-risk-weights.mdunrated-covered-bonds section. Code-side structural fix continues under DOCS_IMPLEMENTATION_PLAN.md D3.29. Closes DOCS_IMPLEMENTATION_PLAN.md D4.62. Ref: CRR Art. 129(5); PRA PS1/26 Art. 129(5). - UKB CR9 row breakdown rewritten to PS1/26 Annex XXII column-a verbatim sub-classes (
docs/features/pillar3-disclosures.md): the Pillar 3 disclosures page CR9 section previously listed compact row labels (institutions / corporates with SL / "other general corporates SME/non-SME") that did not match the verbatim PRA PS1/26 Annex XXII column-asub-classes for either approach. F-IRB CR9 was missing the "Financial corporates and large corporates" row (Art. 147(2)(c)(ii) / Art. 147A driver) that already appears in CR6, leaving the two templates inconsistent for the same population. Rewritten to use the full numbered hierarchy: A-IRB rows 1.1–1.3 (corporates) and 2.1–2.7 (RRE-SME, RRE-non-SME, CRE-SME, CRE-non-SME, QRRE, Other-SME, Other-non-SME); F-IRB rows 1, 2.1 SL, 2.2 financial corporates and large corporates, 2.3–2.4 other general corporates SME/non-SME, 3 total, perps1-26-annex-xxii-credit-risk-irb-disclosure-instructions.pdfpp. 19–20. Cross-references the CR6 H2 anchor so the F-IRB sub-class 2.2 is recognisable as the same row in both templates; Reference Documents block expanded with verified PDF page numbers (Annex XXII p. 18 paras 12–15; pp. 19–20 columna; pp. 20–22 columnsb–h;ps126app1.pdfArt. 147(2)(b)–(d) and Art. 147A). Plan-item observation: D4.84's "missing financial corporates and large corporates row" framing was stale relative to the current file state — that row was already present from commit3d3b346; the residual docs gap was the absence of a CR6 cross-reference and the use of compact / non-verbatim row labels. The correspondingCR9_FIRB_CLASSESandCR9_AIRB_CLASSESgaps insrc/rwa_calc/reporting/pillar3/templates.py(missing F-IRB sub-classes 2.2 and 2.4, missing A-IRB sub-class 1.3, and the seven retail sub-classes 2.1–2.7) are routed to IMPLEMENTATION_PLAN.md as a separate code-side P-coded item. Closes DOCS_IMPLEMENTATION_PLAN.md D4.84. Ref: PRA PS1/26 Annex XXII paras 12–15; columnarow definitions on pp. 19–20.
[0.2.5] - 2026-05-02¶
Fixed¶
- Collateral CRM now nets against CCF=100% E per CRR Art. 223(4) / PS1/26 Art. 223(4) (
engine/crm/processor.py::_initialize_ead,engine/crm/collateral.py::_apply_collateral_unified,engine/ccf.py::_compute_ead): the CRM stage previously netted collateral againstead_gross = on_bal + nominal × CCF(post-CCF) for off-balance-sheet exposures, both in the FIRB LGD* formula and in the SAead_after_collateralreduction. Both CRR Art. 223(4) and PRA PS1/26 Art. 223(4) explicitly require the opposite: when computing the exposure valueEused for CRM (financial collateral via FCCM, other eligible collateral via the Foundation Collateral Method, and the C*/C** threshold tests in Art. 230), off-balance-sheet items shall be valued at 100% of nominal, overriding the regulatory CCF. The actual CCF re-couples afterwards: under SA per Art. 228(1) the CCF is applied toE*, while under FIRB the actual CCF stays in EAD but is absent from the LGD* ratio. Phil's worked example (100m off-BS FIRB, 75% CCF, 50m cash, senior unsecured): pre-fix code produced LGD* = 15%, regulation requires LGD* = 22.5% — code under-stated FIRB LGD by 7.5pp on this exposure. Under SA the same shape under-stated EAD: 100m off-BS, 50% CCF, 30m cash gave EAD = 20m; regulation requires(100−30) × 0.5 = 35m. Fix introduces two new columns on the exposures frame computed in_initialize_ead:ead_for_crm = on_bs_for_ead + nominal_after_provision(CCF=100% basis, used by all CRM-ratio sites) andeffective_ccf = ead_pre_crm / ead_for_crm(used to recouple the actual CCF in SA's post-collateral EAD).ead_gross(post-CCF) is kept unchanged in the schema — multiple downstream sites legitimately need the actual EAD that flows through to RWA. Migrated sites: collateral pro-rata weights for facility / counterparty pools (_build_exposure_lookupsinprocessor.pyand_apply_collateral_unifiedincollateral.py),_generate_netting_collateralallocation, Art. 230 RE 30% threshold cap, the Art. 231 sequential-fill waterfall denominator, the FIRB LGD* formula numerator and denominator,collateral_coverage_pct, and the SAead_after_collateralformula (rewritten from(ead_gross − collateral_adjusted_value)+to(ead_for_crm − collateral_adjusted_value)+ × effective_ccfper Art. 228(1)). Pure on-BS rows are unaffected (ead_for_crm == ead_grossby construction). FIRB / Slottingead_after_collateralcontinues to equalead_gross(collateral modifies LGD, not EAD, under those approaches). AIRB is unaffected (uses own LGD estimate). The CCF stage inengine/ccf.pynow persists the on-BS portion of EAD as a columnon_bs_for_ead(previously a local variable), enabling_initialize_eadto composeead_for_crmwithout recomputing the drawn / interest / provision adjustments. Defensive fallbacks added toapply_collateral,_apply_collateral_unified,_build_exposure_lookups, and_generate_netting_collateralso direct unit-test callers that hand-build exposures frames withead_grossonly continue to work (defaultead_for_crm = ead_gross,effective_ccf = 1.0— semantically correct for pure on-BS rows). New unit tests intests/unit/crm/test_ead_for_crm.py(8 tests): pure on-BS, pure off-BS independent of CCF (SA + FIRB), mixed on-BS+off-BS row blendedeffective_ccf, provision-on-nominal reducesead_for_crm, zero-nominal divide-by-zero guard, and two end-to-end pins through the full CRM processor — Phil's worked FIRB cash example assertinglgd_post_crm == 22.5%, and the SA off-BS analogue assertingead_after_collateral == 35m. Out of scope for this fix (tracked as follow-ups): guarantees Art. 235 / 236 (same regulatory shape —Ewith CCF=100% override — but a separate code surface inengine/crm/guarantees.py); life insurance under Art. 232 (routes via Art. 235 / 236, not FCCM/FCM, so falls under the guarantees follow-up); AIRB CRM under Art. 191A LGD Adjustment Method (uses own-estimate LGDs, not the FCCM/FCM pipeline). Full suite: 4717 unit + 336 contracts/integration + 497 acceptance pass — no existing acceptance fixture combined an off-BS exposure with collateral so no expected-outputs JSON shifted; the new behaviour is only triggered when both conditions are met. Spec updated indocs/specifications/crr/credit-risk-mitigation.md(new "Exposure value for CRM purposes (Art. 223(4))" section with worked cash and SA off-BS examples) anddocs/architecture/pipeline.md(processing-order section now describes the two-EAD-bases pattern). Ref: CRR Art. 111(3), Art. 223(3)–(5), Art. 228(1)–(2), Art. 230, Art. 231 (extracted fromdocs/assets/crr.pdfp.110, 219, 226–228); PRA PS1/26 Art. 166A–166C, Art. 223(4), Art. 228(1), Art. 230 (extracted fromdocs/assets/ps126app1.pdfp.117–120, 200–202, 208–210); BCBS CRE22.55. (a6e15b6.)
Changed¶
- Version bump for PyPI release.
[0.2.4] - 2026-04-30¶
Added¶
- Blog section + main-navigation link: new
docs/blog/series live on the Zensical site, with aBloglink added to the primary site navigation. First two posts published — including "Post 2 — The Pipeline" walking through the immutable bundle pipeline architecture. (874a510add nav link; PRs #289 / #290, commitscceaee4/7fe91fb.)
Changed¶
- MOF undrawn now emits per-sub waterfall rows by descending CCF (
engine/hierarchy.py::_calculate_facility_undrawn, new_expand_mof_facility_undrawn): replaces the prior worst-case single-CCF emission, where the MOF parent's full undrawn headroom flowed at the highest descendant CCF. Each MOF parent now emits onefacility_undrawnrow per committed descendant sub-facility with positive headroom, allocated by waterfall: subs are sorted by descending SA CCF (deterministic tie-break:risk_typethenfacility_reference) and filled in order, capped per-sub atmax(0, sub_limit - sub_drawn)and globally atparent_headroom. When sub-limits sum below the parent's limit, a residual row is emitted at the parent's ownrisk_typeandcounterparty_reference. Each split row carries the sub'srisk_typeandcounterparty_referencenatively, so the prior_derive_facility_share_counterpartyriskiest-CP override is now skipped on MOF parents (it still applies to non-MOF facilities). Per-sub drawn netting (loans + contingents directly mapped to a sub net only that sub's headroom — not the parent's) makes the waterfall reflect actual sub-level utilisation rather than rolling everything up to root before allocating. Uncommitted (committed=False) sub-facilities are skipped entirely from the waterfall — they consume no parent headroom, mirroring the existing parent-level rule that an unconditionally cancellable line carries no commitment EAD. Worked example for the user request that motivated the fix: parent £100m, sub_01 £60m @ MR (50% CCF), sub_02 £60m @ MLR (20% CCF). Old behaviour: 1 row £100m @ 50% → £50m EAD. New behaviour: 2 rows £60m @ 50% + £40m @ 20% (capped) → £38m EAD. Output schema:exposure_reference = "{parent}_UNDRAWN_{sub}"for waterfall rows,"{parent}_UNDRAWN_RESIDUAL"for the residual;source_facility_reference = parenton every row so facility-level collateral allocation and downstream rollups still group by the MOF parent;mof_risk_type_sourcerecords the sub each row came from (null on the residual). The retired private method_derive_mof_risk_typeis replaced by_expand_mof_facility_undrawn. Tests: 8 new unit tests inTestMOFAndFacilityShare(waterfall caps at parent limit, B31 CCF table, per-sub drawn netting, fully-drawn sub drops out, sub-limits-under-parent residual, three-subs mixed CCF, per-sub counterparty, all-undrawn per-sub counterparties, uncommitted sub skipped); 5 existing MOF tests updated to assert per-sub split rows; 4 multi-level facility undrawn tests updated to assert sum-across-rows equals parent headroom. Full unit suite: 4,692 passed; acceptance: 497 passed (1 skipped); contracts + integration: 317 passed. No acceptance goldens shifted because no existing fixture combined a MOF with sub-facilities of differing risk_types. Spec updated indocs/specifications/common/hierarchy-classification.md(new "Multi-Option Facility (MOF) Waterfall Allocation" subsection with two worked examples and edge-case enumeration). Ref: CRR Art. 111 (SA CCFs), Art. 166 (off-balance EAD); PRA PS1/26 Art. 111 Table A1, Art. 166C. (PRs #292 / #293.) - Version bump for PyPI release.
Fixed¶
- CRR F-IRB CCF over-statement for issued OBS items — implement Art. 166(10) fallback (
engine/ccf.py::_firb_ccf_for_col): the CRR F-IRB CCF helper previously blanket-applied 75% to everyMR/MLR/OCrow except the Art. 166(8)(b) short-term trade-LC carve-out, treating Art. 166(8)(d) as the catch-all. CRR Article 166 in fact has two F-IRB CCF clauses: Art. 166(8) prescribes bespoke CCFs for the named commitment types (UCC credit lines, short-term trade LCs, revolving purchased-receivables UCC, "other credit lines / NIFs / RUFs"), and Art. 166(10) is a self-contained residual fallback for off-balance sheet items not in scope of paragraphs 1–8 (100% FR / 50% MR / 20% MLR / 0% LR by Annex I category). The engine now distinguishes the two via a new boolean schema flagis_obs_commitment:True(Art. 166(8)(d) commitment-style — credit lines, NIFs, RUFs) routes to 75%;False(Art. 166(10) issued OBS item — performance bonds, warranties, tender bonds, non-credit-substitute documentary credits / standby LCs, shipping guarantees, customs/tax bonds, self-liquidating documentary credits) routes to the Annex I fallback (50% MR / 20% MLR). The Art. 166(8)(b)is_short_term_trade_lccarve-out continues to win over both buckets (it is a more specific Art. 166(8)(b) rule). Schema additions (data/schemas.py):is_obs_commitmentdefaults toTrueonFACILITY_SCHEMA(a facility row is, by construction, a commitment / credit line) andFalseonCONTINGENTS_SCHEMA(a contingent is, by construction, an issued OBS item); callers may override per row (e.g., a contingent that genuinely represents a NIF/RUF can be taggedTrue). The hierarchy stage (engine/hierarchy.py::_unify_exposuresand_calculate_facility_undrawn) projects the column with the per-source-table default. The CCF calculator's_ensure_columnsdefaultsis_obs_commitment=Trueas a final fallback for unit-test callers that bypass hierarchy, preserving all existing direct-API behaviour. Items affected (over-stated CCF before the fix): MR issued items — performance bonds, tender bonds, advance-payment guarantees, warranties, non-self-liquidating documentary credits, non-credit-substitute irrevocable standby LCs (75% → 50%, Art. 166(10)(b)); MLR issued items — self-liquidating documentary credits, shipping guarantees, customs and tax bonds (75% → 20%, Art. 166(10)(c)). Items unchanged: FR (100% under both Art. 166(8) general and Art. 166(10)(a)); LR UCC (0% under both Art. 166(8)(a) and Art. 166(10)(d)); OC commitments (75% via Art. 166(8)(d)); MLR withis_short_term_trade_lc=True(20% via Art. 166(8)(b)). Fix is CRR-only — Basel 3.1 Art. 166C already aligns F-IRB CCFs to SA Table A1 (50% MR, 20% MLR) so the over-statement only existed in theis_basel_3_1=Falsebranch. Test coverage: 7 new unit tests intests/unit/test_ccf.py(TestFIRBArt16610Fallback) covering MR-issued@50%, MR-commitment@75%, MLR-issued@20%, MLR-commitment@75%, MLR-issued+trade-LC@20% (carve-out priority), OC-issued@50%, and the missing-flag default; 3 new end-to-end integration tests intests/integration/test_classifier_to_crm.py(test_crr_d_ccf7_firb_mr_contingent_falls_to_50_via_art_166_10,test_crr_d_ccf8_firb_mlr_contingent_falls_to_20_via_art_166_10,test_firb_mr_facility_undrawn_keeps_75_via_art_166_8d) that drive data throughHierarchyResolver→ExposureClassifier→CRMProcessorand confirm the per-source-table default routing. Stress / benchmark fixture data updated (tests/acceptance/stress/conftest.py,tests/benchmarks/data_generators.py) and the test fixture builders (tests/fixtures/exposures/facilities.py,tests/fixtures/exposures/contingents.py) gain an optionalis_obs_commitmentfield. Module docstring andCCFCalculatorclass docstring updated to cite Art. 166(8)(a)/(b)/(d) and Art. 166(10), and to correct the prior Art. 166(9) misattribution for short-term trade LCs (Art. 166(9) is in fact the lower-of-two-CCFs rule for overlapping commitments, already handled viaunderlying_risk_type). Spec updated indocs/specifications/crr/credit-conversion-factors.md(new "F-IRB CCFs by source" tables splitting Art. 166(8)(d) credit lines from Art. 166(10) issued items; new CRR-D.CCF7 / CRR-D.CCF8 scenarios). Full suite: 5,531 passed (1 skipped, 11 deselected) — no acceptance goldens shifted because no existing CRR FIRB acceptance fixture combined an FIRB-classified counterparty with an MR or MLR contingent that previously expected 75%. Ref: CRR Art. 166(8)(a)–(d), Art. 166(10) (extracted verbatim fromdocs/assets/crr.pdf). (PR #291.)
[0.2.3] - 2026-04-28¶
Added¶
- Oracle test suite scaffold (PR #286,
301f77f): newtests/oracle/directory containing a small set of hash-locked, hand-derived expected values for SA / IRB scenarios that act as a third-party-friendly oracle independent of the existing acceptance goldens. Each fixture row carries a SHA-256 hash so any drift in inputs or expected outputs is detected as a hash mismatch rather than a silent recomputation. Initial scaffolding only; no production-engine changes.
Changed¶
- Facility undrawn generation now respects the
committedflag (engine/hierarchy.py::_calculate_facility_undrawn): the dormantcommittedBoolean onFACILITY_SCHEMAis now consulted by the hierarchy resolver. Facilities withcommitted=Falseno longer generate a syntheticfacility_undrawnexposure row — an unconditionally cancellable line carries no irrevocable lending commitment, so the bank holds no commitment EAD / RWA against the unused headroom (consistent with the regulatory intuition under CRR Art. 166 and PRA PS1/26 Art. 166C). Loans and contingents already mapped to such facilities are completely unaffected: they remain independent exposure rows with normal counterparty / parent rollup, collateral allocation, and CCF treatment, because they are already on-balance-sheet (loans) or carry their own off-balance EAD (contingents). The schema default forcommittedwas flipped fromFalsetoTrue(data/schemas.py:69) so legacy callers and fixtures that omit the field continue to generate undrawn rows as before — uncommitted is now the explicit, opted-in case. Nullcommittedvalues are also defensively treated as committed. New unit tests intests/unit/test_hierarchy.py:test_uncommitted_facility_suppresses_undrawn_row(no row generated forcommitted=False),test_committed_null_treated_as_committed(null defaults to committed),test_uncommitted_facility_loans_still_flowandtest_uncommitted_facility_contingents_still_flow(mapped loans/contingents flow through_unify_exposuresunchanged, nofacility_undrawnsynthetic row in the unified output). The mislabeledtest_facility_uncommitted_lr_risk_type(which actually usedcommitted=True) was renamed totest_facility_lr_risk_type. No CCF or downstream calculator changes — thecommittedgate simply stops feeding suppressed rows into the existing CCF pipeline. Full suite: 5,521 passed (1 skipped, 11 deselected) — no acceptance goldens shifted because the only fixture that previously heldcommitted=False(FAC_CORP_UNCOMMIT_001intests/fixtures/exposures/facilities.py) was already declared with the comment "0% CCF for unconditionally cancellable" and not asserted against in any expected-output JSON. Docs updated indocs/data-model/input-schemas.md(facilitycommittedrow) anddocs/architecture/components.md(HierarchyResolver method table). Ref: CRR Art. 166 (off-balance-sheet item EAD); PRA PS1/26 Art. 166C (Basel 3.1 CCF treatment for unconditionally cancellable commitments). (PR #288.) - Version bump for PyPI release.
Docs¶
- README accuracy refresh (PR #287,
fce582e): refreshed the project README to match the current state of the codebase — updated test counts, the supported exposure classes table, and the Basel 3.1 implementation status section.
[0.2.2] - 2026-04-27¶
Added¶
- Multiple Option Facility (MOF) and Facility Share support in the undrawn allocation pipeline (
engine/hierarchy.py::_calculate_facility_undrawn): two product patterns that the facility/undrawn pipeline previously did not honour are now applied as overrides on the parent facility's undrawn exposure row, without requiring schema changes. MOFs: any facility with at least onechild_type='facility'row infacility_mappingsis now treated as a Multiple Option Facility — the parent's undrawnrisk_typeis overridden to the descendant sub-facilityrisk_typewhose SA CCF (viaengine/ccf.py::sa_ccf_expression, frame-aware for CRR / PRA PS1/26 Table A1) is highest, so the parent's undrawn EAD reflects the worst-case off-balance commitment among its components rather than the parent's own (often LR / 0%)risk_type. Tie-break on alphabetical lowercase risk_type then alphabetical descendantfacility_referencefor full reproducibility. The new private method_derive_mof_risk_type()walks the existing_build_facility_root_lookup()output to collect descendants at any depth. Facility Shares: when the descendant loans / contingents under a facility reference more than one distinctcounterparty_reference, the undrawn is now allocated to the riskiest member by SA-equivalent risk weight rather than to the facility's owncounterparty_reference. The new private method_derive_facility_share_counterparty()collects the union of distinct counterparties from the descendant loan/contingent set (using the existing_resolve_to_root_facility()helper) and joins each candidate to the resolved counterparty lookup to readentity_typeandcqs. A new module-level helper_preview_sa_rw_expr()mapsentity_typeto the matching SA risk weight table (CENTRAL_GOVT_CENTRAL_BANK_RISK_WEIGHTS, frame-awareINSTITUTION_RISK_WEIGHTS_*,CORPORATE_RISK_WEIGHTS,RETAIL_RISK_WEIGHT,MDB_RISK_WEIGHTS_TABLE_2B,HIGH_RISK_RW) and returns the RW for the candidate's CQS — deliberately SA-only so the preview avoids the circular dependency with the classifier's IRB approach gating; the chosen counterparty still flows through the full classifier and SA/IRB pipeline downstream so the preview is non-binding. Per facility, max RW wins; tie-break on higher CQS then alphabeticalcounterparty_reference. Both overrides are skipped no-ops when their inputs are trivial: a facility with nochild_type='facility'rows is not a MOF (parent risk_type unchanged); a facility with ≤1 distinct member counterparty is not a share (counterparty unchanged). Two new audit columns flow on thefacility_undrawnexposure rows for traceability —original_counterparty_reference(the facility's owncounterparty_referencebefore the share override) andmof_risk_type_source(the descendant facility reference whoserisk_typewon the max-CCF tie). The_calculate_facility_undrawn()signature gains optionalcounterparty_lookupandconfigparameters;_unify_exposures()plumbs the sameconfigthrough fromresolve()so the framework switch reaches both helpers.FACILITY_MAPPING_SCHEMA(data/schemas.py) is unchanged. New unit-test classTestMOFAndFacilityShareintests/unit/test_hierarchy.pycovers six scenarios: MOF parent inherits max-CCF childrisk_typeunder CRR; MOF picks OC over LR under Basel 3.1 (40% beats 10%); plain hierarchies with onlychild_type='loan'rows are not MOFs (parentrisk_typepreserved); facility share with three distinct corporate counterparties at CQS 1/3/5 allocates undrawn to CQS 5 (RW 150%); single-member facility is unchanged; combined MOF + share scenario applies both overrides independently. Full suite: 5,484 passed (1 skipped, 4 deselected) — no acceptance goldens shifted because no existing fixture combines a multi-CP facility share or a MOF with mixed-CCF children.scripts/arch_check.py,ruff check, andty checkall clean. Docs updated indocs/architecture/components.md(new key-features bullets and method rows in the HierarchyResolver section). Ref: CRR Art. 111 (SA CCFs); PRA PS1/26 Art. 111 Table A1; CRR Art. 112-122 (SA risk weights for the preview lookup). (PR #285.)
Changed¶
- Version bump for PyPI release.
Docs¶
- Doc batch (D2.* items, two
/next-docswaves, batchesb39d7e1andbdd0f31): eight regulatory-doc items landed in this window without code changes — Art. 143(2A)/(2B) A-IRB permission conditions (05660d0); Art. 154/157/158/160-166A/184 purchased-receivables pool & dilution risk (2ae4bf1); Art. 234 tranched coverage with P1.30(e) cross-ref (dd488c7); CRE loan-split threshold corrected from "60% LTV" to "55% of property value" (0d5a58d); Art. 119(2)/(3) CRR national-currency short-term institution coverage (1c4a339); OF 09.01 missing rows 0075/0085/0095/0141-0143/0160/0170 (fb0a5d2); plus twoIMPLEMENTATION_PLAN.mdticks (f83bc84,dd0f317). All edits are docs-only.
[0.2.1] - 2026-04-27¶
Added¶
is_airb_model_collateralflag on the collateral table to prevent AIRB collateral double-counting (CRR Art. 181 / Basel 3.1 Art. 169A): under A-IRB, the firm's own modelled LGD already reflects the credit-risk-mitigating effect of any collateral incorporated in the model, so allocating that same collateral to non-AIRB exposures of the counterparty supervisorily double-counts it. New optional Boolean column onCOLLATERAL_SCHEMA(defaultFalse) that asserts the row's collateral has been used to construct the firm's internal LGD model. The CRM allocator (engine/crm/collateral.py::_apply_collateral_unified) is now pool-aware: (1) exposures are partitioned at the start ofapply_collateralinto an AIRB pool (rows where the modelled LGD is preserved by CRM —approach == AIRBAND not falling back to the supervisory formula under Foundation election or Art. 169B insufficient-data) and a non-AIRB pool (FIRB / SA / Slotting and AIRB rows that use the formula); (2)_build_exposure_lookupsinengine/crm/processor.pynow emits pool-specific facility / counterparty EAD aggregates (_ead_facility_airb/_ead_facility_non_airb,_ead_cp_airb/_ead_cp_non_airb); (3) the group-by aggregation splits each metric into_n(unflagged collateral) and_a(flagged) variants via filter onis_airb_model_collateral; (4) pro-rata weights_fw_n/_fw_a/_cw_n/_cw_abake in the pool-match gate so non-matching pools contribute zero. Behaviour: flagged collateral routes only to AIRB-pool rows (facility / counterparty pro-rata over AIRB pool only); unflagged facility / counterparty collateral routes only to non-AIRB rows (AIRB pool excluded from the pro-rata base — non-AIRB rows now absorb 100% of unflagged counterparty / facility collateral that previously was wasted on AIRB rows whose modelled LGD ignores it); direct unflagged collateral is unchanged (1:1 to the named exposure). Direct flagged collateral pledged onto a non-AIRB exposure emits newCRM006(ERROR_AIRB_MODEL_COLLATERAL_MISDIRECTED) data-quality warning viafind_misdirected_airb_model_collateraland is given zero allocation. The inline_airb_uses_formulaexpression in_apply_collateral_unifiedwas lifted into a top-level helperairb_lgd_preserved_expr(config, is_basel_3_1, schema_names)reused by both the LGD branch and the new pool-membership tagging._apply_collateral_unifiedis defensive about missing pool-aware columns (test helpers that supply only_fac_ead_total/_cp_ead_totalare backfilled to legacy behaviour: AIRB total = 0, non-AIRB total = full total). Schema, processor lookups, and pro-rata logic are all backward-compatible with fixtures that don't set the column —ensure_columns(COLLATERAL_SCHEMA)in the loader (engine/loader.py:88) fills the default. Test coverage: newtests/unit/crm/test_airb_model_collateral_flag.py(7 tests across schema, unflagged-counterparty AIRB-exclusion, flagged-counterparty user scenarioloan_1/loan_2/loan_3, CRM006 misdirection emission and silence, homogeneous-FIRB backward-compat); benchmark data generator (tests/benchmarks/data_generators.py) updated to include the new column. Full suite: 5,505 passed, 1 skipped — no acceptance goldens shifted because no existing scenario combined mixed AIRB / non-AIRB exposures with counterparty / facility-level collateral. Docs updated indocs/specifications/crr/credit-risk-mitigation.md(new "Pool-Aware Pro-Rata for AIRB Mixes" subsection under Multi-Level Collateral Allocation) anddocs/specifications/basel31/credit-risk-mitigation.md("AIRB own-LGD anti-double-counting" bullet under Art. 191A(2)(d) anti-double-counting rules). Ref: CRR Art. 181; Basel 3.1 Art. 169A, Art. 191A(2)(d); CRE36.34-36. (PR #280.)
Changed¶
- Version bump for PyPI release.
Internal¶
- Experimental Claude agent-teams configuration for
loop.sh(PRs #282 / #283, commits0dcd821/94311fb/09a4b97/fbf784a): adds/next-docsand/next-itemsparallel batch orchestrators, a Phase 1 docs-implementation team, and full coverage of all fourloop.shmodes. Tooling-only; no engine or test changes.
[0.2.0] - 2026-04-26¶
Changed¶
- Promote
RESIDENTIAL_MORTGAGE/COMMERCIAL_MORTGAGEfrom uppercase magic strings to first-classExposureClassenum members: the SA real-estate loan-splitter (engine/re_splitter.py) labels its secured child row'sexposure_classwith one of two values that, until now, lived only as uppercase string literals (_SECURED_TARGET_RESIDENTIAL = "RESIDENTIAL_MORTGAGE"and_SECURED_TARGET_COMMERCIAL = "COMMERCIAL_MORTGAGE"inengine/classifier.py:158-159), with an explicit code comment noting they "are not in the ExposureClass enum (onlyRETAIL_MORTGAGEis)". The exposure-class enum convention is lowercase string values (e.g."retail_mortgage","corporate"); the loan-splitter therefore broke that convention every time it materialised a non-retail residential or commercial mortgage row. Behavioural impact: none (the SA RW expressions inengine/sa/namespace.pyuppercaseexposure_classvia_uc = pl.col("exposure_class").str.to_uppercase()before any substring match, so both cases route identically). Hygiene impact: the loan-splitter outputs are now indistinguishable from any other classifier output. Changes: (1) addExposureClass.RESIDENTIAL_MORTGAGE = "residential_mortgage"andExposureClass.COMMERCIAL_MORTGAGE = "commercial_mortgage"indomain/enums.pywith docstrings citing CRR Art. 125 / Art. 126 and PRA PS1/26 Art. 124F / Art. 124H; (2) replace the_SECURED_TARGET_*magic-string assignments inengine/classifier.py:158-159withExposureClass.<X>.valuereferences and rewrite the surrounding comment to point at the new enum members; (3) updateengine/re_splitter.pyto importExposureClassand replace the literal"COMMERCIAL_MORTGAGE"filter at line 503 withExposureClass.COMMERCIAL_MORTGAGE.value; (4) update all fourtarget_class=initialisers indata/tables/re_split_parameters.py(RE_SPLIT_PARAMS_CRR_RESIDENTIAL,RE_SPLIT_PARAMS_CRR_COMMERCIAL,RE_SPLIT_PARAMS_B31_RESIDENTIAL,RE_SPLIT_PARAMS_B31_COMMERCIAL) to use enum-value references via a newfrom rwa_calc.domain.enums import ExposureClass(the existing arch-check allowlist permitsdata/tables/to import fromdomain/enums, mirroring the pre-existingCQSimport incrr_risk_weights.py); (5) update test fixtures and assertions intests/unit/test_real_estate_splitter.py,tests/unit/test_b31_re_junior_charges.py,tests/integration/test_re_split_pipeline.py,tests/unit/test_b31_sa_risk_weights.py, andtests/unit/crr/test_crr_sa.pyto use the lowercase enum values, matching what the production classifier now emits. Intentionally not changed: (a) the uppercase"RESIDENTIAL_MORTGAGE"join key in thecrr_risk_weights.py:458LTV-split lookup table — it is joined against_lookup_classwhich is uppercased via.str.to_uppercase(), so it stays uppercase to preserve the case-insensitive routing behaviour; (b) theuc.str.contains("MORTGAGE", literal=True) | uc.str.contains("RESIDENTIAL", literal=True) | uc.str.contains("COMMERCIAL", literal=True) | uc.str.contains("CRE", literal=True)substring matches inengine/sa/namespace.py:_b31_append_real_estate_branchesand_crr_append_real_estate_branches— they cover both the canonical enum values and a real non-enum user-input path ("CRE"appears as a directexposure_classvalue intests/expected_outputs/crr/expected_rwa_crr.json:213for theLOAN_CRE_001acceptance fixture). Switching tois_in([ExposureClass.X.value.upper(), …])would lose that fallback unless the list reintroduced"CRE"as a magic string, defeating the refactor's purpose. Verification: 70 splitter-focused unit + integration tests pass (tests/unit/test_real_estate_splitter.py,tests/unit/test_b31_re_junior_charges.py,tests/integration/test_re_split_pipeline.py); 330 SA / classifier / data-boundary tests pass (tests/unit/test_b31_sa_risk_weights.py,tests/unit/crr/test_crr_sa.py,tests/unit/test_classifier.py,tests/contracts/test_data_layer_boundary.py);scripts/arch_check.pyclean; the broader acceptance + contracts + integration sweep was also run, and the failure count is identical to the parent commit baseline (106 acceptance failures, all pre-existing —hierarchy_resolvererrors in FIRB/AIRB/Slotting/Provisions scenarios that don't touch the SA real-estate path). Ref: CRR Art. 125, Art. 126; PRA PS1/26 Art. 124F, Art. 124H.
Fixed¶
- SME supporting factor
E*aggregated across group of connected clients (CRR Art. 501): the SA SME supporting factor previously evaluated the EUR 1.5m / GBP-equivalentE*exposure threshold on a per-counterparty basis, ignoring Art. 4(1)(39) connected-client aggregation. The check now aggregates across the full group of connected clients, matching the regulatory definition of the obligor used elsewhere in the pipeline. Acceptance scenarios CRR-F* updated; new test class intests/unit/test_supporting_factors.py. (PR #279)
Docs¶
- Clarify counterparty scope of the CRR Art. 125 35% / 75% residential mortgage RW: three doc updates document that CRR Art. 125 is not restricted to retail individuals — any exposure secured by qualifying residential property may receive the 35% secured / residual-counterparty-RW split, contingent on the Art. 125(2) qualifying conditions (value/repayment not materially dependent on borrower credit quality or property cash flows; Art. 208 / Art. 229(1) valuation). The calculator routes individuals via
RETAIL_MORTGAGE(inengine/classifier.pywhenis_mortgage=Trueandcp_entity_type=="individual") and non-retail counterparties viaRESIDENTIAL_MORTGAGE(through the SA real-estate loan-splitterengine/re_splitter.py); both paths apply the same Art. 125 split. (1)docs/user-guide/exposure-classes/retail.md— replaced the misleading "CRR uses a flat 35% (LTV ≤ 80%) or 75% (LTV > 80%)" sentence with the correct split treatment and added a pointer to the loan-splitter for non-retail RRE; (2)docs/framework-comparison/key-differences.md— added a "Counterparty scope of the CRR 35%/75% column" callout under the Residential RE General loan-splitting comparison table noting that the CRR column is regime treatment, not a retail-only label; (3)docs/specifications/crr/sa-risk-weights.md— extended the "Residential Mortgage Exposures (CRR Art. 125)" section with the Art. 125(2)(a)–(d) qualifying-conditions list (mirroring the existing Art. 126(2) list) and a "Counterparty scope" info admonition mapping the two routing buckets and noting that the calculator infers the qualifying-condition gate from theis_mortgageflag rather than independently verifying (a)–(c). No code changes.
[0.1.67] - 2026-04-25¶
Fixed¶
- Guarantor rating routing now beneficiary-aware (CRR Art. 161(3) / Basel 3.1 CRE22.70-85): guarantor substitution previously chose between the guarantor's internal PD and external CQS based purely on the guarantor's own properties — an SA exposure could end up routed through IRB parameter substitution, and an IRB exposure under CRR was always forced through SA RW substitution because parameter substitution was gated on
config.is_basel_3_1. Now the routing is keyed on the beneficiary's approach: IRB beneficiaries use the guarantor's internal PD when available; SA beneficiaries always use external CQS. The F-IRB supervisory LGD used in PD substitution and EL blending now tracks the active framework (0.45 CRR / 0.40 Basel 3.1) instead of being hard-coded. (PR #277)
Docs¶
-
Refresh
docs/data-model/input-schemas.mdto matchsrc/rwa_calc/data/schemas.py: the input schema reference had drifted ~30 columns behind the source of truth. Added documentation foreffective_maturity(CRR Art. 162(3) / PS1/26 numericMoverride that bypasses the 1-year floor) on Facility / Loan / Contingent; the Art. 110A due-diligence override fields (due_diligence_performed,due_diligence_override_rw); A-IRB modelled-EAD and unsecured-LGD fields (ead_modelled,lgd_unsecured,has_sufficient_collateral_data); maturity-floor and SFT flags (has_one_day_maturity_floor,is_sft); theis_payroll_loan35% retail RW flag; counterparty classification flags (is_natural_person,is_social_housing,is_financial_sector_entity,is_ccp_client_cleared,borrower_income_currency,local_currency,sovereign_cqs,institution_cqs); the Basel 3.1 real-estate collateral fields (is_qualifying_re,original_maturity_years,rental_to_interest_ratio,liquidation_period_days,qualifies_for_zero_haircut,is_main_index,insurer_risk_weight,credit_event_reduction); guarantee credit-derivative fields (protection_type,includes_restructuring); specialised-lendingproject_phase; equity / CIU look-through fields (ciu_approach,ciu_mandate_rw,ciu_third_party_calc,fund_reference,fund_nav); and an entirely new CIU Holdings schema section documenting the look-through input (Art. 132(3)). Also corrected severalRequired: Yescells that were stale relative to theColumnSpec(required=...)source of truth (Counterparty, Facility, Loan, Contingent — only the reference IDs andentity_typeare loader-required). No code changes. -
Comprehensive regulatory documentation refresh (D2.34–D2.73): ~30 documentation commits land verbatim citations and clarifications across A-IRB, CRM, slotting, real-estate, SCRA, covered bonds, and SL specs. Highlights: Art. 124B underwriting-standards obligation (D2.60); Art. 124D valuation requirements (D2.43, D2.62); Art. 124E(5)/(7) RE reassessment obligations (D2.56); Art. 122(7)-(8) output-floor election for unrated corporates (D2.46); Art. 129(4A) covered-bond due-diligence CQS step-up (D2.34); Art. 138(1)(g) + Art. 139(6) implicit-support higher-of rule (D2.49); Art. 153(5)(c)-(f) slotting column-assignment rules (D2.68); Art. 161(1)(e)/(f)/(g) purchased-receivables trigger recast (D2.51); Art. 162(2A)(k) revolving maturity precedence (D2.52); Art. 232(3) life insurance derivation (D2.48); Art. 237/239 CRM maturity-mismatch wording aligned to PS1/26 (D2.55); Art. 121(1)(a)/(1)(b) SCRA disclosure barring ladder (D2.54); BEEL substitution for A-IRB defaulted exposures (D2.67); retail A-IRB LGD floor reconciliation (D2.50); removal of Art. 119(2)/(3) national-currency preferential in B3.1 (D2.73). Misc fixes: USD 100bn LFSE threshold (D2.53); CRR FCSM Art. 222 paragraph attributions (D1.47, D2.63); LFSE citation to Art. 142(1)(4) (D1.49); MDBs/IOs in Art. 147A(1)(a) SA-only scope (D2.38). No code changes. (PR #275)
[0.1.66] - 2026-04-24¶
Added¶
effective_maturityoverride (CRR Art. 162(3) / PRA PS1/26): new optional column on Facility / Loan / Contingent that lets firms supply a numeric maturityMdirectly, bypassing the 1-year maturity floor and the date-derived calculation for IRB exposures. When populated, it takes precedence over derived maturity in correlation, K, and maturity-adjustment formulas. Documented indocs/data-model/input-schemas.md. (PR #274)
Fixed¶
- IRB / Slotting exposures secured by real estate no longer receive the 0.15 retail-mortgage correlation (CRR Art. 153 / CRE31.11):
RealEstateSplitter._split_unified_frameinengine/re_splitter.pypreviously split every row flagged by the classifier asre_split_mode='split'(or'whole') regardless of the row'sapproach, emitting a secured child row withexposure_class = RESIDENTIAL_MORTGAGE/COMMERCIAL_MORTGAGE. For FIRB / AIRB / Slotting rows, the IRB correlation expression inengine/irb/formulas.py::_correlation_expr_from_pdreadspl.col("exposure_class")and hits thestr.contains("MORTGAGE")branch →pl.lit(0.15), i.e. the retail-mortgage correlation under CRR Art. 154(3). A FIRB corporate-SME exposure collateralised by residential property was therefore splitting into (a) acorporate_smeresidual row with the correct supervisory-formula-with-SME-adjustment correlation and (b) aRESIDENTIAL_MORTGAGEsecured row stuck at 0.15 — a regime that doesn't exist under IRB. Loan-splitting is an SA-only regulatory mechanism (CRR Art. 125/126 and PRA PS1/26 Art. 124F/H all sit in the Credit Risk: Standardised Approach Part); IRB recognises real-estate collateral via LGD (Art. 161(5) FIRB supervisory RRE floor / AIRB own-estimate LGD / Art. 230-231 funded credit protection), already handled upstream by the CRM processor'scrm_alloc_real_estateallocation. Fix: gateis_split_modeandis_whole_modeonapproach ∈ {standardised, equity}(a new_SA_BOUND_APPROACHESmodule constant backed byApproachTypeenum values). Rows withapproach ∈ {foundation_irb, advanced_irb, slotting}and the classifier's split flag set now fall into the pass-through bucket and retain their originalexposure_class— the downstream IRB correlation formula then correctly lands on the corporate / corporate-SME / retail branches._accumulate_split_errorsis similarly gated so IRB rows do not emit SA-specificRE002zero-cap orRE004CRR rental-coverage warnings. When theapproachcolumn is absent (pure SA-only bundles, older test fixtures) the predicate defaults toTrue— existing SA-only tests continue to pass. 5 new regression tests intests/unit/test_real_estate_splitter.py::TestSplitterApproachGate(parametrised over FIRB/AIRB/Slotting pass-through, whole-loan pass-through, SA-with-explicit-approach still splits, no spurious RE002 on IRB zero-cap rows, mixed SA+IRB batch). Full suite: 4,640 unit passed, 810 acceptance+contracts+integration passed. Ref: CRR Art. 125, Art. 126, Art. 153(1)-(4), Art. 154(3), Art. 161(5), Art. 230-231; PRA PS1/26 Art. 124F, Art. 124H. - Defaulted SA exposures no longer return the base class RW when non-financial collateral columns are populated (PS1/26 Art. 127):
_apply_defaulted_risk_weightinengine/sa/namespace.pypreviously computedsecured_pct = (collateral_re_value + collateral_receivables_value + collateral_other_physical_value) / eadand blendedunsecured_pct × provision_rw + secured_pct × pl.col("risk_weight").pl.col("risk_weight")at that point is the exposure's base class RW (75% for retail, 100% for unrated corporate, 35% for mortgage), so whenever non-financial collateral reached or exceeded EAD, the blended RW collapsed back to the class base — for defaulted regulatory retail with RE collateral the SA path returned 75% instead of the Art. 127(1) 100%/150%. Art. 127(2) defers to the CRM method the institution applies (Art. 191A(2)); under FCCM (the default for SA) eligible financial collateral has already reducedead_finalupstream and eligible RE is routed through class reclassification, so the post-CRM value IS the unsecured portion and no secondary split is required inside the defaulted override. Fix: drop the non-financial collateral split entirely; apply the provision-based 100%/150% toead_finaldirectly (CRR denominator keeps the+ provision_deductedpre-provision reconstruction; B31 usesead_finalper "outstanding amount of the item or facility"). The Basel 3.1 RESI RE non-income-dependent flat-100% branch (Art. 127(3) / CRE20.88) and the HIGH_RISK precedence guard (Art. 128) are unchanged.tests/unit/test_defaulted_secured_split.pyrewritten to assert the new behaviour (regulatorily-incorrect "fully secured returns base RW" cases deleted; new regression tests pin down the retail scenario). New unit tests intests/unit/crr/test_crr_sa.py::TestDefaultedRWApplicationfor defaulted non-mortgage retail under both CRR and Basel 3.1. Acceptance scenario B31-K7 re-baselined (collateral columns no longer produce a blend). Specdocs/specifications/basel31/defaulted-exposures.mdupdated: FR-10.3 reworded to "unsecured portion determined by the CRM method"; D3.19 code-divergence warning on the B31 denominator removed (resolved); secured-portion section rewritten. Ref: PS1/26 Art. 127(1)-(3); Art. 191A(2); CRR Art. 127(1)-(2); CRE20.88-90.
[0.1.65] - 2026-04-21¶
Added¶
- Auto-sync of
config.eur_gbp_ratefrom the loadedfx_ratestable: the pipeline now keeps the scalar EUR/GBP rate used by the IRB SME correlation formula (CRR Art. 153(4)) and the GBP equivalents of EUR regulatory thresholds (RegulatoryThresholds.crr) in step with the(EUR, GBP)row of the loadedfx_ratesinput. Previously these two FX mechanisms were independent: a user could load an up-to-datefx_rates.parquetand get all exposure/collateral/guarantee/provision amounts converted at e.g. 0.90 while the IRB SME correlation and the derived GBP thresholds continued to run at the default 0.8732, silently. Implementation: (1) new modulesrc/rwa_calc/engine/fx_rate_sync.pyexposesextract_eur_gbp_rate(fx_rates: pl.LazyFrame | None) -> Decimal | None— returns the rate when the table contains exactly one(EUR, GBP)row, returnsNoneand logs WARNING"fx_rates table has N (EUR, GBP) rows; skipping eur_gbp_rate auto-sync"when multiple rows match; (2) new methodCalculationConfig.with_fx_rate(eur_gbp_rate)insrc/rwa_calc/contracts/config.pyusesdataclasses.replaceto produce a new config with botheur_gbp_rateandthresholds=RegulatoryThresholds.crr(eur_gbp_rate=...)rebuilt, so the SME turnover threshold, SME exposure threshold, retail max exposure, QRRE limit, and LFSE threshold are all re-derived at the new rate; the method is a no-op on Basel 3.1 (GBP-native per PRA PS1/26 Art. 153(4)) and a no-op when the rate is unchanged; (3)PipelineOrchestrator.run_with_datainsrc/rwa_calc/engine/pipeline.pycallsextract_eur_gbp_rate(data.fx_rates)immediately before_ensure_components_initialized(config)and, when the derived rate differs from the caller-supplied rate, logs WARNING"eur_gbp_rate auto-sync: replacing <old> with <new> from fx_rates table"onrwa_calc.engine.pipelineand swaps the localconfigviawith_fx_rate. New opt-out fieldCalculationConfig.sync_eur_gbp_rate_from_fx_table: bool = Truelets callers force their passed-in rate to win regardless of the data; when False, no WARNING is emitted and the supplied rate stands. Tests: 5 contract tests (tests/contracts/test_config.py::TestCalculationConfig—test_sync_eur_gbp_rate_flag_defaults_true,test_with_fx_rate_rebuilds_thresholds,test_with_fx_rate_noop_when_rate_unchanged,test_with_fx_rate_noop_for_basel_3_1,test_with_fx_rate_preserves_post_init_derivations); 6 unit tests (tests/unit/test_fx_rate_sync.py— single/missing/None/multiple rows, reverse-direction row, Decimal precision); 4 integration tests (tests/integration/test_fx_rate_autosync.py— divergence-warns-and-replaces, same-rate no-warn, opt-out suppresses, B3.1 no-op). Documented indocs/user-guide/methodology/fx-conversion.mdunder a new "Auto-sync ofeur_gbp_ratefrom the FX table" section covering the match rules, divergence warning, multiple-row skip, opt-out flag, and Basel 3.1 behaviour. Ref: CRR Art. 153(4);RegulatoryThresholds.crratcontracts/config.py:619.
Changed¶
- Refactor:
stage_timerlogging format enhanced for clearer pipeline traces. - Refactor: inline sidebar theme CSS for improved styling.
Fixed¶
- Retail Art. 123(c) threshold now aggregates across the full counterparty when no lending group is defined:
HierarchyResolver._enrich_with_lending_groupinengine/hierarchy.pypreviously setlending_group_total_exposureandlending_group_adjusted_exposureto0.0wheneverlending_group_referencewas null, and the classifier's fallback in_build_qualifies_as_retail_exprthen compared the per-rowexposure_for_retail_thresholdagainst the EUR 1m / GBP 880k limit. A counterparty with, say, three GBP 400k loans and no lending group was therefore classified as retail even though the aggregate GBP 1.2m exposure exceeded the threshold. CRR Art. 123(c) read with Art. 4(1)(39) ("group of connected clients") and PRA PS1/26 Art. 123A require aggregation across every exposure to a single obligor — a standalone counterparty is a group-of-one. Fix: the.otherwise(0.0)branches now aggregate via.sum().over("counterparty_reference")so both totals are always populated with the connected-client figure. The now-redundantzero_lending_group_failbranch inengine/classifier.pyis removed. New regression tests:tests/unit/test_hierarchy.py::TestLendingGroupAggregation::test_standalone_counterparty_aggregates_own_exposures(three-loan counterparty, 1.2m aggregate) andtests/unit/test_art123a_retail_criteria.py::TestCounterpartyAggregationWithoutLendingGroup(three cases covering above-threshold B3.1, below-threshold B3.1, and above-threshold CRR). Existingtest_standalone_not_in_lending_groupupdated to expect the counterparty aggregate (50k) rather than 0.0. Ref: CRR Art. 123(c), Art. 4(1)(39); PRA PS1/26 Art. 123A.
[0.1.64] - 2026-04-19¶
Added¶
- stdlib
loggingobservability layer (rwa_calc.observability): a new cross-cutting package (src/rwa_calc/observability/) configures stdlibloggingidempotently on therwa_calcnamespace logger (never root), installs acontextvars-backed correlationrun_idinjected onto every LogRecord, and providesstage_timer— a context manager that emits INFO"stage entered"/"stage completed"records with anelapsed_msextra (WARNING"stage failed"on exception). Every_run_*helper inPipelineOrchestratoris wrapped withstage_timer, so each pipeline run now emits matching entry/exit records for loader, hierarchy_resolver, classifier, crm_processor, re_splitter, calculators, aggregator, and equity_calculator, all sharing one freshly-generated 12-hex-charrun_idbound atrun_with_dataentry and cleared in the existingfinally. Two output formats —"text"(human-readable) and"json"(single-line, audit-friendly with a whitelisted extras set) — are selectable via two new fields onCalculationConfig(log_leveldefault"INFO",log_formatdefault"text") that also flow throughCreditRiskCalc(log_level=..., log_format=...)and both.crr()/.basel_3_1()factories.CreditRiskCalc.calculate()now callsconfigure_logging(config.log_level, config.log_format)before constructing the pipeline; the orchestrator itself does NOT callconfigure_loggingso it remains usable in embedded contexts. Noisy third-party loggers (polars,uvicorn.access,fastapi,asyncio) are pinned to WARNING. Contract: logging is operational-only — data-quality issues remain inCalculationError, and the integration test asserts no log record'smessageequals anyCalculationError.messagein the same run. Enforcement: ruff rulesG/LOG/T20(f-string lazy-formatting, deprecated API detection,print()ban withtests/**+ marimo apps exempted);scripts/arch_check.pygains check 8 (engine modules must declarelogger = logging.getLogger(__name__), noprint(orlogging.basicConfig(— helper modules listed inLOGGER_REQUIRED_EXEMPT);tests/contracts/test_logging_contract.pyasserts every stage module exports a correctly-namedLoggerand thatobservability.__all__is stable;tests/integration/test_logging_pipeline.pyruns the pipeline end-to-end and asserts entry/exit record pairs, sharedrun_id, distinct ids on back-to-back runs, no handler stacking, and no regulatory-error duplication. The ~19print()calls insrc/rwa_calc/ui/marimo/server.py:main()are converted tologger.infowithconfigure_logging("INFO", "text")called at startup. New specdocs/specifications/observability.mddocuments the public API, record schema, levels, correlation-ID lifecycle, reference stage skeleton, enforcement layers, and anti-patterns. CLAUDE.md gains a Logging section mirroring the Error Handling section.CalculationConfigfields are listed indocs/specifications/configuration.md(FR-5.7, CONFIG-7).
Changed¶
- Refactor: hoist SA risk-weight scalars and scaffold
lf.sanamespace (no behavioural change).
[0.1.63] - 2026-04-19¶
Added¶
- Real estate loan-splitter for SA exposures collateralised by property (CRR Art. 125/126, PRA PS1/26 Art. 124F/H): A new pipeline stage (
engine/re_splitter.py) inserted betweenCRMProcessorand the calculators physically partitions a property-collateralised non-RE SA exposure into two rows — a secured row reclassified toRESIDENTIAL_MORTGAGE/COMMERCIAL_MORTGAGEcapped at the regulatory secured-LTV cap, and an uncollateralised residual row that retains the original counterparty exposure class so the standard corporate / retail risk weight applies on the remainder. Both rows share asplit_parent_idlineage key so downstream aggregations reconcile back to the parent exposure. Previously, a corporate / retail loan secured by eligible property collateral that was not already classified as a mortgage received the full counterparty risk weight on the entire EAD, materially overstating capital. Mechanics are identical across regimes; parameters (secured LTV cap / secured RW / prior-charge reduction / counterparty carve-outs) live indata/tables/re_split_parameters.py(re_split_parameters(is_basel_3_1=...)): - CRR Art. 125 (RRE): secured cap = 80% LTV, secured RW = 35%, residual at counterparty CQS RW.
- CRR Art. 126 (CRE): secured cap = 50% LTV, secured RW = 50% — applied only when the rental coverage test (≥ 1.5× interest costs) is met (new optional input
rental_to_interest_ratioon collateral). When not met, no split is applied (RE004informational warning) and the exposure stays in its original class. Default conservative (no split) when the column is absent. - B3.1 Art. 124F (RRE): secured cap = 55% × property value (less prior charges per Art. 124F(2)), secured RW = 20%, residual at counterparty RW.
- B3.1 Art. 124H(1)-(2) (CRE NP/SME): secured cap = 55% × property value, secured RW = 60%, residual at counterparty RW. Restricted to natural persons / SMEs.
- B3.1 Art. 124H(3) (CRE other): no physical split; the whole exposure becomes a single
COMMERCIAL_MORTGAGErow so the existingb31_commercial_rw_exprArt. 124H(3) branch (max(60%, min(cp_rw, Art. 124I RW))) handles it.
The split is gated by a new classifier Phase 4c (_flag_property_reclassification_candidates) that emits re_split_target_class, re_split_mode ("split" / "whole" / null), re_split_property_type, re_split_property_value, and re_split_cre_rental_coverage_met candidate columns. Income-producing real estate continues to use the existing whole-loan path (Art. 124G / Art. 124I bands); already-classified RESIDENTIAL_MORTGAGE / RETAIL_MORTGAGE / COMMERCIAL_MORTGAGE / defaulted / equity / CIU / subordinated / high-risk / covered-bond rows are excluded from the split. The downstream SA RW expressions are reused unchanged — the secured row's LTV is capped by construction at the secured-LTV threshold, so the existing b31_residential_rw_expr / b31_commercial_rw_expr / CRR _apply_residential_mortgage_rw paths produce 35% / 50% / 20% / 60% naturally, and the residual row keeps its original exposure_class and gets the corporate / retail RW. Provisions allocate pro-rata by the EAD share. New audit LazyFrame CRMAdjustedBundle.re_split_audit captures one row per parent (parent EAD, secured/residual EAD, effective cap, target class, regime). New error codes: RE001 (non-eligible RE), RE002 (zero effective cap), RE003 (mixed property types), RE004 (CRR CRE rental coverage failed). New RealEstateSplitterProtocol in contracts/protocols.py. 14 new unit tests (tests/unit/test_real_estate_splitter.py), 3 end-to-end pipeline integration tests (tests/integration/test_re_split_pipeline.py), 2 protocol contract tests. Output floor / aggregator semantics unchanged: each child row contributes its own sa_rwa so portfolio-level totals are mathematically equivalent to the pre-split blended-RW row. Ref: CRR Art. 125, Art. 126(2)(d); PRA PS1/26 Art. 124A, Art. 124F, Art. 124F(2), Art. 124H(1)-(3), Art. 124L; SS10/13.
- Regression coverage: IRB-denied exposures must still use the counterparty's external ECAI rating on SA:
tests/integration/test_model_permissions_pipeline.py::TestIRBDeniedUsesExternalRatingOnSAadds three end-to-end tests that wire a counterparty with both an internal rating (PD +model_id) and an external rating (CQS) through the full pipeline, then assert that whenmodel_permissionsdeny IRB (viafilter_rejectedon exposure-class mismatch, viaunmatched_model_id, and via PRA PS1/26 Art. 147A(1)(a) sovereign SA-only routing) the resulting SA row carriesapproach="SA", the counterparty's externalcqs, and a CQS-basedrisk_weightrather than the unrated fallback — the scenario the CLS006 diagnostic warning already signals. A new_make_external_ratinghelper mirrors the existing_make_internal_ratingshape. These tests pin down the expected behaviour end-to-end; previously,tests/integration/test_model_permissions_pipeline.pyandtests/acceptance/basel31/test_scenario_b31_m_model_permissions.pyalways built internal-only ratings withcqs=None, so the external-rating path through SA after IRB denial was never exercised from rating inheritance throughSACalculator._apply_risk_weights.
Changed¶
- Institution guarantor RW expression unified: SA (
engine/sa/calculator.py::_apply_guarantee_substitution) and IRB (engine/irb/guarantee.py::_compute_guarantor_rw_sa) guarantee substitution paths had near-identical hard-codedpl.when().then()ladders for institution CQS → RW withpl.lit(0.30) if config.is_basel_3_1 else pl.lit(0.50)branches. Extracted shared helperbuild_institution_guarantor_rw_expr(cqs_col, is_basel_3_1)indata/tables/crr_risk_weights.pythat drives values fromINSTITUTION_RISK_WEIGHTS_CRR/INSTITUTION_RISK_WEIGHTS_B31_ECRAso the dicts remain the single source of truth and the two sites cannot drift on future edits. Also removed the deadextra_cols={"is_basel_3_1": ...}column previously emitted by_create_institution_df(never consumed by any downstream join).
Fixed¶
- RGLA / PSE institution-treated exposures now correctly route to IRB (CRR Art. 147(3)/(4)(b), PRA PS1/26 Art. 147A(1)(b)):
rgla_institutionandpse_institutioncounterparties carrying an internal rating were silently forced to SA regardless of IRB permissions. Under CRR the org-wideIRBPermissions.full_irb()map keys IRB eligibility offexposure_class, but the classifier setexposure_classfrom the SA map (RGLA / PSE) whilefull_irb()only listed CGCB / INSTITUTION / corporate / retail / SL — so everyrgla_*/pse_*row'sfirb_permitted_exprevaluated toFalseand fell through to the SA default. Under Basel 3.1 the_b31_sa_onlyfilter additionally sweptExposureClass.RGLA/ExposureClass.PSEinto the Art. 147A(1)(a) sovereign-only set, but Art. 147(3) scopes that restriction to quasi-sovereigns with 0% SA RW (i.e.rgla_sovereign/pse_sovereign/mdb/international_org) — institution-treated variants should follow the Art. 147A(1)(b) INSTITUTION F-IRB-only path. Fix inengine/classifier.py: (1)_build_orgwide_permission_exprsand_resolve_model_permissionsnow key their permission-match expressions onexposure_class_irb; (2)_b31_sa_onlynow keys oncp_entity_typewith the explicit Art. 147(3) list; (3)_b31_institution_no_airbnow keys onexposure_class_irb == INSTITUTION; (4) new Step 4a re-syncsexposure_class_irbwith the reclassifiedexposure_classafter Phases 3-4 (SME / QRRE / retail) so retail-reclassified corporates still match retail model permissions; (5) after approach assignment,exposure_classis rewritten toexposure_class_irbfor IRB-routedrgla_*/pse_*rows so the IRB calculator reads INSTITUTION / CGCB for correlation & LGD selection. SA-routed RGLA / PSE rows keepexposure_class = RGLA/PSEand continue to use Art. 115 / Art. 116 SA risk weight tables. Net effect under CRR: argla_institutionwith internal PD + modelled LGD now correctly lands on A-IRB via the INSTITUTION class; under B3.1 it lands on F-IRB with supervisory LGD per Art. 147A(1)(b). 11 new regression tests intests/unit/test_b31_approach_restrictions.py(TestCRRRGLAPSEIRBRoutingplus additionalTestB31QuasiSovereignSAOnlycases) cover CRR AIRB/FIRB routing, B3.1 FIRB routing, A-IRB blocking under B3.1, LGD clearing, and SA-fallback behaviour for unrated rgla/pse rows. Full suite: 4,711 unit passed, 627 acceptance + integration passed.IRBPermissions.full_irb_b31()permissions map unchanged (RGLA / PSE / MDB entries remain as defensive defaults); docstring updated to clarify the quasi-sovereign scope is tied to the 0%-RW entity treatment, not the SA exposure class label. Ref: CRR Art. 147(3), Art. 147(4)(b); PRA PS1/26 Art. 147A(1)(a), Art. 147A(1)(b) read with Art. 147(3). -
CRR Art. 138 multi-rating resolution now applied:
HierarchyResolver._build_rating_inheritance_lazyinengine/hierarchy.pypreviously collapsed multiple external ratings per counterparty to the single most recent one, silently ignoring assessments from additional nominated ECAIs. Replaced the "most recent wins" logic for external ratings with Art. 138: per-agency dedup (most recent per agency) followed by the 1-rating / 2-rating (higher RW) / ≥ 3-rating (second-best) selection rule. Resolution is performed on CQS rather than RW because within every SA exposure class the CQS → RW mapping is monotone non-decreasing. Internal-rating resolution, inheritance, and the external-rating non-inheritance rule are unchanged. NewTestArt138ExternalRatingResolutionclass intests/unit/test_hierarchy.pycovers single/two/three/four-rating cases, ties at the two lowest CQS, same-agency repeats, and null-CQS rows. Existing fixture counterparties have ≤ 1 external agency each, so no acceptance-golden changes. Ref: CRR Art. 138. -
CRR Art. 120(2) Table 4 short-term rated institution risk weights now applied [P1.99]: The CRR SA branch fell through to Art. 120 Table 3 (long-term) for every rated institution regardless of maturity, so a CQS 2 institution with 1-month residual maturity received the 50% long-term weight instead of the 20% Table 4 short-term weight. Added
INSTITUTION_SHORT_TERM_RISK_WEIGHTS_CRRindata/tables/crr_risk_weights.py(CQS 1-3 = 20%, CQS 4-5 = 50%, CQS 6 = 150%) and a new.when()branch inengine/sa/calculator.pykeyed onresidual_maturity_years <= 0.25withINSTITUTIONexposure class and non-null CQS. CRR Art. 120(2) keys on residual maturity and imposes no domestic-currency restriction (distinct from Art. 119(2)). Diverges from B31 Table 4 which applies 20% uniformly across CQS 1-5. - CRR Art. 121(3) unrated institution short-term 20% RW now applied [P1.121]: The CRR SA branch provided no short-term override for unrated institutions, so a 1-month-original-maturity unrated institution fell through to the Table 5 sovereign-derived fallback (typically 100%). Added
INSTITUTION_SHORT_TERM_UNRATED_RW_CRR = 0.20and a.when()branch inengine/sa/calculator.pykeyed onoriginal_maturity_years <= 0.25withINSTITUTIONexposure class and null-or-zero CQS. Art. 121(3) uses original effective maturity (consistent with the P1.133 B31 PSE/SCRA fix), so a seasoned 5-year bond with 1 month remaining does NOT qualify. Art. 121(6) sovereign floor (applied later via_apply_sovereign_floor_for_institutions) still lifts this to the sovereign weight for FX exposures. Capital previously overstated by up to 80 percentage points for short-term unrated interbank exposures. - Both fixes: 13 new regression tests in
tests/unit/crr/test_crr_institution_standard.py(TestCRRShortTermInstitutionTables,TestCRRShortTermInstitutionSACalculator) cover parametrized CQS 1-6 short-term rated, >3m fall-through, unrated short-term, original vs residual maturity keying, sovereign-floor interaction, and B31 isolation. 2 existing tests intests/unit/test_b31_sa_risk_weights.pythat previously asserted CRR does NOT apply short-term treatment renamed to assert the new correct behaviour. Full suite: 5,329 passed, 21 skipped. Ref: CRR Art. 120(1)-(2), Art. 121(3)/(6). - Short-term PSE/institution treatment now keys on original maturity [P1.133]:
SACalculator._SA_INPUT_CONTRACTextended withoriginal_maturity_years,value_date,maturity_date;calculate_branchderivesoriginal_maturity_yearsinline from(maturity_date - value_date)/365.0when the column is null, so hierarchy-supplied facility data flows through without a new schema column. Five SA call-sites updated to useoriginal_maturity_years: B31 PSE short-term (Art. 116(3)), B31 ECRA rated institution short-term (Art. 120(2)/(2A) incl. 6m trade-goods carve-out), B31 SCRA unrated institution short-term (Art. 121(3)), CRR PSE short-term (Art. 116(3)), and Art. 121(6) trade-goods sovereign-floor exception. Previously a 5-year bond with 1 month residual incorrectly attracted short-term 20% RW; it now correctly receives the long-term CQS/SCRA weight. 8 new regression tests cover seasoned-vs-fresh scenarios across both CRR and B31 branches intests/unit/test_pse_risk_weights.pyandtests/unit/test_b31_sa_risk_weights.py. Understates-capital bug; fix tightens RWs on seasoned short-residual exposures. Ref: CRR Art. 116(3), PRA PS1/26 Art. 120(2)/(2A), Art. 121(3)/(6). - PRA PS1/26 Art. 224 Table 1 B31 haircut corrections [P1.155]:
BASEL31_COLLATERAL_HAIRCUTSindata/tables/haircuts.pyhad 9 stale values (P1.155 originally cited 4; PDF verification found 5 more in the same table). Corrections verified against ps126app1.pdf p.203: sovereign CQS 2-3 3_5y 4%→3% and 10y+ 12%→6%; corp/institution CQS 1 1_3y/3_5y/5_10y 4/6/10%→3/4/6%; corp/institution CQS 2-3 1_3y/3_5y/5_10y/10y+ 6/8/15/15%→4/6/12/20%. The 10y+ CQS 2-3 correction (15%→20%) widens FCCM haircut on long-dated lower-rated corporate bonds (capital increase); the other 8 corrections were conservative over-haircuts whose correction reduces collateral haircut in FCCM, increases collateral value recognised, and lowers post-CRM EAD. Test classTestBasel31BondHaircutsintests/unit/crm/test_crm_basel31.pyparametrized into a singletest_b31_bond_haircuts_match_pra_table_1with 13 cases;test_b31_corp_bond_long_dated_higher_haircutinTestHaircutCalculatorFrameworkBranchingupdated accordingly. All 5,307 tests pass. - CRR Institution CQS 2 risk weight corrected to 50% (CRR Art. 120 Table 3) [P1.149]: The CRR table (misnamed
INSTITUTION_RISK_WEIGHTS_UK) conflated the PRA PS1/26 Basel 3.1 ECRA values (CQS 2 = 30%, unrated = 40%) with a non-existent "UK deviation" to CRR Art. 120, and the SA calculator keyed framework selection offbase_currency == "GBP"viause_uk_deviation. Under CRR Art. 120 Table 3, CQS 2 institutions are 50% and unrated institutions are 100% — no deviation exists in the UK-onshored CRR. Renamed data tables toINSTITUTION_RISK_WEIGHTS_CRRandINSTITUTION_RISK_WEIGHTS_B31_ECRA; replaceduse_uk_deviationboolean (keyed on base currency) withconfig.is_basel_3_1(keyed on framework) throughoutengine/sa/calculator.py,engine/irb/guarantee.py, andengine/equity/calculator.py. Also caught an additional instance of the same root-cause bug inirb/guarantee.py:269where unrated institution guarantors were hard-coded to 40% regardless of framework — now returns 100% under CRR and 40% under B31. CRR-A4 acceptance scenario updated (RW 0.30 → 0.50, RWA £300k → £500k); CRR-D4 updated (blended RW 0.58 → 0.70). Unit tests intests/unit/test_sovereign_floor_institutions.py,test_b31_sa_risk_weights.py,test_covered_bonds.py,test_guarantor_exposure_class_rw.py,crr/test_crr_sa.py,crr/test_crr_tables.py,crr/test_crr_institution_standard.py, andcrr/test_irb_namespace.pyupdated to reflect the correct CRR values. - HVCRE Good slotting EL rate corrected to 0.4% (PRA PS1/26 Art. 158(6) Table B) [P1.150]: Both
B31_SLOTTING_EL_RATES_HVCRE[GOOD](data/tables/b31_slotting.py) andSLOTTING_EL_RATES_HVCRE[GOOD](data/tables/crr_slotting.py) returned 0.8% — mirroring non-HVCRE long-maturity Good — but PRA PS1/26 Table B (Appendix 1 p.108) shows the HVCRE row flat at 0.4% across both Strong (cols A/B) and Good (cols C/D), i.e. HVCRE collapses the subgrade differentiation that non-HVCRE retains. Halves the EL shortfall for HVCRE Good exposures (capital overstatement when EL > provisions). Under UK CRR the substantive Article 158 was omitted by SI 2021/1078 in 2022, so PRA PS1/26 Table B is the only extant UK source — applied symmetrically to both framework data tables. Updated unit tests intests/unit/test_slotting_el_rates.py(renamedtest_hvcre_good_zero_point_eight→test_hvcre_good_zero_point_fourfor both CRR and B31, replacedtest_hvcre_matches_long_maturity_non_hvcrewithtest_hvcre_good_diverges_from_non_hvcre_long_maturityregression guard, parametrized cases("good", True, False, ...)and("good", True, True, ...)now expect 0.004). Updated B31 slotting spec admonition. Acceptance scenarios CRR-E4/E7/E8 unchanged (they assert HVCRE risk weights, not EL rates). - FX haircut on collateral silently zero after FX conversion (CRR Art. 224, PRA PS1/26 Art. 224) [P1.135/P1.136]:
FXConverter.convert_exposures()andconvert_collateral()both rewrite thecurrencycolumn to the reporting currency, so by the timeHaircutCalculator.apply_haircutscomparedcurrency != exposure_currencyboth sides were equal and the 8% Art. 224 FX volatility haircut was silently never applied to any FX-mismatched secured exposure (HIGH capital understatement). Root cause: onlyconvert_exposuresandconvert_guaranteespreservedoriginal_currency;convert_collateral,convert_provisions, andconvert_equity_exposuresdid not — and_build_exposure_lookupssourced the collateral-sideexposure_currencyfrom the post-conversioncurrency. Fix: (1)engine/fx_converter.py— all four sibling converters now aliascurrencyintooriginal_currencyon both the conversion and no-conversion paths; (2)engine/hierarchy.py— removed theapply_fx_conversion/fx_rates is not Nonebranching block (converters now handle the no-op path consistently); (3)engine/crm/processor.py::_build_exposure_lookups— prefersoriginal_currencywith fallback tocurrency; (4)engine/crm/haircuts.py::apply_haircuts— compares the collateral'soriginal_currencywith fallback. Regression coverage: 5 tests intests/unit/crm/test_collateral_fx_mismatch.py(including the post-conversion pipeline path that the existing scalar-basedcalculate_single_haircuttests did not exercise) + 2 tests intests/unit/test_fx_converter.pycovering the new collateral audit column. - Domestic sovereign guarantor 0% RW uses guarantee currency (CRR Art. 114(4)/(7)): The Art. 114(4)/(7) domestic-currency test on a guaranteed portion was being evaluated against the underlying exposure's currency rather than the guarantee's currency, which meant a GBP loan guaranteed by an EU sovereign in that sovereign's domestic currency (e.g. DE in EUR) did not receive 0% RW even though, under the substitution approach (Art. 215-217), the substituted claim against the sovereign is denominated in EUR. The Art. 233(3) 8% FX haircut already handles the cross-currency layer between guarantee and underlying loan; layering Art. 114(4)/(7) on top of the exposure currency effectively nullified Art. 233(3) for sovereign guarantees. Switched the three call sites that implement the check (
engine/crm/guarantees.pyrouting,engine/irb/guarantee.py::_compute_guarantor_rw_sa,engine/sa/calculator.py::_apply_guarantee_substitution) to readguarantee_currency(already populated on guaranteed rows by_apply_guarantee_splits) with a null-safe fallback to the exposure'sdenomination_currency_expr. Added shared helperbuild_domestic_cgcb_guarantor_exprindata/tables/eu_sovereign.pycombining the UK and EU-member branches into a single expression so the three sites cannot drift. New regression coverage:tests/integration/test_domestic_sovereign_guarantor_end_to_end.py(4 end-to-end cases through the full pipeline), a newTestGuarantorSubstitutionReadsGuaranteeCurrencyclass intests/unit/test_guarantor_exposure_class_rw.py(5 cases covering the cross-currency SA+IRB substitution), and an extendedTestDomesticSovereignGuarantorForcedToSAintests/unit/crm/test_guarantor_rating_type.pyadding the reported GBP-loan/EUR-guarantee/DE-sovereign case plus a guard against reading exposure currency (EUR loan + GBP guarantee + DE sovereign must stay IRB). Applies under both CRR and Basel 3.1 / PRA PS1/26. - Domestic sovereign guarantor 0% RW vs internal rating (CRR Art. 114(4)/(7)): When a guarantee from an EU/UK central government/central bank in its domestic currency was provided by a counterparty that the firm rates internally (i.e. carries an
internal_pd) and the firm holds IRB permission for the CGCB exposure class, the guarantor was being routed to the IRB substitution path. The downstream_apply_parameter_substitutionstep inengine/irb/guarantee.pythen overwrote the SA branch's correct 0% RW with the parametric F-IRB risk weight derived from the PD, so e.g. a DE sovereign + EUR guarantor withinternal_pd = 0.001produced ~2.6% instead of the regulatory 0%. The previous EU/UK domestic 0% fix (PR #253) handled the FX-conversion edge case but only inside the SA branch — it did not change routing, so internal-PD guarantors bypassed it. Promoted the Art. 114(4)/(7) check into the guarantor-approach routing step inengine/crm/guarantees.py: domestic-currency CGCB guarantors are now forced toguarantor_approach = "sa"ahead of the internal-PD branch, so the existing SA 0% short-circuit fires regardless of whether the guarantor has an internal rating. Theguarantor_rating_typeaudit field is unchanged — still reports"internal"when an internal PD exists, since the override is an approach decision, not a rating-source decision. Reusesbuild_eu_domestic_currency_expranddenomination_currency_expr(post-FX safe). Applies under both CRR (Art. 114(4) UK/GBP, Art. 114(7) EU member states) and Basel 3.1 (PRA PS1/26 preserves Art. 114(7) by cross-reference via third-country reciprocity). AddedTestDomesticSovereignGuarantorForcedToSAregression class intests/unit/crm/test_guarantor_rating_type.pycovering UK/GBP, DE/EUR (post-FX), PL/PLN (non-euro EU) and a non-domestic DE/USD counter-case under both frameworks.
[0.1.62] - 2026-04-17¶
Changed¶
- Version bump for PyPI release
[0.1.61] - 2026-04-15¶
Fixed¶
- EU sovereign guarantee 0% RW (CRR Art. 114(4)): Exposures guaranteed by an EU member state central government/central bank in that state's domestic currency (e.g. a German sovereign guaranteeing a EUR-denominated exposure) were failing to receive the mandated 0% risk weight when the pipeline's FX converter was active. Root cause:
engine/fx_converter.pyoverwrites the exposure'scurrencycolumn with the reporting currency and stores the pre-conversion denomination inoriginal_currency, but every downstream "denominated in domestic currency" check read the now-overwrittencurrencycolumn. After FX conversion a DE sovereign + EUR exposure appeared as DE + GBP (or whatever the reporting currency was), so the Art. 114(4) short-circuit never fired; unrated EU sovereign guarantors then fell through to.otherwise(1.0)= 100% instead of 0%. Addeddenomination_currency_expr()helper indata/tables/eu_sovereign.pythat returnspl.col("original_currency")when present, elsepl.col("currency"). Extendedbuild_eu_domestic_currency_exprto accept apl.Exprfor the currency side. Updated all seven affected call sites inengine/sa/calculator.py(borrower + guarantor),engine/irb/guarantee.py(guarantor, IRB path), andengine/classifier.py(forced-SA check for EU domestic sovereigns). ExistingTestSAEUDomesticSovereignTreatmentunit tests were bypassing the bug because they fabricated LazyFrames withcurrencyset to the denomination directly; addedTestSAEUDomesticSovereignPostFX/TestIRBEUDomesticSovereignPostFXregression classes covering the post-FX pipeline state.
[0.1.60] - 2026-04-14¶
Changed¶
- Data tables: Eliminated duplicated regulatory values in
data/tables/. Previously most_create_*_dfbuilders hardcoded numeric literals that were already defined in the module's constant dicts (CENTRAL_GOVT_CENTRAL_BANK_RISK_WEIGHTS,CORPORATE_RISK_WEIGHTS,COLLATERAL_HAIRCUTS,BASEL31_FIRB_SUPERVISORY_LGD, etc.), meaning a regulatory update required changes in 2+ places. Builders now derive their values by iterating the authoritative dict: new helpers_build_cqs_rw_df(crr),_build_int_cqs_rw_df(b31),_build_haircut_df(haircuts), and_build_firb_lgd_df/_build_b31_firb_lgd_df(firb_lgd) read values from the dicts via small row-spec tuples that define column ordering.B31_FIRB_LGD_*scalar aliases now derive fromBASEL31_FIRB_SUPERVISORY_LGD. Matches the gold-standard pattern already used inb31_equity_rw.py. New testtests/unit/test_tables_dict_dataframe_parity.py(18 cases) locks in the invariant so regressions cannot reintroduce duplication. DataFrame schemas, column/row ordering, and public API are unchanged — no regulatory values changed. - Data tables: Renamed
data/tables/crr_haircuts.py->data/tables/haircuts.pyanddata/tables/crr_firb_lgd.py->data/tables/firb_lgd.py; both files already held dual-framework content (CRR Art. 224/161 and PRA PS1/26 equivalents), so thecrr_prefix was misleading. Mergeddata/tables/b31_firb_lgd.pyintofirb_lgd.py— it was a thin re-export of the Basel 3.1 LGD dict that physically lived in the CRR-prefixed file. AllBASEL31_*/B31_*constants, lookup helpers (lookup_b31_firb_lgd,get_b31_firb_lgd_table,get_b31_vs_crr_lgd_comparison), and framework-shared helpers (FIRB_OVERCOLLATERALISATION_RATIOS,FIRB_MIN_COLLATERALISATION_THRESHOLDS,CRR_K_SCALING_FACTOR) are now infirb_lgd.py. Module docstrings updated to reflect dual-framework scope;crm_supervisory.pydocstring updated to match. Import sites updated acrossengine/, tests, and docs; publicdata/tables/__init__.pyre-exports preserved. No regulatory values changed.
[0.1.59] - 2026-04-14¶
Changed¶
- Version bump for PyPI release
[0.1.58] - 2026-04-11¶
Fixed¶
- Guarantees (#239): Fixed two bugs in multi-guarantor handling:
- Non-beneficial guarantors consuming EAD: When an exposure has multiple guarantors and some are non-beneficial, the pro-rata scaling no longer wastes EAD on non-beneficial guarantors. After the SA/IRB beneficial check, a new
redistribute_non_beneficial()function reallocates freed portions to beneficial guarantors using a greedy strategy ordered by ascending risk weight (lowest RW fills first), minimising total RWA. - FX/restructuring haircuts applied after capping: The 8% FX mismatch haircut (Art. 233(3-4)) and 40% CDS restructuring exclusion haircut (Art. 233(2)) are now applied to the nominal credit protection value (G) before capping at EAD, per CRR Art. 233/235. Previously, a large cross-currency guarantee that vastly exceeded EAD would incorrectly have coverage reduced (e.g. £200m guarantee on €1m loan → was 920k, now correctly 1m).
- CCF (P1.166): CRR OC (Other Commitments) CCF corrected from 0% to maturity-dependent values. Under CRR, the OC category did not exist — commitments were classified by maturity: >1yr → MR (50% SA / 75% F-IRB), ≤1yr → MLR (20% SA / 75% F-IRB). The only 0% category was LR (unconditionally cancellable). Previously understated capital for all OC-tagged exposures under CRR. SA CRR: OC now receives 50% (>1yr) or 20% (≤1yr, based on maturity_date vs reporting_date); 50% conservative default when maturity_date absent. F-IRB CRR: OC moved from 0% to 75% (both MR and MLR are 75% under F-IRB). Basel 3.1 OC (40%) unchanged. Updated
sa_ccf_expression(),_firb_ccf_for_col(), and_compute_ccf()with maturity-aware override. Spec F-IRB table corrected. 7 unit tests updated, 6 new tests added. - Equity (P1.132): B31 government-supported equity risk weight corrected from 100% to 250% per Art. 133(3). Art. 133(6) is an exclusion clause (own funds deductions, Art. 89(3), Art. 48(4)), not a 100% risk weight — CRR Art. 133(3)(c) legislative equity carve-out has no equivalent in B31. Previously understated capital by 2.5x for government-supported equity under B31. Government-supported equity also removed from transitional floor exclusion (now subject to floor as standard equity, though 250% already exceeds all transitional floors). Updated risk weight table, calculator, transitional floor logic, 8 unit tests, 4 acceptance tests, and spec documentation. Art. 133 paragraph references corrected across codebase (subordinated debt = Art. 133(5) not 133(1); PE/VC = Art. 133(4) not 133(5)).
- Covered Bonds (P1.113): B31 rated covered bond risk weights corrected from BCBS CRE20.28 values to PRA PS1/26 Art. 129(4) Table 7 values. CQS 2: 15%→20%, CQS 6: 50%→100%. PRA retained CRR Table 6A unchanged — did NOT adopt BCBS reductions. Previously understated capital for CQS 2 and CQS 6 covered bonds. Both
B31_COVERED_BOND_RISK_WEIGHTSdict and_create_b31_covered_bond_df()DataFrame corrected. All 77 covered bond tests updated. 3 stale doc divergence warnings converted to "Fixed" admonitions. - Equity (P1.119): CIU fallback risk weight corrected from 150% (CRR) / 250%-400% (B31) to 1,250% per Art. 132(2). Was the highest-severity capital understatement bug (3-8x). Root cause: original implementation used Art. 133 equity risk weights instead of Art. 132(2) punitive CIU fallback. Extracted shared
CIU_FALLBACK_RWconstant and_append_ciu_branches()helper to eliminate CRR/B31 code duplication. Updated risk weight tables, calculator, 27 unit tests, 7 acceptance tests, and both equity spec documents.
[0.1.57] - 2026-04-11¶
Changed¶
- Naming: Renamed functions with "and" in their names to better reflect single responsibility:
_classify_sme_and_retail->_classify_exposure_subtypes(classifier)_determine_approach_and_finalize->_assign_approach(classifier)_sink_and_scan->_spill_to_disk(materialise)_combine_irb_and_slotting->_merge_el_sources(EL summary aggregator)commit_and_push->publish_changes(git ops)- Classifier: Moved
B31_LARGE_CORPORATE_REVENUE_THRESHOLD_GBP(PRA PS1/26 Art. 147A(1)(e)) andB31_SME_TURNOVER_THRESHOLD_GBP(PRA PS1/26 Art. 153(4)) fromengine/classifier.pytodata/tables/b31_risk_weights.pyfor consistency with other B31 regulatory thresholds. Converted fromfloattoDecimal. - Pipeline: Renamed private methods in
PipelineOrchestratorto remove stale fan-out/single-pass terminology:_run_crm_processor_unified->_run_crm_processor,_run_single_pass->_run_calculators,_aggregate_single_pass->_aggregate_results. Section header renamed from "Single-Pass Pipeline" to "Calculation". - Pipeline: Removed dead code
_run_sa_calculatorand_run_irb_calculator(never called from production; superseded bycalculate_branch()in the single-pass path). Associated tests removed.
[0.1.56] - 2026-04-11¶
Changed¶
- Version bump for PyPI release
[0.1.55] - 2026-04-09¶
Fixed¶
- Classifier: Exposures with internal ratings no longer silently route to Standardised Approach when
permission_mode="irb"is set onCreditRiskCalc. Two independent bugs are addressed: - Pipeline downgrade (Bug #1):
PipelineOrchestrator.run_with_datauseddataclasses.replace(config, permission_mode=STANDARDISED)whenmodel_permissionswas absent, which re-ranCalculationConfig.__post_init__and wipedirb_permissionstosa_only(). The pipeline now preserves the user's org-wide IRB permissions and emits amissing_model_permissionspipeline error explaining that per-model gating is disabled. - Silent classifier join failure (Bug #2):
ExposureClassifier._resolve_model_permissionsjoinedexposure.model_idLEFT againstmodel_permissions.model_id. Null or unmatchedmodel_idvalues produced no match and silently routed to SA with no diagnostic. The classifier now tags each IRB-eligible miss with one of three causes (null_model_id,unmatched_model_id,filter_rejected) and emits a rolled-upCLS006(ERROR_MODEL_PERMISSION_UNMATCHED) classification warning per cause with targeted remediation guidance. - Tests: Added
TestModelPermissionsDiagnostics(4 integration tests) andTestPipelineIRBWithoutModelPermissions(1 integration test) intests/integration/test_model_permissions_pipeline.py, plus a regression guardtest_irb_mode_preserves_full_irb_after_pipeline_initintests/unit/test_irb_approach_selection.py. - Docs: Replaced fabricated double-default formula in
crm.mdwith correct CRR Art. 153(3) formulaK_dd = K_obligor × (0.15 + 160 × PD_guarantor)(D3.7). Added eligibility requirements (Art. 202/217), guarantor RW floor, and Basel 3.1 removal warning with cross-link to A-IRB spec. - Docs: SA specialised lending waterfall position documented in
key-differences.md(D2.20). Waterfall item 15 annotated with Art. 122–122B SA SL sub-classification cross-reference. New admonition added explaining SA SL sits within corporates (row 15, Art. 112(1)(g)), with IPRE excluded per Art. 122A(1) ("not a real estate exposure") — IPRE is caught at row 7 (real estate, Art. 124–124L) instead. SA SL section expanded with: - Art. 122A(1) 4-part definition criteria (SPV structure, asset dependency, lender control, asset income repayment)
- Art. 122A(2) sub-type classification (OF, CF, PF)
- IPRE exclusion warning admonition with cross-reference to real estate section
- Art. 122B(1) rated SL fallthrough to corporate CQS table
- Art. 122B(2) unrated risk weight table with article references per row
- Art. 122B(3) operational phase definition (positive net cash-flow + declining LT debt)
- Art. 122B(4)–(5) high-quality PF criteria (8 structural conditions)
[0.1.54] - 2026-04-08¶
Added¶
- COREP: Reporting basis conditionality for output floor (P1.38(c)).
COREPGeneratornow acceptsoutput_floor_config: OutputFloorConfigto gate floor-related COREP template content on entity-type applicability per Art. 92 para 2A: - OF 02.00 rows 0034-0036 (floor activated/multiplier/OF-ADJ) show 0.0 for exempt entities (international subsidiaries, ring-fenced bodies on individual basis, etc.)
- OF 02.01 (output floor comparison) returns None for exempt entities — only applicable entities report the floor comparison
- C 08.07 materiality columns 0160-0180 documented as consolidated-basis-only (Art. 150(1A)), threaded with
is_consolidatedflag for future population - COREPTemplateBundle extended with
reporting_basisandinstitution_typemetadata fields - ResultExporterProtocol and ResultExporter accept
output_floor_configkeyword parameter - Tests: 38 new tests in
tests/unit/test_corep_reporting_basis.pyacross 7 test classes: COREPTemplateBundleMetadata (7), OF0201FloorApplicability (6), OF0200FloorIndicatorRows (7), C0807MaterialityColumns (4), BackwardCompatibility (3), EntityTypeCombinations (9 parametrized), ExporterProtocolCompliance (2). Total: 5,125 (was 5,087). Contract tests: 145. - Tests: 26 new tests in
tests/unit/crm/test_equity_main_index.pyacross 7 test classes: schema validation, CRR/B31 haircut verification for main-index and other-listed, backward compatibility, precedence over eligibility flag, mixed collateral, and full pipeline end-to-end (other-listed EAD = 625k vs main-index EAD = 575k on 1M exposure with 500k equity collateral). Total: 5,087 (was 5,061). - Tests: 36 new CRM acceptance tests in
tests/acceptance/crr/test_scenario_crr_d2_crm_advanced.pyacross 13 test classes covering advanced CRM scenarios not tested by the basic D1-D6/G1-G3 groups: non-beneficial guarantee (guarantor RW = borrower RW), sovereign guarantee 0% substitution, CDS restructuring exclusion (40% haircut, Art. 216(1)/233(2)), CDS with restructuring (no haircut contrast), gold collateral (15% CRR haircut), equity collateral (main-index 15%), overcollateralisation (EAD=0), full CRM chain (provision+collateral+guarantee), mixed collateral types (cash+bond), SA provision EAD deduction, multiple provisions summed, provision+collateral combined, and structural baseline validation. CRR acceptance: 169 (was 133). Total: 5,061 (was 5,025). (P5.3) - COREP: C 09.01 / OF 09.01 — CR GB 1 geographical breakdown SA. One DataFrame per country code + TOTAL. CRR: 13 columns (0010-0090 incl. supporting factors), 23 rows. Basel 3.1: 10 columns (removes supporting factors), 29 rows (adds SL sub-rows 0071-0073, RE sub-rows 0091-0094, removes short-term row). Uses
cp_country_codefrom counterparty schema. Template definitions, generator methods, class maps, framework selectors. - COREP: C 09.02 / OF 09.02 — CR GB 2 geographical breakdown IRB. One DataFrame per country code + TOTAL. CRR: 17 columns (incl. PD, LGD, EL, supporting factors), 16 rows (incl. equity). Basel 3.1: 15 columns (adds 0107 defaulted EV, removes supporting factors), 19 rows (adds corporate sub-rows, restructures retail RE, removes equity).
- Tests: 80 new COREP tests for C 09.01/09.02 across 10 test classes. COREP tests: 635 (was 555). Total: 4,953 (was 4,873). (P2.3)
- COREP: C 08.04 / OF 08.04 — CR IRB RWEA flow statements. 1 column (RWEA) × 9 rows (opening, 7 movement drivers, closing) per IRB exposure class. Closing RWEA (row 0090) populated from pipeline; opening and drivers null (require prior-period data). Slotting excluded. CRR column names "after supporting factors"; Basel 3.1 removes supporting factors reference. Template definitions:
CRR_C08_04_COLUMNS,B31_C08_04_COLUMNS,C08_04_ROWS,C08_04_COLUMN_REFS,get_c08_04_columns(). Generator:_generate_all_c08_04(),_generate_c08_04_for_class().COREPTemplateBundle.c08_04field (dict[str, pl.DataFrame]). Excel export with C 08.04 / OF 08.04 prefix. - Tests: 41 new COREP tests for C 08.04 across 6 test classes (TestC0804TemplateDefinitions: 13, TestC0804Generation: 5, TestC0804ClosingRWEA: 4, TestC0804NullDriverRows: 9, TestC0804B31Features: 3, TestC0804EdgeCases: 7). COREP tests: 555 (was 514). (P2.2)
- Pillar III: UKB CR9 — IRB PD backtesting per exposure class (Art. 452(h)). 8 columns × 17 PD buckets + total row. Basel 3.1 only. Separate F-IRB and A-IRB template sets. Uses
irb_pd_originalfor bucket allocation (beginning-of-period proxy). Includes obligor count, default count, observed default rate, EAD-weighted average PD, arithmetic mean PD, historical annual default rate. - Pillar III: UKB CR9.1 — ECAI mapping PD backtesting (Art. 180(1)(f)). Template definitions only; generation deferred until pipeline provides firm-specific ECAI mapping data.
- Pillar III:
Pillar3TemplateBundle.cr9field added (dict of approach–class keyed DataFrames) - Pillar III: CR9 Excel export via
export_to_excel()with human-readable sheet names (e.g., "UKB CR9 F-IRB Corp") - Tests: 44 new tests for CR9/CR9.1 across 7 test classes (definitions, generation, column values, PD allocation, edge cases, bundle integration, Excel export). Total: 4,832 (was 4,788). (P3.2)
Fixed¶
- Docs: Art. 128 (high-risk items, 150%) UK CRR omission clarified across 6 files (D1.28, D4.9). Art. 128 was omitted from UK onshored CRR by SI 2021/1078, reg. 6(3)(a), effective 1 January 2022 — the high-risk exposure class is a dead letter under current UK CRR. Re-introduced under PRA PS1/26 (Basel 3.1, from 1 January 2027) with paragraphs 1 and 3 retained (paragraph 2 left blank). Files updated:
specifications/crr/sa-risk-weights.md: Added omission admonition, B31 re-introduction note, code bug cross-reference (D3.12), and exposure class waterfall clarification (equity priority 3 > high-risk priority 4)user-guide/exposure-classes/other.md: Restructured "Items Associated with High Risk" section — added framework applicability warning, corrected table to Art. 128 items only (speculative RE, PRA-designated), added waterfall note explaining PE/VC are equity (Art. 133), not high-riskframework-comparison/key-differences.md: Corrected equity table row (removed "(or 150% if Art. 128 high-risk)" — PE/VC is equity per waterfall), added Art. 128 re-introduction admonition to priority waterfall sectionuser-guide/regulatory/crr.md: Added "Omitted Provisions" section documenting Art. 128 and Art. 132 omissions by SI 2021/1078specifications/crr/equity-approach.md: Corrected Art. 128 note to explain waterfall precedence (equity > high-risk) and UK CRR omissionspecifications/common/hierarchy-classification.md: Updated calculator coverage note with Art. 128 framework status and CRR legal basis issue- Docs: Documentation accuracy sweep correcting wrong regulatory values across 13 files (P4.5, P4.6, P4.22):
- PD floors (P4.5): Retail mortgage Basel 3.1 PD floor corrected from 0.05% to 0.10% (Art. 163(1)(b)) in 5 files. QRRE transactor Basel 3.1 PD floor corrected from 0.03% to 0.05% (Art. 163(1)(c)) in 5 files. Affected:
api/configuration.md,user-guide/configuration.md,user-guide/exposure-classes/retail.md,data-model/regulatory-tables.md. - LGD floors (P4.6): Corporate LGD floor code example corrected (RECEIVABLES 15%→10%, CRE 15%→10%, OTHER_PHYSICAL 20%→15%) in
user-guide/configuration.md. Corporateresidential_real_estatefield corrected from 0.05 to 0.10 (Art. 161(5)) inapi/configuration.md— was showing retail floor instead of corporate floor. - Output floor schedule (P4.22): BCBS 6-year schedule (50%/55%/60%/65%/70%/72.5%, 2027–2032) replaced with PRA 4-year schedule (60%/65%/70%/72.5%, 2027–2030) across 12 files. Affected:
plans/implementation-plan.md,api/engine.md,api/contracts.md,framework-comparison/reporting-differences.md,plans/prd.md,specifications/index.md,features/index.md,specifications/regulatory-compliance.md,framework-comparison/index.md,appendix/index.md,framework-comparison/impact-analysis.md,user-guide/configuration.md. - CRM: Decoupled
is_main_indexfromis_eligible_financial_collateralfor equity collateral haircuts (P6.21). Addedis_main_indexBoolean field toCOLLATERAL_SCHEMA. When present, drives haircut lookup directly:True= main-index (CRR 15%, B31 20%),False= other-listed (CRR 25%, B31 30%). When absent, falls back tois_eligible_financial_collateralfor backward compatibility. Previously all eligible equity was forced to the main-index haircut tier. - COREP: OF 02.00 IRB sub-row splits — rows 0295-0297 (FSE/large, SME, non-SME corporates), 0355-0356 (retail RE SME/non-SME), 0382-0385 (corporate RE sub-splits), 0400/0410 (other retail SME/non-SME) now populated from pipeline data instead of hardcoded 0.0. Uses finer-grained aggregation keyed by (approach, exposure_class, is_sme, apply_fi_scalar, property_type).
- COREP: OF 02.00 floor indicator rows 0035/0036 — floor_pct and of_adj now populated from
OutputFloorSummarywhen provided, instead of hardcoded 0.0. - COREP:
_filter_re()fallback chain — gracefully degrades frommaterially_dependent_on_property→has_income_cover→is_income_producingwhen pipeline columns vary. Null handling corrected: only fallback columns usefill_null(False), preserving null-as-unclassified semantics for the primary column. - Equity:
_apply_transitional_floor()now emitsequity_transitional_approachandequity_higher_riskannotation columns for COREP OF 07.00 rows 0371-0374. - Tests: 24 new COREP tests across 4 classes (IRB sub-row splits, floor indicators, RE fallback, equity transitional columns). COREP tests: 687 (was 663). Total: 5,025 (was 5,001). (P2.5)
[0.1.53] - 2026-04-07¶
Changed¶
- Version bump for PyPI release
[0.1.52] - 2026-04-06¶
Changed¶
- Version bump for PyPI release
[0.1.51] - 2026-04-05¶
Changed¶
- Version bump for PyPI release
[0.1.50] - 2026-04-01¶
Changed¶
- Version bump for PyPI release
[0.1.49] - 2026-03-30¶
Changed¶
- Version bump for PyPI release
[0.1.48] - 2026-03-29¶
Changed¶
- Version bump for PyPI release
[0.1.47] - 2026-03-28¶
Changed¶
- Version bump for PyPI release
[0.1.46] - 2026-03-28¶
Changed¶
- Version bump for PyPI release
[0.1.45] - 2026-03-27¶
Added¶
CCP Guarantor Risk Weight Support (CRR Art. 306 / CRE54.14-15)¶
CCP guarantors now receive the prescribed QCCP risk weight (2% proprietary / 4% client-cleared) instead of being treated as generic unrated institutions (40% RW). The guarantee substitution when/then chain in both the SA calculator and IRB namespace checks guarantor_entity_type == "ccp" before the institution/MDB branch, applying QCCP_PROPRIETARY_RW (2%) or QCCP_CLIENT_CLEARED_RW (4%) based on guarantor_is_ccp_client_cleared.
- CRM processor and namespace propagate
is_ccp_client_clearedfrom guarantor counterparty data - Entity type normalization (
.str.to_lowercase()) applied to guarantor entity type joins
[0.1.44] - 2026-03-25¶
Added¶
~~Article 114(4)~~ Article 114(7) EU domestic currency 0% risk weight for EU sovereigns¶
Correction (D4.35)
The original entry cited Art. 114(4). In the UK-onshored CRR, Art. 114(4) covers only the UK central government and Bank of England in sterling. EU member state domestic-currency treatment is provided by Art. 114(7) (third-country reciprocity).
EU member state central government and central bank exposures denominated in that member state's domestic currency now receive 0% risk weight regardless of CQS, per CRR Art. 114(7). Covers all 27 EU member states: eurozone members (EUR) and non-euro members in their national currencies (PLN, SEK, CZK, DKK, HUF, BGN, RON). EU domestic sovereign exposures are also forced to the Standardised Approach, preventing internal models from overriding the regulatory 0% treatment. Applies to both direct exposures and guarantor risk weight substitution (SA and IRB).
is_ccp_client_clearedfield added to data generators
Fixed¶
- CCP exposures now forced to SA approach with correct risk weights (was falling through to generic corporate treatment)
[0.1.43] - 2026-03-24¶
Fixed¶
Guarantee application expanded to facility and counterparty levels¶
Guarantee application previously only matched at direct (loan/exposure/contingent) level. Guarantees linked at facility or counterparty level were silently ignored. Now supports multi-level beneficiary matching: direct, facility (pro-rata across facility's exposures), and counterparty (pro-rata across all counterparty exposures).
[0.1.42] - 2026-03-22¶
Fixed¶
Slotting maturity not derived from maturity_date¶
The is_short_maturity flag for CRR Art. 153(5) specialised lending was never calculated from exposure maturity_date. It defaulted to False, causing all exposures to receive the >= 2.5yr risk weights regardless of actual remaining maturity. Strong category exposures with <2.5yr maturity now correctly receive 50% RW (was 70%), Good receives 70% (was 90%), HVCRE Strong receives 70% (was 95%), and HVCRE Good receives 95% (was 120%).
prepare_columns()now acceptsCalculationConfigand derivesis_short_maturityfrommaturity_dateandreporting_date- Extracted
exact_fractional_years_exprto sharedengine/utils.py(reused by IRB and slotting) - Added
remaining_maturity_yearscolumn to slotting audit trail - Added CRR-E5 through CRR-E8 acceptance scenarios for short-maturity slotting
UK govt guarantee exposure marked "not beneficial" for non-sovereign entity types¶
Guarantor risk weight lookup used regex matching on guarantor_entity_type (e.g., contains("SOVEREIGN")), which only matched sovereign but not central_bank, bank, company, or mdb. These entity types produced null guarantor RW, causing beneficial guarantees to be incorrectly skipped. The lookup now uses guarantor_exposure_class (derived from the existing ENTITY_TYPE_TO_SA_CLASS mapping), ensuring all valid entity types resolve to the correct SA risk weight. Also adds Art. 114(4) domestic sovereign treatment: UK CGCB guarantors in GBP receive 0% RW regardless of CQS. (Correction (D4.35): original entry cited Art. 114(3); Art. 114(3) is the ECB provision, Art. 114(4) is UK domestic currency.) Both SA calculator and IRB namespace are fixed. CRM processor and namespace now propagate guarantor_country_code from counterparty data.
[0.1.41] - 2026-03-22¶
Added¶
~~Article 114(3)~~ Article 114(4) domestic currency 0% risk weight for UK sovereign¶
Correction (D4.35)
The original entry cited Art. 114(3). CRR Art. 114(3) is the ECB 0% provision. The UK domestic currency provision is Art. 114(4).
UK central government and central bank exposures denominated in GBP now receive 0% risk weight regardless of CQS, per CRR Art. 114(4). Previously, 0% was only assigned via CQS 1 external rating lookup. The override applies in both CRR and Basel 3.1 SA risk weight chains. Foreign-currency UK sovereign exposures continue to use the standard CQS-based risk weight table.
[0.1.40] - 2026-03-22¶
Changed¶
Specialised lending now input-driven via counterparty_reference¶
Specialised lending metadata (sl_type, slotting_category, is_hvcre) is now supplied as an input file (exposures/specialised_lending.parquet) keyed by counterparty_reference, rather than being derived from counterparty reference naming conventions. This allows a corporate counterparty to have both SL and non-SL exposures, aligning with CRR Art. 147(8) and BCBS CRE30.6.
- New input file:
ratings/specialised_lending.parquet - Schema change:
exposure_referencereplaced withcounterparty_reference;remaining_maturity_yearsremoved (sourced from loan/facility data) - Removed dead code:
_build_slotting_category_expr(),_build_sl_type_expr(), and counterparty reference naming convention logic in the classifier
Fixed¶
FI scalar (apply_fi_scalar) not applied to IRB correlation¶
The apply_fi_scalar counterparty flag was gated on is_financial_sector_entity, which required the entity_type to be an institution-like value. Counterparties with entity_type="corporate" and apply_fi_scalar=True silently received no 1.25x correlation multiplier. The classifier now derives requires_fi_scalar directly from the user-supplied apply_fi_scalar flag.
Removed dead code: FINANCIAL_SECTOR_ENTITY_TYPES, is_financial_sector_entity, and is_large_financial_sector_entity — set in the classifier but never consumed by any calculation engine.
[0.1.39] - 2026-03-21¶
Fixed¶
- SME managed-as-retail 75% RW now correctly gated on EUR 1m turnover threshold check (was applying 75% RW without verifying threshold)
Changed¶
- Documentation aligned with current codebase state
[0.1.38] - 2026-03-20¶
Fixed¶
- Null
slotting_categoryandsl_typefor non-slotting exposures (was leaving stale values from classification) - Defaulted exposure treatment for SA risk weights now correctly implemented
- Case-insensitive column value validation (lowercase valid values set before comparison)
country_codesandexcluded_book_codescolumns inmodel_permissionsinput are now truly optional — when absent, treated as null (all geographies permitted, no book code exclusions). Previously causedColumnNotFoundError- Documentation aligned with code schemas across 13 files
[0.1.37] - 2026-03-17¶
Fixed¶
- Validation error messages now correctly convert file paths to string (was raising
TypeErrorforPathobjects)
[0.1.36] - 2026-03-15¶
Changed¶
Model ID moved from counterparty to ratings level (Breaking)¶
model_id has been moved from COUNTERPARTY_SCHEMA to RATINGS_SCHEMA. The rating inheritance pipeline now carries model_id alongside internal_pd through parent-child inheritance, eliminating the redundant counterparty-to-exposure propagation path.
- Removed:
model_idfromCOUNTERPARTY_SCHEMA - Added:
model_idtoRATINGS_SCHEMA - Updated: Rating inheritance pipeline carries
internal_model_idthrough coalesce (own → parent) - Updated:
_unify_exposures()sourcesmodel_idfrom rating inheritance instead of counterparty join - Updated: Fixture generators, integration tests, benchmark data generators, and documentation
- Counterparty data handling consolidated
[0.1.35] - 2026-03-11¶
Added¶
Integration Test Infrastructure¶
Comprehensive integration test suite covering the full pipeline from loader to output:
- Phase 1: Hierarchy → Classifier flow tests
- Phase 2: Classifier → CRM and CRM → Calculators flow tests
- Phase 3: Loader → Hierarchy, model permissions, and output floor tests
- Phase 4: Equity flow integration tests
- Integration test strategy document and shared infrastructure
Changed¶
model_idadded to counterparty-level schema (subsequently moved to ratings in 0.1.36)
[0.1.34] - 2026-03-10¶
Added¶
Model-Level IRB Permissions¶
Per-model IRB approach gating replaces the org-wide IRBPermissions config when a model_permissions input file is provided:
- New schema:
MODEL_PERMISSIONS_SCHEMAwithmodel_id,exposure_class,approach,country_codes,excluded_book_codes - New column:
model_idonFACILITY_SCHEMA,LOAN_SCHEMA,CONTINGENTS_SCHEMA— links exposures to their IRB model - Classifier:
_resolve_model_permissions()joins exposures with model permissions, filters by geography and book code, gates approach on both permission and data availability (AIRB requiresinternal_pd+lgd; FIRB requires onlyinternal_pd) - Backward compatible: When no
model_permissionsfile is present, org-wideIRBPermissionsfallback applies - Validation:
model_permissionsincluded invalidate_raw_data_bundle()andvalidate_bundle_values()for schema and value validation model_permissionsfixtures andmodel_idadded to exposure generators- API documentation updated
- 10 unit tests covering AIRB/FIRB gating, geography filters, book code exclusions, and backward compatibility
Rename is_regulated → apply_fi_scalar¶
Simplified FI scalar control on COUNTERPARTY_SCHEMA:
- Schema:
is_regulatedrenamed toapply_fi_scalar— direct user-controlled flag replacing the intermediate boolean - Classifier:
requires_fi_scalarnow derives fromis_financial_sector_entity AND cp_apply_fi_scalar(simpler than the previous two-condition inference fromis_regulated) - Documentation: All references updated across input schemas, architecture, and classification docs
[0.1.33] - 2026-03-09¶
Added¶
Dual Per-Type Rating Resolution¶
Rating inheritance now resolves best internal and best external rating per counterparty independently. CQS is an external-only concept; internal ratings carry PD values without internal CQS.
- Per-type columns:
internal_pd,internal_rating_value,external_cqs,external_rating_value - Per-type inheritance: own internal → parent internal, own external → parent external (independent chains)
- Removed internal CQS references throughout the codebase
Changed¶
- Enhanced netting facility handling in loan data
[0.1.32] - 2026-03-08¶
Added¶
netting_facility_referencefield added toLOAN_SCHEMAand loan data for explicit netting group assignment
[0.1.31] - 2026-03-07¶
Added¶
- Enhanced netting logic for facility siblings (pro-rata allocation within netting groups)
interest_for_eadfunction in CCF module to handle negative interest values
[0.1.30] - 2026-03-06¶
Added¶
Basel 3.1 Engine¶
Full Basel 3.1 framework implementation alongside existing CRR support:
- Revised SA risk weight tables (CRE20.7-26) with LTV-band risk weights for residential and commercial real estate
- Basel 3.1 supervisory haircuts and F-IRB LGD framework dispatch
- Output floor: SA-equivalent RWA calculation on all IRB rows with phase-in schedule
- Basel 3.1 acceptance tests: B31-B (F-IRB), B31-C (A-IRB), B31-D (CRM), B31-E (slotting), B31-G (provisions), B31-H (complex scenarios) — 116 tests total
- IRB: A-IRB LGD floors gated on
is_airbcolumn (CRE30.41) - IRB: QRRE transactor/revolver PD floor distinction (CRR Art. 147(5), CRE30.55)
Dual-Framework Comparison and Analysis¶
- M3.1: CRR vs Basel 3.1 side-by-side comparison with per-exposure RWA delta
- M3.2: Capital impact analysis with driver attribution
- M3.3: Transitional floor schedule modelling with year-by-year phase-in
- M3.4: Enhanced Marimo workbook for interactive impact analysis
EL Shortfall/Excess (CRR Art. 158-159)¶
Expected loss shortfall/excess computation for IRB portfolios, with portfolio-level Tier 2 credit cap per CRR Art. 62(d).
COREP Template Generation (FR-4.6 / M4.1)¶
Regulatory reporting templates for CRR firms following EBA/PRA COREP structure (Regulation (EU) 2021/451):
- C 07.00 — SA credit risk: original exposure, SA EAD, RWA by exposure class, plus risk weight band breakdown
- C 08.01 — IRB totals: original exposure, IRB EAD, RWA, expected loss, weighted-average PD/LGD/maturity by exposure class
- C 08.02 — IRB PD grade breakdown: obligor-grade-level detail with standard PD bands and exposure-weighted averages
COREPGeneratorclass withgenerate()andexport_to_excel()methodsResultExporter.export_to_corep()for multi-sheet Excel exportCalculationResponse.to_corep()convenience method
Programmatic Export API (FR-4.7)¶
Export calculation results to Parquet, CSV, and Excel formats programmatically.
On-Balance Sheet Netting (CRR Article 195)¶
Support for on-balance sheet netting of mutual claims when a legally enforceable netting agreement exists:
- New fields:
has_netting_agreementandnetting_facility_referenceonLOAN_SCHEMAandLoanfixture - Synthetic cash collateral: Negative-drawn netting-eligible loans generate cash collateral that reduces all positive-drawn sibling exposures pro-rata within the same netting facility
- Netting facility resolution: Priority chain — explicit
netting_facility_reference→root_facility_reference→parent_facility_reference - SA: EAD reduced by netting pool (cash = 0% haircut)
- F-IRB: LGD reduced via cash collateral path (0% LGD)
- FX mismatch: 8% haircut applied when currencies differ
Service API Documentation¶
Restructured user-facing documentation to promote the high-level Service API (quick_calculate, RWAService) as the primary entry point:
- Quick Start rewritten with 3-tier progression:
quick_calculateone-liner,RWAServicewith more control, full example with validation/export - New page:
docs/api/service.md— complete Service API reference - API Reference index features Service API as first module
Basel 3.1 Parameter Substitution for IRB Guarantors (CRE22.70-85)¶
IRB guarantee substitution parameters updated for Basel 3.1 framework.
CI/CD Pipeline¶
GitHub Actions workflow with lint, typecheck, and test jobs.
Changed¶
- Replaced
EnumwithStrEnumandIntEnumthroughout the codebase - Centralised data source configuration with
DataSourceRegistryreplacingRequiredFiles - Introduced
BaseRequestclass to reduce duplication in request models - Error factory functions updated to support
Pathtypes alongsidestr - Tests migrated to use
Pathfor file paths
Fixed¶
- Corporate bond haircut CQS grouping corrected per CRR Art. 224
- PD floors and transitional schedule corrected to PRA PS1/26
- Output floor
sa_rwacomputation fixed for acceptance tests - Benchmark data generators now include all schema columns (
is_buy_to_let,interest,bs_type,pledge_percentage,is_qrre_transactor) - Benchmark tests updated for current API:
_unify_exposuressignature (addedfacilitiesarg),CRMProcessor.get_crm_adjusted_bundle - Protocol test stubs updated to include
calculate_branchmethod
[0.1.29] - 2026-02-28¶
Added¶
- F-IRB acceptance tests and expected outputs (CRR-B1 through B7)
Changed¶
- Pipeline refactored to single-pass calculation for unified frame (filter-process-merge pattern)
- Classifier exposure classification logic optimized
- Hierarchy collateral allocation logic simplified
- RWA calculations simplified with filter-process-merge approach
Performance¶
- Pipeline optimizations: pre-computed classifier intermediates, deferred audit string, slimmed counterparty join, eliminated unnecessary
collect_schema()calls - Full CRR pipeline at 100K: ~1.7s mean (SA-only ~1.7s, CRR ~1.9s)
[0.1.28] - 2026-02-24¶
Added¶
- Benchmarking module for RWA Calculator performance testing
Performance¶
- Optimized aggregation data collection and processing
- Optimized hierarchy graph traversal methods
- Optimized exposure enrichment methods
- Optimized pledge resolution and validation in pipeline
[0.1.27] - 2026-02-22¶
Added¶
- Results caching with lazy loading for improved pipeline performance
Changed¶
- Replaced custom validation methods with shared utility functions across hierarchy, loader, pipeline, and processor
- Replaced
enable_irbboolean config withirb_approachenum for clearer IRB permission modelling - Optimized data materialization to reduce redundant
.collect()calls - Multiple speed optimization PRs merged (aggregator, formatters, validation)
[0.1.26] - 2026-02-21¶
Performance¶
- Optimized aggregator processing for large result sets
- Optimized formatter output generation
- Streamlined validation data processing to reduce overhead
- UI speed improvements for interactive calculator
[0.1.25] - 2026-02-20¶
Added¶
IRB Defaulted Exposure Treatment (CRR Art. 153(1)(ii), 154(1)(i))¶
- Defaulted exposures (PD=1.0) receive K=0 under F-IRB and K=max(0, LGD-BEEL) under A-IRB
- Expected loss = LGD × EAD for defaulted exposures
- CRR 1.06 scaling factor correctly applied to defaulted corporate exposures
- New CRR-I acceptance test group with 9 tests (I1 F-IRB corporate, I2 A-IRB retail, I3 A-IRB corporate with CRR scaling)
Fixed¶
- SME supporting factor now correctly uses drawn amount (not EAD) for tier threshold calculation
[0.1.24] - 2026-02-19¶
Added¶
Multi-Level SA Collateral Allocation¶
- Multi-level collateral allocation for SA EAD reduction with overcollateralisation compliance
- Haircut calculator enhancements for multi-level processing
[0.1.23] - 2026-02-17¶
Added¶
SA Provision Handling — Art. 111(1)(a)-(b) Compliance¶
Provisions are now resolved before CCF application using a drawn-first deduction approach, compliant with CRR Art. 111(1)(a)-(b):
Pipeline reorder:
New method: resolve_provisions() with multi-level beneficiary resolution:
- Direct (loan/exposure/contingent): provision matched to specific exposure
- Facility: distributed pro-rata across facility's exposures
- Counterparty: distributed pro-rata across all counterparty exposures
SA drawn-first deduction:
- provision_on_drawn = min(provision, max(0, drawn)) — absorbs provision against drawn first
- Remainder → provision_on_nominal — reduces nominal before CCF
- nominal_after_provision = nominal_amount - provision_on_nominal feeds into CCF
IRB/Slotting: Provisions tracked (provision_allocated) but NOT deducted from EAD (feeds EL shortfall/excess comparison)
New columns:
| Column | Type | Description |
|--------|------|-------------|
| provision_on_drawn | Float64 | Provision absorbed by drawn (SA only) |
| provision_on_nominal | Float64 | Provision reducing nominal before CCF (SA only) |
| nominal_after_provision | Float64 | nominal_amount - provision_on_nominal |
| provision_deducted | Float64 | Total = provision_on_drawn + provision_on_nominal |
| provision_allocated | Float64 | Total provision matched to this exposure |
Other changes:
- finalize_ead() no longer subtracts provisions (already baked into ead_pre_crm)
- _initialize_ead() preserves existing provision columns if set by resolve_provisions
- 14 unit tests in tests/unit/crm/test_provisions.py
- CCF test suite expanded to 57 tests
[0.1.22] - 2026-02-16¶
Changed¶
- Slotting risk weights updated for remaining maturity splits (CRR Art. 153(5))
- Config enhancements for slotting maturity bands
[0.1.21] - 2026-02-16¶
Added¶
Pledge Percentage for Collateral Valuation¶
- Introduced
pledge_percentagefield to allow collateral to be specified as a percentage of the beneficiary's EAD - Collateral processing resolves
pledge_percentageto absolute market values based on beneficiary type (loan, facility, or counterparty level) - Updated input schemas and CRM methodology documentation to reflect the new field
- 403 lines of new tests covering pledge percentage resolution across different beneficiary levels
[0.1.20] - 2026-02-14¶
Added¶
Equity Exposure FX Conversion¶
- New
convert_equity_exposures()method in FX converter for converting equity exposure values to reporting currency - Updated classifier and hierarchy to support equity exposures in FX conversion pipeline
- Enhanced FX rate configuration with equity-specific handling
- Comprehensive tests for equity exposure conversion and currency handling
[0.1.19] - 2026-02-11¶
Added¶
Buy-to-Let Flag¶
- New
is_buy_to_letboolean flag in hierarchy and schemas for identifying BTL exposures - BTL exposures excluded from SME supporting factor discount
- Unit tests verifying BTL flag behaviour in supporting factor calculations
On-Balance EAD Helper¶
- New
on_balance_ead()helper function in CCF module calculating EAD asmax(0, drawn) + interest - Updated CRM processor and namespace to use the new helper
- Comprehensive tests covering various on-balance EAD scenarios
Changed¶
- Updated implementation plan and roadmap documentation with current test results and fixture completion status
[0.1.18] - 2026-02-10¶
Added¶
Facility Hierarchy Enhancements¶
- Facility root lookup and undrawn calculations for full facility hierarchy resolution
- Include contingent liabilities in facility undrawn calculations
- Enhanced facility hierarchy resolution logic
[0.1.17] - 2026-02-10¶
Added¶
- CCF: handle negative drawn amounts in EAD calculations
Fixed¶
- Hierarchy: resolve duplicate mapping issues in facility calculations
[0.1.16] - 2026-02-09¶
Added¶
Cross-Approach CCF Substitution¶
- SA CCF expression and cross-approach substitution for guaranteed IRB exposures
- When an IRB exposure is guaranteed by an SA counterparty, the guaranteed portion uses SA CCFs
- New columns:
ccf_original,ccf_guaranteed,ccf_unguaranteed,guarantee_ratio,guarantor_approach,guarantor_rating_type
Aggregator Enhancements¶
- Updated summaries for post-CRM reporting
- Enhanced approach handling for IRB results
[0.1.15] - 2026-02-08¶
Added¶
- Correlation: rename sovereign exposure class to central govt/central bank
- CI: add GitHub Actions workflow for documentation deployment
[0.1.14] - 2026-02-07¶
Added¶
Overcollateralisation Requirements (CRR Art. 230 / CRE32.9-12)¶
Non-financial collateral now requires overcollateralisation to receive CRM benefit:
| Collateral Type | Overcollateralisation Ratio | Minimum Threshold |
|---|---|---|
| Financial | 1.0x | No minimum |
| Receivables | 1.25x | No minimum |
| Real estate | 1.4x | 30% of EAD |
| Other physical | 1.4x | 30% of EAD |
effectively_secured = adjusted_value / overcollateralisation_ratio- Financial vs non-financial collateral tracked separately for threshold checks
- Multi-level allocation respects overcollateralisation at each level
Changed¶
- Standardized
collateral_typecasing and descriptions across codebase
[0.1.13] - 2026-02-07¶
Added¶
Input Value Validation¶
validate_bundle_values()validates all categorical columns againstCOLUMN_VALUE_CONSTRAINTS- Error code
DQ006for invalid column values - Pipeline calls
_validate_input_data()as non-blocking step (errors collected, not raised)
Fixed¶
- Prevented row duplication in exposure joins when
facility_reference = loan_reference(#71)
[0.1.12] - 2026-02-02¶
Added¶
Equity Exposure Calculator¶
Complete equity exposure RWA calculation supporting two regulatory approaches:
Article 133 - Standardised Approach (SA): | Equity Type | Risk Weight | |-------------|-------------| | Central bank | 0% | | Listed/Exchange-traded/Government-supported | 100% | | Unlisted/Private equity | 250% | | Speculative | 400% |
Article 155 - IRB Simple Risk Weight Method: | Equity Type | Risk Weight | |-------------|-------------| | Central bank | 0% | | Private equity (diversified portfolio) | 190% | | ~~Government-supported~~ | ~~190%~~ | | Exchange-traded/Listed | 290% | | Other equity | 370% |
Correction (D1.27)
"Government-supported: 190%" was incorrectly listed as an Art. 155 category. Art. 155(2) has only three categories: (a) exchange-traded 290%, (b) PE diversified 190%, (c) all other 370%. No "government-supported" category exists in Art. 155.
New Components:
- EquityCalculator class (src/rwa_calc/engine/equity/calculator.py)
- EquityLazyFrame namespace (lf.equity) for fluent calculations
- EquityExpr namespace (expr.equity) for column-level operations
- EquityResultBundle for equity calculation results
- crr_equity_rw.py lookup tables
Features: - Automatic approach determination based on IRB permissions - Diversified portfolio treatment for private equity (190% vs 370%) - Full audit trail generation - Single exposure calculation convenience method
Pre/Post CRM Tracking for Guarantees¶
Enhanced guarantee processing with full tracking of exposure amounts before and after CRM application:
- rwa_pre_crm: RWA calculated on original exposure before guarantee
- rwa_post_crm: RWA calculated after guarantee substitution
- guarantee_rwa_benefit: Reduction in RWA from guarantee protection
- Supports both covered and uncovered portion tracking
Changed¶
- Pipeline now includes equity calculator between CRM and aggregator
CRMAdjustedBundleextended withequity_exposuresfield
[0.1.11] - 2026-01-28¶
Added¶
- Namespace: add exact fractional years calculation
- Config: add MCP server configuration
[0.1.10] - 2026-01-28¶
Added¶
- CCF: include interest in EAD calculations
[0.1.8] - 2026-01-28¶
Added¶
- Data: add script to generate sample data in parquet format
- Correlation: add SME adjustment with EUR/GBP conversion
- Orgs: make org_mappings optional in data loaders
Fixed¶
- Config: update EUR to GBP exchange rate
[0.1.7] - 2026-01-27¶
Added¶
- Tests: add unit tests for API error handling and validation
- Protocols: update aggregation method with new bundles
- Loader: enhance data loading with validation checks
- BDD: add specifications for CRR provisions, risk weights, and supporting factors
Changed¶
- Loans: update loan schema and documentation
[0.1.6] - 2026-01-25¶
Added¶
- Stats: implement backend detection for statistical functions
- Documentation: add detailed implementation plan and project roadmap
Changed¶
- Stats: remove dual stats backend implementation
- Documentation: update optional dependencies and installation instructions
[0.1.5] - 2026-01-25¶
Added¶
- Counterparties: enhance counterparty schema and classification
- Documentation: add logo to documentation theme
Changed¶
- CCF: remove unused CCF module and tests
- Contingents: remove ccf_category and update risk_type
Performance¶
- Benchmark: update results with improved metrics
[0.1.4] - 2026-01-25¶
Added¶
- Deploy: add automated deployment script
Performance¶
- Benchmark: transition to pure Polars expressions
[0.1.3] - 2025-01-24¶
Added¶
Documentation Code Linking¶
- Updated documentation to link code examples to actual source implementations
- Added
pymdownx.snippetsfor embedding real code from source files - Added
mkdocstringsauto-generated API documentation - New
docs/development/documentation-conventions.mdguide for contributors - Source code references with GitHub line number links throughout docs
Mandatory risk_type Column for CCF Determination¶
The risk_type column is now the authoritative source for CCF (Credit Conversion Factor) determination across all facility inputs:
New Columns:
- risk_type (mandatory) - Off-balance sheet risk category: FR, MR, MLR, LR
- ccf_modelled (optional) - A-IRB modelled CCF estimate (0.0-1.5, Retail IRB can exceed 100%)
- is_short_term_trade_lc (optional) - CRR Art. 166(9) exception flag
Risk Type Values (CRR Art. 111):
| Code | SA CCF | F-IRB CCF | Description |
|---|---|---|---|
| FR | 100% | 100% | Full risk - guarantees, credit substitutes |
| MR | 50% | 75% | Medium risk - NIFs, RUFs, committed undrawn |
| MLR | 20% | 75% | Medium-low risk - documentary credits, trade |
| LR | 0% | 0% | Low risk - unconditionally cancellable |
F-IRB Rules:
- CRR Art. 166(8): MR and MLR both become 75% CCF under F-IRB
- CRR Art. 166(9): Short-term trade LCs for goods movement retain 20% (set is_short_term_trade_lc=True)
A-IRB Support:
- When ccf_modelled is provided and approach is A-IRB, this value takes precedence
Removed¶
commitment_type Column and Legacy CCF Functions¶
The following have been removed as risk_type is now the authoritative CCF source:
Removed from schemas:
- commitment_type column from FACILITY_SCHEMA and all intermediate schemas
Removed from crr_ccf.py:
- lookup_ccf() function
- lookup_firb_ccf() function
- calculate_ead_off_balance_sheet() function
- create_ccf_type_mapping_df() function
Removed from ccf.py:
- calculate_single_ccf() method
- CCFResult dataclass
Migration: Replace commitment_type with risk_type:
- unconditionally_cancellable → LR (low_risk)
- committed_other → MR (medium_risk) or MLR (medium_low_risk)
FX Conversion Support (14 new tests)¶
Multi-currency portfolio support with configurable FX conversion:
FXConverter Module (src/rwa_calc/engine/fx_converter.py)
- convert_exposures() - Converts drawn, undrawn, and nominal amounts
- convert_collateral() - Converts market and nominal values
- convert_guarantees() - Converts covered amounts
- convert_provisions() - Converts provision amounts
- Factory function create_fx_converter()
Features:
- Configurable target currency via CalculationConfig.base_currency
- Enable/disable via CalculationConfig.apply_fx_conversion
- Full audit trail: original_currency, original_amount, fx_rate_applied
- Graceful handling of missing FX rates (values unchanged, rate = null)
- Early pipeline integration (HierarchyResolver) for consistent threshold calculations
Data Support:
- New FX_RATES_SCHEMA in src/rwa_calc/data/schemas.py
- fx_rates field added to RawDataBundle
- fx_rates_file config in DataSourceConfig
- Test fixtures in tests/fixtures/fx_rates/
Tests: - 14 unit tests covering all conversion scenarios - Tests for exposure, collateral, guarantee, and provision conversion - Multi-currency batch conversion tests - Alternative base currency tests (EUR, USD)
Polars Namespace Extensions (8 namespaces, 139 new tests)¶
The calculator now provides comprehensive Polars namespace extensions for fluent, chainable calculations across all approaches:
SA Namespace (lf.sa, expr.sa)
- SALazyFrame namespace for Standardised Approach calculations
- Methods: prepare_columns, apply_risk_weights, apply_residential_mortgage_rw, apply_cqs_based_rw, calculate_rwa, apply_supporting_factors, apply_all
- UK deviation handling for institution CQS 2 (30% vs 50%)
- 29 unit tests
IRB Namespace (lf.irb, expr.irb)
- IRBLazyFrame namespace for IRB calculations
- Methods: classify_approach, apply_firb_lgd, prepare_columns, apply_pd_floor, apply_lgd_floor, calculate_correlation, calculate_k, calculate_maturity_adjustment, calculate_rwa, calculate_expected_loss, apply_all_formulas
- Expression methods: floor_pd, floor_lgd, clip_maturity
- 33 unit tests
CRM Namespace (lf.crm)
- CRMLazyFrame namespace for EAD waterfall processing
- Methods: initialize_ead_waterfall, apply_collateral, apply_guarantees, apply_provisions, finalize_ead, apply_all_crm
- SA vs IRB treatment differences handled automatically
- 20 unit tests
Haircuts Namespace (lf.haircuts)
- HaircutsLazyFrame namespace for collateral haircut calculations
- Methods: classify_maturity_band, apply_collateral_haircuts, apply_fx_haircut, apply_maturity_mismatch, calculate_adjusted_value, apply_all_haircuts
- CRR Article 224 supervisory haircuts
- 24 unit tests
Slotting Namespace (lf.slotting, expr.slotting)
- SlottingLazyFrame namespace for specialised lending
- Methods: prepare_columns, apply_slotting_weights, calculate_rwa, apply_all
- CRR vs Basel 3.1 risk weight differences
- HVCRE treatment
- 26 unit tests
Hierarchy Namespace (lf.hierarchy)
- HierarchyLazyFrame namespace for hierarchy resolution
- Methods: resolve_ultimate_parent, calculate_hierarchy_depth, inherit_ratings, coalesce_ratings, calculate_lending_group_totals, add_lending_group_reference, add_collateral_ltv
- Pure LazyFrame join-based traversal (no Python recursion)
- 13 unit tests
Aggregator Namespace (lf.aggregator)
- AggregatorLazyFrame namespace for result combination
- Methods: combine_approach_results, apply_output_floor, calculate_floor_impact, generate_summary_by_class, generate_summary_by_approach, generate_supporting_factor_impact
- Basel 3.1 output floor support
- 12 unit tests
Audit Namespace (lf.audit, expr.audit)
- AuditLazyFrame namespace for audit trail generation
- Methods: build_sa_calculation, build_irb_calculation, build_slotting_calculation, build_crm_calculation, build_haircut_calculation, build_floor_calculation
- AuditExpr namespace for column formatting: format_currency, format_percent, format_ratio, format_bps
- 15 unit tests
Changed¶
- All calculators can now use namespace-based fluent APIs
- Improved code readability with chainable method calls
- Test count increased from 635 to 826 (139 namespace tests + 14 FX converter tests + 38 other tests)
[0.1.2] - 2025-01-24¶
Added¶
Interactive UI Console Command¶
- New
rwa-calc-uiconsole script for starting the UI server when installed from PyPI main()function added toserver.pyfor entry point
Documentation Improvements¶
- New
docs/user-guide/interactive-ui.md- comprehensive UI guide with prerequisites, all three apps, troubleshooting - Updated quickstart with "Choose Your Approach" section (UI vs Python API)
- Added Interactive UI to user guide navigation and recommendations
- Updated all server startup commands to show both PyPI and source installation methods
Changed¶
- Installation instructions clarified for PyPI vs source installations
- UI documentation moved from Development section to User Guide for better discoverability
[0.1.1] - 2025-01-22¶
Added¶
- FX conversion support for multi-currency portfolios
- Polars namespace extensions (8 namespaces)
- Retail classification flag (
cp_is_managed_as_retail)
[0.1.0] - 2025-01-18¶
Added¶
Core Framework¶
- Dual-framework support (CRR and Basel 3.1 configuration)
- Pipeline architecture with discrete processing stages
- Protocol-based component interfaces
- Immutable data contracts (bundles)
Data Loading¶
- Parquet file loader
- Schema validation
- Optional file handling
- Metadata tracking
Hierarchy Resolution¶
- Counterparty hierarchy resolution (up to 10 levels)
- Rating inheritance from parent
- Lending group aggregation
- LazyFrame-based join optimization
Classification¶
- All exposure classes supported
- Approach determination (SA/F-IRB/A-IRB/Slotting)
- SME identification
- Retail eligibility checking
- EAD calculation with CCFs
Standardised Approach¶
- Complete risk weight tables
- Sovereign, Institution, Corporate, Retail classes
- Real estate treatments
- Defaulted exposure handling
IRB Approach¶
- K formula implementation
- Asset correlation with SME adjustment
- Maturity adjustment
- PD and LGD floors
- Expected loss calculation
- 1.06 scaling factor (CRR)
Slotting Approach¶
- All specialised lending types
- Category-based risk weights
- HVCRE treatment
- Pre-operational project finance
Credit Risk Mitigation¶
- Financial collateral (comprehensive method)
- Supervisory haircuts
- Currency mismatch handling
- Guarantees (substitution approach)
- Maturity mismatch adjustment
- Provision allocation
Supporting Factors (CRR)¶
- SME supporting factor (tiered calculation)
- Infrastructure factor
Output¶
- Aggregated results
- Breakdown by approach/class/counterparty
- Export to Parquet/CSV/JSON
- Error accumulation and reporting
Configuration¶
- Factory methods (crr/basel_3_1)
- EUR/GBP rate configuration
- Configurable supporting factors
- PD floor configuration
Testing¶
- 468+ test cases
- Unit tests for all components
- Contract tests for interfaces
- Acceptance test framework
- Test fixtures generation
Documentation¶
- MkDocs with Material theme
- User guide for all audiences
- API reference
- Architecture documentation
- Development guide
Technical¶
- Python 3.13+ support
- Polars LazyFrame optimization
- Pydantic validation
- Type hints throughout
- Ruff formatting/linting
Version History¶
| Version | Date | Status |
|---|---|---|
| 0.2.5 | 2026-05-02 | Current |
| 0.2.4 | 2026-04-30 | Previous |
| 0.2.3 | 2026-04-28 | - |
| 0.2.2 | 2026-04-27 | - |
| 0.2.1 | 2026-04-27 | - |
| 0.2.0 | 2026-04-26 | - |
| 0.1.67 | 2026-04-25 | - |
| 0.1.66 | 2026-04-24 | - |
| 0.1.65 | 2026-04-21 | - |
| 0.1.64 | 2026-04-19 | - |
| 0.1.63 | 2026-04-19 | - |
| 0.1.62 | 2026-04-17 | - |
| 0.1.61 | 2026-04-15 | - |
| 0.1.60 | 2026-04-14 | - |
| 0.1.59 | 2026-04-14 | - |
| 0.1.58 | 2026-04-11 | - |
| 0.1.57 | 2026-04-11 | - |
| 0.1.56 | 2026-04-11 | - |
| 0.1.55 | 2026-04-09 | - |
| 0.1.54 | 2026-04-08 | - |
| 0.1.53 | 2026-04-07 | - |
| 0.1.52 | 2026-04-06 | - |
| 0.1.51 | 2026-04-05 | - |
| 0.1.50 | 2026-04-01 | - |
| 0.1.49 | 2026-03-30 | - |
| 0.1.48 | 2026-03-29 | - |
| 0.1.47 | 2026-03-28 | - |
| 0.1.46 | 2026-03-28 | - |
| 0.1.45 | 2026-03-27 | - |
| 0.1.44 | 2026-03-25 | - |
| 0.1.43 | 2026-03-24 | - |
| 0.1.42 | 2026-03-22 | - |
| 0.1.41 | 2026-03-22 | - |
| 0.1.40 | 2026-03-22 | - |
| 0.1.39 | 2026-03-21 | - |
| 0.1.38 | 2026-03-20 | - |
| 0.1.37 | 2026-03-17 | - |
| 0.1.36 | 2026-03-15 | - |
| 0.1.35 | 2026-03-11 | - |
| 0.1.34 | 2026-03-10 | - |
| 0.1.33 | 2026-03-09 | - |
| 0.1.32 | 2026-03-08 | - |
| 0.1.31 | 2026-03-07 | - |
| 0.1.30 | 2026-03-06 | - |
| 0.1.29 | 2026-02-28 | - |
| 0.1.28 | 2026-02-24 | - |
| 0.1.27 | 2026-02-22 | - |
| 0.1.26 | 2026-02-21 | - |
| 0.1.25 | 2026-02-20 | - |
| 0.1.24 | 2026-02-19 | - |
| 0.1.23 | 2026-02-17 | - |
| 0.1.22 | 2026-02-16 | - |
| 0.1.21 | 2026-02-16 | - |
| 0.1.20 | 2026-02-14 | - |
| 0.1.19 | 2026-02-11 | - |
| 0.1.18 | 2026-02-10 | - |
| 0.1.17 | 2026-02-10 | - |
| 0.1.16 | 2026-02-09 | - |
| 0.1.15 | 2026-02-08 | - |
| 0.1.14 | 2026-02-07 | - |
| 0.1.13 | 2026-02-07 | - |
| 0.1.12 | 2026-02-02 | - |
| 0.1.11 | 2026-01-28 | - |
| 0.1.10 | 2026-01-28 | - |
| 0.1.8 | 2026-01-28 | - |
| 0.1.7 | 2026-01-27 | - |
| 0.1.6 | 2026-01-25 | - |
| 0.1.5 | 2026-01-25 | - |
| 0.1.4 | 2026-01-25 | - |
| 0.1.3 | 2025-01-24 | - |
| 0.1.2 | 2025-01-24 | - |
| 0.1.1 | 2025-01-22 | - |
| 0.1.0 | 2025-01-18 | Initial |
Migration Notes¶
From Previous Versions¶
This is the initial release. No migration required.
CRR to Basel 3.1¶
When transitioning calculations from CRR to Basel 3.1:
-
Update configuration:
-
Review impacted exposures:
- SME exposures (factor removal)
- Infrastructure exposures (factor removal)
-
Low-risk IRB portfolios (output floor)
-
Update data requirements:
- LTV data for Basel 3.1 real estate weights
- Transactor/revolver flags for QRRE
Deprecation Notices¶
CRR-Specific Features (End of 2026)¶
The following CRR-specific features will be removed from active use after December 2026:
- SME supporting factor
- Infrastructure supporting factor
- 1.06 scaling factor
These will remain available for historical calculations and comparison.
Contributing¶
See Development Guide for contribution guidelines.
Support¶
For issues and feature requests, please use the project's issue tracker.