Citation Coverage Matrix¶
This page is generated by scripts/generate_citation_matrix.py from the
live @cites(...) decorators in src/rwa_calc/. Each heading is one
regulatory article; expand a function below it to read the implementation
inline. See citation-tracking.md for the
conventions and canonical citation grammar.
Regenerate after annotation changes:
Last generated: 2026-08-11.
CRR (Capital Requirements Regulation)¶
CRR Art. 111 — Exposure value¶
build_product_to_risk_type_expr — src/rwa_calc/engine/ccf.py:121
@cites("CRR Art. 111")
def build_product_to_risk_type_expr(
product_col: str = "obs_product",
) -> pl.Expr:
"""Build a Polars expression mapping a concrete OBS product to its risk_type.
Resolves the abstract Annex I ``risk_type`` bucket (FR / MLR / ...) from a
normalised concrete product key via the ``obs_product_to_risk_type`` rulepack
CategoryMap (rebound to ``_ANNEX1_PRODUCT_RISK_TYPE`` at module load). The
mapping is framework-invariant (CRR Annex I == PRA PS1/26 Table A1 for every
product in scope). Unknown / unmapped products and nulls produce a null
result, so the caller can leave the existing ``risk_type`` resolution
untouched.
Args:
product_col: Name of the obs_product column on the frame.
Returns:
String Polars expression evaluating to the resolved risk_type (or null
when the product is null / unmapped).
"""
casted = pl.col(product_col).cast(pl.Utf8, strict=False).fill_null("")
lowered = casted.str.to_lowercase()
canonical = lowered.replace_strict(OBS_PRODUCT_SYNONYMS, default=casted.str.to_uppercase())
return canonical.replace_strict(
_ANNEX1_PRODUCT_RISK_TYPE,
default=pl.lit(None, dtype=pl.Utf8),
)
sa_ccf_expression — src/rwa_calc/engine/ccf.py:177
@cites("CRR Art. 111")
def sa_ccf_expression(
risk_type_col: str = "risk_type",
is_basel_3_1: bool = False,
) -> pl.Expr:
"""Polars expression mapping risk_type to SA CCFs.
CRR Art. 111 (Annex I categories) when ``is_basel_3_1`` is False, PRA
PS1/26 Table A1 when True. The CCF values come from the rulepack
(``sa_ccf`` lookup); unrecognised risk_type falls back to the
MR-equivalent ``sa_ccf_default`` (50%).
"""
table = _SA_CCF_B31_MAP if is_basel_3_1 else _SA_CCF_CRR_MAP
canonical = _normalize_risk_type(risk_type_col)
return (
pl.when(canonical == "FR")
.then(pl.lit(table["FR"]))
.when(canonical == "FRC")
.then(pl.lit(table["FRC"]))
.when(canonical == "MR")
.then(pl.lit(table["MR"]))
# CRR Annex I Row 3 issued medium-risk OBS items — explicit 50%
# (mirrors MR / Row 4) so EAD is provably equal, not a default fallback.
.when(canonical == "MR_ISSUED")
.then(pl.lit(table["MR_ISSUED"]))
.when(canonical == "OC")
.then(pl.lit(table["OC"]))
.when(canonical == "MLR")
.then(pl.lit(table["MLR"]))
.when(canonical == "LR")
.then(pl.lit(table["LR"]))
.otherwise(pl.lit(_SA_CCF_DEFAULT))
)
apply_ccf — src/rwa_calc/engine/ccf.py:287
@cites("CRR Art. 111")
@cites("CRR Art. 166")
def apply_ccf(
self,
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""
Apply CCF to calculate EAD for off-balance sheet exposures.
CCF determination follows CRR Art. 111 categories based on risk_type:
- SA: FR=100%, MR=50%, MLR=20%, LR=0%
- F-IRB Art. 166(8)(d): MR/MLR/OC commitments (credit lines / NIFs / RUFs)
when ``is_obs_commitment=True`` -> 75%
- F-IRB Art. 166(10) fallback: issued OBS items (``is_obs_commitment=False``)
-> 100% FR / 50% MR / 20% MLR / 0% LR
- F-IRB Art. 166(8)(b): MLR with ``is_short_term_trade_lc=True`` -> 20%
- A-IRB CRR: Uses ccf_modelled if provided, otherwise falls back to SA
- A-IRB B31: Own CCF only for revolving (non-100% SA); else SA CCF (Art. 166D)
- Art. 111(1)(c): When underlying_risk_type is specified, CCF is capped
at the lower of the commitment's CCF and the underlying OBS item's CCF
Args:
exposures: Exposures with nominal_amount, risk_type, and approach columns
config: Calculation configuration
Returns:
LazyFrame with ead_from_ccf and ccf columns added
"""
schema = exposures.collect_schema()
names = schema.names()
original_has_risk_type = "risk_type" in names
original_has_underlying = "underlying_risk_type" in names
original_has_interest = "interest" in names
has_provision_cols = "nominal_after_provision" in names and "provision_on_drawn" in names
exposures, added_cols = self._ensure_columns(exposures, names, has_provision_cols)
exposures = self._compute_ccf(exposures, config, pack=pack)
exposures = self._compute_ead(exposures, has_provision_cols, config, pack=pack)
exposures = self._build_audit_trail(
exposures, original_has_risk_type, original_has_underlying, original_has_interest
)
# Clean up temp and default-populated columns
return exposures.drop(
"_sa_ccf_from_risk_type",
"_firb_ccf_from_risk_type",
"_nominal_is_zero",
*added_cols,
)
_compute_ccf — src/rwa_calc/engine/ccf.py:419
@cites("CRR Art. 111")
@cites("PS1/26, paragraph 111")
def _compute_ccf(
self,
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Compute CCF based on risk type and approach.
Determines SA and F-IRB CCFs from risk_type, then selects the final CCF
based on the exposure's approach (SA/F-IRB/A-IRB).
CRR Annex I / Art. 111(1) obs_product fill: before resolving CCFs, any row
whose ``risk_type`` is null/empty has its ``risk_type`` resolved from the
concrete ``obs_product`` key via ANNEX1_PRODUCT_RISK_TYPE (framework-
invariant). An explicit ``risk_type`` always wins — the fill is gated on
the existing value being null/empty.
Applies the PRA PS1/26 Art. 111(1) Table A1 Row 4(b) override: a UK
residential-property commitment (``is_uk_residential_mortgage_commitment``)
gets a 50% CCF under Basel 3.1 — on the SA and the F-IRB / Slotting
carrier alike (Art. 166C(1)) — except where the otherwise-resolved CCF is
10% (Row 7 UCC) or 100% (Row 1/2) — the Row 4(b) carve-out.
References:
- CRR Art. 111(1) / Annex I: SA CCF buckets.
- CRR Art. 166(8)(a)-(d): F-IRB bespoke supervisory CCFs.
- CRR Art. 166(9): own-estimate (modelled) A-IRB CCFs are admissible
only within the Art. 166(8) product scope.
- CRR Art. 166(10): residual supervisory fallback for issued OBS items
outside the Art. 166(8) scope.
"""
# CRR Annex I / Art. 111(1): resolve risk_type from the concrete OBS
# product when (and only when) no explicit risk_type was supplied. Explicit
# risk_type always wins; an unmapped/null product yields null and leaves
# risk_type unchanged.
risk_type_is_blank = (
pl.col("risk_type").cast(pl.Utf8, strict=False).fill_null("").str.len_chars() == 0
)
product_risk_type = build_product_to_risk_type_expr("obs_product")
exposures = exposures.with_columns(
pl.when(risk_type_is_blank & product_risk_type.is_not_null())
.then(product_risk_type)
.otherwise(pl.col("risk_type"))
.alias("risk_type"),
)
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
# S9c: the F-IRB-uses-SA-CCF routing gate (Art. 166C) reads the cited pack
# Feature; sa_ccf_expression / _firb_ccf_for_col keep their is_basel_3_1 bool
# plumbing params (Option B). All CCF VALUES stay static data-layer tables.
is_b31 = resolved_pack.feature("firb_uses_sa_ccf")
if is_b31:
# Basel 3.1 Art. 166C: F-IRB uses SA CCFs (PRA PS1/26 Art. 111 Table A1)
# FR=100%, MR=50%, MLR=20%, LR(UCC)=10%
firb_ccf = sa_ccf_expression(is_basel_3_1=True)
else:
# CRR F-IRB: Art. 166(8)(d) -> 75% for credit lines / NIFs / RUFs
# (is_obs_commitment=True); Art. 166(10) -> 100/50/20/0% fallback for
# issued OBS items not in scope of paragraphs 1-8.
firb_ccf = _firb_ccf_for_col("risk_type")
exposures = exposures.with_columns(
sa_ccf_expression(is_basel_3_1=is_b31).alias("_sa_ccf_from_risk_type"),
firb_ccf.alias("_firb_ccf_from_risk_type"),
(pl.col("nominal_amount").cast(pl.Float64, strict=False).abs() < 1e-10).alias(
"_nominal_is_zero"
),
)
# CRR maturity-dependent OC override (Art. 111(1) / Annex I items 2(b),
# 3(b)): "other commitments" attract the MR 50% CCF when their ORIGINAL
# maturity is > 1yr (item 2(b)) and the MLR 20% CCF when it is <= 1yr
# (item 3(b)). The split keys on ORIGINAL maturity, not residual: the
# explicit ``original_maturity_years`` when present, else the
# (maturity_date - value_date) start-date fallback. With no origination
# source the conservative MR 50% default (from sa_ccf_expression) stands.
if not is_b31:
exposures = self._apply_oc_original_maturity_ccf(exposures)
# PRA PS1/26 Art. 111(1) Table A1 Row 4(b): commitments to extend credit
# secured by residential property attract a 50% CCF — "to the extent
# that they are not subject to a conversion factor of 10% or 100%". The
# override lands on BOTH the SA and the F-IRB carrier because Art. 166C(1)
# defines the F-IRB / Slotting CCF as the Art. 111 SA CCF (P1.251).
# No effect under CRR (Table A1 is Basel 3.1 only) — see the gate below.
if is_b31:
exposures = self._apply_uk_residential_mortgage_ccf(exposures)
exposures = self._apply_purchased_receivable_ccf(exposures)
# Art. 111(1)(c): commitment-to-issue lower-of rule.
# When underlying_risk_type is specified, cap CCFs at the underlying item's CCF.
# "the lower of (i) the CCF applicable to the underlying OBS item and
# (ii) the CCF applicable to the commitment type"
has_underlying = pl.col("underlying_risk_type").fill_null("").str.len_chars() > 0
underlying_sa = sa_ccf_expression("underlying_risk_type", is_basel_3_1=is_b31)
exposures = exposures.with_columns(
pl.when(has_underlying)
.then(pl.min_horizontal(pl.col("_sa_ccf_from_risk_type"), underlying_sa))
.otherwise(pl.col("_sa_ccf_from_risk_type"))
.alias("_sa_ccf_from_risk_type"),
pl.when(has_underlying)
.then(
pl.min_horizontal(
pl.col("_firb_ccf_from_risk_type"),
sa_ccf_expression("underlying_risk_type", is_basel_3_1=True)
if is_b31
else _firb_ccf_for_col("underlying_risk_type"),
)
)
.otherwise(pl.col("_firb_ccf_from_risk_type"))
.alias("_firb_ccf_from_risk_type"),
)
# A-IRB CCF: use modelled value, with Basel 3.1 restrictions
ccf_modelled_expr = pl.col("ccf_modelled").cast(pl.Float64, strict=False)
if is_b31:
# Basel 3.1 Art. 166D(1)(a): own-estimate CCFs only for revolving
# facilities whose SA CCF is not 100% (Table A1 Row 2 carve-out).
# Non-revolving A-IRB must use SA CCFs from Table A1.
# Revolving with SA CCF < 100%: own CCF with 50% SA floor (CRE32.27).
airb_revolving_ccf = pl.max_horizontal(
ccf_modelled_expr.fill_null(pl.col("_sa_ccf_from_risk_type")),
pl.col("_sa_ccf_from_risk_type")
* scalar_value(resolved_pack.scalar_param("airb_revolving_ccf_floor_multiplier")),
)
is_eligible_for_own_ccf = pl.col("is_revolving").fill_null(False) & (
pl.col("_sa_ccf_from_risk_type") < 1.0
)
airb_ccf = (
pl.when(is_eligible_for_own_ccf)
.then(airb_revolving_ccf)
.otherwise(pl.col("_sa_ccf_from_risk_type"))
)
else:
# CRR Art. 166(9): own-estimate (modelled) A-IRB CCFs are admissible
# only within the Art. 166(8) product scope — undrawn commitments
# (``is_obs_commitment``) and short-term trade LCs
# (``is_short_term_trade_lc``) — and never for FR/FRC full-risk
# substitutes. Out-of-scope rows (issued OBS items governed by
# Art. 166(10), full-risk items) take the supervisory F-IRB CCF
# unconditionally, so a spuriously low modelled value is ignored.
# In scope, a null modelled CCF falls back to the Art. 166(8)/(10)
# supervisory value (``_firb_ccf_from_risk_type``), NOT the SA
# Art. 111 CCF.
in_166_8_scope = (
pl.col("is_obs_commitment").fill_null(True)
| pl.col("is_short_term_trade_lc").fill_null(False)
) & ~_normalize_risk_type("risk_type").is_in(["FR", "FRC"])
airb_ccf = (
pl.when(in_166_8_scope)
.then(ccf_modelled_expr.fill_null(pl.col("_firb_ccf_from_risk_type")))
.otherwise(pl.col("_firb_ccf_from_risk_type"))
)
# Select final CCF based on approach
return exposures.with_columns(
pl.when(pl.col("_nominal_is_zero"))
.then(pl.lit(0.0))
.when(pl.col("approach") == ApproachType.AIRB.value)
.then(airb_ccf)
.when(pl.col("approach") == ApproachType.FIRB.value)
.then(pl.col("_firb_ccf_from_risk_type"))
# CRR Art. 147(8): specialised-lending slotting is a corporate IRB
# exposure, so its OBS EAD is governed by Art. 166(8) — the F-IRB CCF
# (e.g. MR -> 75%), not the SA 50%. Under Basel 3.1, Art. 166C makes
# F-IRB CCFs equal SA CCFs, so slotting stays on the SA path below.
.when((pl.col("approach") == ApproachType.SLOTTING.value) & (not is_b31))
.then(pl.col("_firb_ccf_from_risk_type"))
.otherwise(pl.col("_sa_ccf_from_risk_type"))
.alias("ccf"),
)
_apply_oc_original_maturity_ccf — src/rwa_calc/engine/ccf.py:598
@cites("CRR Art. 111(1)")
def _apply_oc_original_maturity_ccf(
self,
exposures: pl.LazyFrame,
) -> pl.LazyFrame:
"""Remap the OC ("other commitments") SA CCF on ORIGINAL maturity (CRR).
CRR Annex I items 2(b)/3(b) split "other commitments" on their ORIGINAL
maturity: 50% (MR) when > 1yr, 20% (MLR) when <= 1yr. ``sa_ccf_expression``
supplies the conservative 50% MR default; this override drops the CCF to
the 20% MLR rate only where an original-maturity source exists and is at
or below the ``oc_short_maturity_threshold_days`` (365-day) boundary.
Original maturity is taken from ``original_maturity_years`` when present,
else the ``(maturity_date - value_date)`` start-date fallback. With neither
source the row keeps the conservative 50% MR default.
"""
normalized_rt = pl.col("risk_type").fill_null("").str.to_lowercase()
is_oc = normalized_rt.is_in(["oc", "other_commit"])
schema_names = exposures.collect_schema().names()
# Preferred explicit source: original_maturity_years (years -> days on a
# 365-day year, matching the oc_short_maturity_threshold_days scalar).
if "original_maturity_years" in schema_names:
original_years_available = pl.col("original_maturity_years").is_not_null()
original_years_days = (
pl.col("original_maturity_years").cast(pl.Float64, strict=False) * 365.0
)
else:
original_years_available = pl.lit(False)
original_years_days = pl.lit(None, dtype=pl.Float64)
# Fallback source: (maturity_date - value_date) in days.
if "maturity_date" in schema_names and "value_date" in schema_names:
start_available = (
pl.col("maturity_date").is_not_null() & pl.col("value_date").is_not_null()
)
start_days = (
(pl.col("maturity_date").cast(pl.Date) - pl.col("value_date").cast(pl.Date))
.dt.total_days()
.cast(pl.Float64)
)
else:
start_available = pl.lit(False)
start_days = pl.lit(None, dtype=pl.Float64)
original_maturity_days = (
pl.when(original_years_available).then(original_years_days).otherwise(start_days)
)
has_original_source = original_years_available | start_available
is_short_original = has_original_source & (
original_maturity_days <= _OC_SHORT_MATURITY_THRESHOLD_DAYS
)
return exposures.with_columns(
pl.when(is_oc & is_short_original)
.then(pl.lit(_OC_SHORT_MATURITY_CCF))
.otherwise(pl.col("_sa_ccf_from_risk_type"))
.alias("_sa_ccf_from_risk_type"),
)
resolve_provisions — src/rwa_calc/engine/crm/provisions.py:37
@cites("CRR Art. 111")
def resolve_provisions(
exposures: pl.LazyFrame,
provisions: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""
Resolve provisions with multi-level beneficiary and drawn-first deduction.
This is called *before* CCF so that nominal_after_provision feeds into
the CCF calculation: ``ead_from_ccf = nominal_after_provision * ccf``.
Resolution levels (based on beneficiary_type):
1. Direct (loan/exposure/contingent): join on exposure_reference
2. Facility: join on parent_facility_reference, pro-rata by exposure weight
3. Counterparty: join on counterparty_reference, pro-rata by exposure weight
SA drawn-first deduction (CRR Art. 111(2)):
- ``floored_drawn = max(0, drawn_amount)``
- ``provision_on_drawn = min(provision_allocated, floored_drawn)``
- ``provision_on_nominal = min(remainder, nominal_amount)``
- Interest is never reduced by provision.
IRB/Slotting: provision_on_drawn=0, provision_on_nominal=0 (provisions
feed into EL shortfall/excess instead). provision_allocated is tracked.
Args:
exposures: Exposures with drawn_amount, interest, nominal_amount, approach
provisions: Provision data with beneficiary_reference, amount,
and optionally beneficiary_type
config: Calculation configuration
Returns:
Exposures with provision_allocated, provision_on_drawn,
provision_on_nominal, provision_deducted, nominal_after_provision
"""
prov_schema = provisions.collect_schema()
exp_schema = exposures.collect_schema()
has_beneficiary_type = "beneficiary_type" in prov_schema.names()
has_parent_facility = "parent_facility_reference" in exp_schema.names()
has_risk_type = "risk_type" in exp_schema.names()
if has_beneficiary_type:
# S9e: the SA-CCF table used as the pro-rata provision-weighting basis is
# regime-selected via the cited pack Feature; _resolve_provisions_multi_level
# (and the sa_ccf_expression it calls) keep their is_basel_3_1 bool param
# (Option B). The CCF table VALUES stay static data-layer constants.
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
exposures = _resolve_provisions_multi_level(
exposures,
provisions,
has_parent_facility,
has_risk_type,
resolved_pack.feature("sa_revised_ccf_table"),
)
else:
# Fallback: direct-only join (backward compat)
provisions_agg = provisions.group_by("beneficiary_reference").agg(
pl.col("amount").sum().alias("provision_allocated"),
)
exposures = exposures.join(
provisions_agg,
left_on="exposure_reference",
right_on="beneficiary_reference",
how="left",
).with_columns(
pl.col("provision_allocated").fill_null(0.0),
)
# --- SA drawn-first deduction; IRB/Slotting: no deduction ---
is_sa = pl.col("approach") == ApproachType.SA.value
floored_drawn = pl.col("drawn_amount").clip(lower_bound=0.0)
# provision_on_drawn: min(allocated, floored_drawn) for SA; 0 for IRB
provision_on_drawn = (
pl.when(is_sa)
.then(pl.min_horizontal("provision_allocated", floored_drawn))
.otherwise(pl.lit(0.0))
)
exposures = exposures.with_columns(
provision_on_drawn.alias("provision_on_drawn"),
)
# provision_on_nominal: min(remaining, nominal) for SA; 0 for IRB
remaining = (pl.col("provision_allocated") - pl.col("provision_on_drawn")).clip(lower_bound=0.0)
provision_on_nominal = (
pl.when(is_sa)
.then(pl.min_horizontal(remaining, pl.col("nominal_amount")))
.otherwise(pl.lit(0.0))
)
exposures = exposures.with_columns(
provision_on_nominal.alias("provision_on_nominal"),
)
# provision_deducted = on_drawn + on_nominal
exposures = exposures.with_columns(
(pl.col("provision_on_drawn") + pl.col("provision_on_nominal")).alias("provision_deducted"),
)
# nominal_after_provision for CCF: nominal - provision_on_nominal
exposures = exposures.with_columns(
(pl.col("nominal_amount") - pl.col("provision_on_nominal")).alias(
"nominal_after_provision"
),
)
return exposures
CRR Art. 112 — Exposure classes¶
_add_exposure_class_applied — src/rwa_calc/engine/aggregator/aggregator.py:451
@cites("CRR Art. 112")
@cites("CRR Art. 123")
@cites("CRR Art. 126")
def _add_exposure_class_applied(lf: pl.LazyFrame) -> pl.LazyFrame:
"""Add ``exposure_class_applied`` — the approach-agnostic applied class.
The routing ``exposure_class`` records origination + guarantee substitution
but omits three SA-only applied-treatment movements, so the reconciliation and
COREP class dimensions previously mis-bucketed those rows (the RWA is correct
in every case — only the class label was wrong):
- **SME managed as retail** (CRR Art. 123 / PS1/26 Art. 123A) — a
corporate-SME row that took the 75% retail risk weight logically belongs
to the retail class: Art. 122 corporate has no 75% band, so a 75%-weighted
SME entails retail. The predicate mirrors the SA risk-weight branch exactly
(``engine/sa/risk_weights.py``) so the reported class tracks the applied RW.
- **Defaulted** (CRR Art. 112(1)(j) / Art. 127) — a defaulted SA exposure
belongs to the "Exposures in default" class, which wins over origination
(PS1/26 Table A2 priority 5). High-risk (Art. 128, Basel 3.1) still outranks
default (priority 4), so a defaulted high-risk row keeps its class.
- **Secured by a mortgage on commercial immovable property** (CRR
Art. 112(1)(i) / Art. 126; PS1/26 Art. 112(1)(i) / Art. 124H-124I) — an
exposure whose SA risk weight is set by the commercial real-estate branch
belongs to Art. 112(1)(i), not to the counterparty's class. It reuses the
dispatcher's own :func:`is_commercial_re_class` predicate, whose
``property_type == "commercial"`` limb is precisely what lets a
``corporate``-routed exposure take the Art. 126 50% (or Art. 124I
income-producing) risk weight; sharing one expression is what stops the
reported class and the applied risk weight from drifting apart again.
Both frameworks rank the real-estate class the same way and both make the
protection — not the counterparty — the classifying fact. PS1/26 Art. 112(2)
Table A2 is explicit ("Where an exposure meets the criteria for more than one
exposure class it shall be assigned to the exposure class that has the highest
position in Table A2"): real estate is row (7), retail row (14), corporates
row (15). COREP Annex II ¶62 gives the CRR twin ranking — "Exposures secured
by mortgages on immovable property" is rank 6, corporates and retail rank 9 —
and ¶58 notes class (i) is the one class where "a protection effect is
intrinsically part of the definition of an exposure class". So the limb sits
BELOW default/high-risk (which outrank real estate in both rankings) and ABOVE
the retail limb. Rows already in a real-estate class are left alone: they are
in Art. 112(1)(i) already, and re-labelling them would only shuffle the
C 09.01 "of which" sub-rows without changing the class total.
¶60 is what makes this a reporting-side overlay rather than a classifier
change: the prioritisation governs "the assignment of the Original exposure
pre-conversion factor by exposure classes, without prejudice to the specific
treatment (risk weight) that each specific exposure shall receive within the
assigned exposure class". The routing ``exposure_class`` keeps driving
approach selection, CRM and the risk-weight tables; only the reported class
moves, so no RWA changes.
Only SA rows (``approach_applied == "standardised"``) are re-mapped: IRB
already reclassifies corporate→retail on ``exposure_class`` and reports
default via a PD override (not a class), slotting keeps SPECIALISED_LENDING,
and equity keeps EQUITY — so every non-SA approach keeps ``exposure_class``.
This is a PRE-substitution (obligor-side) class and is applied to guaranteed
exposures too. A guaranteed exposure is physically split into ``__G_`` /
``__REM`` legs (``engine/crm/guarantees.py``) that BOTH carry the obligor's
origination ``exposure_class`` — the guarantor's class lives only in
``post_crm_exposure_class_guaranteed``, which drives the COREP C 07.00
substitution inflow/outflow. So the guaranteed leg of a defaulted (or
SME-managed-as-retail) obligor correctly takes the same applied class as its
remainder: in C 07.00 the whole exposure originates in the obligor's sheet
("Exposures in default" / Retail) and the guaranteed portion leaves as an
outflow. Gating the overlay on ``~is_guaranteed`` would wrongly drop the
guaranteed portion out of that class and understate it.
"""
is_sa = pl.col("approach_applied") == ApproachType.SA.value
upper_class = pl.col("exposure_class").str.to_uppercase()
is_high_risk = pl.col("exposure_class") == ExposureClass.HIGH_RISK.value
sme_managed_as_retail = (
upper_class.str.contains("SME", literal=True)
& (pl.col("cp_is_managed_as_retail") == True) # noqa: E712
& (pl.col("qualifies_as_retail") == True) # noqa: E712
)
# Classes that outrank real estate in BOTH rankings (PS1/26 Table A2 rows
# (1)-(6); COREP Annex II para 62 ranks 1-5) and so keep their own class even
# when property-secured. Defaulted is already handled by the limb above.
outranks_real_estate = pl.col("exposure_class").is_in(
[
ExposureClass.HIGH_RISK.value,
ExposureClass.EQUITY.value,
ExposureClass.COVERED_BOND.value,
ExposureClass.DEFAULTED.value,
]
)
already_real_estate = pl.col("exposure_class").is_in(
[
ExposureClass.RETAIL_MORTGAGE.value,
ExposureClass.RESIDENTIAL_MORTGAGE.value,
ExposureClass.COMMERCIAL_MORTGAGE.value,
]
)
commercial_real_estate = (
is_commercial_re_class(upper_class) & ~outranks_real_estate & ~already_real_estate
)
return lf.with_columns(
pl.when(~is_sa)
.then(pl.col("exposure_class"))
# A null is_defaulted falls through the when() (treated as not defaulted),
# so no fill_null is needed — keep the applied class off origination.
.when((pl.col("is_defaulted") == True) & ~is_high_risk) # noqa: E712
.then(pl.lit(ExposureClass.DEFAULTED.value))
# Art. 112(1)(i) outranks corporates and retail in both frameworks, so
# this limb must precede the retail one below.
.when(commercial_real_estate)
.then(pl.lit(ExposureClass.COMMERCIAL_MORTGAGE.value))
.when(sme_managed_as_retail)
.then(pl.lit(ExposureClass.RETAIL_OTHER.value))
.otherwise(pl.col("exposure_class"))
.alias("exposure_class_applied")
)
_add_reporting_projection — src/rwa_calc/engine/aggregator/aggregator.py:794
@cites("CRR Art. 235")
@cites("CRR Art. 112")
def _add_reporting_projection(lf: pl.LazyFrame) -> pl.LazyFrame:
"""Add the canonical per-leg reporting projection (Phase 7 S2).
The results frame IS the two-leg substitution ledger — CRM physically splits
each guaranteed exposure into ``__G_<guarantor>`` guaranteed legs and
``__REM`` / ``__REM_FL`` / ``__REM_SEN`` retained legs
(``engine/crm/guarantees.py``). This projection names that ledger once, on
the sealed exit, so no downstream consumer re-derives class/approach/method
or sniffs reference suffixes (COREP, Pillar 3, reconciliation, and the UI
all read these columns instead of re-picking among the raw twins):
- ``reporting_class`` — post-substitution class the RWA is bucketed under
(Art. 235: guarantor class on guaranteed legs) = ``exposure_class_post_crm``.
- ``reporting_class_origin`` — obligor applied class, uniform across a
guaranteed exposure's legs (Art. 112/123) = ``exposure_class_applied``.
- ``reporting_approach`` / ``reporting_approach_origin`` — the post- and
pre-substitution approach twins (``approach_post_crm`` / ``approach_applied``).
- ``reporting_country`` / ``reporting_country_origin`` — the post- and
pre-substitution COUNTRY twins, the geographical mirror of the class pair.
PS1/26 Annex II §3.4 ¶86 says outright that "CRM techniques with
substitution effects can change the allocation of an exposure to a
country", and ¶87 splits the geographical breakdown by column: "original
exposure pre-conversion factors" reports at the country of residence of
the IMMEDIATE obligor, "exposure value" and "risk-weighted exposure
amounts" at the country of residence of the ULTIMATE obligor. So the
origin twin is the obligor's own ``cp_country_code`` on every leg, and the
post twin is the guarantor's country on a BENEFICIALLY guaranteed leg —
gated identically to ``reporting_class`` (see
:func:`_add_post_crm_reporting_class`), because a guarantee the engine
DECLINES moves neither the class nor the country. Degrades to the
obligor's country wherever the guarantor's is unknown, so a run with no
CRM guarantee sub-step reports one country on both twins.
- ``reporting_method`` — the STD/FIRB/AIRB/SLOTTING/EQUITY methodology label
of the post-substitution approach (``method_label_expr`` materialised).
- ``reporting_leg_role`` — ``guaranteed`` (the ``__G_`` leg,
``is_guaranteed=True``), ``retained`` (the ``__REM*`` remainder /
Art. 234 tranche legs), or ``whole``. COREP C 07.00 substitution
outflow/inflow reconstruct as two sums over the ``guaranteed`` legs
grouped by origin vs post-substitution class.
- ``reporting_on_balance_sheet`` — declared at source from
``exposure_type`` (loan -> on; facility/contingent -> off; anything else
null = excluded from both on- and off-BS template cells). Mirrors the
production rule in ``reporting/kernel/filters.py`` (``bs_type`` never
reaches the aggregator, so the exposure-type rule IS today's behaviour).
- ``reporting_subclass`` / ``reporting_ead`` / ``reporting_rw`` — aliases of
``exposure_subclass`` / ``ead_final`` / ``risk_weight``.
- ``reporting_gross_drawn`` / ``_interest`` / ``_nominal`` / ``_undrawn`` —
the raw gross carriers (``drawn_amount`` / ``interest`` /
``nominal_amount`` / ``undrawn_amount``) clipped at 0. A negative
drawn/interest is the on-balance netting convention (a deposit under a
``netting_agreement_reference``); the raw carriers seal negative, so
gross-exposure template cells sum these floored twins instead (CRR
Art. 111 SA / Art. 166 IRB). Nulls stay null. Computed after the CRM
guarantee split so the floored amounts are leg-consistent.
- ``reporting_gross_on_bs`` / ``reporting_gross_off_bs`` — the per-side
floored gross carriers a template's on/off-balance-sheet gross cells sum
DIRECTLY, independent of ``reporting_on_balance_sheet`` (which stays a
strict loan/facility/contingent ladder — the unified pipeline emits
``facility_undrawn`` for undrawn commitment headroom, a value that ladder
leaves null, silently dropping the leg from both gross sides while its EAD
stays in the EAD/RWEA cells). The side rule keys on ``exposure_type``:
an on-balance credit type (loan/contingent/facility_undrawn) with an
unknown drawn AND interest stays null (unknown stays unknown), else its
on-side is the floored drawn + interest (a null component counts as 0);
the off-side is a contingent's floored nominal, a facility_undrawn's
floored undrawn (counted exactly ONCE — the two carriers alias the same
headroom), a loan's true 0.0, else null. The legacy ``"facility"`` alias
(never emitted by the pipeline but recognised by the on/off-BS
discriminators and R11-era fixtures) joins the on-side credit types and
takes the aliased-pair ``max_horizontal(nominal, undrawn)`` off-side, so a
type the discriminators put on a side always has that side's carrier
populated. CCR / settlement legs are outside the on/off-BS credit-risk
gross scope, so both sides are null there (their EAD/RWEA still report).
CRR Art. 111 SA / Art. 166 IRB.
- ``reporting_crm_lgd_financial`` / ``_real_estate`` / ``_other_physical`` /
``_receivables`` (W5) — the four "CRM techniques taken into account in LGD
estimates" amounts (COREP C 08.01/02 cols 0180/0190/0200/0210), with the
METHOD-DEPENDENT basis resolved here, once, so no template re-derives it:
an AIRB leg reports the ESTIMATED MARKET VALUE, every other leg the
ADJUSTED value C_i (PS1/26 Annex II p.108 "where exposures are subject to
the Foundation Collateral Method … the adjusted value of collateral Ci …
where exposures are subject to the AIRB approach … the estimated market
value"; CRR Annex II p.101 keys the same split on "where own estimates of
LGD are (not) used" — CRR Art. 181(1)(e)-(f)). The discriminator is
``approach_applied``, NOT the post-substitution twin, because the C 08
sheets themselves key on ``reporting_approach_origin``: resolving the
basis on the post twin would report a market-value figure on a sheet
selected by the origin approach. The financial carrier folds cash on
deposit in (defect D4): ``collateral_category_expr`` routes cash/deposit
to its own category ahead of financial, but Art. 197(1)(a) makes it
eligible financial collateral and col 0180 is where it belongs — the
Art. 231 waterfall already groups the two together. The fold mirrors
``reporting_gross_on_bs``'s two-component convention: a null component
counts as 0, but BOTH null stays null.
- ``reporting_ofcp_lgd_cash_deposit`` / ``_life_insurance`` /
``reporting_ofcp_substitution`` (RD-8) — plain aliases of the three
Art. 200(1) "other funded credit protection" amounts the CRM stage has
ALREADY routed. Whether a leg's protection reports as an Art. 232
guarantee (col 0060) or under the AIRB LGD Modelling Collateral Method
(cols 0171/0172) turns on the run-level ``AIRBCollateralMethod``
election, which never reaches a template — so ``engine/crm/``, holding
the config and the pack, decides once and the projection adds no logic
here. The three are mutually exclusive by construction, which is what
makes the ``{c0170} = {c0171}+{c0172}+{c0173}`` identity and the
0060/0171-0172 exclusivity structural rather than conventional.
- ``guarantee_rwa_benefit`` (Phase 7 decision F8, recorded) — the additive
per-leg Art. 235/236 substitution relief:
``ead_final x guarantee_benefit_rw`` = leg EAD x (borrower-basis RW -
substituted RW). PRE-supporting-factor and PRE-floor by definition (the
branch snapshots the delta before Art. 501/501a and the portfolio
floor), isolating the substitution effect; the applied delta already
folds the double-default override (Art. 153(3)) and the Art. 160(4)
no-better-than-direct floor, so the benefit ties exactly to the relief
the engine granted. 0.0 on retained/whole/non-beneficial legs; NULL
where the substitution machinery never ran (unguaranteed runs, where
the branch delta column is absent). Slotting legs substitute via
RWSM (Art. 235(1), fixed 2026-07-12) and carry real benefits on the
slotting borrower basis.
Called after the residual multiplier and the output floor so the aliases
mirror the sealed final values. Per-row post-floor RWA is deliberately NOT
projected here — the floor is a portfolio-level max and its per-row
allocation is a recorded-decision slice of its own (Phase 7 plan S5).
"""
is_retained_leg = pl.col("exposure_reference").str.contains(r"__REM(?:_FL|_SEN)?$")
leg_role = (
pl.when(pl.col("is_guaranteed") == True) # noqa: E712
.then(pl.lit("guaranteed"))
.when(is_retained_leg)
.then(pl.lit("retained"))
.otherwise(pl.lit("whole"))
)
on_balance_sheet = (
pl.when(pl.col("exposure_type") == "loan")
.then(pl.lit(True))
.when(pl.col("exposure_type").is_in(["facility", "contingent"]))
.then(pl.lit(False))
.otherwise(pl.lit(None, dtype=pl.Boolean))
)
if "guarantee_benefit_rw" in lf.collect_schema().names():
# Every branch (SA/IRB/slotting) produces the delta on guaranteed
# runs; non-beneficial legs are clamped to 0.0 at the branch.
rwa_benefit = pl.col("ead_final") * pl.col("guarantee_benefit_rw")
else:
rwa_benefit = pl.lit(None, dtype=pl.Float64)
# The ¶87 ULTIMATE-obligor country. Read through the same absence-tolerant
# selector ``_beneficial_gate`` uses rather than a schema branch: an
# unguaranteed run never joined a guarantor counterparty, so
# ``guarantor_country_code`` is simply not there and the coalesce yields the
# typed null the gate then routes to ``otherwise``. An empty string is
# treated as unknown for the same reason the class twin does it — a joined
# counterparty row with a blank country is not a country.
guarantor_country = _optional_country(_GUARANTOR_COUNTRY_COL)
# The obligor's own country is read through the same selector, not as a bare
# column: it is a required aggregator-exit column, but the projection is also
# exercised directly on minimal frames, and a hard read would make the
# function's input contract wider than the two twins actually need.
obligor_country = _optional_country("cp_country_code")
country_post = (
pl.when(
(pl.col("is_guaranteed") == True) # noqa: E712
& _beneficial_gate()
& guarantor_country.is_not_null()
& (guarantor_country != "")
)
.then(guarantor_country)
.otherwise(obligor_country)
)
# Per-side floored gross carriers (CRR Art. 111 SA / Art. 166 IRB). See the
# docstring: on-side = floored drawn + interest for the on-balance credit
# types (unknown drawn AND interest -> null); off-side = a contingent's
# nominal, a facility_undrawn's undrawn (once), a loan's true 0.0. CCR /
# settlement legs fall outside the credit-risk gross scope -> null both
# sides. sum_horizontal treats a null component as 0 (never fill_null in
# engine/), and the is_null guard keeps a wholly-unknown on-side null.
# "facility" is a LEGACY OFF-BS ALIAS (Wave 3 amendment): the production
# pipeline never emits it, but reporting_on_balance_sheet / filter_off_bs /
# the c07_bs+c08_bs ladders all put it off-BS, and R11-era unit fixtures use
# it (off-BS gross in undrawn_amount). A type the discriminators put on a
# side MUST have that side's carrier populated, so "facility" joins the
# credit-type list on-side and takes the aliased-pair off-side rule below.
on_bs_carrier = (
pl.when(
pl.col("exposure_type").is_in(["loan", "contingent", "facility_undrawn", "facility"])
)
.then(
pl.when(pl.col("drawn_amount").is_null() & pl.col("interest").is_null())
.then(pl.lit(None, dtype=pl.Float64))
.otherwise(
pl.sum_horizontal(
pl.col("drawn_amount").clip(lower_bound=0.0),
pl.col("interest").clip(lower_bound=0.0),
)
)
)
.otherwise(pl.lit(None, dtype=pl.Float64))
)
# The method-dependent CRM-in-LGD basis (COREP C 08.01/02 cols 0180-0210).
# See the docstring: AIRB legs report the estimated market value, every
# other leg the adjusted value C_i, keyed on the ORIGIN approach because
# that is the approach the C 08 sheets themselves are selected by.
crm_lgd_financial, crm_lgd_re, crm_lgd_other_physical, crm_lgd_receivables = _crm_lgd_carriers()
off_bs_carrier = (
pl.when(pl.col("exposure_type") == "contingent")
.then(pl.col("nominal_amount").clip(lower_bound=0.0))
.when(pl.col("exposure_type") == "facility_undrawn")
.then(pl.col("undrawn_amount").clip(lower_bound=0.0))
.when(pl.col("exposure_type") == "loan")
.then(pl.lit(0.0))
# Legacy "facility" alias: its off-BS carrier home is ambiguous
# (nominal or undrawn), which pipeline facility_undrawn rows alias, so
# max_horizontal counts the pair exactly once. All-null -> null.
.when(pl.col("exposure_type") == "facility")
.then(
pl.max_horizontal(
pl.col("nominal_amount").clip(lower_bound=0.0),
pl.col("undrawn_amount").clip(lower_bound=0.0),
)
)
.otherwise(pl.lit(None, dtype=pl.Float64))
)
return lf.with_columns(
pl.col("exposure_class_post_crm").alias("reporting_class"),
pl.col("exposure_class_applied").alias("reporting_class_origin"),
pl.col("approach_post_crm").alias("reporting_approach"),
pl.col("approach_applied").alias("reporting_approach_origin"),
country_post.alias("reporting_country"),
obligor_country.alias("reporting_country_origin"),
method_label_expr("approach_post_crm").alias("reporting_method"),
leg_role.alias("reporting_leg_role"),
on_balance_sheet.alias("reporting_on_balance_sheet"),
pl.col("exposure_subclass").alias("reporting_subclass"),
pl.col("ead_final").alias("reporting_ead"),
pl.col("risk_weight").alias("reporting_rw"),
rwa_benefit.alias("guarantee_rwa_benefit"),
# Floored gross-exposure carriers (CRR Art. 111 SA / Art. 166 IRB).
# A negative drawn/interest is the on-balance netting convention (a
# deposit under a netting_agreement_reference); the EAD path already
# floors it, but the RAW carriers seal negative and would make a
# gross-exposure template cell (COREP C 07/C 08, Pillar 3 CR4/5/6/10)
# report a negative figure. Clip at 0 so gross cells never go negative;
# nulls stay null (never fill Float nulls to 0.0 — anti-conservative).
# Computed here, after the CRM guarantee split, so they are leg-consistent.
pl.col("drawn_amount").clip(lower_bound=0.0).alias("reporting_gross_drawn"),
pl.col("interest").clip(lower_bound=0.0).alias("reporting_gross_interest"),
pl.col("nominal_amount").clip(lower_bound=0.0).alias("reporting_gross_nominal"),
pl.col("undrawn_amount").clip(lower_bound=0.0).alias("reporting_gross_undrawn"),
on_bs_carrier.alias("reporting_gross_on_bs"),
off_bs_carrier.alias("reporting_gross_off_bs"),
# CRM techniques taken into account in LGD estimates, on the
# method-resolved basis (COREP C 08.01/02 cols 0180/0190/0200/0210).
crm_lgd_financial.alias("reporting_crm_lgd_financial"),
crm_lgd_re.alias("reporting_crm_lgd_real_estate"),
crm_lgd_other_physical.alias("reporting_crm_lgd_other_physical"),
crm_lgd_receivables.alias("reporting_crm_lgd_receivables"),
# RD-8: plain aliases of the three already-routed Art. 200(1) amounts.
_optional_amount("ofcp_lgd_cash_deposit").alias("reporting_ofcp_lgd_cash_deposit"),
_optional_amount("ofcp_lgd_life_insurance").alias("reporting_ofcp_lgd_life_insurance"),
_optional_amount("ofcp_substitution_amount").alias("reporting_ofcp_substitution"),
)
apply_risk_weights — src/rwa_calc/engine/sa/risk_weights.py:341
@cites("CRR Art. 112")
def apply_risk_weights(
lf: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Look up and apply risk weights based on exposure class.
Orchestrates the three-phase SA risk weight assignment:
1. Setup — ensure columns, derive maturity, classify, join CQS table
2. Framework-specific when/then overrides (CRR vs Basel 3.1)
3. Cleanup — sovereign floor, defaulted RW blending, drop temp cols
Branches in the override chain are order-sensitive (first match wins);
the framework override helpers apply them in the sequence prescribed
by the regulation.
"""
exposures, uc, is_domestic_currency, is_uk_domestic = _prepare_risk_weight_lookup(
lf, config, pack=pack
)
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
if resolved_pack.feature("sa_revised_risk_weight_overrides"):
exposures = _apply_b31_risk_weight_overrides(
exposures, uc, is_domestic_currency, is_uk_domestic, config
)
else:
exposures = _apply_crr_risk_weight_overrides(
exposures, uc, is_domestic_currency, is_uk_domestic
)
# Art. 140(2) obligor ST contamination (regime-invariant, post-ladder).
# ORDER: must precede _apply_defaulted_risk_weight — its unconditional Art. 127
# overwrite keeps provision-based defaulted RWs; reordering flips them to 150%.
exposures = _apply_obligor_st_contamination_override(exposures)
# Art. 121(6) (CRR) / CRE20.22 (Basel 3.1): Sovereign RW floor for
# FX-denominated unrated institution exposures. Exception:
# self-liquidating trade items with original maturity <= 1yr.
exposures = _apply_sovereign_floor_for_institutions(exposures, is_domestic_currency)
# Art. 127 defaulted risk weight (secured/unsecured split). Runs after
# the base RW when-chain so defaulted exposures have their non-defaulted
# base RW available for blending with collateral coverage.
exposures = _apply_defaulted_risk_weight(exposures, config, pack=resolved_pack)
# Drop temporary columns used only during risk-weight application.
schema_names = exposures.collect_schema().names()
temp_cols = [
"_lookup_class",
"_lookup_cqs",
"_upper_class",
"_cqs_risk_weight",
"_sovereign_rw",
"risk_weight_rw",
]
return exposures.drop([c for c in temp_cols if c in schema_names])
classify — src/rwa_calc/engine/stages/classify/classifier.py:106
@cites("CRR Art. 112")
@cites("CRR Art. 147")
def classify(
self,
data: ResolvedHierarchyBundle,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> ClassifiedExposuresBundle:
"""
Classify exposures and split by approach.
Args:
data: Hierarchy-resolved data from HierarchyResolver
config: Calculation configuration
Returns:
ClassifiedExposuresBundle with exposures split by approach
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
# Reads top-to-bottom as a recipe; each helper owns one regulatory
# concept. See the sibling sub-modules for per-step regulatory
# references.
exposures = add_counterparty_attributes(
data.exposures,
data.counterparty_lookup.counterparties,
)
exposures = join_specialised_lending(exposures, data.specialised_lending)
# Single schema snapshot — used by the remaining schema-conditional
# helpers (non-contract scratch columns and the EU-sovereign currency
# probe) without re-scanning the LazyFrame. Contract columns
# (hierarchy_exit / cp_lookup_* / raw_model_permissions) need no
# presence gate — sealed inputs always carry them.
schema_names = set(exposures.collect_schema().names())
classification_errors = collect_input_warnings(data, config, pack=resolved_pack)
classified = derive_independent_flags(exposures, config, schema_names, pack=resolved_pack)
classified = classify_exposure_subtypes(classified, config, pack=resolved_pack)
classified = reclassify_corporate_to_retail(
classified, config, schema_names, pack=resolved_pack
)
classified = flag_property_reclassification_candidates(
classified, config, schema_names, pack=resolved_pack
)
classified = sync_irb_exposure_class(classified, pack=resolved_pack)
# CRR Art. 160(2)/(6): the top-down PD for purchased corporate receivables
# must land BEFORE assign_approach — the IRB gate is internal_pd non-null,
# so without it a pool with no obligor PD falls to SA.
classified = derive_purchased_receivables_pd(classified, config, pack=resolved_pack)
has_model_permissions = data.model_permissions is not None
if data.model_permissions is not None:
classified = resolve_model_permissions(classified, data.model_permissions)
classified = assign_approach(
classified,
config,
schema_names,
has_model_permissions=has_model_permissions,
pack=resolved_pack,
)
classified = derive_exposure_subclass(classified, config, pack=resolved_pack)
# Stage-exit edge (producer-side): the diagnostic emits below run
# against in-memory data instead of re-executing the upstream lazy
# plan, and CRMProcessor receives an eager-backed frame. Laziness is
# strictly intra-stage (migration Phase 1).
classified = materialise_edge(classified, config, "classifier_exit")
classification_errors.extend(collect_beel_on_non_defaulted_warnings(classified))
classification_errors.extend(collect_qrre_gate_demotion_warnings(classified))
if has_model_permissions:
classification_errors.extend(emit_model_permission_diagnostics(classified))
# Producer seal (Phase 3): validates the contract and strips
# intra-stage scratch (including _model_permission_diagnostic) —
# pure plan ops over the eager-backed frame. CCR runs carry the
# SA-CCR provenance columns through, so the contract is selected
# by the input frame's brand.
exit_edge = (
CLASSIFIER_EXIT_CCR_EDGE
if sealed_edge_of(data.exposures) == "ccr_exit"
else CLASSIFIER_EXIT_EDGE
)
classified = seal(classified, exit_edge)
return self._build_bundle(classified, data, classification_errors)
CRR Art. 113 — Calculation of risk-weighted exposure amounts¶
calculate_rwa — src/rwa_calc/engine/sa/factors_output.py:50
@cites("CRR Art. 113")
def calculate_rwa(lf: pl.LazyFrame) -> pl.LazyFrame:
"""Compute pre-factor RWA = EAD x Risk Weight.
Emits ``rwa_pre_factor`` for downstream supporting-factor scaling.
References:
- CRR Art. 113(1)-(5): general rule for SA risk-weighted exposure amounts.
"""
return lf.with_columns(
(pl.col("ead_final") * pl.col("risk_weight")).alias("rwa_pre_factor"),
)
apply_intragroup_zero_rw — src/rwa_calc/engine/sa/rw_adjustments.py:636
@cites("CRR Art. 113")
def apply_intragroup_zero_rw(
lf: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Apply the CRR Art. 113(6) core-UK-group 0% risk weight.
On an individual-basis run the scope resolver flips
``intragroup_zero_rw_eligible`` True for exposures to a core-UK-group entity
(both the reporting entity and the counterparty inside the same permission
perimeter). Those rows take the pack's cited 0% final risk weight, applied
as the LAST risk-weight step — after standard SA assignment and every CRM
adjustment (FCSM, life-insurance, guarantee substitution, currency mismatch,
due diligence). The permission is a hard override, not a benefit blended
against the counterparty risk weight, so it deliberately wins over the
due-diligence increase that precedes it.
Keyed on the row's OWN eligibility carrier: a guarantee-split leg of an
eligible intragroup loan inherits the flag (splits copy row columns), while
an external loan merely guaranteed BY a group member is untouched —
Art. 113(6) covers direct exposures to members, not protection from them.
SA-routed rows only. IRB rows are unaffected: the IRB route to a 0%
intragroup exposure is Art. 150(1)(e) permanent partial use, which
reclassifies the exposure to SA upstream, where this override then fires.
The Feature (enabled under both CRR and Basel 3.1 — PS1/26 retains the
permission) carries the regime story, so there is no is_crr branch.
The override fires ONLY on a scoped **individual**-basis run — the one path
on which the resolver is authoritative over the carrier. This closes the
user-loadable bypass: ``intragroup_zero_rw_eligible`` is a declared (optional)
schema column, so an input file could ship it True, but on an unscoped or
consolidated run the resolver never overwrites it; gating here means a stray
input True can never win the 0% without the PRA permission. Reading
``reporting_entity`` / ``reporting_basis`` is scope gating, not regime
branching (arch_check check 17 is untouched).
No-op when the Feature is disabled, the run is not scoped-individual, or the
carrier column is absent (a direct calculator invocation that never went
through the scope resolver).
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
if not resolved_pack.feature("intragroup_zero_rw"):
return lf
# Scope gate (bypass closure): the 0% can only apply where the resolver has
# authoritatively set the carrier — a scoped individual-basis run.
if config.reporting_entity is None or config.reporting_basis is not ReportingBasis.INDIVIDUAL:
return lf
cols = lf.collect_schema().names()
if "intragroup_zero_rw_eligible" not in cols:
return lf
zero_rw = scalar_value(resolved_pack.scalar_param("intragroup_zero_rw_pct"))
# A null carrier routes to the ``otherwise`` branch below (unchanged RW),
# identical to False — no fill needed (and the edge seal already null-fills).
eligible = pl.col("intragroup_zero_rw_eligible")
# SA-routed rows only (Art. 150(1)(e) PPU is the IRB route to SA treatment).
# On the output-floor path the SA pipe runs over the full unified frame, so
# gate on the approach column to leave IRB / slotting SA-equivalent RWs alone.
if "approach" in cols:
eligible = eligible & (pl.col("approach") == ApproachType.SA.value)
return lf.with_columns(
pl.when(eligible)
.then(pl.lit(zero_rw))
.otherwise(pl.col("risk_weight"))
.alias("risk_weight"),
)
CRR Art. 114 — Exposures to central governments or central banks¶
build_eu_domestic_currency_expr — src/rwa_calc/engine/eu_sovereign.py:46
@cites("CRR Art. 114")
@cites("CRR Art. 141")
def build_eu_domestic_currency_expr(
country_col: str,
currency_col: str | pl.Expr = "currency",
) -> pl.Expr:
"""
Build a Polars expression that checks if an exposure is to an EU member
state's central government/central bank denominated in that state's
domestic currency.
Uses replace_strict to map country code → domestic currency, then compares
with the exposure denomination currency.
Args:
country_col: Column name containing the ISO country code
currency_col: Column name (str) or Polars expression for the
exposure's denomination currency. A string is wrapped in
``pl.col(...)``. Callers operating on a post-FX-conversion
LazyFrame should pass ``denomination_currency_expr(...)`` so the
original (pre-conversion) currency is compared — not the reporting
currency.
Returns:
Boolean Polars expression: True when country is EU and currency matches
that country's domestic currency.
"""
currency_expr = pl.col(currency_col) if isinstance(currency_col, str) else currency_col
return (
pl.col(country_col)
.fill_null("")
.replace_strict(_EU_COUNTRY_DOMESTIC_CURRENCY, default=None)
.eq(currency_expr)
)
build_domestic_cgcb_guarantor_expr — src/rwa_calc/engine/eu_sovereign.py:82
@cites("CRR Art. 114")
@cites("CRR Art. 235")
@cites("PS1/26, paragraph 235")
def build_domestic_cgcb_guarantor_expr(
country_col: str,
currency_col: str | pl.Expr,
funding_currency_col: str | pl.Expr | None = None,
) -> pl.Expr:
"""
Build a Polars expression that identifies a domestic-currency CGCB guarantor
under CRR Art. 114(4) and Art. 114(7) (Basel 3.1 preservation).
Combines the UK (GB/GBP) and EU (member state / member-state-domestic-currency)
branches into a single boolean expression.
Callers pass the guarantor's country code column and the currency column to
test against. For guarantee substitution (Art. 215-217) the currency column
should be the **guarantee** currency — the Art. 233(3) 8% FX haircut handles
any mismatch between the guarantee and the underlying exposure separately.
Art. 235(3) funding limb: the Art. 114(4)/(7) 0% extension to a centrally-
guaranteed exposure requires the exposure to be BOTH denominated in the
guarantor's domestic currency (the ``currency_col`` limb) AND *funded* in
that same currency. When ``funding_currency_col`` is supplied, the limb
``funding == currency`` is ANDed in — because ``currency`` has already passed
the domestic-currency test, equality with it is equivalent to "funded in the
domestic currency", and holds uniformly across the UK/GBP and EU branches.
When it is None (the frame carries no funding source) the funding limb is
omitted, preserving the pure-denomination behaviour. Callers should pass a
null-PERMISSIVE funding expression (see :func:`funding_currency_expr`) so an
unreported funding currency reuses the denomination and keeps the exposure's
existing 0% treatment.
Args:
country_col: Column name containing the guarantor's ISO country code.
currency_col: Column name (str) or Polars expression for the currency
to test against the guarantor's domestic currency.
funding_currency_col: Column name (str) or Polars expression for the
exposure's funding currency. When None, the Art. 235(3) funding limb
is not applied.
Returns:
Boolean Polars expression: True when the guarantor is UK CGCB in GBP or
an EU-member CGCB in that member state's domestic currency, and — when a
funding currency is supplied — the exposure is funded in that currency.
"""
currency_expr = pl.col(currency_col) if isinstance(currency_col, str) else currency_col
is_uk_domestic = (pl.col(country_col).fill_null("") == "GB") & (currency_expr == "GBP")
is_eu_domestic = build_eu_domestic_currency_expr(country_col, currency_expr)
denominated_domestic = is_uk_domestic | is_eu_domestic
if funding_currency_col is None:
return denominated_domestic
funding_expr = (
pl.col(funding_currency_col)
if isinstance(funding_currency_col, str)
else funding_currency_col
)
return denominated_domestic & funding_expr.eq(currency_expr)
funding_currency_expr — src/rwa_calc/engine/eu_sovereign.py:169
@cites("CRR Art. 114")
@cites("CRR Art. 235")
def funding_currency_expr(schema_names: list[str] | set[str]) -> pl.Expr | None:
"""
Return the exposure's funding-currency expression for the Art. 235(3) limb.
The Art. 114(4)/(7) 0% risk weight — and its Art. 235(3) extension to
centrally-guaranteed exposures — requires the exposure to be BOTH
denominated AND *funded* in the relevant domestic currency. This helper
yields the "funded in" currency: an explicit ``funding_currency`` column when
present, otherwise the exposure's denomination currency as the proxy the
audit endorses.
Null-PERMISSIVE: a null ``funding_currency`` falls back to the denomination
(``denomination_currency_expr``), so a dataset that does not report a
separate funding currency keeps the treatment it had before this limb existed
(mirrors the Art. 237(2)(a) original-maturity null fallback). Returns None
when the frame carries no currency column at all, signalling the caller to
omit the funding limb entirely.
Args:
schema_names: Column names from ``lf.collect_schema().names()``.
Returns:
Polars expression yielding the funding currency per row, or None when no
currency source is available on the frame.
"""
names = set(schema_names)
has_denomination = "original_currency" in names or "currency" in names
if "funding_currency" in names:
if has_denomination:
return pl.col("funding_currency").fill_null(denomination_currency_expr(names))
return pl.col("funding_currency")
if has_denomination:
return denomination_currency_expr(names)
return None
ecb_rw_expr — src/rwa_calc/engine/sa/central_bank.py:55
@cites("CRR Art. 114(3)")
@cites("PS1/26, paragraph 114")
def ecb_rw_expr() -> pl.Expr:
"""Art. 114(3): the ECB 0% risk weight, read from the common pack.
Exposed as an expression builder rather than a module-scope constant so the
regulatory value stays in the rulepack (arch_check check 5) — the pack-binding
shim ``crr_risk_weight_tables`` is its only engine-side home.
"""
return pl.lit(float(ECB_ZERO_RW))
is_ecb_expr — src/rwa_calc/engine/sa/central_bank.py:67
@cites("CRR Art. 114(3)")
@cites("PS1/26, paragraph 114")
def is_ecb_expr() -> pl.Expr:
"""Art. 114(3): identify exposures to the ECB (0% RW, unconditionally).
``eq_missing`` returns False rather than null for a null ``cp_entity_type``,
so no ``fill_null`` is needed and a missing entity type can never be read as
the ECB.
"""
return pl.col("cp_entity_type").eq_missing(_ECB_ENTITY_TYPE)
build_guarantor_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:129
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("PS1/26, paragraph 117")
@cites("CRR Art. 118")
@cites("CRR Art. 235")
def build_guarantor_rw_expr(
*,
exposure_class_col: str,
entity_type_col: str,
cqs_col: str,
country_code_col: str,
ccp_client_cleared_col: str,
scra_grade_col: str,
is_basel_3_1: bool,
domestic_cgcb_expr: pl.Expr | None = None,
short_term_flag_col: str | None = None,
no_guarantee_expr: pl.Expr | None = None,
) -> pl.Expr:
"""Build the full when/then chain that maps a guarantor to its SA RW.
Dispatches on the guarantor's SA exposure class (derived from
``ENTITY_TYPE_TO_SA_CLASS`` by the caller — e.g. the CRM processor)
rather than regex on entity_type, ensuring all valid entity types are
covered. Reproduces the SA-side reference chain
(``engine/sa/rw_adjustments.py::_build_guarantor_rw_expr``) branch-for-branch.
Branch order (first match wins):
no guarantee -> null (only when ``no_guarantee_expr`` is supplied)
domestic CGCB sovereign (Art. 114(4)/(7)) -> 0%
CGCB CQS table (Art. 114 Table 1)
CCP (CRR Art. 306, CRE54.14-15)
International Organisation (Art. 118) -> 0%
Named MDB (Art. 117(2)) -> 0%
Non-named MDB (Art. 117(1)) — PS1/26 Table 2B under Basel 3.1;
institution treatment (Art. 120 Table 3 / Art. 121 unrated,
no short-term preferential) under CRR
Institution (ECRA / SCRA via build_institution_guarantor_rw_expr —
short-term Art. 120(2) Table 4 when ``short_term_flag_col``
evaluates True, otherwise long-term Table 3)
PSE (Art. 116(2) Table 2A, sovereign-derived for unrated)
RGLA (Art. 115(1)(b) Table 1B, sovereign-derived for unrated)
Corporate (Art. 122 corporate CQS table)
else -> null (no substitution)
The unrated PSE / RGLA fallback is the documented SA-side approximation
(no guarantor sovereign-CQS join exists in the CRM column production):
a GB guarantor receives the 20% RGLA / PSE domestic-currency treatment,
any other country the conservative 100% unrated default — NOT the full
Art. 116(1) Table 2 / Art. 115(1)(a) Table 1A sovereign-derived lookup.
Args:
exposure_class_col: Name of the guarantor SA exposure-class column
(e.g. ``guarantor_exposure_class``).
entity_type_col: Name of the guarantor entity-type column — used for
the CCP override and the named-MDB (``mdb_named``) carve-out.
cqs_col: Name of the integer guarantor CQS column.
country_code_col: Name of the guarantor country-code column — drives
the unrated PSE / RGLA GB-vs-other approximation.
ccp_client_cleared_col: Name of the Boolean client-cleared flag
column (null -> proprietary 2%).
scra_grade_col: Name of the guarantor SCRA-grade column threaded
into ``build_institution_guarantor_rw_expr`` for the B31 unrated
institution dispatch.
is_basel_3_1: Select PS1/26 tables (institution ECRA / corporate
Table 6 / MDB Table 2B) when True, CRR tables when False — for
MDBs that means the Art. 117(1) institution treatment, since CRR
has no MDB table. PSE / RGLA / IO / CCP values are
framework-identical. Threaded by both live call sites from the
cited ``sa_revised_risk_weight_tables`` pack Feature.
domestic_cgcb_expr: Caller-supplied Art. 114(4)/(7) domestic-currency
test (SA and IRB derive domesticity differently). ``None``
disables the domestic 0% branch (treated as never-domestic).
short_term_flag_col: Optional Boolean column routing institution
guarantors to the Art. 120(2) Table 4 short-term dicts. The IRB
chain passes ``None`` today.
no_guarantee_expr: Caller-owned leading guard — rows where it
evaluates True yield null (no substitution priced). ``None``
omits the guard (the chain prices every row).
Returns:
Float64 Polars expression evaluating to the guarantor's SA RW, or
null where no substitution treatment exists.
"""
gec = pl.col(exposure_class_col).fill_null("")
# PSE/RGLA Art. 116(2)/115(1)(b) unrated fallback: domestic-GB guarantors
# get the RGLA/PSE 20% domestic-currency treatment; otherwise the
# conservative 100% PSE/RGLA unrated default applies.
sovereign_derived_unrated = _pse_rgla_unrated_fallback_expr(country_code_col)
cgcb_unrated = float(_CGCB_RW[CQS.UNRATED])
is_domestic_guarantor = domestic_cgcb_expr if domestic_cgcb_expr is not None else pl.lit(False)
skip_substitution = no_guarantee_expr if no_guarantee_expr is not None else pl.lit(False)
return (
pl.when(skip_substitution)
.then(pl.lit(None).cast(pl.Float64))
# Art. 114(4)/(7): Domestic sovereign -> 0% regardless of CQS.
.when((gec == "central_govt_central_bank") & is_domestic_guarantor)
.then(pl.lit(0.0))
# CGCB guarantors via CQS (Table 1 — sovereign weights).
.when(gec == "central_govt_central_bank")
.then(
_cqs_table_lookup_expr(
cqs_col,
_CGCB_RW,
cgcb_unrated,
)
)
# CCP guarantors: 2% proprietary / 4% client-cleared
# (CRR Art. 306, CRE54.14-15) — overrides institution CQS weights.
.when(pl.col(entity_type_col) == "ccp")
.then(
pl.when(pl.col(ccp_client_cleared_col).fill_null(False))
.then(pl.lit(_QCCP_CLIENT_CLEARED_RW))
.otherwise(pl.lit(_QCCP_PROPRIETARY_RW))
)
# International Organisation (Art. 118): 0% unconditional.
.when(gec == "international_organisation")
.then(pl.lit(float(_IO_ZERO_RW)))
# Named MDB (Art. 117(2)): 0% unconditional.
.when((gec == "mdb") & (pl.col(entity_type_col).fill_null("") == "mdb_named"))
.then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
# Rated / unrated non-named MDB (Art. 117(1)) — framework-divergent:
# PS1/26 Art. 117(1)(a)/(b): the dedicated Basel 3.1 MDB Table 2B
# (CQS2 30%, unrated 50%).
# CRR Art. 117(1): non-named MDBs "shall be treated in the same manner
# as exposures to institutions" — Art. 120 Table 3 when rated, the
# Art. 121 unrated institution fallback (100%) otherwise. There is no
# MDB table in CRR. The Art. 119(2)/120(2)/121(3) short-term
# preferential "shall not be applied", so ``short_term_flag_col`` is
# deliberately NOT threaded into this branch (unlike the institution
# branch below). Mirrors the direct, non-guarantor CRR MDB path in
# ``sa/risk_weights.py::_apply_crr_risk_weight_overrides`` (P1.253).
.when(gec == "mdb")
.then(
_cqs_table_lookup_expr(
cqs_col,
_MDB_RW,
float(_MDB_UNRATED_RW),
)
if is_basel_3_1
else build_institution_guarantor_rw_expr(cqs_col, is_basel_3_1=False)
)
# Institution guarantors — RW driven from institution_rw_crr /
# institution_rw_b31_ecra so the pack remains the single source of
# truth. When the short-term flag evaluates True (CRR/PS1/26
# Art. 120(2)), the short-term Table 4 dicts apply instead.
.when(gec == "institution")
.then(
build_institution_guarantor_rw_expr(
cqs_col,
is_basel_3_1,
short_term_flag_col=short_term_flag_col,
scra_grade_col=scra_grade_col,
)
)
# PSE guarantors — Art. 116(2) Table 2A for rated, sovereign-derived for unrated.
.when(gec == "pse")
.then(
_cqs_table_lookup_expr(
cqs_col,
_PSE_OWN_RW,
sovereign_derived_unrated,
)
)
# RGLA guarantors — Art. 115(1)(b) Table 1B for rated, sovereign-derived for unrated.
.when(gec == "rgla")
.then(
_cqs_table_lookup_expr(
cqs_col,
_RGLA_OWN_RW,
sovereign_derived_unrated,
)
)
# Corporate guarantors — Art. 122 corporate CQS table.
# Basel 3.1 (PRA PS1/26 Art. 122(2) Table 6): CQS3 = 75% (CRR: 100%);
# PRA retains CQS5 = 150%. Gated on framework so CRR runs are
# unchanged.
.when(gec.is_in(["corporate", "corporate_sme"]))
.then(build_corporate_guarantor_rw_expr(cqs_col, is_basel_3_1))
.otherwise(pl.lit(None).cast(pl.Float64))
)
build_entity_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:316
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("CRR Art. 118")
@cites("CRR Art. 122")
@cites("CRR Art. 123")
def build_entity_rw_expr(
*,
entity_type_col: str,
cqs_col: str,
is_basel_3_1: bool,
country_code_col: str | None = None,
) -> pl.Expr:
"""Build the entity-level SA risk-weight preview expression.
Compiled by the hierarchy facility-share selection
(``engine/stages/hierarchy/facility_undrawn.py::
_derive_facility_share_counterparty``) to rank candidate counterparties
by SA-equivalent risk weight. The preview is non-binding: the chosen
counterparty still flows through the full classifier and SA/IRB pipeline
downstream. Keeping the preview SA-only avoids a circular dependency with
the classifier's IRB approach gating.
Routes the lowercased ``entity_type`` through the SA exposure-class
buckets (``ENTITY_TYPES_BY_SA_CLASS``) and maps CQS -> RW via the same
table branches as :func:`build_guarantor_rw_expr`:
sovereign (CGCB CQS Table 1, Art. 114)
International Organisation (Art. 118) -> 0%
Named MDB (Art. 117(2)) -> 0%
MDB Table 2B (Art. 117(1))
Institution (Art. 120 Table 3 / PS1/26 ECRA via
``build_institution_guarantor_rw_expr``)
PSE (Art. 116(2) Table 2A, GB/other approximation for unrated)
RGLA (Art. 115(1)(b) Table 1B, GB/other approximation for unrated)
Corporate + covered bond (Art. 122 CRR Table 5 — see note below)
Retail (Art. 123 flat 75%)
High risk (Art. 128 flat 150%)
else -> 1.0 (conservative preview default for unmatched entity
types, e.g. equity / other items)
Branch-parity notes (the pre-existing preview branches are preserved
value-for-value):
- The corporate branch always prices from ``corporate_risk_weights``
(CRR Art. 122 Table 5), NOT the Basel 3.1 Table 6 — matching the
historical preview. Covered bonds use the corporate-equivalent CQS RWs
in the preview; the precise covered-bond table only applies in real
SA pricing.
- The unrated PSE / RGLA fallback is the documented SA-side
approximation (see :func:`build_guarantor_rw_expr`): GB -> 20%
domestic-currency treatment, other / unknown country -> 100% unrated
default. When ``country_code_col`` is ``None`` the 100% default
applies unconditionally.
Args:
entity_type_col: Name of the entity-type column. Null-filled to ""
and lowercased before bucket routing.
cqs_col: Name of the integer CQS column; null / out-of-range values
fall to each table's unrated default.
is_basel_3_1: Select the PS1/26 institution ECRA table when True,
CRR Art. 120 Table 3 when False. The remaining preview branches are
framework-identical: the corporate branch by name (see note above), and
the MDB / CGCB / IO / PSE / RGLA branches because their pack tables carry
identical values in both regimes.
country_code_col: Optional name of the country-code column driving
the unrated PSE / RGLA GB-vs-other approximation. ``None`` falls
back to the conservative 100% unrated default.
Returns:
Float64 Polars expression evaluating to the entity's SA-equivalent
preview risk weight (never null — unmatched entity types yield 1.0).
"""
et = pl.col(entity_type_col).fill_null("").str.to_lowercase()
sovereign_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CENTRAL_GOVT_CENTRAL_BANK.value])
io_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INTERNATIONAL_ORGANISATION.value])
mdb_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.MDB.value])
institution_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INSTITUTION.value])
pse_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.PSE.value])
rgla_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RGLA.value])
corporate_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CORPORATE.value])
covered_bond_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.COVERED_BOND.value])
retail_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RETAIL_OTHER.value])
high_risk_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.HIGH_RISK.value])
unrated_pse_rgla = _pse_rgla_unrated_fallback_expr(country_code_col)
return (
# CGCB (Art. 114 Table 1 — sovereign weights).
pl.when(et.is_in(sovereign_types))
.then(
_cqs_table_lookup_expr(
cqs_col,
_CGCB_RW,
float(_CGCB_RW[CQS.UNRATED]),
)
)
# International Organisation (Art. 118): 0% unconditional.
.when(et.is_in(io_types))
.then(pl.lit(float(_IO_ZERO_RW)))
# Named MDB (Art. 117(2)): 0% unconditional — carved out ahead of Table 2B.
.when(et == "mdb_named")
.then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
# Rated / unrated non-named MDB — Table 2B (CRR Art. 117(1) / PS1/26 Art. 117(1)(a)-(b)).
.when(et.is_in(mdb_types))
.then(_cqs_table_lookup_expr(cqs_col, _MDB_RW, float(_MDB_UNRATED_RW)))
# Institution — Art. 120 Table 3 / PS1/26 ECRA via the shared builder
# so the pack remains the single source of truth.
.when(et.is_in(institution_types))
.then(build_institution_guarantor_rw_expr(cqs_col, is_basel_3_1))
# PSE — Art. 116(2) Table 2A for rated, GB/other approximation for unrated.
.when(et.is_in(pse_types))
.then(_cqs_table_lookup_expr(cqs_col, _PSE_OWN_RW, unrated_pse_rgla))
# RGLA — Art. 115(1)(b) Table 1B for rated, GB/other approximation for unrated.
.when(et.is_in(rgla_types))
.then(_cqs_table_lookup_expr(cqs_col, _RGLA_OWN_RW, unrated_pse_rgla))
# Corporate + covered bond — CRR Art. 122 Table 5 (preview parity:
# not framework-switched; see docstring).
.when(et.is_in(corporate_types + covered_bond_types))
.then(
_cqs_table_lookup_expr(
cqs_col,
_CORPORATE_RW,
float(_CORPORATE_RW[CQS.UNRATED]),
)
)
# Retail (Art. 123): flat 75%.
.when(et.is_in(retail_types))
.then(pl.lit(_RETAIL_RISK_WEIGHT))
# High-risk items (Art. 128): flat 150%.
.when(et.is_in(high_risk_types))
.then(pl.lit(_HIGH_RISK_RW))
# Conservative preview default for unmatched entity types.
.otherwise(pl.lit(1.0))
)
rgla_sovereign_rw_expr — src/rwa_calc/engine/sa/rgla.py:97
@cites("CRR Art. 115")
@cites("CRR Art. 114")
@cites("PS1/26, paragraph 115")
def rgla_sovereign_rw_expr(is_uk_domestic: pl.Expr) -> pl.Expr:
"""Price an Art. 115(2)/(4) RGLA on the Art. 114 central-government ladder.
Order matters and mirrors Art. 114 itself:
1. ``is_uk_domestic`` (GB counterparty, sterling) keeps 0% — that is
Art. 114(4) reached through Art. 115(2), and it is why the GB/sterling
base case is untouched by P1.282.
2. Otherwise the Art. 114(2) Table 1 ladder on the counterparty's sovereign
CQS. This is the limb the old code was missing: a non-sterling devolved
exposure follows the UK's own assessment, so it stops being 0% the moment
the UK leaves CQS1.
3. The residual is the devolved 0%, reachable only for a GB row with no
usable sovereign CQS (``is_rgla_sovereign_expr`` excludes every other
row from this branch), so behaviour there is unchanged.
No cast is needed on ``cp_sovereign_cqs`` here: it is compared against
integer literals and never written into the Int8 ``cqs`` column, unlike the
Art. 114(2A) lift in ``central_bank.py``.
"""
devolved_rw = pl.lit(float(RGLA_UK_DEVOLVED_RW))
ladder = pl.when(pl.col("cp_sovereign_cqs") == int(_CQS_LADDER[0])).then(
pl.lit(float(CENTRAL_GOVT_CENTRAL_BANK_RISK_WEIGHTS[_CQS_LADDER[0]]))
)
for cqs_val in _CQS_LADDER[1:]:
ladder = ladder.when(pl.col("cp_sovereign_cqs") == int(cqs_val)).then(
pl.lit(float(CENTRAL_GOVT_CENTRAL_BANK_RISK_WEIGHTS[cqs_val]))
)
return pl.when(is_uk_domestic).then(devolved_rw).otherwise(ladder.otherwise(devolved_rw))
CRR Art. 115 — Exposures to regional governments or local authorities¶
_create_rgla_df — src/rwa_calc/engine/sa/crr_risk_weight_tables.py:291
@cites("CRR Art. 115")
def _create_rgla_df() -> pl.DataFrame:
"""Create RGLA risk weight lookup DataFrame (Art. 115(1)(b), Table 1B, own-rating).
Rated RGLAs join against this table via their own CQS.
Unrated RGLAs use sovereign-derived treatment handled in the SA calculator.
UK devolved govts (0%) and UK local authorities (20%) are overrides in the calculator.
"""
return _build_cqs_rw_df(
RGLA_RISK_WEIGHTS_OWN_RATING,
"RGLA",
order=_CQS_ORDER_RATED_ONLY,
)
build_guarantor_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:130
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("PS1/26, paragraph 117")
@cites("CRR Art. 118")
@cites("CRR Art. 235")
def build_guarantor_rw_expr(
*,
exposure_class_col: str,
entity_type_col: str,
cqs_col: str,
country_code_col: str,
ccp_client_cleared_col: str,
scra_grade_col: str,
is_basel_3_1: bool,
domestic_cgcb_expr: pl.Expr | None = None,
short_term_flag_col: str | None = None,
no_guarantee_expr: pl.Expr | None = None,
) -> pl.Expr:
"""Build the full when/then chain that maps a guarantor to its SA RW.
Dispatches on the guarantor's SA exposure class (derived from
``ENTITY_TYPE_TO_SA_CLASS`` by the caller — e.g. the CRM processor)
rather than regex on entity_type, ensuring all valid entity types are
covered. Reproduces the SA-side reference chain
(``engine/sa/rw_adjustments.py::_build_guarantor_rw_expr``) branch-for-branch.
Branch order (first match wins):
no guarantee -> null (only when ``no_guarantee_expr`` is supplied)
domestic CGCB sovereign (Art. 114(4)/(7)) -> 0%
CGCB CQS table (Art. 114 Table 1)
CCP (CRR Art. 306, CRE54.14-15)
International Organisation (Art. 118) -> 0%
Named MDB (Art. 117(2)) -> 0%
Non-named MDB (Art. 117(1)) — PS1/26 Table 2B under Basel 3.1;
institution treatment (Art. 120 Table 3 / Art. 121 unrated,
no short-term preferential) under CRR
Institution (ECRA / SCRA via build_institution_guarantor_rw_expr —
short-term Art. 120(2) Table 4 when ``short_term_flag_col``
evaluates True, otherwise long-term Table 3)
PSE (Art. 116(2) Table 2A, sovereign-derived for unrated)
RGLA (Art. 115(1)(b) Table 1B, sovereign-derived for unrated)
Corporate (Art. 122 corporate CQS table)
else -> null (no substitution)
The unrated PSE / RGLA fallback is the documented SA-side approximation
(no guarantor sovereign-CQS join exists in the CRM column production):
a GB guarantor receives the 20% RGLA / PSE domestic-currency treatment,
any other country the conservative 100% unrated default — NOT the full
Art. 116(1) Table 2 / Art. 115(1)(a) Table 1A sovereign-derived lookup.
Args:
exposure_class_col: Name of the guarantor SA exposure-class column
(e.g. ``guarantor_exposure_class``).
entity_type_col: Name of the guarantor entity-type column — used for
the CCP override and the named-MDB (``mdb_named``) carve-out.
cqs_col: Name of the integer guarantor CQS column.
country_code_col: Name of the guarantor country-code column — drives
the unrated PSE / RGLA GB-vs-other approximation.
ccp_client_cleared_col: Name of the Boolean client-cleared flag
column (null -> proprietary 2%).
scra_grade_col: Name of the guarantor SCRA-grade column threaded
into ``build_institution_guarantor_rw_expr`` for the B31 unrated
institution dispatch.
is_basel_3_1: Select PS1/26 tables (institution ECRA / corporate
Table 6 / MDB Table 2B) when True, CRR tables when False — for
MDBs that means the Art. 117(1) institution treatment, since CRR
has no MDB table. PSE / RGLA / IO / CCP values are
framework-identical. Threaded by both live call sites from the
cited ``sa_revised_risk_weight_tables`` pack Feature.
domestic_cgcb_expr: Caller-supplied Art. 114(4)/(7) domestic-currency
test (SA and IRB derive domesticity differently). ``None``
disables the domestic 0% branch (treated as never-domestic).
short_term_flag_col: Optional Boolean column routing institution
guarantors to the Art. 120(2) Table 4 short-term dicts. The IRB
chain passes ``None`` today.
no_guarantee_expr: Caller-owned leading guard — rows where it
evaluates True yield null (no substitution priced). ``None``
omits the guard (the chain prices every row).
Returns:
Float64 Polars expression evaluating to the guarantor's SA RW, or
null where no substitution treatment exists.
"""
gec = pl.col(exposure_class_col).fill_null("")
# PSE/RGLA Art. 116(2)/115(1)(b) unrated fallback: domestic-GB guarantors
# get the RGLA/PSE 20% domestic-currency treatment; otherwise the
# conservative 100% PSE/RGLA unrated default applies.
sovereign_derived_unrated = _pse_rgla_unrated_fallback_expr(country_code_col)
cgcb_unrated = float(_CGCB_RW[CQS.UNRATED])
is_domestic_guarantor = domestic_cgcb_expr if domestic_cgcb_expr is not None else pl.lit(False)
skip_substitution = no_guarantee_expr if no_guarantee_expr is not None else pl.lit(False)
return (
pl.when(skip_substitution)
.then(pl.lit(None).cast(pl.Float64))
# Art. 114(4)/(7): Domestic sovereign -> 0% regardless of CQS.
.when((gec == "central_govt_central_bank") & is_domestic_guarantor)
.then(pl.lit(0.0))
# CGCB guarantors via CQS (Table 1 — sovereign weights).
.when(gec == "central_govt_central_bank")
.then(
_cqs_table_lookup_expr(
cqs_col,
_CGCB_RW,
cgcb_unrated,
)
)
# CCP guarantors: 2% proprietary / 4% client-cleared
# (CRR Art. 306, CRE54.14-15) — overrides institution CQS weights.
.when(pl.col(entity_type_col) == "ccp")
.then(
pl.when(pl.col(ccp_client_cleared_col).fill_null(False))
.then(pl.lit(_QCCP_CLIENT_CLEARED_RW))
.otherwise(pl.lit(_QCCP_PROPRIETARY_RW))
)
# International Organisation (Art. 118): 0% unconditional.
.when(gec == "international_organisation")
.then(pl.lit(float(_IO_ZERO_RW)))
# Named MDB (Art. 117(2)): 0% unconditional.
.when((gec == "mdb") & (pl.col(entity_type_col).fill_null("") == "mdb_named"))
.then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
# Rated / unrated non-named MDB (Art. 117(1)) — framework-divergent:
# PS1/26 Art. 117(1)(a)/(b): the dedicated Basel 3.1 MDB Table 2B
# (CQS2 30%, unrated 50%).
# CRR Art. 117(1): non-named MDBs "shall be treated in the same manner
# as exposures to institutions" — Art. 120 Table 3 when rated, the
# Art. 121 unrated institution fallback (100%) otherwise. There is no
# MDB table in CRR. The Art. 119(2)/120(2)/121(3) short-term
# preferential "shall not be applied", so ``short_term_flag_col`` is
# deliberately NOT threaded into this branch (unlike the institution
# branch below). Mirrors the direct, non-guarantor CRR MDB path in
# ``sa/risk_weights.py::_apply_crr_risk_weight_overrides`` (P1.253).
.when(gec == "mdb")
.then(
_cqs_table_lookup_expr(
cqs_col,
_MDB_RW,
float(_MDB_UNRATED_RW),
)
if is_basel_3_1
else build_institution_guarantor_rw_expr(cqs_col, is_basel_3_1=False)
)
# Institution guarantors — RW driven from institution_rw_crr /
# institution_rw_b31_ecra so the pack remains the single source of
# truth. When the short-term flag evaluates True (CRR/PS1/26
# Art. 120(2)), the short-term Table 4 dicts apply instead.
.when(gec == "institution")
.then(
build_institution_guarantor_rw_expr(
cqs_col,
is_basel_3_1,
short_term_flag_col=short_term_flag_col,
scra_grade_col=scra_grade_col,
)
)
# PSE guarantors — Art. 116(2) Table 2A for rated, sovereign-derived for unrated.
.when(gec == "pse")
.then(
_cqs_table_lookup_expr(
cqs_col,
_PSE_OWN_RW,
sovereign_derived_unrated,
)
)
# RGLA guarantors — Art. 115(1)(b) Table 1B for rated, sovereign-derived for unrated.
.when(gec == "rgla")
.then(
_cqs_table_lookup_expr(
cqs_col,
_RGLA_OWN_RW,
sovereign_derived_unrated,
)
)
# Corporate guarantors — Art. 122 corporate CQS table.
# Basel 3.1 (PRA PS1/26 Art. 122(2) Table 6): CQS3 = 75% (CRR: 100%);
# PRA retains CQS5 = 150%. Gated on framework so CRR runs are
# unchanged.
.when(gec.is_in(["corporate", "corporate_sme"]))
.then(build_corporate_guarantor_rw_expr(cqs_col, is_basel_3_1))
.otherwise(pl.lit(None).cast(pl.Float64))
)
build_entity_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:317
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("CRR Art. 118")
@cites("CRR Art. 122")
@cites("CRR Art. 123")
def build_entity_rw_expr(
*,
entity_type_col: str,
cqs_col: str,
is_basel_3_1: bool,
country_code_col: str | None = None,
) -> pl.Expr:
"""Build the entity-level SA risk-weight preview expression.
Compiled by the hierarchy facility-share selection
(``engine/stages/hierarchy/facility_undrawn.py::
_derive_facility_share_counterparty``) to rank candidate counterparties
by SA-equivalent risk weight. The preview is non-binding: the chosen
counterparty still flows through the full classifier and SA/IRB pipeline
downstream. Keeping the preview SA-only avoids a circular dependency with
the classifier's IRB approach gating.
Routes the lowercased ``entity_type`` through the SA exposure-class
buckets (``ENTITY_TYPES_BY_SA_CLASS``) and maps CQS -> RW via the same
table branches as :func:`build_guarantor_rw_expr`:
sovereign (CGCB CQS Table 1, Art. 114)
International Organisation (Art. 118) -> 0%
Named MDB (Art. 117(2)) -> 0%
MDB Table 2B (Art. 117(1))
Institution (Art. 120 Table 3 / PS1/26 ECRA via
``build_institution_guarantor_rw_expr``)
PSE (Art. 116(2) Table 2A, GB/other approximation for unrated)
RGLA (Art. 115(1)(b) Table 1B, GB/other approximation for unrated)
Corporate + covered bond (Art. 122 CRR Table 5 — see note below)
Retail (Art. 123 flat 75%)
High risk (Art. 128 flat 150%)
else -> 1.0 (conservative preview default for unmatched entity
types, e.g. equity / other items)
Branch-parity notes (the pre-existing preview branches are preserved
value-for-value):
- The corporate branch always prices from ``corporate_risk_weights``
(CRR Art. 122 Table 5), NOT the Basel 3.1 Table 6 — matching the
historical preview. Covered bonds use the corporate-equivalent CQS RWs
in the preview; the precise covered-bond table only applies in real
SA pricing.
- The unrated PSE / RGLA fallback is the documented SA-side
approximation (see :func:`build_guarantor_rw_expr`): GB -> 20%
domestic-currency treatment, other / unknown country -> 100% unrated
default. When ``country_code_col`` is ``None`` the 100% default
applies unconditionally.
Args:
entity_type_col: Name of the entity-type column. Null-filled to ""
and lowercased before bucket routing.
cqs_col: Name of the integer CQS column; null / out-of-range values
fall to each table's unrated default.
is_basel_3_1: Select the PS1/26 institution ECRA table when True,
CRR Art. 120 Table 3 when False. The remaining preview branches are
framework-identical: the corporate branch by name (see note above), and
the MDB / CGCB / IO / PSE / RGLA branches because their pack tables carry
identical values in both regimes.
country_code_col: Optional name of the country-code column driving
the unrated PSE / RGLA GB-vs-other approximation. ``None`` falls
back to the conservative 100% unrated default.
Returns:
Float64 Polars expression evaluating to the entity's SA-equivalent
preview risk weight (never null — unmatched entity types yield 1.0).
"""
et = pl.col(entity_type_col).fill_null("").str.to_lowercase()
sovereign_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CENTRAL_GOVT_CENTRAL_BANK.value])
io_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INTERNATIONAL_ORGANISATION.value])
mdb_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.MDB.value])
institution_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INSTITUTION.value])
pse_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.PSE.value])
rgla_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RGLA.value])
corporate_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CORPORATE.value])
covered_bond_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.COVERED_BOND.value])
retail_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RETAIL_OTHER.value])
high_risk_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.HIGH_RISK.value])
unrated_pse_rgla = _pse_rgla_unrated_fallback_expr(country_code_col)
return (
# CGCB (Art. 114 Table 1 — sovereign weights).
pl.when(et.is_in(sovereign_types))
.then(
_cqs_table_lookup_expr(
cqs_col,
_CGCB_RW,
float(_CGCB_RW[CQS.UNRATED]),
)
)
# International Organisation (Art. 118): 0% unconditional.
.when(et.is_in(io_types))
.then(pl.lit(float(_IO_ZERO_RW)))
# Named MDB (Art. 117(2)): 0% unconditional — carved out ahead of Table 2B.
.when(et == "mdb_named")
.then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
# Rated / unrated non-named MDB — Table 2B (CRR Art. 117(1) / PS1/26 Art. 117(1)(a)-(b)).
.when(et.is_in(mdb_types))
.then(_cqs_table_lookup_expr(cqs_col, _MDB_RW, float(_MDB_UNRATED_RW)))
# Institution — Art. 120 Table 3 / PS1/26 ECRA via the shared builder
# so the pack remains the single source of truth.
.when(et.is_in(institution_types))
.then(build_institution_guarantor_rw_expr(cqs_col, is_basel_3_1))
# PSE — Art. 116(2) Table 2A for rated, GB/other approximation for unrated.
.when(et.is_in(pse_types))
.then(_cqs_table_lookup_expr(cqs_col, _PSE_OWN_RW, unrated_pse_rgla))
# RGLA — Art. 115(1)(b) Table 1B for rated, GB/other approximation for unrated.
.when(et.is_in(rgla_types))
.then(_cqs_table_lookup_expr(cqs_col, _RGLA_OWN_RW, unrated_pse_rgla))
# Corporate + covered bond — CRR Art. 122 Table 5 (preview parity:
# not framework-switched; see docstring).
.when(et.is_in(corporate_types + covered_bond_types))
.then(
_cqs_table_lookup_expr(
cqs_col,
_CORPORATE_RW,
float(_CORPORATE_RW[CQS.UNRATED]),
)
)
# Retail (Art. 123): flat 75%.
.when(et.is_in(retail_types))
.then(pl.lit(_RETAIL_RISK_WEIGHT))
# High-risk items (Art. 128): flat 150%.
.when(et.is_in(high_risk_types))
.then(pl.lit(_HIGH_RISK_RW))
# Conservative preview default for unmatched entity types.
.otherwise(pl.lit(1.0))
)
is_rgla_sovereign_expr — src/rwa_calc/engine/sa/rgla.py:74
@cites("CRR Art. 115")
@cites("PS1/26, paragraph 115")
def is_rgla_sovereign_expr(upper_class: pl.Expr) -> pl.Expr:
"""Select RGLA rows that Art. 115(2)/(4) price as a central government.
Scoped so the branch never captures a row it cannot price better than the
existing chain: a GB row (which previously took the flat 0% and must keep a
defined answer) or any row carrying a usable sovereign CQS. A non-GB
``rgla_sovereign`` with no sovereign assessment is left to fall through to
the ordinary Art. 115(1) ladder exactly as before, so this change cannot
silently re-price rows it has no better basis for.
``eq_missing`` returns False rather than null for a null ``cp_entity_type``,
so a missing entity type can never be read as sovereign-equivalent.
"""
is_sovereign_rgla = (upper_class == "RGLA") & pl.col("cp_entity_type").eq_missing(
_RGLA_SOVEREIGN_ENTITY_TYPE
)
has_sovereign_cqs = pl.col("cp_sovereign_cqs").is_not_null() & (pl.col("cp_sovereign_cqs") > 0)
return is_sovereign_rgla & ((pl.col("cp_country_code") == "GB") | has_sovereign_cqs)
rgla_sovereign_rw_expr — src/rwa_calc/engine/sa/rgla.py:96
@cites("CRR Art. 115")
@cites("CRR Art. 114")
@cites("PS1/26, paragraph 115")
def rgla_sovereign_rw_expr(is_uk_domestic: pl.Expr) -> pl.Expr:
"""Price an Art. 115(2)/(4) RGLA on the Art. 114 central-government ladder.
Order matters and mirrors Art. 114 itself:
1. ``is_uk_domestic`` (GB counterparty, sterling) keeps 0% — that is
Art. 114(4) reached through Art. 115(2), and it is why the GB/sterling
base case is untouched by P1.282.
2. Otherwise the Art. 114(2) Table 1 ladder on the counterparty's sovereign
CQS. This is the limb the old code was missing: a non-sterling devolved
exposure follows the UK's own assessment, so it stops being 0% the moment
the UK leaves CQS1.
3. The residual is the devolved 0%, reachable only for a GB row with no
usable sovereign CQS (``is_rgla_sovereign_expr`` excludes every other
row from this branch), so behaviour there is unchanged.
No cast is needed on ``cp_sovereign_cqs`` here: it is compared against
integer literals and never written into the Int8 ``cqs`` column, unlike the
Art. 114(2A) lift in ``central_bank.py``.
"""
devolved_rw = pl.lit(float(RGLA_UK_DEVOLVED_RW))
ladder = pl.when(pl.col("cp_sovereign_cqs") == int(_CQS_LADDER[0])).then(
pl.lit(float(CENTRAL_GOVT_CENTRAL_BANK_RISK_WEIGHTS[_CQS_LADDER[0]]))
)
for cqs_val in _CQS_LADDER[1:]:
ladder = ladder.when(pl.col("cp_sovereign_cqs") == int(cqs_val)).then(
pl.lit(float(CENTRAL_GOVT_CENTRAL_BANK_RISK_WEIGHTS[cqs_val]))
)
return pl.when(is_uk_domestic).then(devolved_rw).otherwise(ladder.otherwise(devolved_rw))
CRR Art. 116 — Exposures to public sector entities¶
_create_pse_df — src/rwa_calc/engine/sa/crr_risk_weight_tables.py:248
@cites("CRR Art. 116")
def _create_pse_df() -> pl.DataFrame:
"""Create PSE risk weight lookup DataFrame (Art. 116(2), Table 2A, own-rating).
Rated PSEs join against this table via their own CQS.
Unrated PSEs use sovereign-derived treatment handled in the SA calculator.
"""
return _build_cqs_rw_df(
PSE_RISK_WEIGHTS_OWN_RATING,
"PSE",
order=_CQS_ORDER_RATED_ONLY,
)
build_guarantor_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:131
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("PS1/26, paragraph 117")
@cites("CRR Art. 118")
@cites("CRR Art. 235")
def build_guarantor_rw_expr(
*,
exposure_class_col: str,
entity_type_col: str,
cqs_col: str,
country_code_col: str,
ccp_client_cleared_col: str,
scra_grade_col: str,
is_basel_3_1: bool,
domestic_cgcb_expr: pl.Expr | None = None,
short_term_flag_col: str | None = None,
no_guarantee_expr: pl.Expr | None = None,
) -> pl.Expr:
"""Build the full when/then chain that maps a guarantor to its SA RW.
Dispatches on the guarantor's SA exposure class (derived from
``ENTITY_TYPE_TO_SA_CLASS`` by the caller — e.g. the CRM processor)
rather than regex on entity_type, ensuring all valid entity types are
covered. Reproduces the SA-side reference chain
(``engine/sa/rw_adjustments.py::_build_guarantor_rw_expr``) branch-for-branch.
Branch order (first match wins):
no guarantee -> null (only when ``no_guarantee_expr`` is supplied)
domestic CGCB sovereign (Art. 114(4)/(7)) -> 0%
CGCB CQS table (Art. 114 Table 1)
CCP (CRR Art. 306, CRE54.14-15)
International Organisation (Art. 118) -> 0%
Named MDB (Art. 117(2)) -> 0%
Non-named MDB (Art. 117(1)) — PS1/26 Table 2B under Basel 3.1;
institution treatment (Art. 120 Table 3 / Art. 121 unrated,
no short-term preferential) under CRR
Institution (ECRA / SCRA via build_institution_guarantor_rw_expr —
short-term Art. 120(2) Table 4 when ``short_term_flag_col``
evaluates True, otherwise long-term Table 3)
PSE (Art. 116(2) Table 2A, sovereign-derived for unrated)
RGLA (Art. 115(1)(b) Table 1B, sovereign-derived for unrated)
Corporate (Art. 122 corporate CQS table)
else -> null (no substitution)
The unrated PSE / RGLA fallback is the documented SA-side approximation
(no guarantor sovereign-CQS join exists in the CRM column production):
a GB guarantor receives the 20% RGLA / PSE domestic-currency treatment,
any other country the conservative 100% unrated default — NOT the full
Art. 116(1) Table 2 / Art. 115(1)(a) Table 1A sovereign-derived lookup.
Args:
exposure_class_col: Name of the guarantor SA exposure-class column
(e.g. ``guarantor_exposure_class``).
entity_type_col: Name of the guarantor entity-type column — used for
the CCP override and the named-MDB (``mdb_named``) carve-out.
cqs_col: Name of the integer guarantor CQS column.
country_code_col: Name of the guarantor country-code column — drives
the unrated PSE / RGLA GB-vs-other approximation.
ccp_client_cleared_col: Name of the Boolean client-cleared flag
column (null -> proprietary 2%).
scra_grade_col: Name of the guarantor SCRA-grade column threaded
into ``build_institution_guarantor_rw_expr`` for the B31 unrated
institution dispatch.
is_basel_3_1: Select PS1/26 tables (institution ECRA / corporate
Table 6 / MDB Table 2B) when True, CRR tables when False — for
MDBs that means the Art. 117(1) institution treatment, since CRR
has no MDB table. PSE / RGLA / IO / CCP values are
framework-identical. Threaded by both live call sites from the
cited ``sa_revised_risk_weight_tables`` pack Feature.
domestic_cgcb_expr: Caller-supplied Art. 114(4)/(7) domestic-currency
test (SA and IRB derive domesticity differently). ``None``
disables the domestic 0% branch (treated as never-domestic).
short_term_flag_col: Optional Boolean column routing institution
guarantors to the Art. 120(2) Table 4 short-term dicts. The IRB
chain passes ``None`` today.
no_guarantee_expr: Caller-owned leading guard — rows where it
evaluates True yield null (no substitution priced). ``None``
omits the guard (the chain prices every row).
Returns:
Float64 Polars expression evaluating to the guarantor's SA RW, or
null where no substitution treatment exists.
"""
gec = pl.col(exposure_class_col).fill_null("")
# PSE/RGLA Art. 116(2)/115(1)(b) unrated fallback: domestic-GB guarantors
# get the RGLA/PSE 20% domestic-currency treatment; otherwise the
# conservative 100% PSE/RGLA unrated default applies.
sovereign_derived_unrated = _pse_rgla_unrated_fallback_expr(country_code_col)
cgcb_unrated = float(_CGCB_RW[CQS.UNRATED])
is_domestic_guarantor = domestic_cgcb_expr if domestic_cgcb_expr is not None else pl.lit(False)
skip_substitution = no_guarantee_expr if no_guarantee_expr is not None else pl.lit(False)
return (
pl.when(skip_substitution)
.then(pl.lit(None).cast(pl.Float64))
# Art. 114(4)/(7): Domestic sovereign -> 0% regardless of CQS.
.when((gec == "central_govt_central_bank") & is_domestic_guarantor)
.then(pl.lit(0.0))
# CGCB guarantors via CQS (Table 1 — sovereign weights).
.when(gec == "central_govt_central_bank")
.then(
_cqs_table_lookup_expr(
cqs_col,
_CGCB_RW,
cgcb_unrated,
)
)
# CCP guarantors: 2% proprietary / 4% client-cleared
# (CRR Art. 306, CRE54.14-15) — overrides institution CQS weights.
.when(pl.col(entity_type_col) == "ccp")
.then(
pl.when(pl.col(ccp_client_cleared_col).fill_null(False))
.then(pl.lit(_QCCP_CLIENT_CLEARED_RW))
.otherwise(pl.lit(_QCCP_PROPRIETARY_RW))
)
# International Organisation (Art. 118): 0% unconditional.
.when(gec == "international_organisation")
.then(pl.lit(float(_IO_ZERO_RW)))
# Named MDB (Art. 117(2)): 0% unconditional.
.when((gec == "mdb") & (pl.col(entity_type_col).fill_null("") == "mdb_named"))
.then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
# Rated / unrated non-named MDB (Art. 117(1)) — framework-divergent:
# PS1/26 Art. 117(1)(a)/(b): the dedicated Basel 3.1 MDB Table 2B
# (CQS2 30%, unrated 50%).
# CRR Art. 117(1): non-named MDBs "shall be treated in the same manner
# as exposures to institutions" — Art. 120 Table 3 when rated, the
# Art. 121 unrated institution fallback (100%) otherwise. There is no
# MDB table in CRR. The Art. 119(2)/120(2)/121(3) short-term
# preferential "shall not be applied", so ``short_term_flag_col`` is
# deliberately NOT threaded into this branch (unlike the institution
# branch below). Mirrors the direct, non-guarantor CRR MDB path in
# ``sa/risk_weights.py::_apply_crr_risk_weight_overrides`` (P1.253).
.when(gec == "mdb")
.then(
_cqs_table_lookup_expr(
cqs_col,
_MDB_RW,
float(_MDB_UNRATED_RW),
)
if is_basel_3_1
else build_institution_guarantor_rw_expr(cqs_col, is_basel_3_1=False)
)
# Institution guarantors — RW driven from institution_rw_crr /
# institution_rw_b31_ecra so the pack remains the single source of
# truth. When the short-term flag evaluates True (CRR/PS1/26
# Art. 120(2)), the short-term Table 4 dicts apply instead.
.when(gec == "institution")
.then(
build_institution_guarantor_rw_expr(
cqs_col,
is_basel_3_1,
short_term_flag_col=short_term_flag_col,
scra_grade_col=scra_grade_col,
)
)
# PSE guarantors — Art. 116(2) Table 2A for rated, sovereign-derived for unrated.
.when(gec == "pse")
.then(
_cqs_table_lookup_expr(
cqs_col,
_PSE_OWN_RW,
sovereign_derived_unrated,
)
)
# RGLA guarantors — Art. 115(1)(b) Table 1B for rated, sovereign-derived for unrated.
.when(gec == "rgla")
.then(
_cqs_table_lookup_expr(
cqs_col,
_RGLA_OWN_RW,
sovereign_derived_unrated,
)
)
# Corporate guarantors — Art. 122 corporate CQS table.
# Basel 3.1 (PRA PS1/26 Art. 122(2) Table 6): CQS3 = 75% (CRR: 100%);
# PRA retains CQS5 = 150%. Gated on framework so CRR runs are
# unchanged.
.when(gec.is_in(["corporate", "corporate_sme"]))
.then(build_corporate_guarantor_rw_expr(cqs_col, is_basel_3_1))
.otherwise(pl.lit(None).cast(pl.Float64))
)
build_entity_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:318
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("CRR Art. 118")
@cites("CRR Art. 122")
@cites("CRR Art. 123")
def build_entity_rw_expr(
*,
entity_type_col: str,
cqs_col: str,
is_basel_3_1: bool,
country_code_col: str | None = None,
) -> pl.Expr:
"""Build the entity-level SA risk-weight preview expression.
Compiled by the hierarchy facility-share selection
(``engine/stages/hierarchy/facility_undrawn.py::
_derive_facility_share_counterparty``) to rank candidate counterparties
by SA-equivalent risk weight. The preview is non-binding: the chosen
counterparty still flows through the full classifier and SA/IRB pipeline
downstream. Keeping the preview SA-only avoids a circular dependency with
the classifier's IRB approach gating.
Routes the lowercased ``entity_type`` through the SA exposure-class
buckets (``ENTITY_TYPES_BY_SA_CLASS``) and maps CQS -> RW via the same
table branches as :func:`build_guarantor_rw_expr`:
sovereign (CGCB CQS Table 1, Art. 114)
International Organisation (Art. 118) -> 0%
Named MDB (Art. 117(2)) -> 0%
MDB Table 2B (Art. 117(1))
Institution (Art. 120 Table 3 / PS1/26 ECRA via
``build_institution_guarantor_rw_expr``)
PSE (Art. 116(2) Table 2A, GB/other approximation for unrated)
RGLA (Art. 115(1)(b) Table 1B, GB/other approximation for unrated)
Corporate + covered bond (Art. 122 CRR Table 5 — see note below)
Retail (Art. 123 flat 75%)
High risk (Art. 128 flat 150%)
else -> 1.0 (conservative preview default for unmatched entity
types, e.g. equity / other items)
Branch-parity notes (the pre-existing preview branches are preserved
value-for-value):
- The corporate branch always prices from ``corporate_risk_weights``
(CRR Art. 122 Table 5), NOT the Basel 3.1 Table 6 — matching the
historical preview. Covered bonds use the corporate-equivalent CQS RWs
in the preview; the precise covered-bond table only applies in real
SA pricing.
- The unrated PSE / RGLA fallback is the documented SA-side
approximation (see :func:`build_guarantor_rw_expr`): GB -> 20%
domestic-currency treatment, other / unknown country -> 100% unrated
default. When ``country_code_col`` is ``None`` the 100% default
applies unconditionally.
Args:
entity_type_col: Name of the entity-type column. Null-filled to ""
and lowercased before bucket routing.
cqs_col: Name of the integer CQS column; null / out-of-range values
fall to each table's unrated default.
is_basel_3_1: Select the PS1/26 institution ECRA table when True,
CRR Art. 120 Table 3 when False. The remaining preview branches are
framework-identical: the corporate branch by name (see note above), and
the MDB / CGCB / IO / PSE / RGLA branches because their pack tables carry
identical values in both regimes.
country_code_col: Optional name of the country-code column driving
the unrated PSE / RGLA GB-vs-other approximation. ``None`` falls
back to the conservative 100% unrated default.
Returns:
Float64 Polars expression evaluating to the entity's SA-equivalent
preview risk weight (never null — unmatched entity types yield 1.0).
"""
et = pl.col(entity_type_col).fill_null("").str.to_lowercase()
sovereign_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CENTRAL_GOVT_CENTRAL_BANK.value])
io_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INTERNATIONAL_ORGANISATION.value])
mdb_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.MDB.value])
institution_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INSTITUTION.value])
pse_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.PSE.value])
rgla_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RGLA.value])
corporate_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CORPORATE.value])
covered_bond_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.COVERED_BOND.value])
retail_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RETAIL_OTHER.value])
high_risk_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.HIGH_RISK.value])
unrated_pse_rgla = _pse_rgla_unrated_fallback_expr(country_code_col)
return (
# CGCB (Art. 114 Table 1 — sovereign weights).
pl.when(et.is_in(sovereign_types))
.then(
_cqs_table_lookup_expr(
cqs_col,
_CGCB_RW,
float(_CGCB_RW[CQS.UNRATED]),
)
)
# International Organisation (Art. 118): 0% unconditional.
.when(et.is_in(io_types))
.then(pl.lit(float(_IO_ZERO_RW)))
# Named MDB (Art. 117(2)): 0% unconditional — carved out ahead of Table 2B.
.when(et == "mdb_named")
.then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
# Rated / unrated non-named MDB — Table 2B (CRR Art. 117(1) / PS1/26 Art. 117(1)(a)-(b)).
.when(et.is_in(mdb_types))
.then(_cqs_table_lookup_expr(cqs_col, _MDB_RW, float(_MDB_UNRATED_RW)))
# Institution — Art. 120 Table 3 / PS1/26 ECRA via the shared builder
# so the pack remains the single source of truth.
.when(et.is_in(institution_types))
.then(build_institution_guarantor_rw_expr(cqs_col, is_basel_3_1))
# PSE — Art. 116(2) Table 2A for rated, GB/other approximation for unrated.
.when(et.is_in(pse_types))
.then(_cqs_table_lookup_expr(cqs_col, _PSE_OWN_RW, unrated_pse_rgla))
# RGLA — Art. 115(1)(b) Table 1B for rated, GB/other approximation for unrated.
.when(et.is_in(rgla_types))
.then(_cqs_table_lookup_expr(cqs_col, _RGLA_OWN_RW, unrated_pse_rgla))
# Corporate + covered bond — CRR Art. 122 Table 5 (preview parity:
# not framework-switched; see docstring).
.when(et.is_in(corporate_types + covered_bond_types))
.then(
_cqs_table_lookup_expr(
cqs_col,
_CORPORATE_RW,
float(_CORPORATE_RW[CQS.UNRATED]),
)
)
# Retail (Art. 123): flat 75%.
.when(et.is_in(retail_types))
.then(pl.lit(_RETAIL_RISK_WEIGHT))
# High-risk items (Art. 128): flat 150%.
.when(et.is_in(high_risk_types))
.then(pl.lit(_HIGH_RISK_RW))
# Conservative preview default for unmatched entity types.
.otherwise(pl.lit(1.0))
)
pse_jurisdiction_not_permitted_expr — src/rwa_calc/engine/sa/jurisdiction.py:58
@cites("CRR Art. 116(5)")
@cites("PS1/26, paragraph 116")
def pse_jurisdiction_not_permitted_expr() -> pl.Expr:
"""Art. 116(5) third-country PSE jurisdiction gate (True = blocked).
CRR Art. 116(5): a third-country PSE may take the Art. 116(1)/(2)
treatments only where the Treasury has determined that the jurisdiction
"applies supervisory and regulatory arrangements at least equivalent to
those applied in the United Kingdom"; "Otherwise the institutions shall
apply a risk weight of 100 %".
Two limbs are permitted (predicate returns False):
- a UK PSE — Art. 116(1)-(3) apply directly and the equivalence flag is
never consulted, because a UK PSE is not a third-country PSE;
- a third-country PSE whose ``cp_is_equivalent_jurisdiction`` is True.
Regime-invariant, so there is no pack Feature and no regime branch: PS1/26
Art. 116 scopes paragraphs 1-3 to "UK public sector entities" and admits
third-country PSEs only via Art. 116(3A) "[f]or the purpose of Article
116(5) of CRR", recording Art. 116(5) itself as "[Note: Provision not in
PRA Rulebook]" because the Treasury equivalence power stays in CRR.
References:
CRR Art. 116(5); PRA PS1/26 Art. 116(1)-(3) and Art. 116(3A)
"""
# ``is_not_null() &`` on both limbs: a null country code cannot prove
# UK-ness (the convention used by the model-permission geography filter in
# engine/stages/classify/permissions.py) and a null flag is not an
# assertion. See the module docstring for why nulls must not stay Kleene.
#
# The ``cast`` calls are load-bearing, not cosmetic: a frame whose column is
# entirely null carries Polars dtype ``Null`` rather than String/Boolean, and
# ``Null`` propagates through the comparison and the ``|`` so that the final
# ``~`` raises "dtype Null not supported in 'not' operation". Casting pins
# both operands to their declared dtype so an all-null column degrades to a
# clean False instead of blowing up. This is a dtype coercion, NOT a null
# fill — the null-VALUE semantics stay with ``is_not_null()`` above.
equivalent = pl.col("cp_is_equivalent_jurisdiction").cast(pl.Boolean)
equivalence_asserted = equivalent.is_not_null() & equivalent
return ~(_is_uk_counterparty_expr() | equivalence_asserted)
pse_short_term_eligible_expr — src/rwa_calc/engine/sa/jurisdiction.py:100
@cites("CRR Art. 116(3)")
@cites("PS1/26, paragraph 116")
def pse_short_term_eligible_expr(short_term_threshold_years: float) -> pl.Expr:
"""Art. 116(3) short-term PSE eligibility — UK PSEs only (True = 20% applies).
Art. 116(3) grants a flat 20% to PSE exposures "with an original maturity of
three months or less". Two conditions, both required:
1. **Jurisdiction — UK only.** PS1/26 Art. 116(3) reads "exposures to **UK**
public sector entities", and Art. 116(3A) redirects "UK public sector
entities" to mean third-country PSEs **for paragraphs 1 and 2 only** —
paragraph 3 keeps its literal UK scope. CRR Art. 116(5) points the same
way: a third-country PSE may be weighted in the same manner only "in
accordance with paragraph 1 or 2". So an *equivalent* third-country PSE
still falls through to its Table 2 / Table 2A weight and does NOT take
the 20%; a *non-equivalent* one is already caught by
``pse_jurisdiction_not_permitted_expr``. This is the conservative reading
under both regimes and is mandated outright under Basel 3.1 — a 20%
against a Table 2/2A weight of 50%, 100% or 150% is a material
understatement, and splitting the regimes here would leave an
anti-conservative divergence on the same population.
2. **ORIGINAL maturity**, not residual — a seasoned long-dated PSE bond with
a short residual does not qualify.
Args:
short_term_threshold_years: the "three months or less" bound in years,
passed by the caller so the numeric stays with the risk-weight
chain rather than being declared at this module's scope.
"""
original_maturity = pl.col("original_maturity_years")
return (
_is_uk_counterparty_expr()
& original_maturity.is_not_null()
& (original_maturity <= short_term_threshold_years)
)
CRR Art. 117 — Exposures to multilateral development banks¶
lift_institution_cqs — src/rwa_calc/engine/sa/cqs_lift.py:47
@cites("CRR Art. 117(1)")
@cites("CRR Art. 107(2)")
@cites("PS1/26, paragraph 117")
def lift_institution_cqs(exposures: pl.LazyFrame, upper_class: pl.Expr) -> pl.LazyFrame:
"""Lift ``cp_institution_cqs`` into ``cqs`` for MDB / non-QCCP counterparties.
``upper_class`` is the caller's cached ``exposure_class`` uppercase expression,
passed in rather than recomputed so the MDB test stays identical to the one the
rest of the lookup preparation uses.
"""
# CRR Art. 117(1) / PRA PS1/26 Art. 117(1)(a): non-named MDBs are treated
# as institutions, so their primary CQS source is ``cp_institution_cqs``
# (the MDB's own ECAI rating expressed as a CQS). When the exposure has
# no top-level ``cqs`` (no rating attached at the rating-mapping stage)
# but the counterparty carries an ``institution_cqs``, lift it into
# ``cqs`` here so the downstream CQS-keyed branches and joins see it.
# Named MDBs (mdb_named) bypass CQS entirely later — coalescing here is
# harmless for them.
is_mdb_class = upper_class == _MDB_UPPER_CLASS
# CRR Art. 107(2)(a): a non-qualifying CCP counterparty (entity_type "ccp"
# demoted past the Art. 306(1) 2%/4% pin by cp_is_qccp=False) is treated as
# an ordinary institution. Its own ECAI rating is carried on the synthetic
# CCR row as ``cp_institution_cqs`` (the CCR adapter surfaces no top-level
# ``cqs``), so lift it into ``cqs`` here — mirroring the MDB treatment —
# so the Art. 120(1) Table 3 institution ladder resolves (e.g. CQS 2 -> 50%)
# instead of the unrated 100% fallback. Scoped to ``ccp`` entity_type with a
# null ``cqs`` so rated institutions and lending rows are untouched.
is_non_qccp_institution = (
pl.col("cp_entity_type").fill_null("") == _CCP_ENTITY_TYPE
) & ~pl.col("cp_is_qccp").fill_null(True)
return exposures.with_columns(
pl.when((is_mdb_class | is_non_qccp_institution) & pl.col("cqs").is_null())
.then(pl.col("cp_institution_cqs"))
.otherwise(pl.col("cqs"))
.alias("cqs")
)
_create_mdb_df — src/rwa_calc/engine/sa/crr_risk_weight_tables.py:336
@cites("CRR Art. 117")
def _create_mdb_df() -> pl.DataFrame:
"""Create MDB risk weight lookup DataFrame (Art. 117(1), Table 2B).
Named MDBs (Art. 117(2)) get 0% regardless of CQS — handled in the SA calculator.
Rated non-named MDBs join against this table via their own CQS.
Unrated non-named MDBs get 50% (Table 2B unrated row).
"""
return _build_cqs_rw_df(MDB_RISK_WEIGHTS_TABLE_2B, "MDB")
build_guarantor_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:132
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("PS1/26, paragraph 117")
@cites("CRR Art. 118")
@cites("CRR Art. 235")
def build_guarantor_rw_expr(
*,
exposure_class_col: str,
entity_type_col: str,
cqs_col: str,
country_code_col: str,
ccp_client_cleared_col: str,
scra_grade_col: str,
is_basel_3_1: bool,
domestic_cgcb_expr: pl.Expr | None = None,
short_term_flag_col: str | None = None,
no_guarantee_expr: pl.Expr | None = None,
) -> pl.Expr:
"""Build the full when/then chain that maps a guarantor to its SA RW.
Dispatches on the guarantor's SA exposure class (derived from
``ENTITY_TYPE_TO_SA_CLASS`` by the caller — e.g. the CRM processor)
rather than regex on entity_type, ensuring all valid entity types are
covered. Reproduces the SA-side reference chain
(``engine/sa/rw_adjustments.py::_build_guarantor_rw_expr``) branch-for-branch.
Branch order (first match wins):
no guarantee -> null (only when ``no_guarantee_expr`` is supplied)
domestic CGCB sovereign (Art. 114(4)/(7)) -> 0%
CGCB CQS table (Art. 114 Table 1)
CCP (CRR Art. 306, CRE54.14-15)
International Organisation (Art. 118) -> 0%
Named MDB (Art. 117(2)) -> 0%
Non-named MDB (Art. 117(1)) — PS1/26 Table 2B under Basel 3.1;
institution treatment (Art. 120 Table 3 / Art. 121 unrated,
no short-term preferential) under CRR
Institution (ECRA / SCRA via build_institution_guarantor_rw_expr —
short-term Art. 120(2) Table 4 when ``short_term_flag_col``
evaluates True, otherwise long-term Table 3)
PSE (Art. 116(2) Table 2A, sovereign-derived for unrated)
RGLA (Art. 115(1)(b) Table 1B, sovereign-derived for unrated)
Corporate (Art. 122 corporate CQS table)
else -> null (no substitution)
The unrated PSE / RGLA fallback is the documented SA-side approximation
(no guarantor sovereign-CQS join exists in the CRM column production):
a GB guarantor receives the 20% RGLA / PSE domestic-currency treatment,
any other country the conservative 100% unrated default — NOT the full
Art. 116(1) Table 2 / Art. 115(1)(a) Table 1A sovereign-derived lookup.
Args:
exposure_class_col: Name of the guarantor SA exposure-class column
(e.g. ``guarantor_exposure_class``).
entity_type_col: Name of the guarantor entity-type column — used for
the CCP override and the named-MDB (``mdb_named``) carve-out.
cqs_col: Name of the integer guarantor CQS column.
country_code_col: Name of the guarantor country-code column — drives
the unrated PSE / RGLA GB-vs-other approximation.
ccp_client_cleared_col: Name of the Boolean client-cleared flag
column (null -> proprietary 2%).
scra_grade_col: Name of the guarantor SCRA-grade column threaded
into ``build_institution_guarantor_rw_expr`` for the B31 unrated
institution dispatch.
is_basel_3_1: Select PS1/26 tables (institution ECRA / corporate
Table 6 / MDB Table 2B) when True, CRR tables when False — for
MDBs that means the Art. 117(1) institution treatment, since CRR
has no MDB table. PSE / RGLA / IO / CCP values are
framework-identical. Threaded by both live call sites from the
cited ``sa_revised_risk_weight_tables`` pack Feature.
domestic_cgcb_expr: Caller-supplied Art. 114(4)/(7) domestic-currency
test (SA and IRB derive domesticity differently). ``None``
disables the domestic 0% branch (treated as never-domestic).
short_term_flag_col: Optional Boolean column routing institution
guarantors to the Art. 120(2) Table 4 short-term dicts. The IRB
chain passes ``None`` today.
no_guarantee_expr: Caller-owned leading guard — rows where it
evaluates True yield null (no substitution priced). ``None``
omits the guard (the chain prices every row).
Returns:
Float64 Polars expression evaluating to the guarantor's SA RW, or
null where no substitution treatment exists.
"""
gec = pl.col(exposure_class_col).fill_null("")
# PSE/RGLA Art. 116(2)/115(1)(b) unrated fallback: domestic-GB guarantors
# get the RGLA/PSE 20% domestic-currency treatment; otherwise the
# conservative 100% PSE/RGLA unrated default applies.
sovereign_derived_unrated = _pse_rgla_unrated_fallback_expr(country_code_col)
cgcb_unrated = float(_CGCB_RW[CQS.UNRATED])
is_domestic_guarantor = domestic_cgcb_expr if domestic_cgcb_expr is not None else pl.lit(False)
skip_substitution = no_guarantee_expr if no_guarantee_expr is not None else pl.lit(False)
return (
pl.when(skip_substitution)
.then(pl.lit(None).cast(pl.Float64))
# Art. 114(4)/(7): Domestic sovereign -> 0% regardless of CQS.
.when((gec == "central_govt_central_bank") & is_domestic_guarantor)
.then(pl.lit(0.0))
# CGCB guarantors via CQS (Table 1 — sovereign weights).
.when(gec == "central_govt_central_bank")
.then(
_cqs_table_lookup_expr(
cqs_col,
_CGCB_RW,
cgcb_unrated,
)
)
# CCP guarantors: 2% proprietary / 4% client-cleared
# (CRR Art. 306, CRE54.14-15) — overrides institution CQS weights.
.when(pl.col(entity_type_col) == "ccp")
.then(
pl.when(pl.col(ccp_client_cleared_col).fill_null(False))
.then(pl.lit(_QCCP_CLIENT_CLEARED_RW))
.otherwise(pl.lit(_QCCP_PROPRIETARY_RW))
)
# International Organisation (Art. 118): 0% unconditional.
.when(gec == "international_organisation")
.then(pl.lit(float(_IO_ZERO_RW)))
# Named MDB (Art. 117(2)): 0% unconditional.
.when((gec == "mdb") & (pl.col(entity_type_col).fill_null("") == "mdb_named"))
.then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
# Rated / unrated non-named MDB (Art. 117(1)) — framework-divergent:
# PS1/26 Art. 117(1)(a)/(b): the dedicated Basel 3.1 MDB Table 2B
# (CQS2 30%, unrated 50%).
# CRR Art. 117(1): non-named MDBs "shall be treated in the same manner
# as exposures to institutions" — Art. 120 Table 3 when rated, the
# Art. 121 unrated institution fallback (100%) otherwise. There is no
# MDB table in CRR. The Art. 119(2)/120(2)/121(3) short-term
# preferential "shall not be applied", so ``short_term_flag_col`` is
# deliberately NOT threaded into this branch (unlike the institution
# branch below). Mirrors the direct, non-guarantor CRR MDB path in
# ``sa/risk_weights.py::_apply_crr_risk_weight_overrides`` (P1.253).
.when(gec == "mdb")
.then(
_cqs_table_lookup_expr(
cqs_col,
_MDB_RW,
float(_MDB_UNRATED_RW),
)
if is_basel_3_1
else build_institution_guarantor_rw_expr(cqs_col, is_basel_3_1=False)
)
# Institution guarantors — RW driven from institution_rw_crr /
# institution_rw_b31_ecra so the pack remains the single source of
# truth. When the short-term flag evaluates True (CRR/PS1/26
# Art. 120(2)), the short-term Table 4 dicts apply instead.
.when(gec == "institution")
.then(
build_institution_guarantor_rw_expr(
cqs_col,
is_basel_3_1,
short_term_flag_col=short_term_flag_col,
scra_grade_col=scra_grade_col,
)
)
# PSE guarantors — Art. 116(2) Table 2A for rated, sovereign-derived for unrated.
.when(gec == "pse")
.then(
_cqs_table_lookup_expr(
cqs_col,
_PSE_OWN_RW,
sovereign_derived_unrated,
)
)
# RGLA guarantors — Art. 115(1)(b) Table 1B for rated, sovereign-derived for unrated.
.when(gec == "rgla")
.then(
_cqs_table_lookup_expr(
cqs_col,
_RGLA_OWN_RW,
sovereign_derived_unrated,
)
)
# Corporate guarantors — Art. 122 corporate CQS table.
# Basel 3.1 (PRA PS1/26 Art. 122(2) Table 6): CQS3 = 75% (CRR: 100%);
# PRA retains CQS5 = 150%. Gated on framework so CRR runs are
# unchanged.
.when(gec.is_in(["corporate", "corporate_sme"]))
.then(build_corporate_guarantor_rw_expr(cqs_col, is_basel_3_1))
.otherwise(pl.lit(None).cast(pl.Float64))
)
build_entity_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:319
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("CRR Art. 118")
@cites("CRR Art. 122")
@cites("CRR Art. 123")
def build_entity_rw_expr(
*,
entity_type_col: str,
cqs_col: str,
is_basel_3_1: bool,
country_code_col: str | None = None,
) -> pl.Expr:
"""Build the entity-level SA risk-weight preview expression.
Compiled by the hierarchy facility-share selection
(``engine/stages/hierarchy/facility_undrawn.py::
_derive_facility_share_counterparty``) to rank candidate counterparties
by SA-equivalent risk weight. The preview is non-binding: the chosen
counterparty still flows through the full classifier and SA/IRB pipeline
downstream. Keeping the preview SA-only avoids a circular dependency with
the classifier's IRB approach gating.
Routes the lowercased ``entity_type`` through the SA exposure-class
buckets (``ENTITY_TYPES_BY_SA_CLASS``) and maps CQS -> RW via the same
table branches as :func:`build_guarantor_rw_expr`:
sovereign (CGCB CQS Table 1, Art. 114)
International Organisation (Art. 118) -> 0%
Named MDB (Art. 117(2)) -> 0%
MDB Table 2B (Art. 117(1))
Institution (Art. 120 Table 3 / PS1/26 ECRA via
``build_institution_guarantor_rw_expr``)
PSE (Art. 116(2) Table 2A, GB/other approximation for unrated)
RGLA (Art. 115(1)(b) Table 1B, GB/other approximation for unrated)
Corporate + covered bond (Art. 122 CRR Table 5 — see note below)
Retail (Art. 123 flat 75%)
High risk (Art. 128 flat 150%)
else -> 1.0 (conservative preview default for unmatched entity
types, e.g. equity / other items)
Branch-parity notes (the pre-existing preview branches are preserved
value-for-value):
- The corporate branch always prices from ``corporate_risk_weights``
(CRR Art. 122 Table 5), NOT the Basel 3.1 Table 6 — matching the
historical preview. Covered bonds use the corporate-equivalent CQS RWs
in the preview; the precise covered-bond table only applies in real
SA pricing.
- The unrated PSE / RGLA fallback is the documented SA-side
approximation (see :func:`build_guarantor_rw_expr`): GB -> 20%
domestic-currency treatment, other / unknown country -> 100% unrated
default. When ``country_code_col`` is ``None`` the 100% default
applies unconditionally.
Args:
entity_type_col: Name of the entity-type column. Null-filled to ""
and lowercased before bucket routing.
cqs_col: Name of the integer CQS column; null / out-of-range values
fall to each table's unrated default.
is_basel_3_1: Select the PS1/26 institution ECRA table when True,
CRR Art. 120 Table 3 when False. The remaining preview branches are
framework-identical: the corporate branch by name (see note above), and
the MDB / CGCB / IO / PSE / RGLA branches because their pack tables carry
identical values in both regimes.
country_code_col: Optional name of the country-code column driving
the unrated PSE / RGLA GB-vs-other approximation. ``None`` falls
back to the conservative 100% unrated default.
Returns:
Float64 Polars expression evaluating to the entity's SA-equivalent
preview risk weight (never null — unmatched entity types yield 1.0).
"""
et = pl.col(entity_type_col).fill_null("").str.to_lowercase()
sovereign_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CENTRAL_GOVT_CENTRAL_BANK.value])
io_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INTERNATIONAL_ORGANISATION.value])
mdb_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.MDB.value])
institution_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INSTITUTION.value])
pse_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.PSE.value])
rgla_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RGLA.value])
corporate_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CORPORATE.value])
covered_bond_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.COVERED_BOND.value])
retail_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RETAIL_OTHER.value])
high_risk_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.HIGH_RISK.value])
unrated_pse_rgla = _pse_rgla_unrated_fallback_expr(country_code_col)
return (
# CGCB (Art. 114 Table 1 — sovereign weights).
pl.when(et.is_in(sovereign_types))
.then(
_cqs_table_lookup_expr(
cqs_col,
_CGCB_RW,
float(_CGCB_RW[CQS.UNRATED]),
)
)
# International Organisation (Art. 118): 0% unconditional.
.when(et.is_in(io_types))
.then(pl.lit(float(_IO_ZERO_RW)))
# Named MDB (Art. 117(2)): 0% unconditional — carved out ahead of Table 2B.
.when(et == "mdb_named")
.then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
# Rated / unrated non-named MDB — Table 2B (CRR Art. 117(1) / PS1/26 Art. 117(1)(a)-(b)).
.when(et.is_in(mdb_types))
.then(_cqs_table_lookup_expr(cqs_col, _MDB_RW, float(_MDB_UNRATED_RW)))
# Institution — Art. 120 Table 3 / PS1/26 ECRA via the shared builder
# so the pack remains the single source of truth.
.when(et.is_in(institution_types))
.then(build_institution_guarantor_rw_expr(cqs_col, is_basel_3_1))
# PSE — Art. 116(2) Table 2A for rated, GB/other approximation for unrated.
.when(et.is_in(pse_types))
.then(_cqs_table_lookup_expr(cqs_col, _PSE_OWN_RW, unrated_pse_rgla))
# RGLA — Art. 115(1)(b) Table 1B for rated, GB/other approximation for unrated.
.when(et.is_in(rgla_types))
.then(_cqs_table_lookup_expr(cqs_col, _RGLA_OWN_RW, unrated_pse_rgla))
# Corporate + covered bond — CRR Art. 122 Table 5 (preview parity:
# not framework-switched; see docstring).
.when(et.is_in(corporate_types + covered_bond_types))
.then(
_cqs_table_lookup_expr(
cqs_col,
_CORPORATE_RW,
float(_CORPORATE_RW[CQS.UNRATED]),
)
)
# Retail (Art. 123): flat 75%.
.when(et.is_in(retail_types))
.then(pl.lit(_RETAIL_RISK_WEIGHT))
# High-risk items (Art. 128): flat 150%.
.when(et.is_in(high_risk_types))
.then(pl.lit(_HIGH_RISK_RW))
# Conservative preview default for unmatched entity types.
.otherwise(pl.lit(1.0))
)
CRR Art. 118 — Exposures to international organisations¶
_create_io_df — src/rwa_calc/engine/sa/crr_risk_weight_tables.py:360
@cites("CRR Art. 118")
def _create_io_df() -> pl.DataFrame:
"""Create international organisation risk weight lookup DataFrame (Art. 118).
Art. 118 names 16 IOs (EU, IMF, BIS, ECB, EFSF, ESM, IBRD, IFC, IADB,
ADB, AfDB, CEB, NIB, CDB, EBRD, EFSI) that receive 0% unconditionally.
Returns a single-row DataFrame keyed on the unrated CQS sentinel so the
canonical risk-weight value lives alongside the other SA tables; the
SA calculator's inline IO branch is the runtime consumer.
"""
return _build_cqs_rw_df(
INTERNATIONAL_ORG_RISK_WEIGHTS,
"INTERNATIONAL_ORG",
order=(CQS.UNRATED,),
)
build_guarantor_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:134
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("PS1/26, paragraph 117")
@cites("CRR Art. 118")
@cites("CRR Art. 235")
def build_guarantor_rw_expr(
*,
exposure_class_col: str,
entity_type_col: str,
cqs_col: str,
country_code_col: str,
ccp_client_cleared_col: str,
scra_grade_col: str,
is_basel_3_1: bool,
domestic_cgcb_expr: pl.Expr | None = None,
short_term_flag_col: str | None = None,
no_guarantee_expr: pl.Expr | None = None,
) -> pl.Expr:
"""Build the full when/then chain that maps a guarantor to its SA RW.
Dispatches on the guarantor's SA exposure class (derived from
``ENTITY_TYPE_TO_SA_CLASS`` by the caller — e.g. the CRM processor)
rather than regex on entity_type, ensuring all valid entity types are
covered. Reproduces the SA-side reference chain
(``engine/sa/rw_adjustments.py::_build_guarantor_rw_expr``) branch-for-branch.
Branch order (first match wins):
no guarantee -> null (only when ``no_guarantee_expr`` is supplied)
domestic CGCB sovereign (Art. 114(4)/(7)) -> 0%
CGCB CQS table (Art. 114 Table 1)
CCP (CRR Art. 306, CRE54.14-15)
International Organisation (Art. 118) -> 0%
Named MDB (Art. 117(2)) -> 0%
Non-named MDB (Art. 117(1)) — PS1/26 Table 2B under Basel 3.1;
institution treatment (Art. 120 Table 3 / Art. 121 unrated,
no short-term preferential) under CRR
Institution (ECRA / SCRA via build_institution_guarantor_rw_expr —
short-term Art. 120(2) Table 4 when ``short_term_flag_col``
evaluates True, otherwise long-term Table 3)
PSE (Art. 116(2) Table 2A, sovereign-derived for unrated)
RGLA (Art. 115(1)(b) Table 1B, sovereign-derived for unrated)
Corporate (Art. 122 corporate CQS table)
else -> null (no substitution)
The unrated PSE / RGLA fallback is the documented SA-side approximation
(no guarantor sovereign-CQS join exists in the CRM column production):
a GB guarantor receives the 20% RGLA / PSE domestic-currency treatment,
any other country the conservative 100% unrated default — NOT the full
Art. 116(1) Table 2 / Art. 115(1)(a) Table 1A sovereign-derived lookup.
Args:
exposure_class_col: Name of the guarantor SA exposure-class column
(e.g. ``guarantor_exposure_class``).
entity_type_col: Name of the guarantor entity-type column — used for
the CCP override and the named-MDB (``mdb_named``) carve-out.
cqs_col: Name of the integer guarantor CQS column.
country_code_col: Name of the guarantor country-code column — drives
the unrated PSE / RGLA GB-vs-other approximation.
ccp_client_cleared_col: Name of the Boolean client-cleared flag
column (null -> proprietary 2%).
scra_grade_col: Name of the guarantor SCRA-grade column threaded
into ``build_institution_guarantor_rw_expr`` for the B31 unrated
institution dispatch.
is_basel_3_1: Select PS1/26 tables (institution ECRA / corporate
Table 6 / MDB Table 2B) when True, CRR tables when False — for
MDBs that means the Art. 117(1) institution treatment, since CRR
has no MDB table. PSE / RGLA / IO / CCP values are
framework-identical. Threaded by both live call sites from the
cited ``sa_revised_risk_weight_tables`` pack Feature.
domestic_cgcb_expr: Caller-supplied Art. 114(4)/(7) domestic-currency
test (SA and IRB derive domesticity differently). ``None``
disables the domestic 0% branch (treated as never-domestic).
short_term_flag_col: Optional Boolean column routing institution
guarantors to the Art. 120(2) Table 4 short-term dicts. The IRB
chain passes ``None`` today.
no_guarantee_expr: Caller-owned leading guard — rows where it
evaluates True yield null (no substitution priced). ``None``
omits the guard (the chain prices every row).
Returns:
Float64 Polars expression evaluating to the guarantor's SA RW, or
null where no substitution treatment exists.
"""
gec = pl.col(exposure_class_col).fill_null("")
# PSE/RGLA Art. 116(2)/115(1)(b) unrated fallback: domestic-GB guarantors
# get the RGLA/PSE 20% domestic-currency treatment; otherwise the
# conservative 100% PSE/RGLA unrated default applies.
sovereign_derived_unrated = _pse_rgla_unrated_fallback_expr(country_code_col)
cgcb_unrated = float(_CGCB_RW[CQS.UNRATED])
is_domestic_guarantor = domestic_cgcb_expr if domestic_cgcb_expr is not None else pl.lit(False)
skip_substitution = no_guarantee_expr if no_guarantee_expr is not None else pl.lit(False)
return (
pl.when(skip_substitution)
.then(pl.lit(None).cast(pl.Float64))
# Art. 114(4)/(7): Domestic sovereign -> 0% regardless of CQS.
.when((gec == "central_govt_central_bank") & is_domestic_guarantor)
.then(pl.lit(0.0))
# CGCB guarantors via CQS (Table 1 — sovereign weights).
.when(gec == "central_govt_central_bank")
.then(
_cqs_table_lookup_expr(
cqs_col,
_CGCB_RW,
cgcb_unrated,
)
)
# CCP guarantors: 2% proprietary / 4% client-cleared
# (CRR Art. 306, CRE54.14-15) — overrides institution CQS weights.
.when(pl.col(entity_type_col) == "ccp")
.then(
pl.when(pl.col(ccp_client_cleared_col).fill_null(False))
.then(pl.lit(_QCCP_CLIENT_CLEARED_RW))
.otherwise(pl.lit(_QCCP_PROPRIETARY_RW))
)
# International Organisation (Art. 118): 0% unconditional.
.when(gec == "international_organisation")
.then(pl.lit(float(_IO_ZERO_RW)))
# Named MDB (Art. 117(2)): 0% unconditional.
.when((gec == "mdb") & (pl.col(entity_type_col).fill_null("") == "mdb_named"))
.then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
# Rated / unrated non-named MDB (Art. 117(1)) — framework-divergent:
# PS1/26 Art. 117(1)(a)/(b): the dedicated Basel 3.1 MDB Table 2B
# (CQS2 30%, unrated 50%).
# CRR Art. 117(1): non-named MDBs "shall be treated in the same manner
# as exposures to institutions" — Art. 120 Table 3 when rated, the
# Art. 121 unrated institution fallback (100%) otherwise. There is no
# MDB table in CRR. The Art. 119(2)/120(2)/121(3) short-term
# preferential "shall not be applied", so ``short_term_flag_col`` is
# deliberately NOT threaded into this branch (unlike the institution
# branch below). Mirrors the direct, non-guarantor CRR MDB path in
# ``sa/risk_weights.py::_apply_crr_risk_weight_overrides`` (P1.253).
.when(gec == "mdb")
.then(
_cqs_table_lookup_expr(
cqs_col,
_MDB_RW,
float(_MDB_UNRATED_RW),
)
if is_basel_3_1
else build_institution_guarantor_rw_expr(cqs_col, is_basel_3_1=False)
)
# Institution guarantors — RW driven from institution_rw_crr /
# institution_rw_b31_ecra so the pack remains the single source of
# truth. When the short-term flag evaluates True (CRR/PS1/26
# Art. 120(2)), the short-term Table 4 dicts apply instead.
.when(gec == "institution")
.then(
build_institution_guarantor_rw_expr(
cqs_col,
is_basel_3_1,
short_term_flag_col=short_term_flag_col,
scra_grade_col=scra_grade_col,
)
)
# PSE guarantors — Art. 116(2) Table 2A for rated, sovereign-derived for unrated.
.when(gec == "pse")
.then(
_cqs_table_lookup_expr(
cqs_col,
_PSE_OWN_RW,
sovereign_derived_unrated,
)
)
# RGLA guarantors — Art. 115(1)(b) Table 1B for rated, sovereign-derived for unrated.
.when(gec == "rgla")
.then(
_cqs_table_lookup_expr(
cqs_col,
_RGLA_OWN_RW,
sovereign_derived_unrated,
)
)
# Corporate guarantors — Art. 122 corporate CQS table.
# Basel 3.1 (PRA PS1/26 Art. 122(2) Table 6): CQS3 = 75% (CRR: 100%);
# PRA retains CQS5 = 150%. Gated on framework so CRR runs are
# unchanged.
.when(gec.is_in(["corporate", "corporate_sme"]))
.then(build_corporate_guarantor_rw_expr(cqs_col, is_basel_3_1))
.otherwise(pl.lit(None).cast(pl.Float64))
)
build_entity_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:320
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("CRR Art. 118")
@cites("CRR Art. 122")
@cites("CRR Art. 123")
def build_entity_rw_expr(
*,
entity_type_col: str,
cqs_col: str,
is_basel_3_1: bool,
country_code_col: str | None = None,
) -> pl.Expr:
"""Build the entity-level SA risk-weight preview expression.
Compiled by the hierarchy facility-share selection
(``engine/stages/hierarchy/facility_undrawn.py::
_derive_facility_share_counterparty``) to rank candidate counterparties
by SA-equivalent risk weight. The preview is non-binding: the chosen
counterparty still flows through the full classifier and SA/IRB pipeline
downstream. Keeping the preview SA-only avoids a circular dependency with
the classifier's IRB approach gating.
Routes the lowercased ``entity_type`` through the SA exposure-class
buckets (``ENTITY_TYPES_BY_SA_CLASS``) and maps CQS -> RW via the same
table branches as :func:`build_guarantor_rw_expr`:
sovereign (CGCB CQS Table 1, Art. 114)
International Organisation (Art. 118) -> 0%
Named MDB (Art. 117(2)) -> 0%
MDB Table 2B (Art. 117(1))
Institution (Art. 120 Table 3 / PS1/26 ECRA via
``build_institution_guarantor_rw_expr``)
PSE (Art. 116(2) Table 2A, GB/other approximation for unrated)
RGLA (Art. 115(1)(b) Table 1B, GB/other approximation for unrated)
Corporate + covered bond (Art. 122 CRR Table 5 — see note below)
Retail (Art. 123 flat 75%)
High risk (Art. 128 flat 150%)
else -> 1.0 (conservative preview default for unmatched entity
types, e.g. equity / other items)
Branch-parity notes (the pre-existing preview branches are preserved
value-for-value):
- The corporate branch always prices from ``corporate_risk_weights``
(CRR Art. 122 Table 5), NOT the Basel 3.1 Table 6 — matching the
historical preview. Covered bonds use the corporate-equivalent CQS RWs
in the preview; the precise covered-bond table only applies in real
SA pricing.
- The unrated PSE / RGLA fallback is the documented SA-side
approximation (see :func:`build_guarantor_rw_expr`): GB -> 20%
domestic-currency treatment, other / unknown country -> 100% unrated
default. When ``country_code_col`` is ``None`` the 100% default
applies unconditionally.
Args:
entity_type_col: Name of the entity-type column. Null-filled to ""
and lowercased before bucket routing.
cqs_col: Name of the integer CQS column; null / out-of-range values
fall to each table's unrated default.
is_basel_3_1: Select the PS1/26 institution ECRA table when True,
CRR Art. 120 Table 3 when False. The remaining preview branches are
framework-identical: the corporate branch by name (see note above), and
the MDB / CGCB / IO / PSE / RGLA branches because their pack tables carry
identical values in both regimes.
country_code_col: Optional name of the country-code column driving
the unrated PSE / RGLA GB-vs-other approximation. ``None`` falls
back to the conservative 100% unrated default.
Returns:
Float64 Polars expression evaluating to the entity's SA-equivalent
preview risk weight (never null — unmatched entity types yield 1.0).
"""
et = pl.col(entity_type_col).fill_null("").str.to_lowercase()
sovereign_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CENTRAL_GOVT_CENTRAL_BANK.value])
io_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INTERNATIONAL_ORGANISATION.value])
mdb_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.MDB.value])
institution_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INSTITUTION.value])
pse_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.PSE.value])
rgla_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RGLA.value])
corporate_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CORPORATE.value])
covered_bond_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.COVERED_BOND.value])
retail_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RETAIL_OTHER.value])
high_risk_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.HIGH_RISK.value])
unrated_pse_rgla = _pse_rgla_unrated_fallback_expr(country_code_col)
return (
# CGCB (Art. 114 Table 1 — sovereign weights).
pl.when(et.is_in(sovereign_types))
.then(
_cqs_table_lookup_expr(
cqs_col,
_CGCB_RW,
float(_CGCB_RW[CQS.UNRATED]),
)
)
# International Organisation (Art. 118): 0% unconditional.
.when(et.is_in(io_types))
.then(pl.lit(float(_IO_ZERO_RW)))
# Named MDB (Art. 117(2)): 0% unconditional — carved out ahead of Table 2B.
.when(et == "mdb_named")
.then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
# Rated / unrated non-named MDB — Table 2B (CRR Art. 117(1) / PS1/26 Art. 117(1)(a)-(b)).
.when(et.is_in(mdb_types))
.then(_cqs_table_lookup_expr(cqs_col, _MDB_RW, float(_MDB_UNRATED_RW)))
# Institution — Art. 120 Table 3 / PS1/26 ECRA via the shared builder
# so the pack remains the single source of truth.
.when(et.is_in(institution_types))
.then(build_institution_guarantor_rw_expr(cqs_col, is_basel_3_1))
# PSE — Art. 116(2) Table 2A for rated, GB/other approximation for unrated.
.when(et.is_in(pse_types))
.then(_cqs_table_lookup_expr(cqs_col, _PSE_OWN_RW, unrated_pse_rgla))
# RGLA — Art. 115(1)(b) Table 1B for rated, GB/other approximation for unrated.
.when(et.is_in(rgla_types))
.then(_cqs_table_lookup_expr(cqs_col, _RGLA_OWN_RW, unrated_pse_rgla))
# Corporate + covered bond — CRR Art. 122 Table 5 (preview parity:
# not framework-switched; see docstring).
.when(et.is_in(corporate_types + covered_bond_types))
.then(
_cqs_table_lookup_expr(
cqs_col,
_CORPORATE_RW,
float(_CORPORATE_RW[CQS.UNRATED]),
)
)
# Retail (Art. 123): flat 75%.
.when(et.is_in(retail_types))
.then(pl.lit(_RETAIL_RISK_WEIGHT))
# High-risk items (Art. 128): flat 150%.
.when(et.is_in(high_risk_types))
.then(pl.lit(_HIGH_RISK_RW))
# Conservative preview default for unmatched entity types.
.otherwise(pl.lit(1.0))
)
CRR Art. 119 — Exposures to institutions¶
build_institution_guarantor_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:455
@cites("CRR Art. 119")
@cites("CRR Art. 120")
@cites("CRR Art. 121")
def build_institution_guarantor_rw_expr(
cqs_col: str,
is_basel_3_1: bool,
short_term_flag_col: str | None = None,
scra_grade_col: str | None = None,
) -> pl.Expr:
"""Build a CQS → institution risk weight expression from the canonical tables.
Used by SA and IRB guarantee substitution to look up the RW to apply to the
guaranteed portion when the guarantor is an institution. Drives values from
``institution_rw_crr`` / ``institution_rw_b31_ecra`` (long-term, Art. 120
Table 3) or ``institution_short_term_rw_crr`` /
``institution_short_term_rw_b31_ecra`` (short-term, Art. 120(2) Table 4) so
there is a single source of truth.
Args:
cqs_col: Name of the integer CQS column on the frame.
is_basel_3_1: Select PS1/26 ECRA table when True, CRR Art. 120 Table 3
when False.
short_term_flag_col: Optional name of a Boolean column. When provided,
rows where the column evaluates True route to the Art. 120(2)
Table 4 short-term dict (residual maturity ≤ 3 months); rows where
the column is False or null use the long-term Table 3 dict.
scra_grade_col: Optional name of a Utf8 column carrying the guarantor's
SCRA grade ("A" / "A_ENHANCED" / "B" / "C"). When provided AND
``is_basel_3_1`` is True, rows whose CQS column is null (i.e.
unrated under ECRA) dispatch via PRA PS1/26 Art. 121 Table 5 SCRA
grades using ``b31_scra_risk_weights`` (long-term) or
``b31_scra_short_term_risk_weights`` (short-term branch when the
``short_term_flag_col`` evaluates True). A null/missing SCRA grade
falls back to ``b31_scra_risk_weights["C"]`` per CRE20.21
conservative-fallback. The CRR path and the rated B31 path
(CQS 1-6) are entirely unaffected.
Returns:
Float64 Polars expression evaluating to the institution RW.
"""
long_term = _INSTITUTION_RW_B31_ECRA if is_basel_3_1 else _INSTITUTION_RW_CRR
short_term = (
_INSTITUTION_SHORT_TERM_RW_B31_ECRA if is_basel_3_1 else _INSTITUTION_SHORT_TERM_RW_CRR
)
col = pl.col(cqs_col)
use_scra = is_basel_3_1 and scra_grade_col is not None
def _scra_branch(table: dict[str, Decimal]) -> pl.Expr:
scra = pl.col(cast("str", scra_grade_col))
# CRE20.21 conservative fallback: null/missing SCRA grade -> Grade C.
return (
pl.when(scra == "A_ENHANCED")
.then(pl.lit(float(table["A_ENHANCED"])))
.when(scra == "A")
.then(pl.lit(float(table["A"])))
.when(scra == "B")
.then(pl.lit(float(table["B"])))
.otherwise(pl.lit(float(table["C"])))
)
def _branch(table: dict[CQS, Decimal], scra_table: dict[str, Decimal]) -> pl.Expr:
rated = (
pl.when(col == 1)
.then(pl.lit(float(table[CQS.CQS1])))
.when(col == 2)
.then(pl.lit(float(table[CQS.CQS2])))
.when(col == 3)
.then(pl.lit(float(table[CQS.CQS3])))
.when(col.is_in([4, 5]))
.then(pl.lit(float(table[CQS.CQS4])))
.when(col == 6)
.then(pl.lit(float(table[CQS.CQS6])))
.otherwise(pl.lit(float(table[CQS.UNRATED])))
)
if not use_scra:
return rated
# B31 + SCRA available: route unrated (null CQS) rows via SCRA grades.
return pl.when(col.is_null()).then(_scra_branch(scra_table)).otherwise(rated)
long_branch = _branch(long_term, _B31_SCRA_RW)
short_branch = _branch(short_term, _B31_SCRA_SHORT_TERM_RW)
if short_term_flag_col is None:
return long_branch
is_short_term = pl.col(short_term_flag_col).fill_null(False)
return pl.when(is_short_term).then(short_branch).otherwise(long_branch)
CRR Art. 120 — Exposures to rated institutions¶
build_institution_guarantor_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:456
@cites("CRR Art. 119")
@cites("CRR Art. 120")
@cites("CRR Art. 121")
def build_institution_guarantor_rw_expr(
cqs_col: str,
is_basel_3_1: bool,
short_term_flag_col: str | None = None,
scra_grade_col: str | None = None,
) -> pl.Expr:
"""Build a CQS → institution risk weight expression from the canonical tables.
Used by SA and IRB guarantee substitution to look up the RW to apply to the
guaranteed portion when the guarantor is an institution. Drives values from
``institution_rw_crr`` / ``institution_rw_b31_ecra`` (long-term, Art. 120
Table 3) or ``institution_short_term_rw_crr`` /
``institution_short_term_rw_b31_ecra`` (short-term, Art. 120(2) Table 4) so
there is a single source of truth.
Args:
cqs_col: Name of the integer CQS column on the frame.
is_basel_3_1: Select PS1/26 ECRA table when True, CRR Art. 120 Table 3
when False.
short_term_flag_col: Optional name of a Boolean column. When provided,
rows where the column evaluates True route to the Art. 120(2)
Table 4 short-term dict (residual maturity ≤ 3 months); rows where
the column is False or null use the long-term Table 3 dict.
scra_grade_col: Optional name of a Utf8 column carrying the guarantor's
SCRA grade ("A" / "A_ENHANCED" / "B" / "C"). When provided AND
``is_basel_3_1`` is True, rows whose CQS column is null (i.e.
unrated under ECRA) dispatch via PRA PS1/26 Art. 121 Table 5 SCRA
grades using ``b31_scra_risk_weights`` (long-term) or
``b31_scra_short_term_risk_weights`` (short-term branch when the
``short_term_flag_col`` evaluates True). A null/missing SCRA grade
falls back to ``b31_scra_risk_weights["C"]`` per CRE20.21
conservative-fallback. The CRR path and the rated B31 path
(CQS 1-6) are entirely unaffected.
Returns:
Float64 Polars expression evaluating to the institution RW.
"""
long_term = _INSTITUTION_RW_B31_ECRA if is_basel_3_1 else _INSTITUTION_RW_CRR
short_term = (
_INSTITUTION_SHORT_TERM_RW_B31_ECRA if is_basel_3_1 else _INSTITUTION_SHORT_TERM_RW_CRR
)
col = pl.col(cqs_col)
use_scra = is_basel_3_1 and scra_grade_col is not None
def _scra_branch(table: dict[str, Decimal]) -> pl.Expr:
scra = pl.col(cast("str", scra_grade_col))
# CRE20.21 conservative fallback: null/missing SCRA grade -> Grade C.
return (
pl.when(scra == "A_ENHANCED")
.then(pl.lit(float(table["A_ENHANCED"])))
.when(scra == "A")
.then(pl.lit(float(table["A"])))
.when(scra == "B")
.then(pl.lit(float(table["B"])))
.otherwise(pl.lit(float(table["C"])))
)
def _branch(table: dict[CQS, Decimal], scra_table: dict[str, Decimal]) -> pl.Expr:
rated = (
pl.when(col == 1)
.then(pl.lit(float(table[CQS.CQS1])))
.when(col == 2)
.then(pl.lit(float(table[CQS.CQS2])))
.when(col == 3)
.then(pl.lit(float(table[CQS.CQS3])))
.when(col.is_in([4, 5]))
.then(pl.lit(float(table[CQS.CQS4])))
.when(col == 6)
.then(pl.lit(float(table[CQS.CQS6])))
.otherwise(pl.lit(float(table[CQS.UNRATED])))
)
if not use_scra:
return rated
# B31 + SCRA available: route unrated (null CQS) rows via SCRA grades.
return pl.when(col.is_null()).then(_scra_branch(scra_table)).otherwise(rated)
long_branch = _branch(long_term, _B31_SCRA_RW)
short_branch = _branch(short_term, _B31_SCRA_SHORT_TERM_RW)
if short_term_flag_col is None:
return long_branch
is_short_term = pl.col(short_term_flag_col).fill_null(False)
return pl.when(is_short_term).then(short_branch).otherwise(long_branch)
CRR Art. 121 — Exposures to unrated institutions¶
build_institution_guarantor_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:457
@cites("CRR Art. 119")
@cites("CRR Art. 120")
@cites("CRR Art. 121")
def build_institution_guarantor_rw_expr(
cqs_col: str,
is_basel_3_1: bool,
short_term_flag_col: str | None = None,
scra_grade_col: str | None = None,
) -> pl.Expr:
"""Build a CQS → institution risk weight expression from the canonical tables.
Used by SA and IRB guarantee substitution to look up the RW to apply to the
guaranteed portion when the guarantor is an institution. Drives values from
``institution_rw_crr`` / ``institution_rw_b31_ecra`` (long-term, Art. 120
Table 3) or ``institution_short_term_rw_crr`` /
``institution_short_term_rw_b31_ecra`` (short-term, Art. 120(2) Table 4) so
there is a single source of truth.
Args:
cqs_col: Name of the integer CQS column on the frame.
is_basel_3_1: Select PS1/26 ECRA table when True, CRR Art. 120 Table 3
when False.
short_term_flag_col: Optional name of a Boolean column. When provided,
rows where the column evaluates True route to the Art. 120(2)
Table 4 short-term dict (residual maturity ≤ 3 months); rows where
the column is False or null use the long-term Table 3 dict.
scra_grade_col: Optional name of a Utf8 column carrying the guarantor's
SCRA grade ("A" / "A_ENHANCED" / "B" / "C"). When provided AND
``is_basel_3_1`` is True, rows whose CQS column is null (i.e.
unrated under ECRA) dispatch via PRA PS1/26 Art. 121 Table 5 SCRA
grades using ``b31_scra_risk_weights`` (long-term) or
``b31_scra_short_term_risk_weights`` (short-term branch when the
``short_term_flag_col`` evaluates True). A null/missing SCRA grade
falls back to ``b31_scra_risk_weights["C"]`` per CRE20.21
conservative-fallback. The CRR path and the rated B31 path
(CQS 1-6) are entirely unaffected.
Returns:
Float64 Polars expression evaluating to the institution RW.
"""
long_term = _INSTITUTION_RW_B31_ECRA if is_basel_3_1 else _INSTITUTION_RW_CRR
short_term = (
_INSTITUTION_SHORT_TERM_RW_B31_ECRA if is_basel_3_1 else _INSTITUTION_SHORT_TERM_RW_CRR
)
col = pl.col(cqs_col)
use_scra = is_basel_3_1 and scra_grade_col is not None
def _scra_branch(table: dict[str, Decimal]) -> pl.Expr:
scra = pl.col(cast("str", scra_grade_col))
# CRE20.21 conservative fallback: null/missing SCRA grade -> Grade C.
return (
pl.when(scra == "A_ENHANCED")
.then(pl.lit(float(table["A_ENHANCED"])))
.when(scra == "A")
.then(pl.lit(float(table["A"])))
.when(scra == "B")
.then(pl.lit(float(table["B"])))
.otherwise(pl.lit(float(table["C"])))
)
def _branch(table: dict[CQS, Decimal], scra_table: dict[str, Decimal]) -> pl.Expr:
rated = (
pl.when(col == 1)
.then(pl.lit(float(table[CQS.CQS1])))
.when(col == 2)
.then(pl.lit(float(table[CQS.CQS2])))
.when(col == 3)
.then(pl.lit(float(table[CQS.CQS3])))
.when(col.is_in([4, 5]))
.then(pl.lit(float(table[CQS.CQS4])))
.when(col == 6)
.then(pl.lit(float(table[CQS.CQS6])))
.otherwise(pl.lit(float(table[CQS.UNRATED])))
)
if not use_scra:
return rated
# B31 + SCRA available: route unrated (null CQS) rows via SCRA grades.
return pl.when(col.is_null()).then(_scra_branch(scra_table)).otherwise(rated)
long_branch = _branch(long_term, _B31_SCRA_RW)
short_branch = _branch(short_term, _B31_SCRA_SHORT_TERM_RW)
if short_term_flag_col is None:
return long_branch
is_short_term = pl.col(short_term_flag_col).fill_null(False)
return pl.when(is_short_term).then(short_branch).otherwise(long_branch)
_crr_append_institution_maturity_branches — src/rwa_calc/engine/sa/risk_weights.py:762
@cites("CRR Art. 121")
def _crr_append_institution_maturity_branches(chain: _RWChain, uc: pl.Expr) -> ChainedThen:
"""Append CRR Art. 120/121/131 institution branches, short-dated first.
The Art. 131 Table 7 dedicated short-term ECAI branch is prepended ahead of
the Art. 120(2) Table 4 general short-term branch: when the exposure carries
an issue-specific short-term credit assessment (``has_short_term_ecai=True``)
Table 7 applies regardless of the residual-maturity gate that drives the
Table 4 path. Mirrors the Basel 3.1 Table 4A pattern.
The unrated limbs close the chain, short-dated before long-dated, so the
Art. 121(3) flat 20% takes precedence over the Art. 121(1) Table 5
sovereign-derived lookup that follows it.
"""
is_institution = uc.str.contains("INSTITUTION", literal=True)
is_rated = pl.col("cqs").is_not_null() & (pl.col("cqs") > 0)
is_unrated = pl.col("cqs").is_null() | (pl.col("cqs") <= 0)
residual_mty = pl.col("residual_maturity_years").fill_null(1.0)
original_mty = pl.col("original_maturity_years").fill_null(1.0)
# Producer-guaranteed non-null (hierarchy is_not_null()/lit(False); contract False).
has_st_ecai = pl.col("has_short_term_ecai")
return (
# Art. 131 Table 7: rated institution with a dedicated short-term ECAI
# assessment. CQS 1=20%, CQS 2=50%, CQS 3=100%, CQS 4-6=150%. The
# producer only flags maturity-qualifying rows, so no re-check here.
chain.when(is_institution & is_rated & has_st_ecai)
.then(
pl.when(pl.col("cqs") == 1)
.then(pl.lit(_SA_CRR_RW["st_ecai_cqs1"]))
.when(pl.col("cqs") == 2)
.then(pl.lit(_SA_CRR_RW["st_ecai_cqs2"]))
.when(pl.col("cqs") == 3)
.then(pl.lit(_SA_CRR_RW["st_ecai_cqs3"]))
.otherwise(pl.lit(_SA_CRR_RW["st_ecai_high"]))
)
# Art. 120(2) Table 4: rated institution short-term (residual maturity
# <= 3m). Also fires on derived ORIGINAL maturity when
# residual_maturity_years is not populated upstream — original is
# derived from (maturity_date - value_date) earlier in the SA pipeline,
# mirroring the B31 ECRA short-term gate so date-only fixtures still
# qualify for Table 4 preferential weights.
.when(is_institution & is_rated & ((residual_mty <= 0.25) | (original_mty <= 0.25)))
.then(
pl.when(pl.col("cqs") <= 3)
.then(pl.lit(_SA_CRR_RW["inst_st_low"]))
.when(pl.col("cqs") <= 5)
.then(pl.lit(_SA_CRR_RW["inst_st_mid"]))
.otherwise(pl.lit(_SA_CRR_RW["inst_st_high"]))
)
# Art. 121(3): unrated institution with ORIGINAL effective maturity <= 3m.
# Overrides the Table 5 sovereign-derived fallback; Art. 121(6) sovereign
# floor (applied later) still raises this in FX.
.when(is_institution & is_unrated & (original_mty <= 0.25))
.then(pl.lit(_SA_CRR_RW["inst_unrated_st"]))
# Art. 121(1) Table 5: an unrated institution takes the risk weight of
# the CQS to which the central government of its jurisdiction of
# incorporation is assigned — 1/2/3/4/5/6 -> 20/50/100/100/100/150%.
# Long-dated only: it sits BEHIND the Art. 121(3) branch above, which
# is why it carries no maturity gate of its own. Art. 121(2) (central
# government unrated -> 100%) is the ``unrated_default`` that
# ``sovereign_derived_rw_expr`` applies when cp_sovereign_cqs is null.
#
# Art. 121(4) trade finance is EXCLUDED at ANY maturity — see
# ``crr_art_121_4_trade_finance_expr``. Excluded rows are NOT given
# Art. 121(4)'s 50%: no such branch exists, so they fall through to the
# base CQS join and land on INSTITUTION_RISK_WEIGHTS_CRR[UNRATED] =
# 100%. That is a DELIBERATE INTERIM, not a derived answer, and it is
# conservative against BOTH candidate readings of the article:
# - purposive (4) as a trade-finance floor -> 50%
# - literal (4) ("Notwithstanding paragraphs 2 and 3", NOT 1, so
# Table 5 is never displaced) -> 20% at sovereign CQS 1
# 100% over-states both. Settling which reading governs needs the
# primary text read against the PRA Rulebook rendering and is filed as
# a finding, not decided here. Pinned by
# ``test_p1_316_trade_finance_stays_on_the_100pct_residual`` so the
# value cannot drift silently between the three candidates.
.when(is_institution & is_unrated & ~crr_art_121_4_trade_finance_expr())
.then(
sovereign_derived_rw_expr(
INSTITUTION_RISK_WEIGHTS_SOVEREIGN_DERIVED,
float(INSTITUTION_RISK_WEIGHTS_CRR[CQS.UNRATED]),
)
)
)
crr_art_121_4_trade_finance_expr — src/rwa_calc/engine/sa/sovereign_derived.py:103
@cites("CRR Art. 121")
def crr_art_121_4_trade_finance_expr() -> pl.Expr:
"""Art. 121(4) trade-finance exposures to unrated institutions.
Art. 121(4) prescribes a flat **50%** (20% where residual maturity is three
months or less) for trade finance under Art. 162(3) second subparagraph
point (b), "Notwithstanding paragraphs 2 and 3".
**Which paragraph (4) displaces is genuinely ambiguous, and this predicate
does not resolve it.** It names (2) and (3), *not* (1). So:
- On a PURPOSIVE reading, (4) is a floor for trade finance generally and
the answer is 50%.
- On a LITERAL reading, (4) never displaces (1), so Table 5 still governs
and the answer is 20% at sovereign CQS 1.
The two readings differ by 30pp in OPPOSITE directions. Neither rate is
implemented and no pack entry exists for either (P1.326 / P7.8 own them),
so this predicate holds the rows OUT of the ladder and they land on the
Art. 121 unrated **100%** residual via the base CQS join — over-stating
both candidates, which is the right way to be wrong while the question is
open. Do NOT add a 50% branch on the strength of this docstring; settling
it needs the primary text read against the PRA Rulebook rendering.
What is NOT ambiguous is the direction of the error if these rows are let
into the ladder: 20% against a required 50% is a 30pp UNDERSTATEMENT, and
that is why the exclusion exists at all.
**There is deliberately no maturity gate.** Only Art. 121(4)'s 20% limb is
maturity-conditioned; its 50% limb applies at every maturity. The sibling
exemption in ``_apply_sovereign_floor_for_institutions`` does carry a
one-year condition — that is CRE20.22 footnote 13, a different rule — and
copying its shape here re-opens 20% at sovereign CQS 1 on any trade LC
longer than a year. That is the guard gap that dropped P1.316 on its first
pass, so the 5-year trade-LC case is pinned explicitly by
``tests/unit/crr/test_p1_316_art_121_1_table_5_unrated_institution.py``.
``eq_missing`` keeps the predicate null-safe without adding a ``fill_null``
site: the flag reaches the SA branch non-null today, but a negated Kleene
null would silently drop rows from the ladder.
"""
return pl.col("is_short_term_trade_lc").eq_missing(True)
CRR Art. 122 — Exposures to corporates¶
_compute_guarantor_rw_sa — src/rwa_calc/engine/irb/guarantee.py:255
@cites("CRR Art. 122")
@cites("CRR Art. 235")
def _compute_guarantor_rw_sa(
lf: pl.LazyFrame,
cols: list[str],
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Compute the guarantor's SA risk weight via the shared builder.
Compiles ``build_guarantor_rw_expr`` (data/tables/guarantor_rw.py) with
the IRB chain's column names — the same branch chain and order as the
SA-side twin (engine/sa/namespace.py::_build_guarantor_rw_expr). This
closes the IRB-guarantor PSE / RGLA substitution gap (the recorded
Phase 4 fix) plus the IO 0%, named-MDB 0% and MDB Table 2B closures.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
# Ensure guarantor_exposure_class is available (set by CRM processor;
# fallback for unit tests that construct LazyFrames directly)
if "guarantor_exposure_class" not in cols:
from rwa_calc.engine.entity_class_maps import ENTITY_TYPE_TO_SA_CLASS
lf = lf.with_columns(
pl.col("guarantor_entity_type")
.fill_null("")
.replace_strict(ENTITY_TYPE_TO_SA_CLASS, default="")
.alias("guarantor_exposure_class"),
)
if "guarantor_is_ccp_client_cleared" not in cols:
lf = lf.with_columns(
pl.lit(None).cast(pl.Boolean).alias("guarantor_is_ccp_client_cleared"),
)
# B31 SCRA dispatch fallback: ensure ``guarantor_scra_grade`` is referenceable
# by ``build_institution_guarantor_rw_expr``. The CRM processor populates this
# column from counterparties.scra_grade (engine/crm/guarantees.py); fall back
# to null for unit tests that construct LazyFrames directly without going
# through the CRM join.
if "guarantor_scra_grade" not in cols:
lf = lf.with_columns(
pl.lit(None).cast(pl.String).alias("guarantor_scra_grade"),
)
_gec = pl.col("guarantor_exposure_class").fill_null("")
# Art. 114(4)/(7): Domestic CGCB guarantors -> 0% RW regardless of CQS.
# Evaluate the domestic-currency (denomination) test against the guarantee
# currency (the currency of the substituted exposure to the sovereign); the
# Art. 233(3) 8% FX haircut separately handles any mismatch between the
# guarantee and the underlying exposure. Fall back to the exposure's pre-FX
# denomination when `guarantee_currency` is missing (legacy / no-guarantee
# rows). Art. 235(3): the 0% extension additionally requires the exposure to
# be *funded* in the domestic currency, so the funding limb (null-PERMISSIVE
# fallback to the denomination — see `funding_currency_expr`) is ANDed in.
_irb_schema_names = lf.collect_schema().names()
_has_country = "guarantor_country_code" in _irb_schema_names
_has_exposure_ccy_irb = (
"original_currency" in _irb_schema_names or "currency" in _irb_schema_names
)
_has_guarantee_ccy_irb = "guarantee_currency" in _irb_schema_names
if _has_guarantee_ccy_irb and _has_exposure_ccy_irb:
_ccy_expr_irb = pl.col("guarantee_currency").fill_null(
denomination_currency_expr(_irb_schema_names)
)
elif _has_guarantee_ccy_irb:
_ccy_expr_irb = pl.col("guarantee_currency")
elif _has_exposure_ccy_irb:
_ccy_expr_irb = denomination_currency_expr(_irb_schema_names)
else:
_ccy_expr_irb = None
_is_domestic_guarantor = (
build_domestic_cgcb_guarantor_expr(
"guarantor_country_code", _ccy_expr_irb, funding_currency_expr(_irb_schema_names)
)
if _has_country and _ccy_expr_irb is not None
else pl.lit(False)
)
# The shared expression's unrated PSE/RGLA fallback reads the guarantor
# country column; ensure it is referenceable for direct (non-pipeline)
# invocation, mirroring the ccp / scra fallbacks above. The pipeline
# always carries it (joined by engine/crm/guarantees.py).
if not _has_country:
lf = lf.with_columns(
pl.lit(None).cast(pl.String).alias("guarantor_country_code"),
)
return lf.with_columns(
build_guarantor_rw_expr(
exposure_class_col="guarantor_exposure_class",
entity_type_col="guarantor_entity_type",
cqs_col="guarantor_cqs",
country_code_col="guarantor_country_code",
ccp_client_cleared_col="guarantor_is_ccp_client_cleared",
scra_grade_col="guarantor_scra_grade",
is_basel_3_1=resolved_pack.feature("sa_revised_risk_weight_tables"),
domestic_cgcb_expr=_is_domestic_guarantor,
# No borrower-maturity short-term flag is threaded on the IRB
# path today (the SA twin derives one from its own stage
# scratch); long-term Table 3 applies throughout.
short_term_flag_col=None,
no_guarantee_expr=pl.col("guaranteed_portion").fill_null(0) <= 0,
).alias("guarantor_rw_sa"),
)
build_entity_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:321
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("CRR Art. 118")
@cites("CRR Art. 122")
@cites("CRR Art. 123")
def build_entity_rw_expr(
*,
entity_type_col: str,
cqs_col: str,
is_basel_3_1: bool,
country_code_col: str | None = None,
) -> pl.Expr:
"""Build the entity-level SA risk-weight preview expression.
Compiled by the hierarchy facility-share selection
(``engine/stages/hierarchy/facility_undrawn.py::
_derive_facility_share_counterparty``) to rank candidate counterparties
by SA-equivalent risk weight. The preview is non-binding: the chosen
counterparty still flows through the full classifier and SA/IRB pipeline
downstream. Keeping the preview SA-only avoids a circular dependency with
the classifier's IRB approach gating.
Routes the lowercased ``entity_type`` through the SA exposure-class
buckets (``ENTITY_TYPES_BY_SA_CLASS``) and maps CQS -> RW via the same
table branches as :func:`build_guarantor_rw_expr`:
sovereign (CGCB CQS Table 1, Art. 114)
International Organisation (Art. 118) -> 0%
Named MDB (Art. 117(2)) -> 0%
MDB Table 2B (Art. 117(1))
Institution (Art. 120 Table 3 / PS1/26 ECRA via
``build_institution_guarantor_rw_expr``)
PSE (Art. 116(2) Table 2A, GB/other approximation for unrated)
RGLA (Art. 115(1)(b) Table 1B, GB/other approximation for unrated)
Corporate + covered bond (Art. 122 CRR Table 5 — see note below)
Retail (Art. 123 flat 75%)
High risk (Art. 128 flat 150%)
else -> 1.0 (conservative preview default for unmatched entity
types, e.g. equity / other items)
Branch-parity notes (the pre-existing preview branches are preserved
value-for-value):
- The corporate branch always prices from ``corporate_risk_weights``
(CRR Art. 122 Table 5), NOT the Basel 3.1 Table 6 — matching the
historical preview. Covered bonds use the corporate-equivalent CQS RWs
in the preview; the precise covered-bond table only applies in real
SA pricing.
- The unrated PSE / RGLA fallback is the documented SA-side
approximation (see :func:`build_guarantor_rw_expr`): GB -> 20%
domestic-currency treatment, other / unknown country -> 100% unrated
default. When ``country_code_col`` is ``None`` the 100% default
applies unconditionally.
Args:
entity_type_col: Name of the entity-type column. Null-filled to ""
and lowercased before bucket routing.
cqs_col: Name of the integer CQS column; null / out-of-range values
fall to each table's unrated default.
is_basel_3_1: Select the PS1/26 institution ECRA table when True,
CRR Art. 120 Table 3 when False. The remaining preview branches are
framework-identical: the corporate branch by name (see note above), and
the MDB / CGCB / IO / PSE / RGLA branches because their pack tables carry
identical values in both regimes.
country_code_col: Optional name of the country-code column driving
the unrated PSE / RGLA GB-vs-other approximation. ``None`` falls
back to the conservative 100% unrated default.
Returns:
Float64 Polars expression evaluating to the entity's SA-equivalent
preview risk weight (never null — unmatched entity types yield 1.0).
"""
et = pl.col(entity_type_col).fill_null("").str.to_lowercase()
sovereign_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CENTRAL_GOVT_CENTRAL_BANK.value])
io_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INTERNATIONAL_ORGANISATION.value])
mdb_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.MDB.value])
institution_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INSTITUTION.value])
pse_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.PSE.value])
rgla_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RGLA.value])
corporate_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CORPORATE.value])
covered_bond_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.COVERED_BOND.value])
retail_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RETAIL_OTHER.value])
high_risk_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.HIGH_RISK.value])
unrated_pse_rgla = _pse_rgla_unrated_fallback_expr(country_code_col)
return (
# CGCB (Art. 114 Table 1 — sovereign weights).
pl.when(et.is_in(sovereign_types))
.then(
_cqs_table_lookup_expr(
cqs_col,
_CGCB_RW,
float(_CGCB_RW[CQS.UNRATED]),
)
)
# International Organisation (Art. 118): 0% unconditional.
.when(et.is_in(io_types))
.then(pl.lit(float(_IO_ZERO_RW)))
# Named MDB (Art. 117(2)): 0% unconditional — carved out ahead of Table 2B.
.when(et == "mdb_named")
.then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
# Rated / unrated non-named MDB — Table 2B (CRR Art. 117(1) / PS1/26 Art. 117(1)(a)-(b)).
.when(et.is_in(mdb_types))
.then(_cqs_table_lookup_expr(cqs_col, _MDB_RW, float(_MDB_UNRATED_RW)))
# Institution — Art. 120 Table 3 / PS1/26 ECRA via the shared builder
# so the pack remains the single source of truth.
.when(et.is_in(institution_types))
.then(build_institution_guarantor_rw_expr(cqs_col, is_basel_3_1))
# PSE — Art. 116(2) Table 2A for rated, GB/other approximation for unrated.
.when(et.is_in(pse_types))
.then(_cqs_table_lookup_expr(cqs_col, _PSE_OWN_RW, unrated_pse_rgla))
# RGLA — Art. 115(1)(b) Table 1B for rated, GB/other approximation for unrated.
.when(et.is_in(rgla_types))
.then(_cqs_table_lookup_expr(cqs_col, _RGLA_OWN_RW, unrated_pse_rgla))
# Corporate + covered bond — CRR Art. 122 Table 5 (preview parity:
# not framework-switched; see docstring).
.when(et.is_in(corporate_types + covered_bond_types))
.then(
_cqs_table_lookup_expr(
cqs_col,
_CORPORATE_RW,
float(_CORPORATE_RW[CQS.UNRATED]),
)
)
# Retail (Art. 123): flat 75%.
.when(et.is_in(retail_types))
.then(pl.lit(_RETAIL_RISK_WEIGHT))
# High-risk items (Art. 128): flat 150%.
.when(et.is_in(high_risk_types))
.then(pl.lit(_HIGH_RISK_RW))
# Conservative preview default for unmatched entity types.
.otherwise(pl.lit(1.0))
)
build_corporate_guarantor_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:544
@cites("CRR Art. 122")
def build_corporate_guarantor_rw_expr(
cqs_col: str,
is_basel_3_1: bool,
) -> pl.Expr:
"""Build a CQS → corporate risk weight expression from the canonical tables.
Used by SA and IRB guarantee substitution to look up the RW to apply to the
guaranteed portion when the guarantor is a corporate. Drives values from
``corporate_risk_weights`` (CRR Art. 122 Table 5) or
``b31_corporate_risk_weights`` (PRA PS1/26 Art. 122(2) Table 6) so there is
a single source of truth — and B3.1 corporate CQS3 correctly maps to 75%
(Table 6) instead of CRR Table 5's 100%.
Args:
cqs_col: Name of the integer CQS column on the frame.
is_basel_3_1: Select PS1/26 Art. 122(2) Table 6 when True, CRR Art. 122
Table 5 when False.
Returns:
Float64 Polars expression evaluating to the corporate RW.
"""
col = pl.col(cqs_col)
if is_basel_3_1:
rw_1 = float(_B31_CORPORATE_RW[1])
rw_2 = float(_B31_CORPORATE_RW[2])
rw_3 = float(_B31_CORPORATE_RW[3])
rw_4 = float(_B31_CORPORATE_RW[4])
rw_5 = float(_B31_CORPORATE_RW[5])
rw_6 = float(_B31_CORPORATE_RW[6])
rw_unrated = float(_B31_CORPORATE_RW[None])
else:
rw_1 = float(_CORPORATE_RW[CQS.CQS1])
rw_2 = float(_CORPORATE_RW[CQS.CQS2])
rw_3 = float(_CORPORATE_RW[CQS.CQS3])
rw_4 = float(_CORPORATE_RW[CQS.CQS4])
rw_5 = float(_CORPORATE_RW[CQS.CQS5])
rw_6 = float(_CORPORATE_RW[CQS.CQS6])
rw_unrated = float(_CORPORATE_RW[CQS.UNRATED])
return (
pl.when(col == 1)
.then(pl.lit(rw_1))
.when(col == 2)
.then(pl.lit(rw_2))
.when(col == 3)
.then(pl.lit(rw_3))
.when(col == 4)
.then(pl.lit(rw_4))
.when(col == 5)
.then(pl.lit(rw_5))
.when(col == 6)
.then(pl.lit(rw_6))
.otherwise(pl.lit(rw_unrated))
)
CRR Art. 123 — Retail exposures¶
_add_exposure_class_applied — src/rwa_calc/engine/aggregator/aggregator.py:452
@cites("CRR Art. 112")
@cites("CRR Art. 123")
@cites("CRR Art. 126")
def _add_exposure_class_applied(lf: pl.LazyFrame) -> pl.LazyFrame:
"""Add ``exposure_class_applied`` — the approach-agnostic applied class.
The routing ``exposure_class`` records origination + guarantee substitution
but omits three SA-only applied-treatment movements, so the reconciliation and
COREP class dimensions previously mis-bucketed those rows (the RWA is correct
in every case — only the class label was wrong):
- **SME managed as retail** (CRR Art. 123 / PS1/26 Art. 123A) — a
corporate-SME row that took the 75% retail risk weight logically belongs
to the retail class: Art. 122 corporate has no 75% band, so a 75%-weighted
SME entails retail. The predicate mirrors the SA risk-weight branch exactly
(``engine/sa/risk_weights.py``) so the reported class tracks the applied RW.
- **Defaulted** (CRR Art. 112(1)(j) / Art. 127) — a defaulted SA exposure
belongs to the "Exposures in default" class, which wins over origination
(PS1/26 Table A2 priority 5). High-risk (Art. 128, Basel 3.1) still outranks
default (priority 4), so a defaulted high-risk row keeps its class.
- **Secured by a mortgage on commercial immovable property** (CRR
Art. 112(1)(i) / Art. 126; PS1/26 Art. 112(1)(i) / Art. 124H-124I) — an
exposure whose SA risk weight is set by the commercial real-estate branch
belongs to Art. 112(1)(i), not to the counterparty's class. It reuses the
dispatcher's own :func:`is_commercial_re_class` predicate, whose
``property_type == "commercial"`` limb is precisely what lets a
``corporate``-routed exposure take the Art. 126 50% (or Art. 124I
income-producing) risk weight; sharing one expression is what stops the
reported class and the applied risk weight from drifting apart again.
Both frameworks rank the real-estate class the same way and both make the
protection — not the counterparty — the classifying fact. PS1/26 Art. 112(2)
Table A2 is explicit ("Where an exposure meets the criteria for more than one
exposure class it shall be assigned to the exposure class that has the highest
position in Table A2"): real estate is row (7), retail row (14), corporates
row (15). COREP Annex II ¶62 gives the CRR twin ranking — "Exposures secured
by mortgages on immovable property" is rank 6, corporates and retail rank 9 —
and ¶58 notes class (i) is the one class where "a protection effect is
intrinsically part of the definition of an exposure class". So the limb sits
BELOW default/high-risk (which outrank real estate in both rankings) and ABOVE
the retail limb. Rows already in a real-estate class are left alone: they are
in Art. 112(1)(i) already, and re-labelling them would only shuffle the
C 09.01 "of which" sub-rows without changing the class total.
¶60 is what makes this a reporting-side overlay rather than a classifier
change: the prioritisation governs "the assignment of the Original exposure
pre-conversion factor by exposure classes, without prejudice to the specific
treatment (risk weight) that each specific exposure shall receive within the
assigned exposure class". The routing ``exposure_class`` keeps driving
approach selection, CRM and the risk-weight tables; only the reported class
moves, so no RWA changes.
Only SA rows (``approach_applied == "standardised"``) are re-mapped: IRB
already reclassifies corporate→retail on ``exposure_class`` and reports
default via a PD override (not a class), slotting keeps SPECIALISED_LENDING,
and equity keeps EQUITY — so every non-SA approach keeps ``exposure_class``.
This is a PRE-substitution (obligor-side) class and is applied to guaranteed
exposures too. A guaranteed exposure is physically split into ``__G_`` /
``__REM`` legs (``engine/crm/guarantees.py``) that BOTH carry the obligor's
origination ``exposure_class`` — the guarantor's class lives only in
``post_crm_exposure_class_guaranteed``, which drives the COREP C 07.00
substitution inflow/outflow. So the guaranteed leg of a defaulted (or
SME-managed-as-retail) obligor correctly takes the same applied class as its
remainder: in C 07.00 the whole exposure originates in the obligor's sheet
("Exposures in default" / Retail) and the guaranteed portion leaves as an
outflow. Gating the overlay on ``~is_guaranteed`` would wrongly drop the
guaranteed portion out of that class and understate it.
"""
is_sa = pl.col("approach_applied") == ApproachType.SA.value
upper_class = pl.col("exposure_class").str.to_uppercase()
is_high_risk = pl.col("exposure_class") == ExposureClass.HIGH_RISK.value
sme_managed_as_retail = (
upper_class.str.contains("SME", literal=True)
& (pl.col("cp_is_managed_as_retail") == True) # noqa: E712
& (pl.col("qualifies_as_retail") == True) # noqa: E712
)
# Classes that outrank real estate in BOTH rankings (PS1/26 Table A2 rows
# (1)-(6); COREP Annex II para 62 ranks 1-5) and so keep their own class even
# when property-secured. Defaulted is already handled by the limb above.
outranks_real_estate = pl.col("exposure_class").is_in(
[
ExposureClass.HIGH_RISK.value,
ExposureClass.EQUITY.value,
ExposureClass.COVERED_BOND.value,
ExposureClass.DEFAULTED.value,
]
)
already_real_estate = pl.col("exposure_class").is_in(
[
ExposureClass.RETAIL_MORTGAGE.value,
ExposureClass.RESIDENTIAL_MORTGAGE.value,
ExposureClass.COMMERCIAL_MORTGAGE.value,
]
)
commercial_real_estate = (
is_commercial_re_class(upper_class) & ~outranks_real_estate & ~already_real_estate
)
return lf.with_columns(
pl.when(~is_sa)
.then(pl.col("exposure_class"))
# A null is_defaulted falls through the when() (treated as not defaulted),
# so no fill_null is needed — keep the applied class off origination.
.when((pl.col("is_defaulted") == True) & ~is_high_risk) # noqa: E712
.then(pl.lit(ExposureClass.DEFAULTED.value))
# Art. 112(1)(i) outranks corporates and retail in both frameworks, so
# this limb must precede the retail one below.
.when(commercial_real_estate)
.then(pl.lit(ExposureClass.COMMERCIAL_MORTGAGE.value))
.when(sme_managed_as_retail)
.then(pl.lit(ExposureClass.RETAIL_OTHER.value))
.otherwise(pl.col("exposure_class"))
.alias("exposure_class_applied")
)
build_entity_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:322
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("CRR Art. 118")
@cites("CRR Art. 122")
@cites("CRR Art. 123")
def build_entity_rw_expr(
*,
entity_type_col: str,
cqs_col: str,
is_basel_3_1: bool,
country_code_col: str | None = None,
) -> pl.Expr:
"""Build the entity-level SA risk-weight preview expression.
Compiled by the hierarchy facility-share selection
(``engine/stages/hierarchy/facility_undrawn.py::
_derive_facility_share_counterparty``) to rank candidate counterparties
by SA-equivalent risk weight. The preview is non-binding: the chosen
counterparty still flows through the full classifier and SA/IRB pipeline
downstream. Keeping the preview SA-only avoids a circular dependency with
the classifier's IRB approach gating.
Routes the lowercased ``entity_type`` through the SA exposure-class
buckets (``ENTITY_TYPES_BY_SA_CLASS``) and maps CQS -> RW via the same
table branches as :func:`build_guarantor_rw_expr`:
sovereign (CGCB CQS Table 1, Art. 114)
International Organisation (Art. 118) -> 0%
Named MDB (Art. 117(2)) -> 0%
MDB Table 2B (Art. 117(1))
Institution (Art. 120 Table 3 / PS1/26 ECRA via
``build_institution_guarantor_rw_expr``)
PSE (Art. 116(2) Table 2A, GB/other approximation for unrated)
RGLA (Art. 115(1)(b) Table 1B, GB/other approximation for unrated)
Corporate + covered bond (Art. 122 CRR Table 5 — see note below)
Retail (Art. 123 flat 75%)
High risk (Art. 128 flat 150%)
else -> 1.0 (conservative preview default for unmatched entity
types, e.g. equity / other items)
Branch-parity notes (the pre-existing preview branches are preserved
value-for-value):
- The corporate branch always prices from ``corporate_risk_weights``
(CRR Art. 122 Table 5), NOT the Basel 3.1 Table 6 — matching the
historical preview. Covered bonds use the corporate-equivalent CQS RWs
in the preview; the precise covered-bond table only applies in real
SA pricing.
- The unrated PSE / RGLA fallback is the documented SA-side
approximation (see :func:`build_guarantor_rw_expr`): GB -> 20%
domestic-currency treatment, other / unknown country -> 100% unrated
default. When ``country_code_col`` is ``None`` the 100% default
applies unconditionally.
Args:
entity_type_col: Name of the entity-type column. Null-filled to ""
and lowercased before bucket routing.
cqs_col: Name of the integer CQS column; null / out-of-range values
fall to each table's unrated default.
is_basel_3_1: Select the PS1/26 institution ECRA table when True,
CRR Art. 120 Table 3 when False. The remaining preview branches are
framework-identical: the corporate branch by name (see note above), and
the MDB / CGCB / IO / PSE / RGLA branches because their pack tables carry
identical values in both regimes.
country_code_col: Optional name of the country-code column driving
the unrated PSE / RGLA GB-vs-other approximation. ``None`` falls
back to the conservative 100% unrated default.
Returns:
Float64 Polars expression evaluating to the entity's SA-equivalent
preview risk weight (never null — unmatched entity types yield 1.0).
"""
et = pl.col(entity_type_col).fill_null("").str.to_lowercase()
sovereign_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CENTRAL_GOVT_CENTRAL_BANK.value])
io_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INTERNATIONAL_ORGANISATION.value])
mdb_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.MDB.value])
institution_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INSTITUTION.value])
pse_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.PSE.value])
rgla_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RGLA.value])
corporate_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CORPORATE.value])
covered_bond_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.COVERED_BOND.value])
retail_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RETAIL_OTHER.value])
high_risk_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.HIGH_RISK.value])
unrated_pse_rgla = _pse_rgla_unrated_fallback_expr(country_code_col)
return (
# CGCB (Art. 114 Table 1 — sovereign weights).
pl.when(et.is_in(sovereign_types))
.then(
_cqs_table_lookup_expr(
cqs_col,
_CGCB_RW,
float(_CGCB_RW[CQS.UNRATED]),
)
)
# International Organisation (Art. 118): 0% unconditional.
.when(et.is_in(io_types))
.then(pl.lit(float(_IO_ZERO_RW)))
# Named MDB (Art. 117(2)): 0% unconditional — carved out ahead of Table 2B.
.when(et == "mdb_named")
.then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
# Rated / unrated non-named MDB — Table 2B (CRR Art. 117(1) / PS1/26 Art. 117(1)(a)-(b)).
.when(et.is_in(mdb_types))
.then(_cqs_table_lookup_expr(cqs_col, _MDB_RW, float(_MDB_UNRATED_RW)))
# Institution — Art. 120 Table 3 / PS1/26 ECRA via the shared builder
# so the pack remains the single source of truth.
.when(et.is_in(institution_types))
.then(build_institution_guarantor_rw_expr(cqs_col, is_basel_3_1))
# PSE — Art. 116(2) Table 2A for rated, GB/other approximation for unrated.
.when(et.is_in(pse_types))
.then(_cqs_table_lookup_expr(cqs_col, _PSE_OWN_RW, unrated_pse_rgla))
# RGLA — Art. 115(1)(b) Table 1B for rated, GB/other approximation for unrated.
.when(et.is_in(rgla_types))
.then(_cqs_table_lookup_expr(cqs_col, _RGLA_OWN_RW, unrated_pse_rgla))
# Corporate + covered bond — CRR Art. 122 Table 5 (preview parity:
# not framework-switched; see docstring).
.when(et.is_in(corporate_types + covered_bond_types))
.then(
_cqs_table_lookup_expr(
cqs_col,
_CORPORATE_RW,
float(_CORPORATE_RW[CQS.UNRATED]),
)
)
# Retail (Art. 123): flat 75%.
.when(et.is_in(retail_types))
.then(pl.lit(_RETAIL_RISK_WEIGHT))
# High-risk items (Art. 128): flat 150%.
.when(et.is_in(high_risk_types))
.then(pl.lit(_HIGH_RISK_RW))
# Conservative preview default for unmatched entity types.
.otherwise(pl.lit(1.0))
)
_crr_append_retail_branches — src/rwa_calc/engine/sa/risk_weights.py:679
@cites("CRR Art. 123")
def _crr_append_retail_branches(chain: _RWChain, uc: pl.Expr) -> ChainedThen:
"""Append CRR retail-class risk-weight branches (Art. 123).
Covers the regulatory retail class only (uc contains "RETAIL"):
- Non-regulatory retail (fails qualifying criteria): 100% (Art. 123(c)).
- Payroll/pension loans: 35% (CRR Art. 123 second subparagraph, inserted
by CRR2 Reg. (EU) 2019/876 F68 — scalar identical to PRA PS1/26
Art. 123(4), reused from ``_SA_B31_RW``).
- Regulatory retail (non-mortgage): 75% flat (Art. 123).
The SME-managed-as-retail branch stays in the parent override (it gates
on SME class membership, not just retail) and the corporate-SME branch
is non-retail (Art. 122).
"""
return (
# Non-regulatory retail (fails qualifying criteria): 100%.
chain.when(
uc.str.contains("RETAIL", literal=True)
& (pl.col("qualifies_as_retail").fill_null(False) == False) # noqa: E712
)
.then(pl.lit(_SA_CRR_RW["non_reg_retail"]))
# Payroll/pension loans: 35% (CRR Art. 123 second subparagraph,
# inserted by CRR2 Reg. (EU) 2019/876 F68). Scalar identical to the
# Basel 3.1 payroll RW (PRA PS1/26 Art. 123(4)), so the same
# B31_RETAIL_PAYROLL_LOAN_RW constant is reused via _SA_B31_RW.
.when(uc.str.contains("RETAIL", literal=True) & pl.col("is_payroll_loan").fill_null(False))
.then(pl.lit(_SA_B31_RW["payroll"]))
# Regulatory retail (non-mortgage): 75% flat.
.when(uc.str.contains("RETAIL", literal=True))
.then(pl.lit(_SA_SHARED_RW["retail"]))
)
_build_qualifies_as_retail_expr — src/rwa_calc/engine/stages/classify/attributes.py:642
@cites("CRR Art. 123")
@cites("PS1/26, paragraph 123A")
def _build_qualifies_as_retail_expr(
config: CalculationConfig,
max_retail_exposure: float,
*,
pack: ResolvedRulepack | None = None,
) -> pl.Expr:
"""Build qualifies_as_retail expression with Art. 123A enforcement.
CRR: Threshold check only — aggregated exposure ≤ EUR 1m.
Basel 3.1 Art. 123A adds two-path qualifying criteria:
- Art. 123A(1)(a): SME entities (revenue > 0 and < GBP 44m) auto-qualify
without needing pool management attestation.
- Art. 123A(1)(b)(ii): an obligor's aggregate exposure must not exceed
GBP 880k (threshold limb) AND no single obligor's aggregate exposure may
exceed 0.2% of the total regulatory-retail portfolio (granularity limb,
BCBS CRE20.66). Both limbs are Basel-3.1-only. The granularity limb is
gated on ``config.enforce_retail_granularity`` (default True) so it can
be suppressed under CRE20.66's national-discretion clause.
- Art. 123A(1)(b)(iii): Non-SME entities must be managed as part of a
retail pool (cp_is_managed_as_retail=True) to qualify. Null values
default to True for backward compatibility.
References:
PRA PS1/26 Art. 123A(1)(a)-(b), CRR Art. 123
"""
# Hierarchy resolver now populates lending_group_adjusted_exposure with the
# counterparty aggregate when no lending group exists, so the threshold
# check is a single comparison across both cases.
threshold_fail = pl.col("lending_group_adjusted_exposure") > max_retail_exposure
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
if not resolved_pack.feature("retail_art_123a_two_path_applicable"):
# CRR: threshold check only
return (
pl.when(threshold_fail)
.then(pl.lit(False))
.otherwise(pl.lit(True))
.alias("qualifies_as_retail")
)
# Basel 3.1: Art. 123A two-path qualifying criteria.
# Art. 123A(1)(a): SME auto-qualification — counterparty meets the
# Art. 4(1)(128D) SME size test (turnover < EUR 50m OR balance-sheet
# total < EUR 43m when turnover null).
is_sme_for_art_123a = is_sme_by_size_expr(config, pack=resolved_pack)
# Art. 123A(1)(b)(ii) granularity limb (BCBS CRE20.66): no single obligor's
# aggregate exposure may exceed 0.2% of the total regulatory-retail
# portfolio. Candidate-retail rows are the entity-type RETAIL_OTHER
# population (``_sa_class``); the denominator counts each obligor once by
# dividing the per-obligor aggregate (``lending_group_adjusted_exposure``)
# by the obligor's line-count, masking non-retail rows to 0, then summing.
granularity_limit = float(_RETAIL_GRANULARITY_LIMIT)
is_retail_candidate = pl.col("_sa_class") == ExposureClass.RETAIL_OTHER.value
obligor_agg = pl.col("lending_group_adjusted_exposure")
# Guard the nullable ``counterparty_reference`` partition: a null key would
# otherwise pool all unmapped rows into a single bucket (see
# ``partition_by_nullable`` / ``NULLABLE_PARTITION_KEYS``). Null-keyed rows
# count as their own single-line obligor.
obligor_line_count = partition_by_nullable(
pl.len().over("counterparty_reference"),
"counterparty_reference",
pl.lit(1),
)
portfolio_total = (
pl.when(is_retail_candidate).then(obligor_agg / obligor_line_count).otherwise(pl.lit(0.0))
).sum()
granularity_fail = (
is_retail_candidate
& (portfolio_total > 0)
& (obligor_agg / portfolio_total > granularity_limit)
)
expr = (
pl.when(threshold_fail)
.then(pl.lit(False))
# Art. 123A(1)(a): SMEs auto-qualify — no condition 3 needed
.when(is_sme_for_art_123a)
.then(pl.lit(True))
)
# Art. 123A(1)(b)(ii) granularity limb: > 0.2% of the retail portfolio.
# Gated on config.enforce_retail_granularity (default True) so the limb
# can be suppressed where granularity is assessed by another method under
# CRE20.66's national-discretion clause, or to isolate the other limbs.
if config.enforce_retail_granularity:
expr = expr.when(granularity_fail).then(pl.lit(False))
# Art. 123A(1)(b)(iii): Non-SME must be managed as retail pool.
# Null defaults to True (Art. 123A — documented KEEP: a null pool-
# management flag preserves backward-compatible qualifying behaviour).
expr = expr.when(
pl.col("cp_is_managed_as_retail").fill_null(True) == False # noqa: E712
).then(pl.lit(False))
return expr.otherwise(pl.lit(True)).alias("qualifies_as_retail")
CRR Art. 124 — Exposures secured by mortgages on immovable property¶
_crr_append_real_estate_branches — src/rwa_calc/engine/sa/risk_weights.py:713
@cites("CRR Art. 124")
def _crr_append_real_estate_branches(chain: _RWChain, uc: pl.Expr) -> ChainedThen:
"""Append CRR commercial-then-residential RE branches (Art. 125-126)."""
ltv_safe = pl.col("ltv").fill_null(1.0)
# CRR Art. 126(2)(d) proportion split for CRE with income cover and LTV > 50%:
# secured_share = min(1.0, 50% / LTV) -> portion attracting 50% RW
# residual_share = 1.0 - secured_share -> portion attracting unsecured
# counterparty RW (Art. 124(1) -> Art. 122 corporate CQS)
# When LTV <= 50% the clamp drives secured_share = 1.0 so the average collapses
# to the preferential 50% RW, matching the pre-split behaviour.
cre_secured_share = pl.min_horizontal(pl.lit(1.0), _SA_CRR_RW["cre_ltv_threshold"] / ltv_safe)
cre_residual_share = pl.lit(1.0) - cre_secured_share
# CRR Art. 124(1): the residual leg attracts the counterparty's UNSECURED
# risk weight, i.e. the Art. 122 corporate CQS lookup — NOT a fixed 100%.
# Look up counterparty CQS against CORPORATE_RISK_WEIGHTS directly (rather
# than via the join-derived ``risk_weight``) so the rule still fires when
# the upstream class lookup did not resolve to CORPORATE (e.g. exposures
# reclassified to COMMERCIAL_MORTGAGE by the real-estate splitter).
cre_residual_rw = cqs_table_lookup_expr(
"cqs",
CORPORATE_RISK_WEIGHTS,
pl.lit(float(CORPORATE_RISK_WEIGHTS[CQS.UNRATED])),
)
return (
# Commercial RE must precede residential — see is_commercial_re_class.
# CRR Art. 126: LTV + income cover.
chain.when(is_commercial_re_class(uc))
.then(
pl.when(pl.col("has_income_cover").fill_null(False))
.then(
_SA_CRR_RW["cre_rw_low"] * cre_secured_share + cre_residual_rw * cre_residual_share
)
.otherwise(pl.lit(_SA_CRR_RW["cre_rw_standard"]))
)
# CRR Art. 125 LTV split.
.when(_is_residential_re_class(uc))
.then(
pl.when(pl.col("ltv").fill_null(0.0) <= _SA_CRR_RW["resi_ltv_threshold"])
.then(pl.lit(_SA_CRR_RW["resi_rw_low"]))
.otherwise(
_SA_CRR_RW["resi_rw_low"] * _SA_CRR_RW["resi_ltv_threshold"] / ltv_safe
+ _SA_CRR_RW["resi_rw_high"]
* (ltv_safe - _SA_CRR_RW["resi_ltv_threshold"])
/ ltv_safe
)
)
)
CRR Art. 125 — Exposures fully and completely secured by mortgages on residential property¶
split — src/rwa_calc/engine/stages/re_split/splitter.py:203
@cites("CRR Art. 125")
@cites("CRR Art. 126")
@cites("PS1/26, paragraph 124F")
def split(
self,
data: CRMAdjustedBundle,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> CRMAdjustedBundle:
"""Apply RE loan-splitting to candidate rows.
See module docstring for the regime-specific decision matrix.
"""
# S9g: the RE-split regime gate reads the cited pack Feature; the split
# parameter VALUES (LTV caps / RW) stay in data/tables/re_split_parameters.py,
# and re_split_parameters / _split_unified_frame keep their is_basel_3_1 bool
# plumbing params (Option B). One read feeds both the params lookup and the
# allocation control flow.
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
is_b31 = resolved_pack.feature("sa_re_split_revised_parameters")
params = re_split_parameters(is_basel_3_1=is_b31)
rrep = params["residential"]
crep = params["commercial"]
unified, audit, errors = _split_unified_frame(
data.exposures,
rrep=rrep,
crep=crep,
is_basel_3_1=is_b31,
)
# Producer seal (Phase 3): pure plan-level conform + brand — the
# orchestrator materialises and re-seals at the re_split_exit stage
# edge. Contract selected by the input frame's brand (CCR runs carry
# the SA-CCR provenance columns through the split).
exit_edge = (
RE_SPLIT_EXIT_CCR_EDGE
if sealed_edge_of(data.exposures) == "crm_exit_ccr"
else RE_SPLIT_EXIT_EDGE
)
return CRMAdjustedBundle(
exposures=seal(unified, exit_edge),
equity_exposures=data.equity_exposures,
ciu_holdings=data.ciu_holdings,
collateral_allocation=data.collateral_allocation,
collateral_link_allocation=data.collateral_link_allocation,
re_split_audit=audit,
securitisation_audit=data.securitisation_audit,
crm_errors=list(data.crm_errors) + errors,
)
CRR Art. 126 — Exposures fully and completely secured by mortgages on commercial immovable property¶
_add_exposure_class_applied — src/rwa_calc/engine/aggregator/aggregator.py:453
@cites("CRR Art. 112")
@cites("CRR Art. 123")
@cites("CRR Art. 126")
def _add_exposure_class_applied(lf: pl.LazyFrame) -> pl.LazyFrame:
"""Add ``exposure_class_applied`` — the approach-agnostic applied class.
The routing ``exposure_class`` records origination + guarantee substitution
but omits three SA-only applied-treatment movements, so the reconciliation and
COREP class dimensions previously mis-bucketed those rows (the RWA is correct
in every case — only the class label was wrong):
- **SME managed as retail** (CRR Art. 123 / PS1/26 Art. 123A) — a
corporate-SME row that took the 75% retail risk weight logically belongs
to the retail class: Art. 122 corporate has no 75% band, so a 75%-weighted
SME entails retail. The predicate mirrors the SA risk-weight branch exactly
(``engine/sa/risk_weights.py``) so the reported class tracks the applied RW.
- **Defaulted** (CRR Art. 112(1)(j) / Art. 127) — a defaulted SA exposure
belongs to the "Exposures in default" class, which wins over origination
(PS1/26 Table A2 priority 5). High-risk (Art. 128, Basel 3.1) still outranks
default (priority 4), so a defaulted high-risk row keeps its class.
- **Secured by a mortgage on commercial immovable property** (CRR
Art. 112(1)(i) / Art. 126; PS1/26 Art. 112(1)(i) / Art. 124H-124I) — an
exposure whose SA risk weight is set by the commercial real-estate branch
belongs to Art. 112(1)(i), not to the counterparty's class. It reuses the
dispatcher's own :func:`is_commercial_re_class` predicate, whose
``property_type == "commercial"`` limb is precisely what lets a
``corporate``-routed exposure take the Art. 126 50% (or Art. 124I
income-producing) risk weight; sharing one expression is what stops the
reported class and the applied risk weight from drifting apart again.
Both frameworks rank the real-estate class the same way and both make the
protection — not the counterparty — the classifying fact. PS1/26 Art. 112(2)
Table A2 is explicit ("Where an exposure meets the criteria for more than one
exposure class it shall be assigned to the exposure class that has the highest
position in Table A2"): real estate is row (7), retail row (14), corporates
row (15). COREP Annex II ¶62 gives the CRR twin ranking — "Exposures secured
by mortgages on immovable property" is rank 6, corporates and retail rank 9 —
and ¶58 notes class (i) is the one class where "a protection effect is
intrinsically part of the definition of an exposure class". So the limb sits
BELOW default/high-risk (which outrank real estate in both rankings) and ABOVE
the retail limb. Rows already in a real-estate class are left alone: they are
in Art. 112(1)(i) already, and re-labelling them would only shuffle the
C 09.01 "of which" sub-rows without changing the class total.
¶60 is what makes this a reporting-side overlay rather than a classifier
change: the prioritisation governs "the assignment of the Original exposure
pre-conversion factor by exposure classes, without prejudice to the specific
treatment (risk weight) that each specific exposure shall receive within the
assigned exposure class". The routing ``exposure_class`` keeps driving
approach selection, CRM and the risk-weight tables; only the reported class
moves, so no RWA changes.
Only SA rows (``approach_applied == "standardised"``) are re-mapped: IRB
already reclassifies corporate→retail on ``exposure_class`` and reports
default via a PD override (not a class), slotting keeps SPECIALISED_LENDING,
and equity keeps EQUITY — so every non-SA approach keeps ``exposure_class``.
This is a PRE-substitution (obligor-side) class and is applied to guaranteed
exposures too. A guaranteed exposure is physically split into ``__G_`` /
``__REM`` legs (``engine/crm/guarantees.py``) that BOTH carry the obligor's
origination ``exposure_class`` — the guarantor's class lives only in
``post_crm_exposure_class_guaranteed``, which drives the COREP C 07.00
substitution inflow/outflow. So the guaranteed leg of a defaulted (or
SME-managed-as-retail) obligor correctly takes the same applied class as its
remainder: in C 07.00 the whole exposure originates in the obligor's sheet
("Exposures in default" / Retail) and the guaranteed portion leaves as an
outflow. Gating the overlay on ``~is_guaranteed`` would wrongly drop the
guaranteed portion out of that class and understate it.
"""
is_sa = pl.col("approach_applied") == ApproachType.SA.value
upper_class = pl.col("exposure_class").str.to_uppercase()
is_high_risk = pl.col("exposure_class") == ExposureClass.HIGH_RISK.value
sme_managed_as_retail = (
upper_class.str.contains("SME", literal=True)
& (pl.col("cp_is_managed_as_retail") == True) # noqa: E712
& (pl.col("qualifies_as_retail") == True) # noqa: E712
)
# Classes that outrank real estate in BOTH rankings (PS1/26 Table A2 rows
# (1)-(6); COREP Annex II para 62 ranks 1-5) and so keep their own class even
# when property-secured. Defaulted is already handled by the limb above.
outranks_real_estate = pl.col("exposure_class").is_in(
[
ExposureClass.HIGH_RISK.value,
ExposureClass.EQUITY.value,
ExposureClass.COVERED_BOND.value,
ExposureClass.DEFAULTED.value,
]
)
already_real_estate = pl.col("exposure_class").is_in(
[
ExposureClass.RETAIL_MORTGAGE.value,
ExposureClass.RESIDENTIAL_MORTGAGE.value,
ExposureClass.COMMERCIAL_MORTGAGE.value,
]
)
commercial_real_estate = (
is_commercial_re_class(upper_class) & ~outranks_real_estate & ~already_real_estate
)
return lf.with_columns(
pl.when(~is_sa)
.then(pl.col("exposure_class"))
# A null is_defaulted falls through the when() (treated as not defaulted),
# so no fill_null is needed — keep the applied class off origination.
.when((pl.col("is_defaulted") == True) & ~is_high_risk) # noqa: E712
.then(pl.lit(ExposureClass.DEFAULTED.value))
# Art. 112(1)(i) outranks corporates and retail in both frameworks, so
# this limb must precede the retail one below.
.when(commercial_real_estate)
.then(pl.lit(ExposureClass.COMMERCIAL_MORTGAGE.value))
.when(sme_managed_as_retail)
.then(pl.lit(ExposureClass.RETAIL_OTHER.value))
.otherwise(pl.col("exposure_class"))
.alias("exposure_class_applied")
)
split — src/rwa_calc/engine/stages/re_split/splitter.py:204
@cites("CRR Art. 125")
@cites("CRR Art. 126")
@cites("PS1/26, paragraph 124F")
def split(
self,
data: CRMAdjustedBundle,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> CRMAdjustedBundle:
"""Apply RE loan-splitting to candidate rows.
See module docstring for the regime-specific decision matrix.
"""
# S9g: the RE-split regime gate reads the cited pack Feature; the split
# parameter VALUES (LTV caps / RW) stay in data/tables/re_split_parameters.py,
# and re_split_parameters / _split_unified_frame keep their is_basel_3_1 bool
# plumbing params (Option B). One read feeds both the params lookup and the
# allocation control flow.
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
is_b31 = resolved_pack.feature("sa_re_split_revised_parameters")
params = re_split_parameters(is_basel_3_1=is_b31)
rrep = params["residential"]
crep = params["commercial"]
unified, audit, errors = _split_unified_frame(
data.exposures,
rrep=rrep,
crep=crep,
is_basel_3_1=is_b31,
)
# Producer seal (Phase 3): pure plan-level conform + brand — the
# orchestrator materialises and re-seals at the re_split_exit stage
# edge. Contract selected by the input frame's brand (CCR runs carry
# the SA-CCR provenance columns through the split).
exit_edge = (
RE_SPLIT_EXIT_CCR_EDGE
if sealed_edge_of(data.exposures) == "crm_exit_ccr"
else RE_SPLIT_EXIT_EDGE
)
return CRMAdjustedBundle(
exposures=seal(unified, exit_edge),
equity_exposures=data.equity_exposures,
ciu_holdings=data.ciu_holdings,
collateral_allocation=data.collateral_allocation,
collateral_link_allocation=data.collateral_link_allocation,
re_split_audit=audit,
securitisation_audit=data.securitisation_audit,
crm_errors=list(data.crm_errors) + errors,
)
CRR Art. 127 — Exposures in default¶
_crr_defaulted_re_secured_share — src/rwa_calc/engine/sa/risk_weights.py:1509
@cites("CRR Art. 127")
@cites("PS1/26, paragraph 127")
@cites("CRR Art. 127")
def _crr_defaulted_re_secured_share(upper_class: pl.Expr) -> pl.Expr:
"""CRR Art. 127(3): the Art. 125-secured share of a defaulted RRE exposure.
Art. 127(3) gives a flat 100% to "the exposure value remaining after
specific credit risk adjustments of exposures **fully and completely
secured** by mortgages on residential property **in accordance with
Article 125**". Art. 124(1) defines that phrase as a capped PART of the
exposure — "the part treated as fully and completely secured shall not be
higher than the pledged amount of the market value" — with Art. 125(2)(d)
setting the cap at 80% of value. The remainder is not "fully and completely
secured" and stays on Art. 127(1), which by its own words governs only
"the unsecured part". Hence a share, blended by the caller, rather than a
flat override of the whole row.
Returns **null** where the rule does not apply, so the caller keeps the
Art. 127(1) provision RW untouched. Null is also the answer for a null or
non-positive ``ltv``: without a usable LTV there is no defensible secured
share, and defaulting it to "fully secured" would hand out the 100% leg on
missing data — the anti-conservative failure mode this batch keeps finding.
**Art. 127(4) (commercial property) is deliberately NOT implemented here.**
Its trigger is "secured … in accordance with Article 126", and the engine's
only proxy for the Art. 126(2) qualifying test is ``has_income_cover``,
which **P1.263 records as carrying the INVERTED sense** on the CRR branch.
Because this blend REDUCES RWA, building the commercial limb on a flag that
is known to be backwards would grant relief to the wrong population.
Gating on the CRE class alone would be broader than the article allows,
also in the relieving direction. Deferred to P1.315, after P1.263 settles
the flag's meaning.
"""
ltv = pl.col("ltv")
is_rre_only = _is_residential_re_class(upper_class) & ~is_commercial_re_class(upper_class)
return (
pl.when(is_rre_only & ltv.is_not_null() & (ltv > 0.0))
.then(pl.min_horizontal(pl.lit(1.0), pl.lit(_SA_CRR_RW["resi_ltv_threshold"]) / ltv))
.otherwise(pl.lit(None).cast(pl.Float64))
)
_crr_defaulted_re_secured_share — src/rwa_calc/engine/sa/risk_weights.py:1511
@cites("CRR Art. 127")
@cites("PS1/26, paragraph 127")
@cites("CRR Art. 127")
def _crr_defaulted_re_secured_share(upper_class: pl.Expr) -> pl.Expr:
"""CRR Art. 127(3): the Art. 125-secured share of a defaulted RRE exposure.
Art. 127(3) gives a flat 100% to "the exposure value remaining after
specific credit risk adjustments of exposures **fully and completely
secured** by mortgages on residential property **in accordance with
Article 125**". Art. 124(1) defines that phrase as a capped PART of the
exposure — "the part treated as fully and completely secured shall not be
higher than the pledged amount of the market value" — with Art. 125(2)(d)
setting the cap at 80% of value. The remainder is not "fully and completely
secured" and stays on Art. 127(1), which by its own words governs only
"the unsecured part". Hence a share, blended by the caller, rather than a
flat override of the whole row.
Returns **null** where the rule does not apply, so the caller keeps the
Art. 127(1) provision RW untouched. Null is also the answer for a null or
non-positive ``ltv``: without a usable LTV there is no defensible secured
share, and defaulting it to "fully secured" would hand out the 100% leg on
missing data — the anti-conservative failure mode this batch keeps finding.
**Art. 127(4) (commercial property) is deliberately NOT implemented here.**
Its trigger is "secured … in accordance with Article 126", and the engine's
only proxy for the Art. 126(2) qualifying test is ``has_income_cover``,
which **P1.263 records as carrying the INVERTED sense** on the CRR branch.
Because this blend REDUCES RWA, building the commercial limb on a flag that
is known to be backwards would grant relief to the wrong population.
Gating on the CRE class alone would be broader than the article allows,
also in the relieving direction. Deferred to P1.315, after P1.263 settles
the flag's meaning.
"""
ltv = pl.col("ltv")
is_rre_only = _is_residential_re_class(upper_class) & ~is_commercial_re_class(upper_class)
return (
pl.when(is_rre_only & ltv.is_not_null() & (ltv > 0.0))
.then(pl.min_horizontal(pl.lit(1.0), pl.lit(_SA_CRR_RW["resi_ltv_threshold"]) / ltv))
.otherwise(pl.lit(None).cast(pl.Float64))
)
CRR Art. 128 — Items associated with particular high risk¶
UK CRR omitted Art. 128 by SI 2021/1078 reg. 6(3)(a) (effective 1 Jan 2022) — exposures that would have attracted 150% fall through to the 100% OTHER class under UK CRR. The 150% treatment is reintroduced under Basel 3.1 — see PS1/26, paragraph 128 for the live decorator on engine/sa/namespace.py::_b31_append_high_risk_branch.
CRR Art. 129 — Exposures in the form of covered bonds¶
crr_unrated_cb_rw_expr — src/rwa_calc/engine/sa/covered_bond.py:70
@cites("CRR Art. 129")
def crr_unrated_cb_rw_expr() -> pl.Expr:
"""CRR Art. 129(5): derive an unrated covered bond's RW from the issuer's.
When ``cp_institution_cqs`` is null (the issuing institution is itself
unrated) the Art. 121 fallback issuer weight of 100% applies, deriving a
covered bond weight of 50%.
Uses the CRR-specific 4-key derivation dict: Art. 129(5) admits only
sub-paragraphs (a)-(d), so a 50% issuer weight maps to 20%, NOT the B31
value of 25%.
"""
cqs_to_cb_rw = _cqs_to_cb_rw(INSTITUTION_RISK_WEIGHTS_CRR, COVERED_BOND_UNRATED_DERIVATION_CRR)
unrated_inst_rw = INSTITUTION_RISK_WEIGHTS_CRR[CQS.UNRATED]
unrated_cb_rw = float(COVERED_BOND_UNRATED_DERIVATION_CRR[unrated_inst_rw])
return _ecra_chain(cqs_to_cb_rw).otherwise(pl.lit(unrated_cb_rw))
b31_unrated_cb_rw_expr — src/rwa_calc/engine/sa/covered_bond.py:88
@cites("CRR Art. 129")
@cites("PS1/26, paragraph 129")
def b31_unrated_cb_rw_expr(scra_default_rw: float) -> pl.Expr:
"""PS1/26 Art. 129(5): as CRR, but the issuer weight may come from SCRA.
Art. 129(5) operates on the resulting issuer weight regardless of its
source, so the ECRA ladder (``cp_institution_cqs``) is tried first and an
unrated issuer falls through to the SCRA grades (``cp_scra_grade``).
``scra_default_rw`` is the conservative Grade-C-equivalent residual, passed
in from the caller's pack binding rather than re-read here.
"""
cqs_to_cb_rw = _cqs_to_cb_rw(
INSTITUTION_RISK_WEIGHTS_B31_ECRA, COVERED_BOND_UNRATED_DERIVATION_B31
)
expr = _ecra_chain(cqs_to_cb_rw)
for grade, cb_rw in B31_COVERED_BOND_UNRATED_FROM_SCRA.items():
expr = expr.when(pl.col("cp_scra_grade") == grade).then(pl.lit(float(cb_rw)))
return expr.otherwise(pl.lit(scra_default_rw))
_create_covered_bond_df — src/rwa_calc/engine/sa/crr_risk_weight_tables.py:559
CRR Art. 130 — Items representing securitisation positions¶
Out of scope — securitisation is handled by a separate calculator domain (CRR Title II, Chapter 5). This calculator covers Chapters 1-4 only.
CRR Art. 131 — Exposures to institutions and corporates with a short-term credit assessment¶
apply_short_term_rating_override — src/rwa_calc/engine/stages/hierarchy/enrich.py:161
@cites("CRR Art. 131")
@cites("CRR Art. 140")
def apply_short_term_rating_override(
exposures: pl.LazyFrame,
ratings: pl.LazyFrame | None,
counterparty_lookup: CounterpartyLookup | None = None,
errors: list[CalculationError] | None = None,
) -> pl.LazyFrame:
"""Apply per-exposure short-term rating override.
Short-term ECAI assessments under PRA PS1/26 Art. 120(2B) Table 4A and
Art. 122(3) Table 6A are issue-specific — each rating row attaches to a
single exposure via ``(scope_type, scope_id)``. When a short-term rating
row matches an exposure, its ``cqs`` overrides the counterparty-level
rating attached by ``attach_counterparty_rating`` and the derived
``has_short_term_ecai`` flag is set to True, signalling the SA engine to
route via Table 4A / Table 6A.
Scope matching:
- ``scope_type='facility'`` -> matches the source facility's drawn loans,
its synthetic ``facility_undrawn`` row, and any descendant exposure via
``parent_facility_reference`` / ``root_facility_reference``.
- ``scope_type='loan'`` -> matches the loan exposure with the same
``exposure_reference`` and ``exposure_type='loan'``.
- ``scope_type='contingent'`` -> matches the contingent exposure with the
same ``exposure_reference`` and ``exposure_type='contingent'``.
Ties (multiple short-term ratings for the same exposure) are resolved by
picking the row with the lowest CQS, breaking ties by latest
``rating_date``. This mirrors the external best-rating selection in
``ratings.build_rating_inheritance_lazy``.
Always returns ``exposures`` augmented with a ``has_short_term_ecai``
boolean column (False when no override matched).
"""
st_ratings = _prepare_short_term_lookup(ratings)
if st_ratings is None:
return exposures.with_columns(pl.lit(False).alias("has_short_term_ecai"))
exp_schema = set(exposures.collect_schema().names())
match_branches = _build_short_term_match_branches(exp_schema)
# Track which scope branches actually produce a match so we can
# coalesce the resulting cqs in priority order: loan > contingent >
# facility (most specific wins).
joined_scopes: list[str] = []
for scope, key_expr in match_branches:
scope_lookup = st_ratings.filter(pl.col("_st_scope_type") == scope).select(
[
pl.col("_st_cp"),
pl.col("_st_scope_id"),
pl.col("_st_cqs").alias(f"_st_{scope}_cqs"),
]
)
exposures = exposures.with_columns(key_expr.alias(f"_match_key_{scope}"))
exposures = exposures.join(
scope_lookup,
left_on=["counterparty_reference", f"_match_key_{scope}"],
right_on=["_st_cp", "_st_scope_id"],
how="left",
).drop(f"_match_key_{scope}")
joined_scopes.append(scope)
if not joined_scopes:
return exposures.with_columns(pl.lit(False).alias("has_short_term_ecai"))
# Coalesce in priority order: loan > contingent > facility (most
# specific scope wins).
priority = ["loan", "contingent", "facility"]
ordered = [s for s in priority if s in joined_scopes]
st_cqs_expr = pl.coalesce([pl.col(f"_st_{s}_cqs") for s in ordered])
# Art. 140(1) / CRE21.16 obligor-class gate: short-term ECAI assessments may
# be used ONLY for institution / corporate obligors. Join the raw entity_type
# (from the counterparty lookup — no class column exists on the frame yet;
# the classifier derives it later) and disqualify a match on any other class.
# A mis-scoped match is ignored: has_short_term_ecai stays False, cqs keeps
# its counterparty-level value and _st_assessment_cqs stays null, so the row
# AND the Art. 120(3)(c) spillover / Art. 140(2) contamination helpers that
# run AFTER the gate all inherit the rejection for free.
# Ordering: (1) scope-match [above] -> (2) THIS GATE -> (3) spillover ->
# (4) contamination flags.
has_gate = counterparty_lookup is not None
if has_gate:
eligible = list(ENTITY_TYPES_BY_SA_CLASS["institution"]) + list(
ENTITY_TYPES_BY_SA_CLASS["corporate"]
)
gate_lookup = counterparty_lookup.counterparties.select(
pl.col("counterparty_reference"),
pl.col("entity_type").str.to_lowercase().alias("_st_gate_entity_type"),
)
exposures = exposures.join(gate_lookup, on="counterparty_reference", how="left")
# fill_null("") before is_in: Polars propagates null through is_in, so a
# null/unknown entity_type (or a join-miss) would otherwise yield a NULL
# eligibility -> a null has_short_term_ecai flag AND a null-dropped DQ009
# filter (warning silently lost). "" resolves to ineligible -> clean
# rejection + DQ009 emitted. Mirrors the entity_type null-guard idiom in
# engine/sa/risk_weights.py.
st_class_eligible = pl.col("_st_gate_entity_type").fill_null("").is_in(eligible)
else:
st_class_eligible = pl.lit(True) # noqa: FBT003
# Override: when a short-term cqs matched AND the obligor class is eligible,
# replace the cqs column and set has_short_term_ecai=True. SA Tables 4A / 6A
# are keyed off cqs only — rating_agency / rating_value are audit columns
# added later by the classifier and intentionally not overridden here.
#
# Two scratch columns are carried into the obligor-level Art. 120(3)(c)
# spillover step below: ``_st_assessment_cqs`` (the matched short-term ECAI
# cqs, non-null only on the directly-rated ELIGIBLE exposure) and
# ``_general_cqs`` (the obligor's pre-override counterparty cqs).
has_st = st_cqs_expr.is_not_null() & st_class_eligible
# Art. 140(1) DQ warning: a match rejected purely by the class gate (matched
# but ineligible) is a mis-scoped rating — record one DQ009 per such
# exposure before the scratch entity_type is dropped.
if errors is not None and has_gate:
_record_misscoped_st_ratings(
exposures, st_cqs_expr.is_not_null() & st_class_eligible.not_(), errors
)
exposures = exposures.with_columns(
[
has_st.alias("has_short_term_ecai"),
pl.when(has_st)
.then(st_cqs_expr)
.otherwise(pl.lit(None, dtype=pl.Int8))
.cast(pl.Int8)
.alias("_st_assessment_cqs"),
pl.col("cqs").cast(pl.Int8).alias("_general_cqs"),
pl.when(has_st).then(st_cqs_expr).otherwise(pl.col("cqs")).cast(pl.Int8).alias("cqs"),
]
)
exposures = _apply_obligor_short_term_spillover(exposures)
# Art. 140(2) obligor-level contamination flags — reads the pristine
# ``_st_assessment_cqs`` scratch here, BEFORE the drop below. The two flag
# columns it emits are not ``_st_*`` scratch, so they survive the drop.
exposures = _apply_obligor_st_contamination_flags(exposures)
scratch = [f"_st_{s}_cqs" for s in joined_scopes] + ["_st_assessment_cqs", "_general_cqs"]
if has_gate:
scratch.append("_st_gate_entity_type")
return exposures.drop(scratch)
_apply_obligor_short_term_spillover — src/rwa_calc/engine/stages/hierarchy/enrich.py:863
@cites("CRR Art. 131")
def _apply_obligor_short_term_spillover(exposures: pl.LazyFrame) -> pl.LazyFrame:
"""Spill a less-favourable short-term ECAI assessment across the obligor.
CRR Art. 131(2) / PRA PS1/26 Art. 120(3)(c): when an obligor carries a
short-term issue-specific ECAI assessment (Table 4A / Table 7) that maps to
a LESS favourable (higher) risk weight than that obligor's general
preferential short-term treatment (Table 4), the general preferential
treatment is disapplied and ALL of that obligor's unrated SHORT-TERM claims
take the short-term assessment's cqs — not just the directly-rated
exposure. The spillover is bounded to the short-term maturity window;
long-term claims on the same obligor are unaffected.
Reads two scratch columns produced by ``apply_short_term_rating_override``:
``_st_assessment_cqs`` (the matched short-term cqs, non-null only on the
directly-rated exposure) and ``_general_cqs`` (the obligor's pre-override
counterparty cqs).
The "less favourable" test is a CQS-band comparison mirroring the Table 4
vs Table 4A / Table 7 structure, so no risk-weight scalars are duplicated in
the hierarchy stage. Table 4 (general preferential) assigns 20% to CQS 1-3,
50% to CQS 4-5 and 150% to CQS 6; Table 4A / Table 7 (short-term assessment)
assign 20%/50%/100%/150% to CQS 1/2/3/4+. Hence the assessment is worse iff:
- general cqs 1-3 (Table 4 20%): assessment cqs >= 2
- general cqs 4-5 (Table 4 50%): assessment cqs >= 3
- general cqs 6 (Table 4 150%): never
Both short-term tables are identical across CRR and Basel 3.1 over this cqs
range, so the gate is regime-independent.
"""
schema = set(exposures.collect_schema().names())
required = {
"counterparty_reference",
"has_short_term_ecai",
"_st_assessment_cqs",
"_general_cqs",
"value_date",
"maturity_date",
"cqs",
}
if not required <= schema:
return exposures
# Short-term maturity window: original maturity <= 3m (<= 6m for self-
# liquidating trade-finance LCs). Derived from (maturity - value) dates,
# mirroring the SA-stage derivation of ``original_maturity_years``. Missing
# dates fall back to "not short-term" (conservative — no contamination of
# long-term or unknown-maturity claims).
original_mty = (
pl.col("maturity_date").cast(pl.Int32) - pl.col("value_date").cast(pl.Int32)
).cast(pl.Float64) / 365.0
is_trade_lc = (
pl.col("is_short_term_trade_lc").fill_null(False)
if "is_short_term_trade_lc" in schema
else pl.lit(False) # noqa: FBT003
)
in_st_window = ((original_mty <= 0.25) | (is_trade_lc & (original_mty <= 0.5))).fill_null(False)
# Obligor-level aggregates (guarded against null-key partition collapse).
# ``obligor_st_cqs``: worst (highest) short-term-assessment cqs among the
# obligor's short-term-window exposures. ``obligor_general_cqs``: the
# obligor's general preferential cqs.
st_assessment_cqs = pl.when(pl.col("has_short_term_ecai") & in_st_window).then(
pl.col("_st_assessment_cqs")
)
obligor_st_cqs = partition_by_nullable(
st_assessment_cqs.max().over("counterparty_reference"),
"counterparty_reference",
pl.lit(None, dtype=pl.Int8),
)
obligor_general_cqs = partition_by_nullable(
pl.col("_general_cqs").min().over("counterparty_reference"),
"counterparty_reference",
pl.col("_general_cqs"),
)
# Art. 120(3)(c) "less favourable" test — see docstring for the band map.
less_favourable = ((obligor_general_cqs <= 3) & (obligor_st_cqs >= 2)) | (
(obligor_general_cqs >= 4) & (obligor_general_cqs <= 5) & (obligor_st_cqs >= 3)
)
fires = obligor_st_cqs.is_not_null() & less_favourable.fill_null(False)
# Spill onto the obligor's unrated short-term claims only. Directly-rated
# exposures already carry has_short_term_ecai=True and their own cqs, so are
# excluded via ``~has_short_term_ecai``.
spill = fires & in_st_window & ~pl.col("has_short_term_ecai")
return exposures.with_columns(
[
(pl.col("has_short_term_ecai") | spill).alias("has_short_term_ecai"),
pl.when(spill).then(obligor_st_cqs).otherwise(pl.col("cqs")).cast(pl.Int8).alias("cqs"),
]
)
CRR Art. 132 — Exposures in the form of units or shares in collective investment undertakings (CIUs)¶
UK CRR omitted Art. 132; PRA reintroduced CIU treatment via PS1/26 paragraph 132. Implementation lives at engine/equity/calculator.py::_append_ciu_branches and is cited under PS1/26, paragraph 132 in the PS1/26 section below.
CRR Art. 133 — Equity exposures¶
calculate_branch — src/rwa_calc/engine/equity/calculator.py:228
@cites("CRR Art. 133")
def calculate_branch(
self,
exposures: pl.LazyFrame,
config: CalculationConfig,
) -> pl.LazyFrame:
"""
Calculate equity RWA on pre-filtered equity-only rows.
Args:
exposures: Pre-filtered equity rows only
config: Calculation configuration
Returns:
LazyFrame with equity RWA columns populated
"""
approach = self._determine_approach(config)
exposures = self._prepare_columns(exposures, config)
# Art. 155(3) PD/LGD computes RWEA inside the branch (K formula) and
# bypasses both the IRB Simple transitional floor and _calculate_rwa.
if approach == EquityApproach.PD_LGD:
return self._apply_equity_weights_pd_lgd(exposures, config)
if approach == EquityApproach.IRB_SIMPLE:
exposures = self._apply_equity_weights_irb_simple(exposures, config)
else:
exposures = self._apply_equity_weights_sa(exposures, config)
exposures = self._apply_transitional_floor(exposures, config)
return self._calculate_rwa(exposures)
get_equity_result_bundle — src/rwa_calc/engine/equity/calculator.py:262
@cites("CRR Art. 133")
@cites("CRR Art. 155")
def get_equity_result_bundle(
self,
data: CRMAdjustedBundle,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> EquityResultBundle:
"""
Calculate equity RWA and return as a bundle.
Args:
data: CRM-adjusted exposures
config: Calculation configuration
Returns:
EquityResultBundle with results and audit trail
"""
errors: list[CalculationError] = []
exposures = data.equity_exposures
if exposures is None:
empty_frame = pl.LazyFrame(
{
"exposure_reference": pl.Series([], dtype=pl.String),
"equity_type": pl.Series([], dtype=pl.String),
"ead_final": pl.Series([], dtype=pl.Float64),
"risk_weight": pl.Series([], dtype=pl.Float64),
"rwa": pl.Series([], dtype=pl.Float64),
}
)
return EquityResultBundle(
results=empty_frame,
calculation_audit=empty_frame,
approach=EquityApproach.SA,
errors=[],
)
approach = self._determine_approach(config, pack=pack)
exposures = self._prepare_columns(exposures, config)
exposures = self._resolve_look_through_rw(exposures, data.ciu_holdings, config, pack=pack)
# Art. 155(3) PD/LGD computes RWEA inside the branch and bypasses both
# the IRB Simple transitional floor and _calculate_rwa.
if approach == EquityApproach.PD_LGD:
exposures = self._apply_equity_weights_pd_lgd(exposures, config, pack=pack)
else:
if approach == EquityApproach.IRB_SIMPLE:
exposures = self._apply_equity_weights_irb_simple(exposures, config)
else:
exposures = self._apply_equity_weights_sa(exposures, config, pack=pack)
exposures = self._apply_transitional_floor(exposures, config, pack=pack)
exposures = self._calculate_rwa(exposures)
audit = self._build_audit(exposures, approach)
return EquityResultBundle(
results=exposures,
calculation_audit=audit,
approach=approach,
errors=errors,
)
CRR Art. 134 — Other items¶
_apply_b31_risk_weight_overrides — src/rwa_calc/engine/sa/risk_weights.py:1015
@cites("CRR Art. 134")
@cites("CRR Art. 137")
def _apply_b31_risk_weight_overrides(
exposures: pl.LazyFrame,
uc: pl.Expr,
is_domestic_currency: pl.Expr,
is_uk_domestic: pl.Expr,
config: CalculationConfig,
) -> pl.LazyFrame:
"""Apply Basel 3.1 class-specific risk-weight overrides (CRE20, PRA PS1/26).
``is_domestic_currency`` (UK or EU domestic currency) scopes the
Art. 114(4)/(7) CGCB 0% branch; ``is_uk_domestic`` (GB counterparty in
GBP) scopes the narrower Art. 115(5) flat-20% RGLA branch.
"""
# Save the CQS-based risk weight before overrides — needed for the
# Basel 3.1 general CRE min(60%, counterparty_rw) logic (CRE20.85).
exposures = exposures.with_columns(
pl.col("risk_weight").fill_null(1.0).alias("_cqs_risk_weight")
)
# Build the override chain in regulatory precedence order:
# CGCB / QCCP / subordinated debt [early overrides, before RE/CQS]
# real estate (ADC, other-RE, residential, commercial)
# sovereign-like (PSE, RGLA)
# MDB / IO
# institution maturity (ECRA short, SCRA short, SCRA long)
# corporate / retail / misc (IG, SME, SL, QRRE, payroll, retail, ...)
# covered bond / high risk / other items / equity
chain = (
pl.when(pl.col("risk_type") == _SETTLEMENT_FAILED_TRADE_RISK_TYPE) # P8.43 failed trade
.then(pl.lit(_OWN_FUNDS_TO_RWA_FACTOR))
.when(
pl.col("risk_type") == _CCR_DEFAULT_FUND_RISK_TYPE
) # P8.49 default fund (Art. 308/309)
.then(pl.lit(_OWN_FUNDS_TO_RWA_FACTOR))
.when(is_ecb_expr()) # Art. 114(3): ECB 0%, unconditional, both regimes
.then(ecb_rw_expr())
.when(uc.str.contains("CENTRAL_GOVT", literal=True) & is_domestic_currency)
.then(pl.lit(0.0))
# Art. 137(1)-(2) Table 9: nominated ECA / MEIP score → direct sovereign
# RW when no ECAI rating is present. Takes precedence over the Art. 114
# unrated 100% fallback but not over the Art. 114(4)/(7) domestic 0%.
# Identical to the CRR arm — MEIP risk weights are unchanged under PS1/26.
.when(
uc.str.contains("CENTRAL_GOVT", literal=True)
& (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
& pl.col("cp_eca_score").is_not_null()
)
.then(_eca_meip_rw_expr())
# QCCP trade exposures (CRR Art. 306, CRE54.14-15). The 2%/4% pin is
# for QUALIFYING CCPs only (Art. 272 Def (88)): an explicit
# cp_is_qccp=False demotes a ``ccp`` entity_type to the standard
# institution ladder (Art. 107(2)(a)). An absent flag is treated as
# qualifying so legacy ``ccp`` rows keep the prescribed weight.
.when((pl.col("cp_entity_type") == "ccp") & pl.col("cp_is_qccp").fill_null(True))
.then(
pl.when(pl.col("cp_is_ccp_client_cleared").fill_null(False))
.then(pl.lit(_SA_SHARED_RW["qccp_client_cleared"]))
.otherwise(pl.lit(_SA_SHARED_RW["qccp_proprietary"]))
)
# Subordinated debt: flat 150% (CRE20.47) — overrides all CQS-based
# weights for institution + corporate.
.when(_is_b31_subordinated_debt(uc))
.then(pl.lit(_SA_B31_RW["sub_debt"]))
)
chain = _b31_append_real_estate_branches(chain, uc)
# Sovereign-like treatments (PSE then RGLA).
chain = (
chain.when((uc == "PSE") & pse_jurisdiction_not_permitted_expr())
.then(pl.lit(_SA_SHARED_RW["pse_non_equivalent_jurisdiction"]))
# PSE short-term (Art. 116(3)): UK PSE, ORIGINAL maturity <= 3m -> 20%.
.when((uc == "PSE") & pse_short_term_eligible_expr(0.25)) # Art. 116(3) 3 months
.then(pl.lit(_SA_SHARED_RW["pse_short_term"]))
# PSE unrated: sovereign-derived RW lookup (Art. 116(1), Table 2).
# Maps cp_sovereign_cqs -> RW; falls back to 100% when sovereign
# CQS is unknown.
.when((uc == "PSE") & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0)))
.then(
sovereign_derived_rw_expr(
PSE_RISK_WEIGHTS_SOVEREIGN_DERIVED,
_SA_SHARED_RW["pse_unrated"],
)
)
# Art. 115(2)/(4) — RGLAs treated as their central government are
# priced on the Art. 114 ladder, not pinned to 0%; see engine/sa/rgla.py.
.when(is_rgla_sovereign_expr(uc))
.then(rgla_sovereign_rw_expr(is_uk_domestic))
# RGLA domestic currency -> 20% (Art. 115(5)). Scoped to the UK/GBP
# limb only: Art. 115(5) restricts the flat 20% to UK RGLAs
# denominated (and funded) in sterling. EU-domestic-currency RGLAs
# fall through to the Art. 115(1) rating tables below (own rating,
# then sovereign-derived) — the composite is_domestic_currency flag
# is deliberately NOT reused here.
.when((uc == "RGLA") & is_uk_domestic)
.then(pl.lit(_SA_SHARED_RW["rgla_domestic"]))
# RGLA unrated non-domestic: sovereign-derived (Art. 115(1)(a)
# Table 1A). Maps cp_sovereign_cqs -> RW; falls back to 100% when
# sovereign CQS is unknown.
.when((uc == "RGLA") & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0)))
.then(
sovereign_derived_rw_expr(
RGLA_RISK_WEIGHTS_SOVEREIGN_DERIVED,
_SA_SHARED_RW["rgla_unrated"],
)
)
# International Organisation -> 0% (Art. 118).
.when(uc == "INTERNATIONAL_ORGANISATION")
.then(pl.lit(_SA_SHARED_RW["io"]))
# Named MDB -> 0% (Art. 117(2)).
.when((uc == "MDB") & (pl.col("cp_entity_type").fill_null("") == "mdb_named"))
.then(pl.lit(_SA_SHARED_RW["mdb_named"]))
# Unrated non-named MDB -> 50% (Art. 117(1), Table 2B).
.when((uc == "MDB") & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0)))
.then(pl.lit(_SA_SHARED_RW["mdb_unrated"]))
)
chain = _b31_append_institution_maturity_branches(chain, uc)
chain = _b31_append_corporate_maturity_branches(chain, uc)
chain = _b31_append_high_risk_branch(chain, uc)
# Corporate / retail / misc tail of the chain.
is_unrated_corporate = (
uc.str.contains("CORPORATE", literal=True)
& ~uc.str.contains("SME", literal=True)
& (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
)
chain = (
chain
# Investment-grade assessment (Art. 122(6)/(8)) — only active under
# use_investment_grade_assessment. IG -> 65%, non-IG -> 135%.
.when(
pl.lit(config.use_investment_grade_assessment)
& is_unrated_corporate
& (pl.col("cp_is_investment_grade").fill_null(False) == True) # noqa: E712
)
.then(pl.lit(_SA_B31_RW["corporate_ig"]))
.when(
pl.lit(config.use_investment_grade_assessment)
& is_unrated_corporate
& (pl.col("cp_is_investment_grade").fill_null(False) != True) # noqa: E712
)
.then(pl.lit(_SA_B31_RW["corporate_nig"]))
# SME managed as retail: 75% (Art. 123, aggregated <= EUR 1m).
.when(
uc.str.contains("SME", literal=True)
& (pl.col("cp_is_managed_as_retail") == True) # noqa: E712
& (pl.col("qualifies_as_retail") == True) # noqa: E712
)
.then(pl.lit(_SA_SHARED_RW["retail"]))
# SA Specialised Lending — unrated only (Art. 122A-122B). Rated SL
# exposures use the corporate CQS table (Art. 122A(3)).
.when(
(
uc.str.contains("SPECIALISED", literal=True)
| (pl.col("sl_type").fill_null("").str.len_chars() > 0)
)
& (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
)
.then(b31_sa_sl_rw_expr())
# Corporate SME: 85% — unrated only (Art. 122(11)). A rated SME
# (CQS 1-6) keeps its Art. 122(2) Table-6 weight from the rw_table join.
.when(
uc.str.contains("CORPORATE", literal=True)
& uc.str.contains("SME", literal=True)
& (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
)
.then(pl.lit(_SA_B31_RW["corporate_sme"]))
)
# Retail-class branches (Art. 123).
chain = _b31_append_retail_branches(chain, uc)
exposures = exposures.with_columns(
chain
# Unrated covered bonds: derive from issuer institution RW
# (Art. 129(5)). ECRA (rated issuer, cp_institution_cqs) checked
# first, then SCRA (unrated issuer, cp_scra_grade) as fallback.
.when(
uc.str.contains("COVERED_BOND", literal=True)
& (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
)
.then(b31_unrated_cb_rw_expr(_SA_B31_RW["unrated_cb_default"]))
# Other Items (Art. 134): sub-type-specific risk weights.
.when(
(uc == "OTHER")
& (pl.col("cp_entity_type").fill_null("").is_in(["other_cash", "other_gold"]))
)
.then(pl.lit(_SA_SHARED_RW["other_cash"]))
.when(
(uc == "OTHER")
& (pl.col("cp_entity_type").fill_null("") == "other_items_in_collection")
)
.then(pl.lit(_SA_SHARED_RW["other_collection"]))
.when((uc == "OTHER") & (pl.col("cp_entity_type").fill_null("") == "other_residual_lease"))
.then(pl.lit(1.0) / pl.col("residual_maturity_years").fill_null(1.0).clip(lower_bound=1.0))
.when(uc == "OTHER")
.then(pl.lit(_SA_SHARED_RW["other_default"]))
# Equity (Art. 133(3)): 250% — full equity treatment (CIU,
# transitional floor) lives in the dedicated equity table.
.when(uc == "EQUITY")
.then(pl.lit(_SA_B31_RW["equity"]))
.otherwise(pl.col("risk_weight").fill_null(1.0))
.alias("risk_weight")
)
return exposures
_apply_crr_risk_weight_overrides — src/rwa_calc/engine/sa/risk_weights.py:1225
@cites("CRR Art. 134")
@cites("CRR Art. 137")
def _apply_crr_risk_weight_overrides(
exposures: pl.LazyFrame,
uc: pl.Expr,
is_domestic_currency: pl.Expr,
is_uk_domestic: pl.Expr,
) -> pl.LazyFrame:
"""Apply CRR class-specific risk-weight overrides (Art. 112-134).
``is_domestic_currency`` (UK or EU domestic currency) scopes the
Art. 114(4)/(7) CGCB 0% branch; ``is_uk_domestic`` (GB counterparty in
GBP) scopes the narrower Art. 115(5) flat-20% RGLA branch.
"""
chain = (
pl.when(pl.col("risk_type") == _SETTLEMENT_FAILED_TRADE_RISK_TYPE) # P8.43 failed trade
.then(pl.lit(_OWN_FUNDS_TO_RWA_FACTOR))
.when(
pl.col("risk_type") == _CCR_DEFAULT_FUND_RISK_TYPE
) # P8.49 default fund (Art. 308/309)
.then(pl.lit(_OWN_FUNDS_TO_RWA_FACTOR))
.when(is_ecb_expr()) # Art. 114(3): ECB 0%, ahead of 114(4)/(7)
.then(ecb_rw_expr())
# Art. 114(4)/(7): Domestic CGCB -> 0% RW (overrides all CQS).
.when(uc.str.contains("CENTRAL_GOVT", literal=True) & is_domestic_currency)
.then(pl.lit(0.0))
# Art. 137(1)-(2) Table 9: nominated ECA / MEIP score → direct sovereign
# RW when no ECAI rating is present. Takes precedence over the Art. 114
# unrated 100% fallback but not over the Art. 114(4)/(7) domestic 0%.
.when(
uc.str.contains("CENTRAL_GOVT", literal=True)
& (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
& pl.col("cp_eca_score").is_not_null()
)
.then(_eca_meip_rw_expr())
# QCCP trade exposures (CRR Art. 306, CRE54.14-15). The 2%/4% pin is
# for QUALIFYING CCPs only (Art. 272 Def (88)): an explicit
# cp_is_qccp=False demotes a ``ccp`` entity_type to the standard
# institution ladder (Art. 107(2)(a)). An absent flag is treated as
# qualifying so legacy ``ccp`` rows keep the prescribed weight.
.when((pl.col("cp_entity_type") == "ccp") & pl.col("cp_is_qccp").fill_null(True))
.then(
pl.when(pl.col("cp_is_ccp_client_cleared").fill_null(False))
.then(pl.lit(_SA_SHARED_RW["qccp_client_cleared"]))
.otherwise(pl.lit(_SA_SHARED_RW["qccp_proprietary"]))
)
)
chain = _crr_append_real_estate_branches(chain, uc)
# SME / retail branches.
chain = (
# SME managed as retail: 75% (CRR Art. 123, aggregated <= EUR 1m).
chain.when(
uc.str.contains("SME", literal=True)
& (pl.col("cp_is_managed_as_retail") == True) # noqa: E712
& (pl.col("qualifies_as_retail") == True) # noqa: E712
)
.then(pl.lit(_SA_SHARED_RW["retail"]))
# Art. 122(2): an unrated corporate takes "a 100 % risk weight or the
# risk weight of exposures to the central government of the jurisdiction
# in which the corporate is incorporated, whichever is the HIGHER".
# Only a CQS6 sovereign (150%) binds — the Art. 114 ladder is
# 0/20/50/100/100/150 and an unrated sovereign is 100%, so the 100% floor
# dominates everywhere else.
#
# Covers SME and non-SME alike: CRR Art. 122 draws no SME distinction
# (the former SME arm here was just Art. 122(2) restated), and CRR SME
# relief is the Art. 501 supporting factor, not a risk weight. A rated
# corporate is untouched — Art. 122(2) reaches only exposures "for which
# such a credit assessment is not available".
.when(
uc.str.contains("CORPORATE", literal=True)
& (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
)
.then(
pl.max_horizontal(
pl.lit(_SA_CRR_RW["corporate_sme"]),
sovereign_derived_rw_expr(
CENTRAL_GOVT_CENTRAL_BANK_RISK_WEIGHTS,
_SA_SHARED_RW["cgcb_unrated"],
),
)
)
)
# Retail-class branches (Art. 123).
chain = _crr_append_retail_branches(chain, uc)
# Sovereign-like (PSE, RGLA, MDB, IO).
chain = (
chain.when((uc == "PSE") & pse_jurisdiction_not_permitted_expr())
.then(pl.lit(_SA_SHARED_RW["pse_non_equivalent_jurisdiction"]))
# PSE short-term (Art. 116(3)): UK PSE, ORIGINAL maturity <= 3m -> 20%.
.when((uc == "PSE") & pse_short_term_eligible_expr(0.25)) # Art. 116(3) 3 months
.then(pl.lit(_SA_SHARED_RW["pse_short_term"]))
# PSE unrated: sovereign-derived RW lookup (Art. 116(1), Table 2).
# Maps cp_sovereign_cqs -> RW; falls back to 100% when sovereign
# CQS is unknown.
.when((uc == "PSE") & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0)))
.then(
sovereign_derived_rw_expr(
PSE_RISK_WEIGHTS_SOVEREIGN_DERIVED,
_SA_SHARED_RW["pse_unrated"],
)
)
# Art. 115(2)/(4) — RGLAs treated as their central government are
# priced on the Art. 114 ladder, not pinned to 0%; see engine/sa/rgla.py.
.when(is_rgla_sovereign_expr(uc))
.then(rgla_sovereign_rw_expr(is_uk_domestic))
# RGLA domestic currency -> 20% (Art. 115(5)). Scoped to the UK/GBP
# limb only: Art. 115(5) restricts the flat 20% to UK RGLAs
# denominated (and funded) in sterling. EU-domestic-currency RGLAs
# fall through to the Art. 115(1) rating tables below (own rating,
# then sovereign-derived) — the composite is_domestic_currency flag
# is deliberately NOT reused here.
.when((uc == "RGLA") & is_uk_domestic)
.then(pl.lit(_SA_SHARED_RW["rgla_domestic"]))
# RGLA unrated non-domestic: sovereign-derived (Art. 115(1)(a)
# Table 1A). Maps cp_sovereign_cqs -> RW; falls back to 100% when
# sovereign CQS is unknown.
.when((uc == "RGLA") & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0)))
.then(
sovereign_derived_rw_expr(
RGLA_RISK_WEIGHTS_SOVEREIGN_DERIVED,
_SA_SHARED_RW["rgla_unrated"],
)
)
# International Organisation -> 0% (Art. 118).
.when(uc == "INTERNATIONAL_ORGANISATION")
.then(pl.lit(_SA_SHARED_RW["io"]))
# Named MDB -> 0% (Art. 117(2)).
.when((uc == "MDB") & (pl.col("cp_entity_type").fill_null("") == "mdb_named"))
.then(pl.lit(_SA_SHARED_RW["mdb_named"]))
# CRR Art. 117(1): non-named MDBs are treated as institutions and use
# the institution risk weight tables (Art. 120 Table 3 if rated, Art.
# 121 Table 5 sovereign-derived if unrated). The dedicated Basel 3.1
# Table 2B path (PRA PS1/26 Art. 117(1)(a)) does NOT apply under CRR.
# The Art. 119(2)/120(2)/121(3) short-term carve-outs are excluded for
# MDBs by Art. 117(1), so no short-term branch is consulted here.
# Rated non-named MDB: Art. 120 Table 3 (institution own CQS).
.when((uc == "MDB") & pl.col("cqs").is_not_null() & (pl.col("cqs") > 0))
.then(build_institution_guarantor_rw_expr("cqs", is_basel_3_1=False))
# Unrated non-named MDB: Art. 121 Table 5 sovereign-derived; Art. 121
# fallback (100%) when the MDB's home sovereign CQS is unknown.
.when((uc == "MDB") & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0)))
.then(
sovereign_derived_rw_expr(
INSTITUTION_RISK_WEIGHTS_SOVEREIGN_DERIVED,
float(INSTITUTION_RISK_WEIGHTS_CRR[CQS.UNRATED]),
)
)
)
chain = _crr_append_institution_maturity_branches(chain, uc)
chain = _crr_append_corporate_maturity_branches(chain, uc)
# Covered bond / high risk / other items / equity tail.
exposures = exposures.with_columns(
chain
# Unrated covered bonds: derive from issuer institution RW (CRR Art. 129(5)).
.when(
uc.str.contains("COVERED_BOND", literal=True)
& (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
)
.then(crr_unrated_cb_rw_expr())
# CRR Art. 128 (high-risk items, 150%) was OMITTED from UK onshored CRR
# by SI 2021/1078 reg. 6(3)(a) with effect from 1 January 2022. Exposures
# that map to HIGH_RISK under the entity-type table therefore fall through
# to the OTHER (residual) class at 100% under UK CRR. The 150% treatment
# is re-introduced under PRA PS1/26 Basel 3.1 — see
# _apply_b31_risk_weight_overrides.
# Other Items (Art. 134): sub-type-specific risk weights.
.when(
(uc == "OTHER")
& (pl.col("cp_entity_type").fill_null("").is_in(["other_cash", "other_gold"]))
)
.then(pl.lit(_SA_SHARED_RW["other_cash"]))
.when(
(uc == "OTHER")
& (pl.col("cp_entity_type").fill_null("") == "other_items_in_collection")
)
.then(pl.lit(_SA_SHARED_RW["other_collection"]))
.when((uc == "OTHER") & (pl.col("cp_entity_type").fill_null("") == "other_residual_lease"))
.then(pl.lit(1.0) / pl.col("residual_maturity_years").fill_null(1.0).clip(lower_bound=1.0))
.when(uc == "OTHER")
.then(pl.lit(_SA_SHARED_RW["other_default"]))
# Equity (Art. 133(2)): flat 100%.
.when(uc == "EQUITY")
.then(pl.lit(_SA_CRR_RW["equity"]))
.otherwise(pl.col("risk_weight").fill_null(1.0))
.alias("risk_weight")
)
return exposures
CRR Art. 135 — Use of credit assessments by ECAIs¶
attach_counterparty_rating — src/rwa_calc/engine/stages/hierarchy/enrich.py:104
@cites("CRR Art. 135")
@cites("CRR Art. 136")
@cites("CRR Art. 138")
@cites("CRR Art. 139")
def attach_counterparty_rating(
exposures: pl.LazyFrame,
counterparty_lookup: CounterpartyLookup,
) -> pl.LazyFrame:
"""Join counterparty rating fields onto every exposure row.
``cqs`` and ``pd`` are used by SA / IRB calculators; ``internal_pd`` is
used by the classifier to gate IRB approach on internal-rating
availability; ``external_cqs`` is carried for audit trail; ``model_id``
(sourced from ``internal_model_id`` via the rating inheritance pipeline)
links to model_permissions for per-model approach gating.
"""
cp_schema = set(counterparty_lookup.counterparties.collect_schema().names())
cp_select = [pl.col("counterparty_reference"), pl.col("cqs"), pl.col("pd")]
if "internal_pd" in cp_schema:
cp_select.append(pl.col("internal_pd"))
if "external_cqs" in cp_schema:
cp_select.append(pl.col("external_cqs"))
if "external_rating_is_issue_specific" in cp_schema:
cp_select.append(pl.col("external_rating_is_issue_specific"))
if "internal_model_id" in cp_schema:
cp_select.append(pl.col("internal_model_id"))
exposures = exposures.join(
counterparty_lookup.counterparties.select(cp_select),
on="counterparty_reference",
how="left",
)
# Ensure internal_pd, external_cqs, and model_id always exist for classifier
rating_defaults = []
if "internal_pd" not in cp_schema:
rating_defaults.append(pl.lit(None).cast(pl.Float64).alias("internal_pd"))
if "external_cqs" not in cp_schema:
rating_defaults.append(pl.lit(None).cast(pl.Int8).alias("external_cqs"))
# PRA PS1/26 Art. 139(2B): default provenance flag when no external rating
# resolved — treat as issue-specific (legacy behaviour, no disapplication).
if "external_rating_is_issue_specific" not in cp_schema:
rating_defaults.append(
pl.lit(True).cast(pl.Boolean).alias("external_rating_is_issue_specific")
)
if rating_defaults:
exposures = exposures.with_columns(rating_defaults)
# model_id: sourced from internal_model_id (rating inheritance pipeline).
# We know internal_model_id was joined from cp_schema above.
if "internal_model_id" in cp_schema:
return exposures.with_columns(pl.col("internal_model_id").alias("model_id")).drop(
"internal_model_id"
)
return exposures.with_columns(pl.lit(None).cast(pl.String).alias("model_id"))
CRR Art. 136 — Mapping of ECAI's credit assessments¶
attach_counterparty_rating — src/rwa_calc/engine/stages/hierarchy/enrich.py:105
@cites("CRR Art. 135")
@cites("CRR Art. 136")
@cites("CRR Art. 138")
@cites("CRR Art. 139")
def attach_counterparty_rating(
exposures: pl.LazyFrame,
counterparty_lookup: CounterpartyLookup,
) -> pl.LazyFrame:
"""Join counterparty rating fields onto every exposure row.
``cqs`` and ``pd`` are used by SA / IRB calculators; ``internal_pd`` is
used by the classifier to gate IRB approach on internal-rating
availability; ``external_cqs`` is carried for audit trail; ``model_id``
(sourced from ``internal_model_id`` via the rating inheritance pipeline)
links to model_permissions for per-model approach gating.
"""
cp_schema = set(counterparty_lookup.counterparties.collect_schema().names())
cp_select = [pl.col("counterparty_reference"), pl.col("cqs"), pl.col("pd")]
if "internal_pd" in cp_schema:
cp_select.append(pl.col("internal_pd"))
if "external_cqs" in cp_schema:
cp_select.append(pl.col("external_cqs"))
if "external_rating_is_issue_specific" in cp_schema:
cp_select.append(pl.col("external_rating_is_issue_specific"))
if "internal_model_id" in cp_schema:
cp_select.append(pl.col("internal_model_id"))
exposures = exposures.join(
counterparty_lookup.counterparties.select(cp_select),
on="counterparty_reference",
how="left",
)
# Ensure internal_pd, external_cqs, and model_id always exist for classifier
rating_defaults = []
if "internal_pd" not in cp_schema:
rating_defaults.append(pl.lit(None).cast(pl.Float64).alias("internal_pd"))
if "external_cqs" not in cp_schema:
rating_defaults.append(pl.lit(None).cast(pl.Int8).alias("external_cqs"))
# PRA PS1/26 Art. 139(2B): default provenance flag when no external rating
# resolved — treat as issue-specific (legacy behaviour, no disapplication).
if "external_rating_is_issue_specific" not in cp_schema:
rating_defaults.append(
pl.lit(True).cast(pl.Boolean).alias("external_rating_is_issue_specific")
)
if rating_defaults:
exposures = exposures.with_columns(rating_defaults)
# model_id: sourced from internal_model_id (rating inheritance pipeline).
# We know internal_model_id was joined from cp_schema above.
if "internal_model_id" in cp_schema:
return exposures.with_columns(pl.col("internal_model_id").alias("model_id")).drop(
"internal_model_id"
)
return exposures.with_columns(pl.lit(None).cast(pl.String).alias("model_id"))
CRR Art. 137 — Use of credit assessments by export credit agencies¶
_eca_meip_rw_expr — src/rwa_calc/engine/sa/risk_weights.py:449
@cites("CRR Art. 137")
def _eca_meip_rw_expr() -> pl.Expr:
"""Build Polars expression mapping ``cp_eca_score`` (0-7) to sovereign RW.
Maps directly to the rulepack ``eca_meip_risk_weights`` table per CRR
Art. 137(2) Table 9 —
no intermediate CQS step. When ``cp_eca_score`` is null or out of range
the expression returns null so callers can defer to the standard
Art. 114 unrated fallback.
"""
col = pl.col("cp_eca_score")
expr = pl.when(col == 0).then(pl.lit(_ECA_MEIP_RW[0]))
for score in range(1, 8):
expr = expr.when(col == score).then(pl.lit(_ECA_MEIP_RW[score]))
return expr.otherwise(pl.lit(None, dtype=pl.Float64))
_apply_b31_risk_weight_overrides — src/rwa_calc/engine/sa/risk_weights.py:1016
@cites("CRR Art. 134")
@cites("CRR Art. 137")
def _apply_b31_risk_weight_overrides(
exposures: pl.LazyFrame,
uc: pl.Expr,
is_domestic_currency: pl.Expr,
is_uk_domestic: pl.Expr,
config: CalculationConfig,
) -> pl.LazyFrame:
"""Apply Basel 3.1 class-specific risk-weight overrides (CRE20, PRA PS1/26).
``is_domestic_currency`` (UK or EU domestic currency) scopes the
Art. 114(4)/(7) CGCB 0% branch; ``is_uk_domestic`` (GB counterparty in
GBP) scopes the narrower Art. 115(5) flat-20% RGLA branch.
"""
# Save the CQS-based risk weight before overrides — needed for the
# Basel 3.1 general CRE min(60%, counterparty_rw) logic (CRE20.85).
exposures = exposures.with_columns(
pl.col("risk_weight").fill_null(1.0).alias("_cqs_risk_weight")
)
# Build the override chain in regulatory precedence order:
# CGCB / QCCP / subordinated debt [early overrides, before RE/CQS]
# real estate (ADC, other-RE, residential, commercial)
# sovereign-like (PSE, RGLA)
# MDB / IO
# institution maturity (ECRA short, SCRA short, SCRA long)
# corporate / retail / misc (IG, SME, SL, QRRE, payroll, retail, ...)
# covered bond / high risk / other items / equity
chain = (
pl.when(pl.col("risk_type") == _SETTLEMENT_FAILED_TRADE_RISK_TYPE) # P8.43 failed trade
.then(pl.lit(_OWN_FUNDS_TO_RWA_FACTOR))
.when(
pl.col("risk_type") == _CCR_DEFAULT_FUND_RISK_TYPE
) # P8.49 default fund (Art. 308/309)
.then(pl.lit(_OWN_FUNDS_TO_RWA_FACTOR))
.when(is_ecb_expr()) # Art. 114(3): ECB 0%, unconditional, both regimes
.then(ecb_rw_expr())
.when(uc.str.contains("CENTRAL_GOVT", literal=True) & is_domestic_currency)
.then(pl.lit(0.0))
# Art. 137(1)-(2) Table 9: nominated ECA / MEIP score → direct sovereign
# RW when no ECAI rating is present. Takes precedence over the Art. 114
# unrated 100% fallback but not over the Art. 114(4)/(7) domestic 0%.
# Identical to the CRR arm — MEIP risk weights are unchanged under PS1/26.
.when(
uc.str.contains("CENTRAL_GOVT", literal=True)
& (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
& pl.col("cp_eca_score").is_not_null()
)
.then(_eca_meip_rw_expr())
# QCCP trade exposures (CRR Art. 306, CRE54.14-15). The 2%/4% pin is
# for QUALIFYING CCPs only (Art. 272 Def (88)): an explicit
# cp_is_qccp=False demotes a ``ccp`` entity_type to the standard
# institution ladder (Art. 107(2)(a)). An absent flag is treated as
# qualifying so legacy ``ccp`` rows keep the prescribed weight.
.when((pl.col("cp_entity_type") == "ccp") & pl.col("cp_is_qccp").fill_null(True))
.then(
pl.when(pl.col("cp_is_ccp_client_cleared").fill_null(False))
.then(pl.lit(_SA_SHARED_RW["qccp_client_cleared"]))
.otherwise(pl.lit(_SA_SHARED_RW["qccp_proprietary"]))
)
# Subordinated debt: flat 150% (CRE20.47) — overrides all CQS-based
# weights for institution + corporate.
.when(_is_b31_subordinated_debt(uc))
.then(pl.lit(_SA_B31_RW["sub_debt"]))
)
chain = _b31_append_real_estate_branches(chain, uc)
# Sovereign-like treatments (PSE then RGLA).
chain = (
chain.when((uc == "PSE") & pse_jurisdiction_not_permitted_expr())
.then(pl.lit(_SA_SHARED_RW["pse_non_equivalent_jurisdiction"]))
# PSE short-term (Art. 116(3)): UK PSE, ORIGINAL maturity <= 3m -> 20%.
.when((uc == "PSE") & pse_short_term_eligible_expr(0.25)) # Art. 116(3) 3 months
.then(pl.lit(_SA_SHARED_RW["pse_short_term"]))
# PSE unrated: sovereign-derived RW lookup (Art. 116(1), Table 2).
# Maps cp_sovereign_cqs -> RW; falls back to 100% when sovereign
# CQS is unknown.
.when((uc == "PSE") & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0)))
.then(
sovereign_derived_rw_expr(
PSE_RISK_WEIGHTS_SOVEREIGN_DERIVED,
_SA_SHARED_RW["pse_unrated"],
)
)
# Art. 115(2)/(4) — RGLAs treated as their central government are
# priced on the Art. 114 ladder, not pinned to 0%; see engine/sa/rgla.py.
.when(is_rgla_sovereign_expr(uc))
.then(rgla_sovereign_rw_expr(is_uk_domestic))
# RGLA domestic currency -> 20% (Art. 115(5)). Scoped to the UK/GBP
# limb only: Art. 115(5) restricts the flat 20% to UK RGLAs
# denominated (and funded) in sterling. EU-domestic-currency RGLAs
# fall through to the Art. 115(1) rating tables below (own rating,
# then sovereign-derived) — the composite is_domestic_currency flag
# is deliberately NOT reused here.
.when((uc == "RGLA") & is_uk_domestic)
.then(pl.lit(_SA_SHARED_RW["rgla_domestic"]))
# RGLA unrated non-domestic: sovereign-derived (Art. 115(1)(a)
# Table 1A). Maps cp_sovereign_cqs -> RW; falls back to 100% when
# sovereign CQS is unknown.
.when((uc == "RGLA") & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0)))
.then(
sovereign_derived_rw_expr(
RGLA_RISK_WEIGHTS_SOVEREIGN_DERIVED,
_SA_SHARED_RW["rgla_unrated"],
)
)
# International Organisation -> 0% (Art. 118).
.when(uc == "INTERNATIONAL_ORGANISATION")
.then(pl.lit(_SA_SHARED_RW["io"]))
# Named MDB -> 0% (Art. 117(2)).
.when((uc == "MDB") & (pl.col("cp_entity_type").fill_null("") == "mdb_named"))
.then(pl.lit(_SA_SHARED_RW["mdb_named"]))
# Unrated non-named MDB -> 50% (Art. 117(1), Table 2B).
.when((uc == "MDB") & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0)))
.then(pl.lit(_SA_SHARED_RW["mdb_unrated"]))
)
chain = _b31_append_institution_maturity_branches(chain, uc)
chain = _b31_append_corporate_maturity_branches(chain, uc)
chain = _b31_append_high_risk_branch(chain, uc)
# Corporate / retail / misc tail of the chain.
is_unrated_corporate = (
uc.str.contains("CORPORATE", literal=True)
& ~uc.str.contains("SME", literal=True)
& (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
)
chain = (
chain
# Investment-grade assessment (Art. 122(6)/(8)) — only active under
# use_investment_grade_assessment. IG -> 65%, non-IG -> 135%.
.when(
pl.lit(config.use_investment_grade_assessment)
& is_unrated_corporate
& (pl.col("cp_is_investment_grade").fill_null(False) == True) # noqa: E712
)
.then(pl.lit(_SA_B31_RW["corporate_ig"]))
.when(
pl.lit(config.use_investment_grade_assessment)
& is_unrated_corporate
& (pl.col("cp_is_investment_grade").fill_null(False) != True) # noqa: E712
)
.then(pl.lit(_SA_B31_RW["corporate_nig"]))
# SME managed as retail: 75% (Art. 123, aggregated <= EUR 1m).
.when(
uc.str.contains("SME", literal=True)
& (pl.col("cp_is_managed_as_retail") == True) # noqa: E712
& (pl.col("qualifies_as_retail") == True) # noqa: E712
)
.then(pl.lit(_SA_SHARED_RW["retail"]))
# SA Specialised Lending — unrated only (Art. 122A-122B). Rated SL
# exposures use the corporate CQS table (Art. 122A(3)).
.when(
(
uc.str.contains("SPECIALISED", literal=True)
| (pl.col("sl_type").fill_null("").str.len_chars() > 0)
)
& (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
)
.then(b31_sa_sl_rw_expr())
# Corporate SME: 85% — unrated only (Art. 122(11)). A rated SME
# (CQS 1-6) keeps its Art. 122(2) Table-6 weight from the rw_table join.
.when(
uc.str.contains("CORPORATE", literal=True)
& uc.str.contains("SME", literal=True)
& (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
)
.then(pl.lit(_SA_B31_RW["corporate_sme"]))
)
# Retail-class branches (Art. 123).
chain = _b31_append_retail_branches(chain, uc)
exposures = exposures.with_columns(
chain
# Unrated covered bonds: derive from issuer institution RW
# (Art. 129(5)). ECRA (rated issuer, cp_institution_cqs) checked
# first, then SCRA (unrated issuer, cp_scra_grade) as fallback.
.when(
uc.str.contains("COVERED_BOND", literal=True)
& (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
)
.then(b31_unrated_cb_rw_expr(_SA_B31_RW["unrated_cb_default"]))
# Other Items (Art. 134): sub-type-specific risk weights.
.when(
(uc == "OTHER")
& (pl.col("cp_entity_type").fill_null("").is_in(["other_cash", "other_gold"]))
)
.then(pl.lit(_SA_SHARED_RW["other_cash"]))
.when(
(uc == "OTHER")
& (pl.col("cp_entity_type").fill_null("") == "other_items_in_collection")
)
.then(pl.lit(_SA_SHARED_RW["other_collection"]))
.when((uc == "OTHER") & (pl.col("cp_entity_type").fill_null("") == "other_residual_lease"))
.then(pl.lit(1.0) / pl.col("residual_maturity_years").fill_null(1.0).clip(lower_bound=1.0))
.when(uc == "OTHER")
.then(pl.lit(_SA_SHARED_RW["other_default"]))
# Equity (Art. 133(3)): 250% — full equity treatment (CIU,
# transitional floor) lives in the dedicated equity table.
.when(uc == "EQUITY")
.then(pl.lit(_SA_B31_RW["equity"]))
.otherwise(pl.col("risk_weight").fill_null(1.0))
.alias("risk_weight")
)
return exposures
_apply_crr_risk_weight_overrides — src/rwa_calc/engine/sa/risk_weights.py:1226
@cites("CRR Art. 134")
@cites("CRR Art. 137")
def _apply_crr_risk_weight_overrides(
exposures: pl.LazyFrame,
uc: pl.Expr,
is_domestic_currency: pl.Expr,
is_uk_domestic: pl.Expr,
) -> pl.LazyFrame:
"""Apply CRR class-specific risk-weight overrides (Art. 112-134).
``is_domestic_currency`` (UK or EU domestic currency) scopes the
Art. 114(4)/(7) CGCB 0% branch; ``is_uk_domestic`` (GB counterparty in
GBP) scopes the narrower Art. 115(5) flat-20% RGLA branch.
"""
chain = (
pl.when(pl.col("risk_type") == _SETTLEMENT_FAILED_TRADE_RISK_TYPE) # P8.43 failed trade
.then(pl.lit(_OWN_FUNDS_TO_RWA_FACTOR))
.when(
pl.col("risk_type") == _CCR_DEFAULT_FUND_RISK_TYPE
) # P8.49 default fund (Art. 308/309)
.then(pl.lit(_OWN_FUNDS_TO_RWA_FACTOR))
.when(is_ecb_expr()) # Art. 114(3): ECB 0%, ahead of 114(4)/(7)
.then(ecb_rw_expr())
# Art. 114(4)/(7): Domestic CGCB -> 0% RW (overrides all CQS).
.when(uc.str.contains("CENTRAL_GOVT", literal=True) & is_domestic_currency)
.then(pl.lit(0.0))
# Art. 137(1)-(2) Table 9: nominated ECA / MEIP score → direct sovereign
# RW when no ECAI rating is present. Takes precedence over the Art. 114
# unrated 100% fallback but not over the Art. 114(4)/(7) domestic 0%.
.when(
uc.str.contains("CENTRAL_GOVT", literal=True)
& (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
& pl.col("cp_eca_score").is_not_null()
)
.then(_eca_meip_rw_expr())
# QCCP trade exposures (CRR Art. 306, CRE54.14-15). The 2%/4% pin is
# for QUALIFYING CCPs only (Art. 272 Def (88)): an explicit
# cp_is_qccp=False demotes a ``ccp`` entity_type to the standard
# institution ladder (Art. 107(2)(a)). An absent flag is treated as
# qualifying so legacy ``ccp`` rows keep the prescribed weight.
.when((pl.col("cp_entity_type") == "ccp") & pl.col("cp_is_qccp").fill_null(True))
.then(
pl.when(pl.col("cp_is_ccp_client_cleared").fill_null(False))
.then(pl.lit(_SA_SHARED_RW["qccp_client_cleared"]))
.otherwise(pl.lit(_SA_SHARED_RW["qccp_proprietary"]))
)
)
chain = _crr_append_real_estate_branches(chain, uc)
# SME / retail branches.
chain = (
# SME managed as retail: 75% (CRR Art. 123, aggregated <= EUR 1m).
chain.when(
uc.str.contains("SME", literal=True)
& (pl.col("cp_is_managed_as_retail") == True) # noqa: E712
& (pl.col("qualifies_as_retail") == True) # noqa: E712
)
.then(pl.lit(_SA_SHARED_RW["retail"]))
# Art. 122(2): an unrated corporate takes "a 100 % risk weight or the
# risk weight of exposures to the central government of the jurisdiction
# in which the corporate is incorporated, whichever is the HIGHER".
# Only a CQS6 sovereign (150%) binds — the Art. 114 ladder is
# 0/20/50/100/100/150 and an unrated sovereign is 100%, so the 100% floor
# dominates everywhere else.
#
# Covers SME and non-SME alike: CRR Art. 122 draws no SME distinction
# (the former SME arm here was just Art. 122(2) restated), and CRR SME
# relief is the Art. 501 supporting factor, not a risk weight. A rated
# corporate is untouched — Art. 122(2) reaches only exposures "for which
# such a credit assessment is not available".
.when(
uc.str.contains("CORPORATE", literal=True)
& (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
)
.then(
pl.max_horizontal(
pl.lit(_SA_CRR_RW["corporate_sme"]),
sovereign_derived_rw_expr(
CENTRAL_GOVT_CENTRAL_BANK_RISK_WEIGHTS,
_SA_SHARED_RW["cgcb_unrated"],
),
)
)
)
# Retail-class branches (Art. 123).
chain = _crr_append_retail_branches(chain, uc)
# Sovereign-like (PSE, RGLA, MDB, IO).
chain = (
chain.when((uc == "PSE") & pse_jurisdiction_not_permitted_expr())
.then(pl.lit(_SA_SHARED_RW["pse_non_equivalent_jurisdiction"]))
# PSE short-term (Art. 116(3)): UK PSE, ORIGINAL maturity <= 3m -> 20%.
.when((uc == "PSE") & pse_short_term_eligible_expr(0.25)) # Art. 116(3) 3 months
.then(pl.lit(_SA_SHARED_RW["pse_short_term"]))
# PSE unrated: sovereign-derived RW lookup (Art. 116(1), Table 2).
# Maps cp_sovereign_cqs -> RW; falls back to 100% when sovereign
# CQS is unknown.
.when((uc == "PSE") & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0)))
.then(
sovereign_derived_rw_expr(
PSE_RISK_WEIGHTS_SOVEREIGN_DERIVED,
_SA_SHARED_RW["pse_unrated"],
)
)
# Art. 115(2)/(4) — RGLAs treated as their central government are
# priced on the Art. 114 ladder, not pinned to 0%; see engine/sa/rgla.py.
.when(is_rgla_sovereign_expr(uc))
.then(rgla_sovereign_rw_expr(is_uk_domestic))
# RGLA domestic currency -> 20% (Art. 115(5)). Scoped to the UK/GBP
# limb only: Art. 115(5) restricts the flat 20% to UK RGLAs
# denominated (and funded) in sterling. EU-domestic-currency RGLAs
# fall through to the Art. 115(1) rating tables below (own rating,
# then sovereign-derived) — the composite is_domestic_currency flag
# is deliberately NOT reused here.
.when((uc == "RGLA") & is_uk_domestic)
.then(pl.lit(_SA_SHARED_RW["rgla_domestic"]))
# RGLA unrated non-domestic: sovereign-derived (Art. 115(1)(a)
# Table 1A). Maps cp_sovereign_cqs -> RW; falls back to 100% when
# sovereign CQS is unknown.
.when((uc == "RGLA") & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0)))
.then(
sovereign_derived_rw_expr(
RGLA_RISK_WEIGHTS_SOVEREIGN_DERIVED,
_SA_SHARED_RW["rgla_unrated"],
)
)
# International Organisation -> 0% (Art. 118).
.when(uc == "INTERNATIONAL_ORGANISATION")
.then(pl.lit(_SA_SHARED_RW["io"]))
# Named MDB -> 0% (Art. 117(2)).
.when((uc == "MDB") & (pl.col("cp_entity_type").fill_null("") == "mdb_named"))
.then(pl.lit(_SA_SHARED_RW["mdb_named"]))
# CRR Art. 117(1): non-named MDBs are treated as institutions and use
# the institution risk weight tables (Art. 120 Table 3 if rated, Art.
# 121 Table 5 sovereign-derived if unrated). The dedicated Basel 3.1
# Table 2B path (PRA PS1/26 Art. 117(1)(a)) does NOT apply under CRR.
# The Art. 119(2)/120(2)/121(3) short-term carve-outs are excluded for
# MDBs by Art. 117(1), so no short-term branch is consulted here.
# Rated non-named MDB: Art. 120 Table 3 (institution own CQS).
.when((uc == "MDB") & pl.col("cqs").is_not_null() & (pl.col("cqs") > 0))
.then(build_institution_guarantor_rw_expr("cqs", is_basel_3_1=False))
# Unrated non-named MDB: Art. 121 Table 5 sovereign-derived; Art. 121
# fallback (100%) when the MDB's home sovereign CQS is unknown.
.when((uc == "MDB") & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0)))
.then(
sovereign_derived_rw_expr(
INSTITUTION_RISK_WEIGHTS_SOVEREIGN_DERIVED,
float(INSTITUTION_RISK_WEIGHTS_CRR[CQS.UNRATED]),
)
)
)
chain = _crr_append_institution_maturity_branches(chain, uc)
chain = _crr_append_corporate_maturity_branches(chain, uc)
# Covered bond / high risk / other items / equity tail.
exposures = exposures.with_columns(
chain
# Unrated covered bonds: derive from issuer institution RW (CRR Art. 129(5)).
.when(
uc.str.contains("COVERED_BOND", literal=True)
& (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
)
.then(crr_unrated_cb_rw_expr())
# CRR Art. 128 (high-risk items, 150%) was OMITTED from UK onshored CRR
# by SI 2021/1078 reg. 6(3)(a) with effect from 1 January 2022. Exposures
# that map to HIGH_RISK under the entity-type table therefore fall through
# to the OTHER (residual) class at 100% under UK CRR. The 150% treatment
# is re-introduced under PRA PS1/26 Basel 3.1 — see
# _apply_b31_risk_weight_overrides.
# Other Items (Art. 134): sub-type-specific risk weights.
.when(
(uc == "OTHER")
& (pl.col("cp_entity_type").fill_null("").is_in(["other_cash", "other_gold"]))
)
.then(pl.lit(_SA_SHARED_RW["other_cash"]))
.when(
(uc == "OTHER")
& (pl.col("cp_entity_type").fill_null("") == "other_items_in_collection")
)
.then(pl.lit(_SA_SHARED_RW["other_collection"]))
.when((uc == "OTHER") & (pl.col("cp_entity_type").fill_null("") == "other_residual_lease"))
.then(pl.lit(1.0) / pl.col("residual_maturity_years").fill_null(1.0).clip(lower_bound=1.0))
.when(uc == "OTHER")
.then(pl.lit(_SA_SHARED_RW["other_default"]))
# Equity (Art. 133(2)): flat 100%.
.when(uc == "EQUITY")
.then(pl.lit(_SA_CRR_RW["equity"]))
.otherwise(pl.col("risk_weight").fill_null(1.0))
.alias("risk_weight")
)
return exposures
CRR Art. 138 — General requirements¶
attach_counterparty_rating — src/rwa_calc/engine/stages/hierarchy/enrich.py:106
@cites("CRR Art. 135")
@cites("CRR Art. 136")
@cites("CRR Art. 138")
@cites("CRR Art. 139")
def attach_counterparty_rating(
exposures: pl.LazyFrame,
counterparty_lookup: CounterpartyLookup,
) -> pl.LazyFrame:
"""Join counterparty rating fields onto every exposure row.
``cqs`` and ``pd`` are used by SA / IRB calculators; ``internal_pd`` is
used by the classifier to gate IRB approach on internal-rating
availability; ``external_cqs`` is carried for audit trail; ``model_id``
(sourced from ``internal_model_id`` via the rating inheritance pipeline)
links to model_permissions for per-model approach gating.
"""
cp_schema = set(counterparty_lookup.counterparties.collect_schema().names())
cp_select = [pl.col("counterparty_reference"), pl.col("cqs"), pl.col("pd")]
if "internal_pd" in cp_schema:
cp_select.append(pl.col("internal_pd"))
if "external_cqs" in cp_schema:
cp_select.append(pl.col("external_cqs"))
if "external_rating_is_issue_specific" in cp_schema:
cp_select.append(pl.col("external_rating_is_issue_specific"))
if "internal_model_id" in cp_schema:
cp_select.append(pl.col("internal_model_id"))
exposures = exposures.join(
counterparty_lookup.counterparties.select(cp_select),
on="counterparty_reference",
how="left",
)
# Ensure internal_pd, external_cqs, and model_id always exist for classifier
rating_defaults = []
if "internal_pd" not in cp_schema:
rating_defaults.append(pl.lit(None).cast(pl.Float64).alias("internal_pd"))
if "external_cqs" not in cp_schema:
rating_defaults.append(pl.lit(None).cast(pl.Int8).alias("external_cqs"))
# PRA PS1/26 Art. 139(2B): default provenance flag when no external rating
# resolved — treat as issue-specific (legacy behaviour, no disapplication).
if "external_rating_is_issue_specific" not in cp_schema:
rating_defaults.append(
pl.lit(True).cast(pl.Boolean).alias("external_rating_is_issue_specific")
)
if rating_defaults:
exposures = exposures.with_columns(rating_defaults)
# model_id: sourced from internal_model_id (rating inheritance pipeline).
# We know internal_model_id was joined from cp_schema above.
if "internal_model_id" in cp_schema:
return exposures.with_columns(pl.col("internal_model_id").alias("model_id")).drop(
"internal_model_id"
)
return exposures.with_columns(pl.lit(None).cast(pl.String).alias("model_id"))
CRR Art. 139 — Issuer and issue credit assessment¶
attach_counterparty_rating — src/rwa_calc/engine/stages/hierarchy/enrich.py:107
@cites("CRR Art. 135")
@cites("CRR Art. 136")
@cites("CRR Art. 138")
@cites("CRR Art. 139")
def attach_counterparty_rating(
exposures: pl.LazyFrame,
counterparty_lookup: CounterpartyLookup,
) -> pl.LazyFrame:
"""Join counterparty rating fields onto every exposure row.
``cqs`` and ``pd`` are used by SA / IRB calculators; ``internal_pd`` is
used by the classifier to gate IRB approach on internal-rating
availability; ``external_cqs`` is carried for audit trail; ``model_id``
(sourced from ``internal_model_id`` via the rating inheritance pipeline)
links to model_permissions for per-model approach gating.
"""
cp_schema = set(counterparty_lookup.counterparties.collect_schema().names())
cp_select = [pl.col("counterparty_reference"), pl.col("cqs"), pl.col("pd")]
if "internal_pd" in cp_schema:
cp_select.append(pl.col("internal_pd"))
if "external_cqs" in cp_schema:
cp_select.append(pl.col("external_cqs"))
if "external_rating_is_issue_specific" in cp_schema:
cp_select.append(pl.col("external_rating_is_issue_specific"))
if "internal_model_id" in cp_schema:
cp_select.append(pl.col("internal_model_id"))
exposures = exposures.join(
counterparty_lookup.counterparties.select(cp_select),
on="counterparty_reference",
how="left",
)
# Ensure internal_pd, external_cqs, and model_id always exist for classifier
rating_defaults = []
if "internal_pd" not in cp_schema:
rating_defaults.append(pl.lit(None).cast(pl.Float64).alias("internal_pd"))
if "external_cqs" not in cp_schema:
rating_defaults.append(pl.lit(None).cast(pl.Int8).alias("external_cqs"))
# PRA PS1/26 Art. 139(2B): default provenance flag when no external rating
# resolved — treat as issue-specific (legacy behaviour, no disapplication).
if "external_rating_is_issue_specific" not in cp_schema:
rating_defaults.append(
pl.lit(True).cast(pl.Boolean).alias("external_rating_is_issue_specific")
)
if rating_defaults:
exposures = exposures.with_columns(rating_defaults)
# model_id: sourced from internal_model_id (rating inheritance pipeline).
# We know internal_model_id was joined from cp_schema above.
if "internal_model_id" in cp_schema:
return exposures.with_columns(pl.col("internal_model_id").alias("model_id")).drop(
"internal_model_id"
)
return exposures.with_columns(pl.lit(None).cast(pl.String).alias("model_id"))
CRR Art. 140 — Long-term and short-term credit assessments¶
_apply_obligor_st_contamination_override — src/rwa_calc/engine/sa/risk_weights.py:401
@cites("CRR Art. 140")
@cites("PS1/26, paragraph 140")
def _apply_obligor_st_contamination_override(exposures: pl.LazyFrame) -> pl.LazyFrame:
"""Apply the Art. 140(2) obligor-level short-term contamination RW override.
CRR Art. 140(2) / PRA PS1/26 Art. 140(2) (CRE21.17-18), reading the two
per-obligor flags from ``_apply_obligor_st_contamination_flags``:
(a) 150% broadcast (Table 7 CQS 4+) onto ALL the obligor's unrated unsecured
claims (short- OR long-term); (b) 100% floor (max(RW, 100%)) on its unrated
unsecured SHORT-TERM claims when a 50%-attracting (Table 7 CQS 2) assessment
exists. The 150% hard override dominates the floor (checked first).
A target is UNSECURED (``~is_guaranteed`` — a guaranteed leg keeps its
guarantor RW), lacks its OWN short-term assessment (``~has_own_short_term_ecai``
— the directly-rated leg is the SOURCE, never a target), and is either
genuinely unrated (``cqs`` null) OR was handed a short-term cqs by the
Art. 120(3)(c) spillover (``has_short_term_ecai``). Including the spilled arm
is the P1.225 co-fire fix: the spillover overwrites ``cqs`` /
``has_short_term_ecai``, so the pre-fix ``cqs.is_null() & ~has_short_term_ecai``
predicate dropped spilled legs and Art. 140(2) never bound when both fired.
A leg with only an inherited long-term cqs stays excluded as before.
"""
is_target = pl.col("has_own_short_term_ecai").not_() & (
pl.col("cqs").is_null() | pl.col("has_short_term_ecai")
)
is_unsecured = pl.col("is_guaranteed").not_()
# Reuse the SA short-term window (original maturity <= 3m, <= 6m for
# self-liquidating trade LCs) used by the institution ST branches. No
# fill_null: a null maturity yields a null gate the when-chain treats as
# "not short-term".
original_mty = pl.col("original_maturity_years")
is_st = (original_mty <= 0.25) | (pl.col("is_short_term_trade_lc") & (original_mty <= 0.5))
return exposures.with_columns(
pl.when(pl.col("obligor_st_150_contamination") & is_target & is_unsecured)
.then(pl.lit(1.50))
.when(pl.col("obligor_st_50_floor") & is_target & is_unsecured & is_st)
.then(pl.max_horizontal(pl.col("risk_weight"), pl.lit(1.00)))
.otherwise(pl.col("risk_weight"))
.alias("risk_weight")
)
apply_short_term_rating_override — src/rwa_calc/engine/stages/hierarchy/enrich.py:162
@cites("CRR Art. 131")
@cites("CRR Art. 140")
def apply_short_term_rating_override(
exposures: pl.LazyFrame,
ratings: pl.LazyFrame | None,
counterparty_lookup: CounterpartyLookup | None = None,
errors: list[CalculationError] | None = None,
) -> pl.LazyFrame:
"""Apply per-exposure short-term rating override.
Short-term ECAI assessments under PRA PS1/26 Art. 120(2B) Table 4A and
Art. 122(3) Table 6A are issue-specific — each rating row attaches to a
single exposure via ``(scope_type, scope_id)``. When a short-term rating
row matches an exposure, its ``cqs`` overrides the counterparty-level
rating attached by ``attach_counterparty_rating`` and the derived
``has_short_term_ecai`` flag is set to True, signalling the SA engine to
route via Table 4A / Table 6A.
Scope matching:
- ``scope_type='facility'`` -> matches the source facility's drawn loans,
its synthetic ``facility_undrawn`` row, and any descendant exposure via
``parent_facility_reference`` / ``root_facility_reference``.
- ``scope_type='loan'`` -> matches the loan exposure with the same
``exposure_reference`` and ``exposure_type='loan'``.
- ``scope_type='contingent'`` -> matches the contingent exposure with the
same ``exposure_reference`` and ``exposure_type='contingent'``.
Ties (multiple short-term ratings for the same exposure) are resolved by
picking the row with the lowest CQS, breaking ties by latest
``rating_date``. This mirrors the external best-rating selection in
``ratings.build_rating_inheritance_lazy``.
Always returns ``exposures`` augmented with a ``has_short_term_ecai``
boolean column (False when no override matched).
"""
st_ratings = _prepare_short_term_lookup(ratings)
if st_ratings is None:
return exposures.with_columns(pl.lit(False).alias("has_short_term_ecai"))
exp_schema = set(exposures.collect_schema().names())
match_branches = _build_short_term_match_branches(exp_schema)
# Track which scope branches actually produce a match so we can
# coalesce the resulting cqs in priority order: loan > contingent >
# facility (most specific wins).
joined_scopes: list[str] = []
for scope, key_expr in match_branches:
scope_lookup = st_ratings.filter(pl.col("_st_scope_type") == scope).select(
[
pl.col("_st_cp"),
pl.col("_st_scope_id"),
pl.col("_st_cqs").alias(f"_st_{scope}_cqs"),
]
)
exposures = exposures.with_columns(key_expr.alias(f"_match_key_{scope}"))
exposures = exposures.join(
scope_lookup,
left_on=["counterparty_reference", f"_match_key_{scope}"],
right_on=["_st_cp", "_st_scope_id"],
how="left",
).drop(f"_match_key_{scope}")
joined_scopes.append(scope)
if not joined_scopes:
return exposures.with_columns(pl.lit(False).alias("has_short_term_ecai"))
# Coalesce in priority order: loan > contingent > facility (most
# specific scope wins).
priority = ["loan", "contingent", "facility"]
ordered = [s for s in priority if s in joined_scopes]
st_cqs_expr = pl.coalesce([pl.col(f"_st_{s}_cqs") for s in ordered])
# Art. 140(1) / CRE21.16 obligor-class gate: short-term ECAI assessments may
# be used ONLY for institution / corporate obligors. Join the raw entity_type
# (from the counterparty lookup — no class column exists on the frame yet;
# the classifier derives it later) and disqualify a match on any other class.
# A mis-scoped match is ignored: has_short_term_ecai stays False, cqs keeps
# its counterparty-level value and _st_assessment_cqs stays null, so the row
# AND the Art. 120(3)(c) spillover / Art. 140(2) contamination helpers that
# run AFTER the gate all inherit the rejection for free.
# Ordering: (1) scope-match [above] -> (2) THIS GATE -> (3) spillover ->
# (4) contamination flags.
has_gate = counterparty_lookup is not None
if has_gate:
eligible = list(ENTITY_TYPES_BY_SA_CLASS["institution"]) + list(
ENTITY_TYPES_BY_SA_CLASS["corporate"]
)
gate_lookup = counterparty_lookup.counterparties.select(
pl.col("counterparty_reference"),
pl.col("entity_type").str.to_lowercase().alias("_st_gate_entity_type"),
)
exposures = exposures.join(gate_lookup, on="counterparty_reference", how="left")
# fill_null("") before is_in: Polars propagates null through is_in, so a
# null/unknown entity_type (or a join-miss) would otherwise yield a NULL
# eligibility -> a null has_short_term_ecai flag AND a null-dropped DQ009
# filter (warning silently lost). "" resolves to ineligible -> clean
# rejection + DQ009 emitted. Mirrors the entity_type null-guard idiom in
# engine/sa/risk_weights.py.
st_class_eligible = pl.col("_st_gate_entity_type").fill_null("").is_in(eligible)
else:
st_class_eligible = pl.lit(True) # noqa: FBT003
# Override: when a short-term cqs matched AND the obligor class is eligible,
# replace the cqs column and set has_short_term_ecai=True. SA Tables 4A / 6A
# are keyed off cqs only — rating_agency / rating_value are audit columns
# added later by the classifier and intentionally not overridden here.
#
# Two scratch columns are carried into the obligor-level Art. 120(3)(c)
# spillover step below: ``_st_assessment_cqs`` (the matched short-term ECAI
# cqs, non-null only on the directly-rated ELIGIBLE exposure) and
# ``_general_cqs`` (the obligor's pre-override counterparty cqs).
has_st = st_cqs_expr.is_not_null() & st_class_eligible
# Art. 140(1) DQ warning: a match rejected purely by the class gate (matched
# but ineligible) is a mis-scoped rating — record one DQ009 per such
# exposure before the scratch entity_type is dropped.
if errors is not None and has_gate:
_record_misscoped_st_ratings(
exposures, st_cqs_expr.is_not_null() & st_class_eligible.not_(), errors
)
exposures = exposures.with_columns(
[
has_st.alias("has_short_term_ecai"),
pl.when(has_st)
.then(st_cqs_expr)
.otherwise(pl.lit(None, dtype=pl.Int8))
.cast(pl.Int8)
.alias("_st_assessment_cqs"),
pl.col("cqs").cast(pl.Int8).alias("_general_cqs"),
pl.when(has_st).then(st_cqs_expr).otherwise(pl.col("cqs")).cast(pl.Int8).alias("cqs"),
]
)
exposures = _apply_obligor_short_term_spillover(exposures)
# Art. 140(2) obligor-level contamination flags — reads the pristine
# ``_st_assessment_cqs`` scratch here, BEFORE the drop below. The two flag
# columns it emits are not ``_st_*`` scratch, so they survive the drop.
exposures = _apply_obligor_st_contamination_flags(exposures)
scratch = [f"_st_{s}_cqs" for s in joined_scopes] + ["_st_assessment_cqs", "_general_cqs"]
if has_gate:
scratch.append("_st_gate_entity_type")
return exposures.drop(scratch)
_apply_obligor_st_contamination_flags — src/rwa_calc/engine/stages/hierarchy/enrich.py:958
@cites("CRR Art. 140")
@cites("PS1/26, paragraph 140")
def _apply_obligor_st_contamination_flags(exposures: pl.LazyFrame) -> pl.LazyFrame:
"""Flag obligor-level short-term rating contamination (Art. 140(2)).
CRR Art. 140(2) / PRA PS1/26 Art. 140(2) (CRE21.17-18): a short-term ECAI
assessment on ANY of an obligor's facilities contaminates that obligor's
unrated UNSECURED exposures:
- (a) an assessment attracting 150% (Table 7 CQS 4+) broadcasts 150% to ALL
the obligor's unrated unsecured claims — short- OR long-term;
- (b) an assessment attracting 50% (Table 7 CQS 2) floors the obligor's
unrated SHORT-TERM unsecured claims at 100%.
Emits two obligor-broadcast Boolean flags plus one per-exposure flag, all
read by the SA risk-weight override (engine/sa/risk_weights.py). Reads the
pristine ``_st_assessment_cqs`` scratch (non-null only on the directly-rated
exposure) BEFORE it is dropped — a spilled row carries a null assessment cqs
and so never contributes. This is a DISTINCT mechanism from the
Art. 120(3)(c) short-term spillover above, which modifies
``has_short_term_ecai`` / ``cqs``; this helper touches neither. Regime-
independent (Table 7 is identical across CRR and Basel 3.1), mirroring
``_apply_obligor_short_term_spillover``. Called immediately after that
spillover in ``apply_short_term_rating_override``, where the three inputs
(``counterparty_reference`` / ``has_short_term_ecai`` / ``_st_assessment_cqs``)
are already materialised, so no presence guard is needed.
``has_own_short_term_ecai`` (per-exposure, non-broadcast) records whether
THIS leg carries its OWN issue-specific short-term assessment. It is the
discriminator the SA Art. 140(2) override needs to tell a directly-rated
trigger (contamination SOURCE — keeps its own weight) from a leg that only
INHERITED a short-term cqs via the Art. 120(3)(c) spillover. The spillover
overwrites ``has_short_term_ecai`` / ``cqs`` on a spilled leg, so without
this pristine flag the floor / 150% broadcast would evade the spilled leg
(P1.225 co-fire defect).
"""
# The directly-rated ST facility's assessment cqs drives the obligor flags;
# ``_st_assessment_cqs`` is non-null only there (a spilled row's is null and
# drops out of the ``.max()``). Table 7: CQS 4+ -> 150%, CQS 2 -> 50%.
st_150 = pl.col("has_short_term_ecai") & (pl.col("_st_assessment_cqs") >= 4)
st_50 = pl.col("has_short_term_ecai") & (pl.col("_st_assessment_cqs") == 2)
return exposures.with_columns(
[
partition_by_nullable(
st_150.max().over("counterparty_reference"),
"counterparty_reference",
pl.lit(False), # noqa: FBT003
).alias("obligor_st_150_contamination"),
partition_by_nullable(
st_50.max().over("counterparty_reference"),
"counterparty_reference",
pl.lit(False), # noqa: FBT003
).alias("obligor_st_50_floor"),
pl.col("_st_assessment_cqs").is_not_null().alias("has_own_short_term_ecai"),
]
)
CRR Art. 141 — Domestic and foreign currency items¶
build_eu_domestic_currency_expr — src/rwa_calc/engine/eu_sovereign.py:47
@cites("CRR Art. 114")
@cites("CRR Art. 141")
def build_eu_domestic_currency_expr(
country_col: str,
currency_col: str | pl.Expr = "currency",
) -> pl.Expr:
"""
Build a Polars expression that checks if an exposure is to an EU member
state's central government/central bank denominated in that state's
domestic currency.
Uses replace_strict to map country code → domestic currency, then compares
with the exposure denomination currency.
Args:
country_col: Column name containing the ISO country code
currency_col: Column name (str) or Polars expression for the
exposure's denomination currency. A string is wrapped in
``pl.col(...)``. Callers operating on a post-FX-conversion
LazyFrame should pass ``denomination_currency_expr(...)`` so the
original (pre-conversion) currency is compared — not the reporting
currency.
Returns:
Boolean Polars expression: True when country is EU and currency matches
that country's domestic currency.
"""
currency_expr = pl.col(currency_col) if isinstance(currency_col, str) else currency_col
return (
pl.col(country_col)
.fill_null("")
.replace_strict(_EU_COUNTRY_DOMESTIC_CURRENCY, default=None)
.eq(currency_expr)
)
CRR Art. 142 — Definitions¶
classify_exposure_subtypes — src/rwa_calc/engine/stages/classify/subtypes.py:65
@cites("CRR Art. 153(2)")
@cites("CRR Art. 142(1)(4)")
@cites("CRR Art. 154(4)")
@cites("PS1/26, paragraph 153")
@cites("PS1/26, paragraph 147")
def classify_exposure_subtypes(
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""
Merge SME, retail, and QRRE classification into a single .with_columns().
Works because they operate on non-overlapping initial exposure_class values:
SME only touches "corporate", retail only touches "retail_other",
QRRE specialises qualifying revolving retail.
Also derives ``requires_fi_scalar`` — the gate for the 1.25x asset-value
correlation multiplier (CRR Art. 153(2) / PS1/26 Art. 153(2)). This is a
MANDATORY treatment for large financial sector entities, not a user
election, so it is DERIVED from the entity-type flag and total assets:
requires_fi_scalar = apply_fi_scalar
OR (is_financial_sector_entity
AND total_assets >= threshold)
The threshold is the LFSE size test (CRR Art. 142(1)(4): EUR 70bn on an
individual/consolidated basis, converted GBP via the FX seam; PS1/26 IRB
Part glossary: GBP 79bn native, at the highest level of consolidation).
``total_assets`` is a GBP figure, mirroring the SME balance-sheet gate.
The user-supplied ``apply_fi_scalar`` is retained as an authoritative
True-OVERRIDE (a firm may know an entity is a large or UNREGULATED FSE
even when size data says otherwise) — it can never SUPPRESS a derived
True. A null ``total_assets`` on a flagged FSE leaves largeness
undetermined: the scalar is NOT applied (the whole-FSE population mostly
sits below the threshold), and ``audit.collect_input_warnings`` emits
CLS009 so the data gap is never a silent under-statement. The unregulated
FSE limb (Art. 142(1)(5), size-independent) needs a regulated-status input
the schema does not carry and is deferred to a schema-enablement change;
``apply_fi_scalar`` is the interim override for known unregulated FSEs.
Sets: exposure_class (updated), is_sme, requires_fi_scalar, is_hvcre
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
qrre_max_limit = float(
regulatory_threshold(resolved_pack, "qrre_max_limit", config.eur_gbp_rate)
)
lfse_total_assets_threshold = float(
regulatory_threshold(resolved_pack, "lfse_total_assets_threshold", config.eur_gbp_rate)
)
is_sme_by_size = is_sme_by_size_expr(config, pack=resolved_pack)
# PRA PS1/26 Art. 124(3) / Art. 124K: ADC exposures retain the CORPORATE
# class and route to the 150% Art. 124K(1) ADC RW — they must not be
# reclassified to CORPORATE_SME. ``is_adc`` is always present after
# ``_derive_independent_flags``.
is_adc = pl.col("is_adc").fill_null(False)
# Conditions reused across expressions. ``is_sme_by_size`` evaluates
# CRR Art. 4(1)(128D) / Commission Rec 2003/361/EC using turnover when
# present and total assets as a fallback. Art. 501 supporting factor
# eligibility is handled separately in sa/supporting_factors.py and
# remains turnover-only per Art. 501(2)(c).
is_corporate_sme = (
(pl.col("exposure_class") == ExposureClass.CORPORATE.value) & is_sme_by_size & ~is_adc
)
is_retail_sme = (
(pl.col("exposure_class") == ExposureClass.RETAIL_OTHER.value)
& (pl.col("qualifies_as_retail") == False) # noqa: E712
& is_sme_by_size
)
# Specialised lending is a corporate sub-type (Art. 112(1)(g)) and is
# flagged as SME when the counterparty meets the size test. The
# exposure_class must remain SPECIALISED_LENDING so approach assignment
# routes it to the slotting calculator; only the is_sme flag is set.
# Art. 501 supporting-factor eligibility is gated separately on
# turnover non-null in sa/supporting_factors.py.
is_sl_sme = (
pl.col("exposure_class") == ExposureClass.SPECIALISED_LENDING.value
) & is_sme_by_size
# QRRE qualification (CRR Art. 154(4)(a)-(c) / PS1/26 Art. 147(5A)(a)-(c)):
# (a) the exposures are to individuals (natural persons);
# (b) they are revolving, UNSECURED, and — to the extent they are not
# drawn — immediately and unconditionally cancellable; and
# (c) the largest per-individual aggregate nominal exposure across the
# sub-portfolio is <= the limit (EUR 100k CRR / GBP 90k B31).
# The same conditions apply under both regimes (only the (c) limit value
# differs, resolved from the pack), so the gates are NOT regime-Featured.
# Conditions (5A)(d) low loss-rate volatility and (5A)(e) consistency with
# the sub-portfolio's underlying risk characteristics are supervisory,
# portfolio-level attestations — not per-exposure inputs — and are out of
# scope for row-level classification.
#
# (a) individuals; (b) unsecured + unconditionally-cancellable-when-undrawn.
# Each is a reusable module-level predicate (also read by the CLS010
# demotion-warning collector in ``audit.py``) — see the helpers below.
is_qrre_individual = natural_person_expr()
is_qrre_unsecured = qrre_unsecured_expr()
is_qrre_cancellable = qrre_undrawn_cancellable_expr()
# CRR Art. 154(4)(c) / PS1/26 Art. 147(5A)(c) cap the *aggregate* nominal
# exposure to any single individual across the QRRE sub-portfolio at the
# limit (EUR 100k / GBP 90k), not each facility individually. Aggregate
# ``facility_limit`` (the committed/nominal basis) per
# ``counterparty_reference`` before comparing. The driver columns
# (``is_revolving`` / ``facility_limit`` / ``is_secured`` / ``risk_type`` /
# ``undrawn_amount``) are hierarchy_exit contract columns — always present,
# null-gated by value.
#
# The QRRE sub-portfolio is the qualifying revolving retail population.
# Only those rows contribute to the per-individual aggregate; non-QRRE
# facilities (e.g. a term loan to the same obligor) are masked to 0.
is_qrre_candidate = (
(pl.col("exposure_class") == ExposureClass.RETAIL_OTHER.value)
& (pl.col("qualifies_as_retail") == True) # noqa: E712
& (pl.col("is_revolving") == True) # noqa: E712
& is_qrre_individual
& is_qrre_unsecured
& is_qrre_cancellable
)
facility_limit = pl.col("facility_limit").fill_null(float("inf"))
candidate_limit = pl.when(is_qrre_candidate).then(facility_limit).otherwise(pl.lit(0.0))
# Guard the nullable ``counterparty_reference`` partition: a null key
# would otherwise pool all unmapped rows into a single bucket (see
# ``partition_by_nullable`` / ``NULLABLE_PARTITION_KEYS``). Null-keyed
# rows fall back to their own per-row candidate limit.
obligor_aggregate_limit = partition_by_nullable(
candidate_limit.sum().over("counterparty_reference"),
"counterparty_reference",
candidate_limit,
)
is_qrre = is_qrre_candidate & (obligor_aggregate_limit <= qrre_max_limit)
# FI scalar (1.25x correlation) — mandatory for large FSEs (Art. 153(2)).
# An FSE is "large" when total assets meet the Art. 142(1)(4) / PS1/26
# glossary threshold. Null total_assets -> the >= test is null -> False:
# size undetermined, no scalar (CLS009 flags the gap in audit.py). The
# user flag is OR-ed in as an authoritative override that can never
# suppress a derived True.
is_large_fse = pl.col("cp_is_financial_sector_entity").fill_null(False) & (
pl.col("cp_total_assets") >= lfse_total_assets_threshold
).fill_null(False)
requires_fi_scalar = pl.col("cp_apply_fi_scalar").fill_null(False) | is_large_fse
return exposures.with_columns(
[
# --- exposure_class update (SME + retail + QRRE combined) ---
# Priority order: mortgage, QRRE, SME retail, non-qualifying retail,
# corporate SME, keep current.
pl.when(
# Retail mortgage — stays RETAIL_MORTGAGE regardless of threshold
(pl.col("is_mortgage") == True) # noqa: E712
& (
(pl.col("exposure_class") == ExposureClass.RETAIL_OTHER.value)
| (pl.col("cp_entity_type") == "individual")
)
)
.then(pl.lit(ExposureClass.RETAIL_MORTGAGE.value))
.when(
# QRRE: qualifying revolving retail under QRRE limit (Art. 147(5))
is_qrre
)
.then(pl.lit(ExposureClass.RETAIL_QRRE.value))
.when(
# SME retail that doesn't qualify → CORPORATE_SME
is_retail_sme
)
.then(pl.lit(ExposureClass.CORPORATE_SME.value))
.when(
# Other retail that doesn't qualify → CORPORATE
(pl.col("exposure_class") == ExposureClass.RETAIL_OTHER.value)
& (pl.col("qualifies_as_retail") == False) # noqa: E712
)
.then(pl.lit(ExposureClass.CORPORATE.value))
.when(
# Corporate with SME revenue → CORPORATE_SME
is_corporate_sme
)
.then(pl.lit(ExposureClass.CORPORATE_SME.value))
.otherwise(pl.col("exposure_class"))
.alias("exposure_class"),
# --- is_sme flag ---
# True for: corporate SME, retail reclassified to CORPORATE_SME,
# or specialised lending with SME counterparty (keeps SPECIALISED_LENDING class).
(is_corporate_sme | is_retail_sme | is_sl_sme).alias("is_sme"),
# --- FI scalar: derived (large FSE) OR user override (Art. 153(2)) ---
requires_fi_scalar.alias("requires_fi_scalar"),
# --- HVCRE flag (from specialised lending join, null → False) ---
pl.col("is_hvcre").fill_null(False).alias("is_hvcre"),
]
)
CRR Art. 143 — Permission to use the IRB Approach¶
resolve_model_permissions — src/rwa_calc/engine/stages/classify/permissions.py:54
@cites("CRR Art. 143")
@cites("CRR Art. 148")
@cites("CRR Art. 150")
def resolve_model_permissions(
exposures: pl.LazyFrame,
model_permissions: pl.LazyFrame,
) -> pl.LazyFrame:
"""
Join exposures with model_permissions to produce per-row permission flags.
model_id originates on internal ratings and is propagated to exposures by
the rating inheritance pipeline. This method resolves which IRB approach each
exposure is permitted to use based on:
- model_id match (rating's model_id must exist in model_permissions)
- exposure_class match
- Geography filter: country_codes is null OR cp_country_code is in the list
- Book code exclusion: excluded_book_codes is null OR book_code NOT in the list
Priority: AIRB > FIRB. If a model has both, AIRB wins for exposures that
also have modelled LGD; otherwise FIRB is used if the exposure has internal_pd.
Sets: model_airb_permitted (bool), model_firb_permitted (bool),
model_slotting_permitted (bool)
Exposures without a model_id get all flags as False (→ SA fallback).
Synthetic CCR rows reach this stage with ``model_id = null`` because the
rating-inheritance attach that renames ``internal_model_id`` -> ``model_id``
only runs over hierarchy-resolved lending rows. When the counterparty
lookup carried an ``internal_model_id`` it was surfaced as
``cp_internal_model_id`` by ``_add_counterparty_attributes``; coalescing it
into ``model_id`` here lets an IRB-permissioned counterparty's CCR
derivative exposure resolve a model permission instead of falling back to
SA. The coalesce is a no-op for lending rows whose ``model_id`` is already
populated (CRR Art. 153(1); CRR Art. 162(2)(b)).
"""
# Recover model_id for rows that carry only the counterparty's resolved
# internal_model_id (synthetic CCR rows). No-op when model_id is already
# set. Both columns are contract-guaranteed: model_id on hierarchy_exit,
# cp_internal_model_id via the sealed counterparty lookup join.
exposures = exposures.with_columns(
pl.coalesce(pl.col("model_id"), pl.col("cp_internal_model_id")).alias("model_id")
)
# The model_permissions frame is sealed at the loader edge
# (raw_model_permissions), so the optional columns country_codes /
# excluded_book_codes / ppu_reason are always present — absent input
# columns arrive as typed nulls (all geographies / no exclusions /
# no PPU labelling).
# Join exposures with model_permissions on model_id
# Each exposure may match multiple permission rows (AIRB + FIRB for same model)
joined = exposures.join(
model_permissions.select(
pl.col("model_id").alias("mp_model_id"),
pl.col("exposure_class").alias("mp_exposure_class"),
pl.col("approach").alias("mp_approach"),
pl.col("country_codes").alias("mp_country_codes"),
pl.col("excluded_book_codes").alias("mp_excluded_book_codes"),
pl.col("ppu_reason").alias("mp_ppu_reason"),
),
left_on="model_id",
right_on="mp_model_id",
how="left",
)
# Track whether the join produced any matching permission row for this
# exposure (before filters are applied). Used downstream to distinguish
# "model_id did not match any permission row" from "model_id matched
# but filters rejected every row", so the diagnostic column can point
# the user at the right remediation. Note: Polars drops the right
# join key (mp_model_id) when left_on != right_on, so we probe via
# mp_exposure_class which stays in the joined frame.
joined = joined.with_columns(pl.col("mp_exposure_class").is_not_null().alias("_mp_row_joined"))
# Apply filters: exposure_class match, geography, book code exclusion
# A permission row is valid when:
# 1. exposure_class_irb matches (use IRB class so rgla/pse entities typed
# as institution / sovereign match model permissions keyed on
# INSTITUTION / CGCB per CRR Art. 147(3)-(4))
# 2. geography passes (country_codes is null OR cp_country_code in list)
# 3. book code not excluded (excluded_book_codes is null OR book_code NOT in list)
exposure_class_match = pl.col("exposure_class_irb") == pl.col("mp_exposure_class")
# Null-safe filter logic (P1.114):
# Polars `str.contains(<expr>)` propagates null when the needle is null,
# producing kleene-3-valued OR results (null | null = null) that silently
# block permission grants. Guard each branch:
# - geo: a null cp_country_code cannot prove scope-in, so it fails the
# filter when mp_country_codes is non-null (conservative).
# - book: a null book_code cannot be in any exclusion list, so the
# contains() result is coerced to False before negation.
geo_passes = pl.col("mp_country_codes").is_null() | (
pl.col("cp_country_code").is_not_null()
& pl.col("mp_country_codes").str.contains(pl.col("cp_country_code"))
)
book_not_excluded = pl.col("mp_excluded_book_codes").is_null() | ~(
pl.col("mp_excluded_book_codes").str.contains(pl.col("book_code")).fill_null(False)
)
permission_valid = exposure_class_match & geo_passes & book_not_excluded
# Compute per-row permission flags
airb_permitted = (permission_valid & (pl.col("mp_approach") == ApproachType.AIRB.value)).alias(
"_airb_match"
)
firb_permitted = (permission_valid & (pl.col("mp_approach") == ApproachType.FIRB.value)).alias(
"_firb_match"
)
slotting_permitted = (
permission_valid & (pl.col("mp_approach") == ApproachType.SLOTTING.value)
).alias("_slotting_match")
# SA-precedence (P1.145, CRR Art. 150(1) PPU carve-out): when the same
# (model_id, exposure_class) yields both an IRB permission row and a
# standardised row, the standardised row wins. AIRB-wins via .max()
# would silently expand IRB scope beyond the firm's permission.
sa_block = (permission_valid & (pl.col("mp_approach") == ApproachType.SA.value)).alias(
"_sa_block_match"
)
# CRR Art. 150(1) PPU / Art. 148 roll-out provenance: capture the ppu_reason
# from the surviving SA-precedence row only. Null on non-SA rows so the
# max().over() roll-up below picks up the SA row's label (and stays null
# when no SA-routing permission applied).
sa_ppu_reason = (
pl.when(sa_block).then(pl.col("mp_ppu_reason")).otherwise(None).alias("_sa_ppu_reason")
)
# Add match flags then aggregate: group by all original columns,
# take max of the match flags (any valid AIRB/FIRB/slotting permission → True),
# then AND-NOT the SA block to apply the SA-precedence rule.
result = joined.with_columns(
airb_permitted, firb_permitted, slotting_permitted, sa_block, sa_ppu_reason
)
# Aggregate back to one row per exposure using .over() to avoid group_by.
# SA-precedence override is applied AFTER the .max() roll-up so any SA
# row with permission_valid=True flips all IRB flags to False.
result = result.with_columns(
pl.col("_sa_block_match").max().over("exposure_reference").alias("_sa_block"),
pl.col("_mp_row_joined").max().over("exposure_reference").alias("_mp_joined_any"),
pl.col("_sa_ppu_reason").max().over("exposure_reference").alias("ppu_reason"),
).with_columns(
(pl.col("_airb_match").max().over("exposure_reference") & ~pl.col("_sa_block")).alias(
"model_airb_permitted"
),
(pl.col("_firb_match").max().over("exposure_reference") & ~pl.col("_sa_block")).alias(
"model_firb_permitted"
),
(pl.col("_slotting_match").max().over("exposure_reference") & ~pl.col("_sa_block")).alias(
"model_slotting_permitted"
),
)
# Diagnostic column: tag WHY a row did not get an IRB permission match.
# Three causes with distinct remediations:
# null_model_id → rating.model_id is null (fix ratings table)
# unmatched_model_id → model_id absent from model_permissions (stale ref)
# filter_rejected → matched but filtered by class/geo/book scope
# Null when the exposure DID get a match (happy path).
has_any_match = (
pl.col("model_airb_permitted")
| pl.col("model_firb_permitted")
| pl.col("model_slotting_permitted")
)
result = result.with_columns(
pl.when(has_any_match)
.then(pl.lit(None, dtype=pl.String))
.when(pl.col("model_id").is_null())
.then(pl.lit("null_model_id"))
.when(~pl.col("_mp_joined_any"))
.then(pl.lit("unmatched_model_id"))
.otherwise(pl.lit("filter_rejected"))
.alias("_model_permission_diagnostic")
)
# Drop the join columns and keep one row per exposure deterministically
# (P1.145, Step 3): sort by a total-order key so that whichever row of
# the duplicate-permission join survives `unique(keep="first")` does
# not depend on the physical row order of the input parquet. The
# priority key keeps the most-informative diagnostic on the surviving
# row (null > filter_rejected > unmatched_model_id > null_model_id).
diagnostic_priority = (
pl.when(pl.col("_model_permission_diagnostic").is_null())
.then(pl.lit(0))
.when(pl.col("_model_permission_diagnostic") == "filter_rejected")
.then(pl.lit(1))
.when(pl.col("_model_permission_diagnostic") == "unmatched_model_id")
.then(pl.lit(2))
.otherwise(pl.lit(3))
.alias("_diagnostic_priority")
)
result = (
result.with_columns(diagnostic_priority)
.sort(
[
"exposure_reference",
"_diagnostic_priority",
"mp_approach",
"mp_country_codes",
"mp_excluded_book_codes",
],
nulls_last=True,
maintain_order=True,
)
.unique(subset=["exposure_reference"], keep="first", maintain_order=True)
.select(
pl.exclude(
"mp_exposure_class",
"mp_approach",
"mp_country_codes",
"mp_excluded_book_codes",
"mp_ppu_reason",
"_sa_ppu_reason",
"_airb_match",
"_firb_match",
"_slotting_match",
"_sa_block_match",
"_sa_block",
"_mp_row_joined",
"_mp_joined_any",
"_diagnostic_priority",
)
)
)
return result
CRR Art. 144 — Competent authorities' assessment of an application¶
Supervisory process — out of scope for a calculator. IRB permissions enter the engine via the model_permissions input table; the calculator trusts whatever rows the firm supplies.
CRR Art. 145 — Prior experience with IRB approaches¶
Supervisory process — out of scope. Prior-experience review is tracked in supervisory records, not in calculator state.
CRR Art. 146 — Measures to be taken where requirements cease to be met¶
Supervisory revocation — out of scope. Revocation is realised operationally by removing rows from the model_permissions input.
CRR Art. 147 — Methodology to assign exposures to exposure classes¶
_align_irb_exposure_class — src/rwa_calc/engine/stages/classify/approach.py:352
@cites("CRR Art. 147")
@cites("CRR Art. 147(5)")
@cites("PS1/26, paragraph 147")
def _align_irb_exposure_class(exposures: pl.LazyFrame) -> pl.LazyFrame:
"""Align exposure_class with exposure_class_irb for IRB-routed rows.
The IRB calculator reads ``exposure_class`` (not ``exposure_class_irb``)
for correlation / LGD / floor selection, so any IRB-routed row whose IRB
class legitimately differs from its SA class must have ``exposure_class``
rewritten to the IRB value. Three entity populations diverge after
``sync_irb_exposure_class``; the first two are aligned here, the third is
deliberately not:
- rgla_* / pse_* rows, whose SA labels RGLA / PSE differ from the IRB
CGCB / INSTITUTION class (CRR Art. 147(3)/147(4)(b)).
- natural persons expelled from retail to CORPORATE by the SA
regulatory-retail monetary cap / granularity limb but kept in the IRB
retail class (CRR Art. 147(5)(a)(i) / PS1/26 Art. 147(5)(a)(i) — the cap
conditions the SME limb only). ``sync_irb_exposure_class`` restores
their ``exposure_class_irb`` to RETAIL_OTHER; this step propagates it to
``exposure_class`` so the retail IRB formula applies.
- CRR non-named ``mdb`` rows, whose IRB class is INSTITUTION (Art.
147(4)(c)) while their SA class stays MDB — the ``preserve_derived_irb_class``
limb of ``sync_irb_exposure_class``, gated on the
``crr_non_named_mdb_institution_irb_class`` pack Feature (P1.276).
**This population is intentionally NOT aligned.** Under CRR every IRB
formula parameter is identical across MDB / INSTITUTION / CGCB — the
correlation tuple is the same (``CORRELATION_PARAMS``; MDB falls through
to CORPORATE), the FI 1.25x scalar reads ``requires_fi_scalar`` rather
than the class, the F-IRB supervisory LGD keys on
``(collateral_type, seniority, is_fse)``, and ``_pd_floor_expression``
selects on ``exposure_class`` (so an MDB takes the corporate floor arm
either way). Alignment would therefore be a parameter no-op that
needlessly moved the reported exposure class. If institution-specific IRB
treatment is ever introduced, this omission stops being a no-op and this
row population must be added to ``needs_alignment``.
The first two are gated on the ``exposure_class_irb != exposure_class``
difference (a no-op for every other IRB-routed row, where the two are
already equal), so QRRE / mortgage / SME subtyping is never reverted. Note
the gate is the ``is_rgla_pse | natural_person_diverged`` predicate below,
NOT the generic inequality — which is what keeps the MDB divergence
unpropagated.
"""
is_rgla_pse = pl.col("cp_entity_type").is_in(list(RGLA_PSE_ENTITY_TYPES))
natural_person_diverged = natural_person_expr() & (
pl.col("exposure_class_irb") != pl.col("exposure_class")
)
needs_alignment = is_rgla_pse | natural_person_diverged
return exposures.with_columns(
pl.when(
pl.col("approach").is_in([ApproachType.FIRB.value, ApproachType.AIRB.value])
& needs_alignment
)
.then(pl.col("exposure_class_irb"))
.otherwise(pl.col("exposure_class"))
.alias("exposure_class")
)
_align_irb_exposure_class — src/rwa_calc/engine/stages/classify/approach.py:353
@cites("CRR Art. 147")
@cites("CRR Art. 147(5)")
@cites("PS1/26, paragraph 147")
def _align_irb_exposure_class(exposures: pl.LazyFrame) -> pl.LazyFrame:
"""Align exposure_class with exposure_class_irb for IRB-routed rows.
The IRB calculator reads ``exposure_class`` (not ``exposure_class_irb``)
for correlation / LGD / floor selection, so any IRB-routed row whose IRB
class legitimately differs from its SA class must have ``exposure_class``
rewritten to the IRB value. Three entity populations diverge after
``sync_irb_exposure_class``; the first two are aligned here, the third is
deliberately not:
- rgla_* / pse_* rows, whose SA labels RGLA / PSE differ from the IRB
CGCB / INSTITUTION class (CRR Art. 147(3)/147(4)(b)).
- natural persons expelled from retail to CORPORATE by the SA
regulatory-retail monetary cap / granularity limb but kept in the IRB
retail class (CRR Art. 147(5)(a)(i) / PS1/26 Art. 147(5)(a)(i) — the cap
conditions the SME limb only). ``sync_irb_exposure_class`` restores
their ``exposure_class_irb`` to RETAIL_OTHER; this step propagates it to
``exposure_class`` so the retail IRB formula applies.
- CRR non-named ``mdb`` rows, whose IRB class is INSTITUTION (Art.
147(4)(c)) while their SA class stays MDB — the ``preserve_derived_irb_class``
limb of ``sync_irb_exposure_class``, gated on the
``crr_non_named_mdb_institution_irb_class`` pack Feature (P1.276).
**This population is intentionally NOT aligned.** Under CRR every IRB
formula parameter is identical across MDB / INSTITUTION / CGCB — the
correlation tuple is the same (``CORRELATION_PARAMS``; MDB falls through
to CORPORATE), the FI 1.25x scalar reads ``requires_fi_scalar`` rather
than the class, the F-IRB supervisory LGD keys on
``(collateral_type, seniority, is_fse)``, and ``_pd_floor_expression``
selects on ``exposure_class`` (so an MDB takes the corporate floor arm
either way). Alignment would therefore be a parameter no-op that
needlessly moved the reported exposure class. If institution-specific IRB
treatment is ever introduced, this omission stops being a no-op and this
row population must be added to ``needs_alignment``.
The first two are gated on the ``exposure_class_irb != exposure_class``
difference (a no-op for every other IRB-routed row, where the two are
already equal), so QRRE / mortgage / SME subtyping is never reverted. Note
the gate is the ``is_rgla_pse | natural_person_diverged`` predicate below,
NOT the generic inequality — which is what keeps the MDB divergence
unpropagated.
"""
is_rgla_pse = pl.col("cp_entity_type").is_in(list(RGLA_PSE_ENTITY_TYPES))
natural_person_diverged = natural_person_expr() & (
pl.col("exposure_class_irb") != pl.col("exposure_class")
)
needs_alignment = is_rgla_pse | natural_person_diverged
return exposures.with_columns(
pl.when(
pl.col("approach").is_in([ApproachType.FIRB.value, ApproachType.AIRB.value])
& needs_alignment
)
.then(pl.col("exposure_class_irb"))
.otherwise(pl.col("exposure_class"))
.alias("exposure_class")
)
derive_independent_flags — src/rwa_calc/engine/stages/classify/attributes.py:301
@cites("CRR Art. 147")
@cites("PS1/26, paragraph 147")
def derive_independent_flags(
exposures: pl.LazyFrame,
config: CalculationConfig,
schema_names: set[str],
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""
Compute all flags that depend only on raw input columns.
Uses two .with_columns() batches: the first pre-computes shared
intermediates (uppercase strings, entity-type mapping) that the
second batch references, avoiding redundant str.to_uppercase()
and replace_strict() calls.
Sets: exposure_class_sa, exposure_class_irb, exposure_class, is_mortgage,
is_defaulted, is_infrastructure,
qualifies_as_retail, retail_threshold_exclusion_applied, is_adc
Art. 123A enforcement (Basel 3.1 only):
- Art. 123A(1)(a): SME entities (revenue > 0 and < threshold) auto-qualify
for retail treatment without needing conditions 1/3.
- Art. 123A(1)(b)(iii): Non-SME entities must be managed as part of a
retail pool (cp_is_managed_as_retail=True). Null defaults to True for
backward compatibility.
- CRR: threshold check only (no Art. 123A).
ADC derivation (PRA PS1/26 Art. 124(3) / Art. 124K):
- Derives ``is_adc=True`` for corporate (non-natural-person) exposures
whose financed property is under construction (``is_under_construction``
on the loan/facility) or whose product type signals development finance.
- Natural persons fail the corporate gate even when
``is_under_construction=True``.
- Any pre-existing non-null ``is_adc`` on the input row (e.g. propagated
from collateral by upstream stages) takes precedence via
``pl.coalesce`` so the derivation cannot override an explicit
user-supplied flag.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
max_retail_exposure = float(
regulatory_threshold(resolved_pack, "retail_max_exposure", config.eur_gbp_rate)
)
# SL override: exposures with sl_type (from specialised_lending join) get
# SPECIALISED_LENDING class regardless of counterparty entity_type.
sl_override = pl.col("sl_type").is_not_null()
# Batch 1: Pre-compute shared intermediates to avoid redundant work.
# - _sa_class: entity type → SA class mapping (used 3× below)
# - _irb_class: entity type → IRB class mapping
# - _pt_upper: product_type uppercased (used in is_mortgage, infrastructure)
exposures = exposures.with_columns(
[
pl.col("cp_entity_type")
.replace_strict(ENTITY_TYPE_TO_SA_CLASS, default=ExposureClass.OTHER.value)
.alias("_sa_class"),
pl.col("cp_entity_type")
.replace_strict(ENTITY_TYPE_TO_IRB_CLASS, default=ExposureClass.OTHER.value)
.alias("_irb_class"),
pl.col("product_type").str.to_uppercase().alias("_pt_upper"),
]
)
# CRR Art. 128 (high-risk class, 150%) was OMITTED from the UK onshored
# CRR text by SI 2021/1078 reg. 6(3)(a) with effect from 1 January 2022.
# Under CRR, entity types that map to HIGH_RISK fall through to the
# residual OTHER class. The 150% high-risk treatment is re-introduced
# under PRA PS1/26 Basel 3.1 (Art. 128), so the SA-class label is
# preserved as HIGH_RISK in that regime.
if not resolved_pack.feature("b31_high_risk_class_applicable"):
exposures = exposures.with_columns(
pl.when(pl.col("_sa_class") == ExposureClass.HIGH_RISK.value)
.then(pl.lit(ExposureClass.OTHER.value))
.otherwise(pl.col("_sa_class"))
.alias("_sa_class"),
)
# CRR Art. 147(3)(b) admits only the Art. 117(2) named (0% RW) MDBs to the
# central-government IRB class; Art. 147(4)(c) assigns "exposures to
# multilateral development banks which are not assigned a 0 % risk weight
# under Article 117" to the INSTITUTIONS class. PS1/26 Art. 147(3)(f) drops
# the split — every MDB is quasi-sovereign there — so the reroute reads the
# cited CRR-only pack Feature and the base map stays framework-invariant
# (same shape as the high-risk demotion above). ``mdb_named`` is untouched
# in both regimes (P1.276).
if resolved_pack.feature("crr_non_named_mdb_institution_irb_class"):
exposures = exposures.with_columns(
pl.when(pl.col("cp_entity_type") == "mdb")
.then(pl.lit(ExposureClass.INSTITUTION.value))
.otherwise(pl.col("_irb_class"))
.alias("_irb_class"),
)
sl_class = pl.lit(ExposureClass.SPECIALISED_LENDING.value)
# Art. 112 Table A2: Under SA, specialised lending is a corporate sub-type
# (Art. 112(1)(g)), not a separate exposure class. exposure_class_sa reflects
# this by mapping SL → CORPORATE. exposure_class retains SPECIALISED_LENDING
# because approach routing needs it for slotting/AIRB selection.
sl_sa_class = pl.lit(ExposureClass.CORPORATE.value)
# Batch 2: Derive all flags from pre-computed intermediates.
exposures = exposures.with_columns(
[
# --- Exposure class mappings (SL table overrides entity_type) ---
# SA class: SL is a corporate sub-type (Art. 112(1)(g))
pl.when(sl_override)
.then(sl_sa_class)
.otherwise(pl.col("_sa_class"))
.alias("exposure_class_sa"),
# IRB class: SL is a legitimate sub-class (Art. 147(8))
pl.when(sl_override)
.then(sl_class)
.otherwise(pl.col("_irb_class"))
.alias("exposure_class_irb"),
# Primary class: retains SPECIALISED_LENDING for approach routing
pl.when(sl_override)
.then(sl_class)
.otherwise(pl.col("_sa_class"))
.alias("exposure_class"),
# --- Mortgage flag ---
_build_is_mortgage_expr(),
# --- Default flags ---
# Per-exposure default detection per CRR Art. 178: an exposure
# is defaulted when EITHER (a) the counterparty is in default
# (cp_default_status — propagates to all that counterparty's
# exposures), OR (b) a row-level ``is_defaulted`` flag has been
# set upstream (e.g. by the loan parquet, letting a single
# defaulted exposure on an otherwise-performing counterparty
# trigger Art. 153(1)(ii) / 154(1)(i)). ``beel`` is consumed by
# the A-IRB defaulted formula (Art. 154(1)(i)) and Pool C of
# Art. 158(5) but is NOT itself a trigger — see
# ``_build_is_defaulted_expr`` and the DQ008 companion check.
_build_is_defaulted_expr(),
# --- Infrastructure flag (uses _pt_upper) ---
pl.col("_pt_upper").str.contains("INFRASTRUCTURE").alias("is_infrastructure"),
# --- ADC classification (PRA PS1/26 Art. 124(3) / Art. 124K) ---
# Derive ``is_adc`` from the loan/facility ``is_under_construction``
# flag (or a development-finance product_type) gated on a corporate
# / non-natural-person counterparty. Coalesce with any pre-existing
# ``is_adc`` value so an explicit user-supplied flag wins.
_build_is_adc_expr(schema_names),
# --- Retail threshold check + Art. 123A conditions (B31) ---
_build_qualifies_as_retail_expr(config, max_retail_exposure, pack=resolved_pack),
pl.when(pl.col("residential_collateral_value") > 0)
.then(pl.lit(True))
.otherwise(pl.lit(False))
.alias("retail_threshold_exclusion_applied"),
]
).drop(["_sa_class", "_irb_class", "_pt_upper"])
# PRA PS1/26 Art. 124E(1)(b)/(2) — Basel 3.1 only: re-route natural-person
# residential exposures to the income-producing whole-loan track (Art. 124G)
# when the borrower breaches the three-property limit. An explicit upstream
# income flag still wins (coalesce precedence). CRR routing is untouched.
if resolved_pack.feature("b31_art_124e_three_property_limit_applies"):
exposures = exposures.with_columns(
_build_has_income_cover_expr(),
)
return exposures
natural_person_expr — src/rwa_calc/engine/stages/classify/attributes.py:500
@cites("CRR Art. 147(5)")
@cites("PS1/26, paragraph 147")
def natural_person_expr() -> pl.Expr:
"""Return an expression flagging a counterparty as a natural person.
CRR Art. 147(5)(a)(i) / PS1/26 Art. 147(5)(a)(i): exposures to natural
persons enter the IRB retail exposure class with NO monetary cap, unlike
the SME limb (a)(ii). The signal is the explicit ``is_natural_person``
flag OR one of the documented natural-person ``entity_type`` aliases
(``individual`` / ``natural_person`` / ``retail`` — all mapping to
RETAIL_OTHER). A null flag AND a non-natural entity type resolve to
False, so an unknown obligor is treated as NOT a natural person and the
monetary cap keeps binding (conservative direction of error).
"""
return pl.col("cp_is_natural_person").fill_null(False) | pl.col("cp_entity_type").is_in(
list(NATURAL_PERSON_ENTITY_TYPES)
)
classify — src/rwa_calc/engine/stages/classify/classifier.py:107
@cites("CRR Art. 112")
@cites("CRR Art. 147")
def classify(
self,
data: ResolvedHierarchyBundle,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> ClassifiedExposuresBundle:
"""
Classify exposures and split by approach.
Args:
data: Hierarchy-resolved data from HierarchyResolver
config: Calculation configuration
Returns:
ClassifiedExposuresBundle with exposures split by approach
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
# Reads top-to-bottom as a recipe; each helper owns one regulatory
# concept. See the sibling sub-modules for per-step regulatory
# references.
exposures = add_counterparty_attributes(
data.exposures,
data.counterparty_lookup.counterparties,
)
exposures = join_specialised_lending(exposures, data.specialised_lending)
# Single schema snapshot — used by the remaining schema-conditional
# helpers (non-contract scratch columns and the EU-sovereign currency
# probe) without re-scanning the LazyFrame. Contract columns
# (hierarchy_exit / cp_lookup_* / raw_model_permissions) need no
# presence gate — sealed inputs always carry them.
schema_names = set(exposures.collect_schema().names())
classification_errors = collect_input_warnings(data, config, pack=resolved_pack)
classified = derive_independent_flags(exposures, config, schema_names, pack=resolved_pack)
classified = classify_exposure_subtypes(classified, config, pack=resolved_pack)
classified = reclassify_corporate_to_retail(
classified, config, schema_names, pack=resolved_pack
)
classified = flag_property_reclassification_candidates(
classified, config, schema_names, pack=resolved_pack
)
classified = sync_irb_exposure_class(classified, pack=resolved_pack)
# CRR Art. 160(2)/(6): the top-down PD for purchased corporate receivables
# must land BEFORE assign_approach — the IRB gate is internal_pd non-null,
# so without it a pool with no obligor PD falls to SA.
classified = derive_purchased_receivables_pd(classified, config, pack=resolved_pack)
has_model_permissions = data.model_permissions is not None
if data.model_permissions is not None:
classified = resolve_model_permissions(classified, data.model_permissions)
classified = assign_approach(
classified,
config,
schema_names,
has_model_permissions=has_model_permissions,
pack=resolved_pack,
)
classified = derive_exposure_subclass(classified, config, pack=resolved_pack)
# Stage-exit edge (producer-side): the diagnostic emits below run
# against in-memory data instead of re-executing the upstream lazy
# plan, and CRMProcessor receives an eager-backed frame. Laziness is
# strictly intra-stage (migration Phase 1).
classified = materialise_edge(classified, config, "classifier_exit")
classification_errors.extend(collect_beel_on_non_defaulted_warnings(classified))
classification_errors.extend(collect_qrre_gate_demotion_warnings(classified))
if has_model_permissions:
classification_errors.extend(emit_model_permission_diagnostics(classified))
# Producer seal (Phase 3): validates the contract and strips
# intra-stage scratch (including _model_permission_diagnostic) —
# pure plan ops over the eager-backed frame. CCR runs carry the
# SA-CCR provenance columns through, so the contract is selected
# by the input frame's brand.
exit_edge = (
CLASSIFIER_EXIT_CCR_EDGE
if sealed_edge_of(data.exposures) == "ccr_exit"
else CLASSIFIER_EXIT_EDGE
)
classified = seal(classified, exit_edge)
return self._build_bundle(classified, data, classification_errors)
sync_irb_exposure_class — src/rwa_calc/engine/stages/classify/subtypes.py:479
@cites("CRR Art. 147(5)")
@cites("CRR Art. 147(4)")
@cites("PS1/26, paragraph 147")
def sync_irb_exposure_class(
exposures: pl.LazyFrame,
*,
pack: ResolvedRulepack,
) -> pl.LazyFrame:
"""Sync exposure_class_irb with the (possibly mutated) exposure_class.
Subtype classification and corporate→retail reclassification mutate
``exposure_class`` in place without touching ``exposure_class_irb``,
which was set once in ``_add_counterparty_attributes``. Re-align them
so downstream IRB permission lookups and approach filters see the
reclassified class.
rgla_* / pse_* entity types are excluded because their SA and IRB
classes are definitionally different (CRR Art. 147(3)/147(4)(b)) —
``exposure_class_irb`` already carries the correct CGCB / INSTITUTION
value from ``ENTITY_TYPE_TO_IRB_CLASS`` and must not be overwritten.
Non-named MDBs join that exclusion under CRR only (P1.276): CRR
Art. 147(4)(c) assigns "exposures to multilateral development banks which
are not assigned a 0 % risk weight under Article 117" to the INSTITUTIONS
class while Art. 112 keeps them in their own SA class, so the two classes
are definitionally different in exactly the rgla_* / pse_* sense and the
derived ``exposure_class_irb`` must survive. Gated on the cited
``crr_non_named_mdb_institution_irb_class`` pack Feature — PS1/26
Art. 147(3)(f) has no such split (all MDBs are quasi-sovereign there), so
under Basel 3.1 the MDB rows keep syncing to their SA class as before.
Natural-person IRB retail restoration (CRR Art. 147(5)(a)(i) / PS1/26
Art. 147(5)(a)(i)): the SA regulatory-retail test (``qualifies_as_retail``,
Art. 123 / 123A) applies the EUR 1,000,000 / GBP 880,000 monetary cap AND
(under B31) the Art. 123A(1)(b)(ii) 0.2% granularity limb to natural
persons, expelling large or portfolio-dominant individuals to CORPORATE.
Neither condition exists in the IRB retail class: Art. 147(5)(a) caps the
SME limb (ii) only, and Art. 147(5) has no granularity limb. So a natural
person expelled to CORPORATE keeps the IRB retail class, provided the
Art. 147(5)(c) management-basis condition holds — i.e. the obligor is not
managed individually as a corporate (``is_managed_as_retail`` not
explicitly False; a null flag defaults to True, matching the
Art. 123A(1)(b)(iii) backward-compatible KEEP). This leaves the SA
``exposure_class`` and ``qualifies_as_retail`` untouched — the SA/IRB
divergence lives only in ``exposure_class_irb``.
"""
# Art. 147(5)(c): a natural person managed individually as corporate
# (is_managed_as_retail explicitly False) is NOT IRB retail. Null → True
# (documented KEEP, mirrors _build_qualifies_as_retail_expr).
managed_as_retail = pl.col("cp_is_managed_as_retail").fill_null(True)
restore_retail_irb = (
natural_person_expr()
& (pl.col("exposure_class") == ExposureClass.CORPORATE.value)
& managed_as_retail
)
# Entity types whose derived IRB class is definitionally distinct from the
# SA class and must not be overwritten by the sync.
preserve_derived_irb_class = pl.col("cp_entity_type").is_in(list(RGLA_PSE_ENTITY_TYPES))
if pack.feature("crr_non_named_mdb_institution_irb_class"):
preserve_derived_irb_class = preserve_derived_irb_class | (
pl.col("cp_entity_type") == "mdb"
)
return exposures.with_columns(
pl.when(preserve_derived_irb_class)
.then(pl.col("exposure_class_irb"))
.when(restore_retail_irb)
.then(pl.lit(ExposureClass.RETAIL_OTHER.value))
.otherwise(pl.col("exposure_class"))
.alias("exposure_class_irb")
)
sync_irb_exposure_class — src/rwa_calc/engine/stages/classify/subtypes.py:480
@cites("CRR Art. 147(5)")
@cites("CRR Art. 147(4)")
@cites("PS1/26, paragraph 147")
def sync_irb_exposure_class(
exposures: pl.LazyFrame,
*,
pack: ResolvedRulepack,
) -> pl.LazyFrame:
"""Sync exposure_class_irb with the (possibly mutated) exposure_class.
Subtype classification and corporate→retail reclassification mutate
``exposure_class`` in place without touching ``exposure_class_irb``,
which was set once in ``_add_counterparty_attributes``. Re-align them
so downstream IRB permission lookups and approach filters see the
reclassified class.
rgla_* / pse_* entity types are excluded because their SA and IRB
classes are definitionally different (CRR Art. 147(3)/147(4)(b)) —
``exposure_class_irb`` already carries the correct CGCB / INSTITUTION
value from ``ENTITY_TYPE_TO_IRB_CLASS`` and must not be overwritten.
Non-named MDBs join that exclusion under CRR only (P1.276): CRR
Art. 147(4)(c) assigns "exposures to multilateral development banks which
are not assigned a 0 % risk weight under Article 117" to the INSTITUTIONS
class while Art. 112 keeps them in their own SA class, so the two classes
are definitionally different in exactly the rgla_* / pse_* sense and the
derived ``exposure_class_irb`` must survive. Gated on the cited
``crr_non_named_mdb_institution_irb_class`` pack Feature — PS1/26
Art. 147(3)(f) has no such split (all MDBs are quasi-sovereign there), so
under Basel 3.1 the MDB rows keep syncing to their SA class as before.
Natural-person IRB retail restoration (CRR Art. 147(5)(a)(i) / PS1/26
Art. 147(5)(a)(i)): the SA regulatory-retail test (``qualifies_as_retail``,
Art. 123 / 123A) applies the EUR 1,000,000 / GBP 880,000 monetary cap AND
(under B31) the Art. 123A(1)(b)(ii) 0.2% granularity limb to natural
persons, expelling large or portfolio-dominant individuals to CORPORATE.
Neither condition exists in the IRB retail class: Art. 147(5)(a) caps the
SME limb (ii) only, and Art. 147(5) has no granularity limb. So a natural
person expelled to CORPORATE keeps the IRB retail class, provided the
Art. 147(5)(c) management-basis condition holds — i.e. the obligor is not
managed individually as a corporate (``is_managed_as_retail`` not
explicitly False; a null flag defaults to True, matching the
Art. 123A(1)(b)(iii) backward-compatible KEEP). This leaves the SA
``exposure_class`` and ``qualifies_as_retail`` untouched — the SA/IRB
divergence lives only in ``exposure_class_irb``.
"""
# Art. 147(5)(c): a natural person managed individually as corporate
# (is_managed_as_retail explicitly False) is NOT IRB retail. Null → True
# (documented KEEP, mirrors _build_qualifies_as_retail_expr).
managed_as_retail = pl.col("cp_is_managed_as_retail").fill_null(True)
restore_retail_irb = (
natural_person_expr()
& (pl.col("exposure_class") == ExposureClass.CORPORATE.value)
& managed_as_retail
)
# Entity types whose derived IRB class is definitionally distinct from the
# SA class and must not be overwritten by the sync.
preserve_derived_irb_class = pl.col("cp_entity_type").is_in(list(RGLA_PSE_ENTITY_TYPES))
if pack.feature("crr_non_named_mdb_institution_irb_class"):
preserve_derived_irb_class = preserve_derived_irb_class | (
pl.col("cp_entity_type") == "mdb"
)
return exposures.with_columns(
pl.when(preserve_derived_irb_class)
.then(pl.col("exposure_class_irb"))
.when(restore_retail_irb)
.then(pl.lit(ExposureClass.RETAIL_OTHER.value))
.otherwise(pl.col("exposure_class"))
.alias("exposure_class_irb")
)
CRR Art. 147A — IRB approach restrictions (Basel 3.1)¶
Implemented in engine/stages/classify/approach.py::_apply_b31_approach_restrictions. CRR-side decoration is deferred — Art. 147A is a Basel 3.1 amendment with no original CRR equivalent, and watchfire's bundled CRR index does not cover the A suffix (see citation-tracking.md on alphanumeric article handling). The PS1/26 paragraph mapping is pending a future review.
CRR Art. 148 — Conditions for implementing the IRB Approach across different classes of exposure and business units¶
resolve_model_permissions — src/rwa_calc/engine/stages/classify/permissions.py:55
@cites("CRR Art. 143")
@cites("CRR Art. 148")
@cites("CRR Art. 150")
def resolve_model_permissions(
exposures: pl.LazyFrame,
model_permissions: pl.LazyFrame,
) -> pl.LazyFrame:
"""
Join exposures with model_permissions to produce per-row permission flags.
model_id originates on internal ratings and is propagated to exposures by
the rating inheritance pipeline. This method resolves which IRB approach each
exposure is permitted to use based on:
- model_id match (rating's model_id must exist in model_permissions)
- exposure_class match
- Geography filter: country_codes is null OR cp_country_code is in the list
- Book code exclusion: excluded_book_codes is null OR book_code NOT in the list
Priority: AIRB > FIRB. If a model has both, AIRB wins for exposures that
also have modelled LGD; otherwise FIRB is used if the exposure has internal_pd.
Sets: model_airb_permitted (bool), model_firb_permitted (bool),
model_slotting_permitted (bool)
Exposures without a model_id get all flags as False (→ SA fallback).
Synthetic CCR rows reach this stage with ``model_id = null`` because the
rating-inheritance attach that renames ``internal_model_id`` -> ``model_id``
only runs over hierarchy-resolved lending rows. When the counterparty
lookup carried an ``internal_model_id`` it was surfaced as
``cp_internal_model_id`` by ``_add_counterparty_attributes``; coalescing it
into ``model_id`` here lets an IRB-permissioned counterparty's CCR
derivative exposure resolve a model permission instead of falling back to
SA. The coalesce is a no-op for lending rows whose ``model_id`` is already
populated (CRR Art. 153(1); CRR Art. 162(2)(b)).
"""
# Recover model_id for rows that carry only the counterparty's resolved
# internal_model_id (synthetic CCR rows). No-op when model_id is already
# set. Both columns are contract-guaranteed: model_id on hierarchy_exit,
# cp_internal_model_id via the sealed counterparty lookup join.
exposures = exposures.with_columns(
pl.coalesce(pl.col("model_id"), pl.col("cp_internal_model_id")).alias("model_id")
)
# The model_permissions frame is sealed at the loader edge
# (raw_model_permissions), so the optional columns country_codes /
# excluded_book_codes / ppu_reason are always present — absent input
# columns arrive as typed nulls (all geographies / no exclusions /
# no PPU labelling).
# Join exposures with model_permissions on model_id
# Each exposure may match multiple permission rows (AIRB + FIRB for same model)
joined = exposures.join(
model_permissions.select(
pl.col("model_id").alias("mp_model_id"),
pl.col("exposure_class").alias("mp_exposure_class"),
pl.col("approach").alias("mp_approach"),
pl.col("country_codes").alias("mp_country_codes"),
pl.col("excluded_book_codes").alias("mp_excluded_book_codes"),
pl.col("ppu_reason").alias("mp_ppu_reason"),
),
left_on="model_id",
right_on="mp_model_id",
how="left",
)
# Track whether the join produced any matching permission row for this
# exposure (before filters are applied). Used downstream to distinguish
# "model_id did not match any permission row" from "model_id matched
# but filters rejected every row", so the diagnostic column can point
# the user at the right remediation. Note: Polars drops the right
# join key (mp_model_id) when left_on != right_on, so we probe via
# mp_exposure_class which stays in the joined frame.
joined = joined.with_columns(pl.col("mp_exposure_class").is_not_null().alias("_mp_row_joined"))
# Apply filters: exposure_class match, geography, book code exclusion
# A permission row is valid when:
# 1. exposure_class_irb matches (use IRB class so rgla/pse entities typed
# as institution / sovereign match model permissions keyed on
# INSTITUTION / CGCB per CRR Art. 147(3)-(4))
# 2. geography passes (country_codes is null OR cp_country_code in list)
# 3. book code not excluded (excluded_book_codes is null OR book_code NOT in list)
exposure_class_match = pl.col("exposure_class_irb") == pl.col("mp_exposure_class")
# Null-safe filter logic (P1.114):
# Polars `str.contains(<expr>)` propagates null when the needle is null,
# producing kleene-3-valued OR results (null | null = null) that silently
# block permission grants. Guard each branch:
# - geo: a null cp_country_code cannot prove scope-in, so it fails the
# filter when mp_country_codes is non-null (conservative).
# - book: a null book_code cannot be in any exclusion list, so the
# contains() result is coerced to False before negation.
geo_passes = pl.col("mp_country_codes").is_null() | (
pl.col("cp_country_code").is_not_null()
& pl.col("mp_country_codes").str.contains(pl.col("cp_country_code"))
)
book_not_excluded = pl.col("mp_excluded_book_codes").is_null() | ~(
pl.col("mp_excluded_book_codes").str.contains(pl.col("book_code")).fill_null(False)
)
permission_valid = exposure_class_match & geo_passes & book_not_excluded
# Compute per-row permission flags
airb_permitted = (permission_valid & (pl.col("mp_approach") == ApproachType.AIRB.value)).alias(
"_airb_match"
)
firb_permitted = (permission_valid & (pl.col("mp_approach") == ApproachType.FIRB.value)).alias(
"_firb_match"
)
slotting_permitted = (
permission_valid & (pl.col("mp_approach") == ApproachType.SLOTTING.value)
).alias("_slotting_match")
# SA-precedence (P1.145, CRR Art. 150(1) PPU carve-out): when the same
# (model_id, exposure_class) yields both an IRB permission row and a
# standardised row, the standardised row wins. AIRB-wins via .max()
# would silently expand IRB scope beyond the firm's permission.
sa_block = (permission_valid & (pl.col("mp_approach") == ApproachType.SA.value)).alias(
"_sa_block_match"
)
# CRR Art. 150(1) PPU / Art. 148 roll-out provenance: capture the ppu_reason
# from the surviving SA-precedence row only. Null on non-SA rows so the
# max().over() roll-up below picks up the SA row's label (and stays null
# when no SA-routing permission applied).
sa_ppu_reason = (
pl.when(sa_block).then(pl.col("mp_ppu_reason")).otherwise(None).alias("_sa_ppu_reason")
)
# Add match flags then aggregate: group by all original columns,
# take max of the match flags (any valid AIRB/FIRB/slotting permission → True),
# then AND-NOT the SA block to apply the SA-precedence rule.
result = joined.with_columns(
airb_permitted, firb_permitted, slotting_permitted, sa_block, sa_ppu_reason
)
# Aggregate back to one row per exposure using .over() to avoid group_by.
# SA-precedence override is applied AFTER the .max() roll-up so any SA
# row with permission_valid=True flips all IRB flags to False.
result = result.with_columns(
pl.col("_sa_block_match").max().over("exposure_reference").alias("_sa_block"),
pl.col("_mp_row_joined").max().over("exposure_reference").alias("_mp_joined_any"),
pl.col("_sa_ppu_reason").max().over("exposure_reference").alias("ppu_reason"),
).with_columns(
(pl.col("_airb_match").max().over("exposure_reference") & ~pl.col("_sa_block")).alias(
"model_airb_permitted"
),
(pl.col("_firb_match").max().over("exposure_reference") & ~pl.col("_sa_block")).alias(
"model_firb_permitted"
),
(pl.col("_slotting_match").max().over("exposure_reference") & ~pl.col("_sa_block")).alias(
"model_slotting_permitted"
),
)
# Diagnostic column: tag WHY a row did not get an IRB permission match.
# Three causes with distinct remediations:
# null_model_id → rating.model_id is null (fix ratings table)
# unmatched_model_id → model_id absent from model_permissions (stale ref)
# filter_rejected → matched but filtered by class/geo/book scope
# Null when the exposure DID get a match (happy path).
has_any_match = (
pl.col("model_airb_permitted")
| pl.col("model_firb_permitted")
| pl.col("model_slotting_permitted")
)
result = result.with_columns(
pl.when(has_any_match)
.then(pl.lit(None, dtype=pl.String))
.when(pl.col("model_id").is_null())
.then(pl.lit("null_model_id"))
.when(~pl.col("_mp_joined_any"))
.then(pl.lit("unmatched_model_id"))
.otherwise(pl.lit("filter_rejected"))
.alias("_model_permission_diagnostic")
)
# Drop the join columns and keep one row per exposure deterministically
# (P1.145, Step 3): sort by a total-order key so that whichever row of
# the duplicate-permission join survives `unique(keep="first")` does
# not depend on the physical row order of the input parquet. The
# priority key keeps the most-informative diagnostic on the surviving
# row (null > filter_rejected > unmatched_model_id > null_model_id).
diagnostic_priority = (
pl.when(pl.col("_model_permission_diagnostic").is_null())
.then(pl.lit(0))
.when(pl.col("_model_permission_diagnostic") == "filter_rejected")
.then(pl.lit(1))
.when(pl.col("_model_permission_diagnostic") == "unmatched_model_id")
.then(pl.lit(2))
.otherwise(pl.lit(3))
.alias("_diagnostic_priority")
)
result = (
result.with_columns(diagnostic_priority)
.sort(
[
"exposure_reference",
"_diagnostic_priority",
"mp_approach",
"mp_country_codes",
"mp_excluded_book_codes",
],
nulls_last=True,
maintain_order=True,
)
.unique(subset=["exposure_reference"], keep="first", maintain_order=True)
.select(
pl.exclude(
"mp_exposure_class",
"mp_approach",
"mp_country_codes",
"mp_excluded_book_codes",
"mp_ppu_reason",
"_sa_ppu_reason",
"_airb_match",
"_firb_match",
"_slotting_match",
"_sa_block_match",
"_sa_block",
"_mp_row_joined",
"_mp_joined_any",
"_diagnostic_priority",
)
)
)
return result
CRR Art. 149 — Conditions for reverting to the use of less sophisticated approaches¶
Supervisory process — out of scope. Reversion is realised operationally via removal of permission rows; the calculator automatically falls back to SA when no IRB permission matches.
CRR Art. 150 — Conditions for permanent partial use¶
resolve_model_permissions — src/rwa_calc/engine/stages/classify/permissions.py:56
@cites("CRR Art. 143")
@cites("CRR Art. 148")
@cites("CRR Art. 150")
def resolve_model_permissions(
exposures: pl.LazyFrame,
model_permissions: pl.LazyFrame,
) -> pl.LazyFrame:
"""
Join exposures with model_permissions to produce per-row permission flags.
model_id originates on internal ratings and is propagated to exposures by
the rating inheritance pipeline. This method resolves which IRB approach each
exposure is permitted to use based on:
- model_id match (rating's model_id must exist in model_permissions)
- exposure_class match
- Geography filter: country_codes is null OR cp_country_code is in the list
- Book code exclusion: excluded_book_codes is null OR book_code NOT in the list
Priority: AIRB > FIRB. If a model has both, AIRB wins for exposures that
also have modelled LGD; otherwise FIRB is used if the exposure has internal_pd.
Sets: model_airb_permitted (bool), model_firb_permitted (bool),
model_slotting_permitted (bool)
Exposures without a model_id get all flags as False (→ SA fallback).
Synthetic CCR rows reach this stage with ``model_id = null`` because the
rating-inheritance attach that renames ``internal_model_id`` -> ``model_id``
only runs over hierarchy-resolved lending rows. When the counterparty
lookup carried an ``internal_model_id`` it was surfaced as
``cp_internal_model_id`` by ``_add_counterparty_attributes``; coalescing it
into ``model_id`` here lets an IRB-permissioned counterparty's CCR
derivative exposure resolve a model permission instead of falling back to
SA. The coalesce is a no-op for lending rows whose ``model_id`` is already
populated (CRR Art. 153(1); CRR Art. 162(2)(b)).
"""
# Recover model_id for rows that carry only the counterparty's resolved
# internal_model_id (synthetic CCR rows). No-op when model_id is already
# set. Both columns are contract-guaranteed: model_id on hierarchy_exit,
# cp_internal_model_id via the sealed counterparty lookup join.
exposures = exposures.with_columns(
pl.coalesce(pl.col("model_id"), pl.col("cp_internal_model_id")).alias("model_id")
)
# The model_permissions frame is sealed at the loader edge
# (raw_model_permissions), so the optional columns country_codes /
# excluded_book_codes / ppu_reason are always present — absent input
# columns arrive as typed nulls (all geographies / no exclusions /
# no PPU labelling).
# Join exposures with model_permissions on model_id
# Each exposure may match multiple permission rows (AIRB + FIRB for same model)
joined = exposures.join(
model_permissions.select(
pl.col("model_id").alias("mp_model_id"),
pl.col("exposure_class").alias("mp_exposure_class"),
pl.col("approach").alias("mp_approach"),
pl.col("country_codes").alias("mp_country_codes"),
pl.col("excluded_book_codes").alias("mp_excluded_book_codes"),
pl.col("ppu_reason").alias("mp_ppu_reason"),
),
left_on="model_id",
right_on="mp_model_id",
how="left",
)
# Track whether the join produced any matching permission row for this
# exposure (before filters are applied). Used downstream to distinguish
# "model_id did not match any permission row" from "model_id matched
# but filters rejected every row", so the diagnostic column can point
# the user at the right remediation. Note: Polars drops the right
# join key (mp_model_id) when left_on != right_on, so we probe via
# mp_exposure_class which stays in the joined frame.
joined = joined.with_columns(pl.col("mp_exposure_class").is_not_null().alias("_mp_row_joined"))
# Apply filters: exposure_class match, geography, book code exclusion
# A permission row is valid when:
# 1. exposure_class_irb matches (use IRB class so rgla/pse entities typed
# as institution / sovereign match model permissions keyed on
# INSTITUTION / CGCB per CRR Art. 147(3)-(4))
# 2. geography passes (country_codes is null OR cp_country_code in list)
# 3. book code not excluded (excluded_book_codes is null OR book_code NOT in list)
exposure_class_match = pl.col("exposure_class_irb") == pl.col("mp_exposure_class")
# Null-safe filter logic (P1.114):
# Polars `str.contains(<expr>)` propagates null when the needle is null,
# producing kleene-3-valued OR results (null | null = null) that silently
# block permission grants. Guard each branch:
# - geo: a null cp_country_code cannot prove scope-in, so it fails the
# filter when mp_country_codes is non-null (conservative).
# - book: a null book_code cannot be in any exclusion list, so the
# contains() result is coerced to False before negation.
geo_passes = pl.col("mp_country_codes").is_null() | (
pl.col("cp_country_code").is_not_null()
& pl.col("mp_country_codes").str.contains(pl.col("cp_country_code"))
)
book_not_excluded = pl.col("mp_excluded_book_codes").is_null() | ~(
pl.col("mp_excluded_book_codes").str.contains(pl.col("book_code")).fill_null(False)
)
permission_valid = exposure_class_match & geo_passes & book_not_excluded
# Compute per-row permission flags
airb_permitted = (permission_valid & (pl.col("mp_approach") == ApproachType.AIRB.value)).alias(
"_airb_match"
)
firb_permitted = (permission_valid & (pl.col("mp_approach") == ApproachType.FIRB.value)).alias(
"_firb_match"
)
slotting_permitted = (
permission_valid & (pl.col("mp_approach") == ApproachType.SLOTTING.value)
).alias("_slotting_match")
# SA-precedence (P1.145, CRR Art. 150(1) PPU carve-out): when the same
# (model_id, exposure_class) yields both an IRB permission row and a
# standardised row, the standardised row wins. AIRB-wins via .max()
# would silently expand IRB scope beyond the firm's permission.
sa_block = (permission_valid & (pl.col("mp_approach") == ApproachType.SA.value)).alias(
"_sa_block_match"
)
# CRR Art. 150(1) PPU / Art. 148 roll-out provenance: capture the ppu_reason
# from the surviving SA-precedence row only. Null on non-SA rows so the
# max().over() roll-up below picks up the SA row's label (and stays null
# when no SA-routing permission applied).
sa_ppu_reason = (
pl.when(sa_block).then(pl.col("mp_ppu_reason")).otherwise(None).alias("_sa_ppu_reason")
)
# Add match flags then aggregate: group by all original columns,
# take max of the match flags (any valid AIRB/FIRB/slotting permission → True),
# then AND-NOT the SA block to apply the SA-precedence rule.
result = joined.with_columns(
airb_permitted, firb_permitted, slotting_permitted, sa_block, sa_ppu_reason
)
# Aggregate back to one row per exposure using .over() to avoid group_by.
# SA-precedence override is applied AFTER the .max() roll-up so any SA
# row with permission_valid=True flips all IRB flags to False.
result = result.with_columns(
pl.col("_sa_block_match").max().over("exposure_reference").alias("_sa_block"),
pl.col("_mp_row_joined").max().over("exposure_reference").alias("_mp_joined_any"),
pl.col("_sa_ppu_reason").max().over("exposure_reference").alias("ppu_reason"),
).with_columns(
(pl.col("_airb_match").max().over("exposure_reference") & ~pl.col("_sa_block")).alias(
"model_airb_permitted"
),
(pl.col("_firb_match").max().over("exposure_reference") & ~pl.col("_sa_block")).alias(
"model_firb_permitted"
),
(pl.col("_slotting_match").max().over("exposure_reference") & ~pl.col("_sa_block")).alias(
"model_slotting_permitted"
),
)
# Diagnostic column: tag WHY a row did not get an IRB permission match.
# Three causes with distinct remediations:
# null_model_id → rating.model_id is null (fix ratings table)
# unmatched_model_id → model_id absent from model_permissions (stale ref)
# filter_rejected → matched but filtered by class/geo/book scope
# Null when the exposure DID get a match (happy path).
has_any_match = (
pl.col("model_airb_permitted")
| pl.col("model_firb_permitted")
| pl.col("model_slotting_permitted")
)
result = result.with_columns(
pl.when(has_any_match)
.then(pl.lit(None, dtype=pl.String))
.when(pl.col("model_id").is_null())
.then(pl.lit("null_model_id"))
.when(~pl.col("_mp_joined_any"))
.then(pl.lit("unmatched_model_id"))
.otherwise(pl.lit("filter_rejected"))
.alias("_model_permission_diagnostic")
)
# Drop the join columns and keep one row per exposure deterministically
# (P1.145, Step 3): sort by a total-order key so that whichever row of
# the duplicate-permission join survives `unique(keep="first")` does
# not depend on the physical row order of the input parquet. The
# priority key keeps the most-informative diagnostic on the surviving
# row (null > filter_rejected > unmatched_model_id > null_model_id).
diagnostic_priority = (
pl.when(pl.col("_model_permission_diagnostic").is_null())
.then(pl.lit(0))
.when(pl.col("_model_permission_diagnostic") == "filter_rejected")
.then(pl.lit(1))
.when(pl.col("_model_permission_diagnostic") == "unmatched_model_id")
.then(pl.lit(2))
.otherwise(pl.lit(3))
.alias("_diagnostic_priority")
)
result = (
result.with_columns(diagnostic_priority)
.sort(
[
"exposure_reference",
"_diagnostic_priority",
"mp_approach",
"mp_country_codes",
"mp_excluded_book_codes",
],
nulls_last=True,
maintain_order=True,
)
.unique(subset=["exposure_reference"], keep="first", maintain_order=True)
.select(
pl.exclude(
"mp_exposure_class",
"mp_approach",
"mp_country_codes",
"mp_excluded_book_codes",
"mp_ppu_reason",
"_sa_ppu_reason",
"_airb_match",
"_firb_match",
"_slotting_match",
"_sa_block_match",
"_sa_block",
"_mp_row_joined",
"_mp_joined_any",
"_diagnostic_priority",
)
)
)
return result
CRR Art. 151 — Treatment by exposure class¶
apply_irb_formulas — src/rwa_calc/engine/irb/formulas.py:531
@cites("CRR Art. 151")
@cites("CRR Art. 153")
@cites("CRR Art. 154")
def apply_irb_formulas(
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""
Apply IRB formulas to exposures using pure Polars expressions.
Uses polars-normal-stats for statistical functions (normal_cdf, normal_ppf),
enabling full lazy evaluation, query optimization, and streaming.
Expects columns: pd, lgd, ead_final, exposure_class
Optional: maturity, turnover_m (for SME correlation adjustment)
Adds columns: pd_floored, lgd_floored, correlation, k, maturity_adjustment,
scaling_factor, risk_weight, rwa, expected_loss
Args:
exposures: LazyFrame with IRB exposures
config: Calculation configuration
Returns:
LazyFrame with IRB calculations added
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
scaling_factor = scalar_value(resolved_pack.scalar_param("irb_scaling_factor"))
# Ensure calculator-internal derived columns exist (maturity / turnover_m
# are produced by ``prepare_columns`` on the namespace path and are not
# crm_exit contract columns).
schema = exposures.collect_schema()
schema_names = schema.names()
if "maturity" not in schema_names:
exposures = exposures.with_columns(pl.lit(2.5).alias("maturity"))
if "turnover_m" not in schema_names:
exposures = exposures.with_columns(pl.lit(None).cast(pl.Float64).alias("turnover_m"))
# Step 1: Apply per-exposure-class PD floor (CRR: uniform, Basel 3.1: differentiated).
# fill_nan(None) first: a NaN PD passes straight through max_horizontal/clip and
# would poison K -> rwa; treating it as null routes it to the regulatory floor.
pd_floor_expr = _pd_floor_expression(config, pack=resolved_pack)
exposures = exposures.with_columns(
pl.max_horizontal(pl.col("pd").fill_nan(None), pd_floor_expr).alias("pd_floored")
)
# Step 2: Apply LGD floor (Basel 3.1 A-IRB only, CRR has no LGD floors)
# LGD floors only apply to A-IRB own-estimate LGDs (CRE30.41).
# F-IRB supervisory LGDs are regulatory values and don't need flooring.
if resolved_pack.feature("airb_lgd_floor"):
if "collateral_type" in schema_names:
lgd_floor_expr = _lgd_floor_expression_with_collateral(
config,
has_seniority=True,
has_exposure_class=True,
pack=resolved_pack,
)
else:
lgd_floor_expr = _lgd_floor_expression(
config,
has_seniority=True,
has_exposure_class=True,
pack=resolved_pack,
)
# Art. 161(5)(b) / 164(4)(c) LGD* blend for (partially) secured rows
blended_expr = _lgd_floor_blended_expression(config, pack=resolved_pack)
lgd_floor_expr = (
pl.when(blended_expr.is_not_null()).then(blended_expr).otherwise(lgd_floor_expr)
)
is_airb = pl.col("is_airb").fill_null(False) if "is_airb" in schema_names else pl.lit(False)
# fill_nan(None): treat a NaN own-estimate LGD as null so the A-IRB
# regulatory floor governs (max_horizontal does not scrub NaN).
floored_lgd = pl.max_horizontal(pl.col("lgd").fill_nan(None), lgd_floor_expr)
exposures = exposures.with_columns(
pl.when(is_airb).then(floored_lgd).otherwise(pl.col("lgd")).alias("lgd_floored")
)
else:
exposures = exposures.with_columns(pl.col("lgd").alias("lgd_floored"))
# Step 3: Calculate correlation using pure Polars expressions
# B31 uses GBP-native thresholds (Art. 153(4)); CRR converts GBP→EUR via rate
eur_gbp_rate = float(config.eur_gbp_rate)
sme_turnover_m = (
float(regulatory_threshold(resolved_pack, "sme_turnover_threshold", config.eur_gbp_rate))
/ 1_000_000
)
exposures = exposures.with_columns(
_polars_correlation_expr(
eur_gbp_rate=eur_gbp_rate,
is_b31=resolved_pack.feature("irb_correlation_sme_gbp_native"),
sme_turnover_threshold_m=sme_turnover_m,
).alias("correlation")
)
# Step 4: Calculate K using pure Polars with polars-normal-stats
exposures = exposures.with_columns(_polars_capital_k_expr().alias("k"))
# Step 5: Calculate maturity adjustment (only for non-retail)
is_retail = (
pl.col("exposure_class")
.cast(pl.String)
.fill_null("CORPORATE")
.str.to_uppercase()
.str.contains("RETAIL")
)
exposures = exposures.with_columns(
pl.when(is_retail)
.then(pl.lit(1.0))
.otherwise(_polars_maturity_adjustment_expr())
.alias("maturity_adjustment")
)
# Step 6-9: Final calculations (pure Polars expressions)
exposures = exposures.with_columns(
[
pl.lit(scaling_factor).alias("scaling_factor"),
(
pl.col("k")
* 12.5
* scaling_factor
* pl.col("ead_final")
* pl.col("maturity_adjustment")
).alias("rwa"),
(pl.col("k") * 12.5 * scaling_factor * pl.col("maturity_adjustment")).alias(
"risk_weight"
),
(pl.col("pd_floored") * pl.col("lgd_floored") * pl.col("ead_final")).alias(
"expected_loss"
),
]
)
# Step 10: Override for defaulted exposures (CRR Art. 153(1)(ii) / 154(1)(i))
# Delegates to the single source of truth in adjustments.py to avoid divergence.
from rwa_calc.engine.irb.adjustments import apply_defaulted_treatment
exposures = apply_defaulted_treatment(exposures)
return exposures
CRR Art. 152 — Treatment of exposures in the form of CIU units (IRB)¶
Not implemented — CIU look-through under IRB is out of scope for this calculator. Equity-class CIU treatment under SA is in scope via engine/equity/calculator.py::_append_ciu_branches (see PS1/26, paragraph 132).
CRR Art. 107 — Approaches to credit risk¶
lift_institution_cqs — src/rwa_calc/engine/sa/cqs_lift.py:48
@cites("CRR Art. 117(1)")
@cites("CRR Art. 107(2)")
@cites("PS1/26, paragraph 117")
def lift_institution_cqs(exposures: pl.LazyFrame, upper_class: pl.Expr) -> pl.LazyFrame:
"""Lift ``cp_institution_cqs`` into ``cqs`` for MDB / non-QCCP counterparties.
``upper_class`` is the caller's cached ``exposure_class`` uppercase expression,
passed in rather than recomputed so the MDB test stays identical to the one the
rest of the lookup preparation uses.
"""
# CRR Art. 117(1) / PRA PS1/26 Art. 117(1)(a): non-named MDBs are treated
# as institutions, so their primary CQS source is ``cp_institution_cqs``
# (the MDB's own ECAI rating expressed as a CQS). When the exposure has
# no top-level ``cqs`` (no rating attached at the rating-mapping stage)
# but the counterparty carries an ``institution_cqs``, lift it into
# ``cqs`` here so the downstream CQS-keyed branches and joins see it.
# Named MDBs (mdb_named) bypass CQS entirely later — coalescing here is
# harmless for them.
is_mdb_class = upper_class == _MDB_UPPER_CLASS
# CRR Art. 107(2)(a): a non-qualifying CCP counterparty (entity_type "ccp"
# demoted past the Art. 306(1) 2%/4% pin by cp_is_qccp=False) is treated as
# an ordinary institution. Its own ECAI rating is carried on the synthetic
# CCR row as ``cp_institution_cqs`` (the CCR adapter surfaces no top-level
# ``cqs``), so lift it into ``cqs`` here — mirroring the MDB treatment —
# so the Art. 120(1) Table 3 institution ladder resolves (e.g. CQS 2 -> 50%)
# instead of the unrated 100% fallback. Scoped to ``ccp`` entity_type with a
# null ``cqs`` so rated institutions and lending rows are untouched.
is_non_qccp_institution = (
pl.col("cp_entity_type").fill_null("") == _CCP_ENTITY_TYPE
) & ~pl.col("cp_is_qccp").fill_null(True)
return exposures.with_columns(
pl.when((is_mdb_class | is_non_qccp_institution) & pl.col("cqs").is_null())
.then(pl.col("cp_institution_cqs"))
.otherwise(pl.col("cqs"))
.alias("cqs")
)
CRR Art. 109 — Treatment of securitisation positions¶
allocate — src/rwa_calc/engine/securitisation/allocator.py:116
@cites("CRR Art. 109")
@cites(CRR_ART_244)
@cites("PS1/26, paragraph 147A")
def allocate(
self,
data: RawDataBundle,
config: CalculationConfig, # noqa: ARG002 -- config reserved for future use
) -> tuple[RawDataBundle, pl.LazyFrame | None, list[CalculationError]]:
"""Resolve allocations into a per-exposure lookup.
Args:
data: Raw data bundle from loader.
config: Calculation configuration (currently unused; reserved
so the SRT validation gate can later read framework flags).
Returns:
Tuple of (original raw bundle, resolved lookup or None, list
of validation errors). The lookup is None when no allocations
were supplied; an empty input frame returns an empty lookup.
"""
if data.securitisation_allocations is None:
return data, None, []
# Materialise once -- the allocator runs row-level validation that
# is far easier to reason about on a concrete frame than on a
# lazy plan, and the input table is by definition small (one row
# per exposure-pool pair).
raw = data.securitisation_allocations.collect()
if raw.height == 0:
return data, empty_resolved_lookup(), []
errors: list[CalculationError] = []
# ------------------------------------------------------------------
# Step 1: SEC002 -- drop rows with invalid allocation_pct.
# ------------------------------------------------------------------
invalid_pct = raw.filter(
(pl.col("allocation_pct").is_null())
| (pl.col("allocation_pct") <= 0.0)
| (pl.col("allocation_pct") > 1.0)
)
if invalid_pct.height > 0:
errors.append(
securitisation_warning(
code=ERROR_SEC_INVALID_PCT,
message=(
f"{invalid_pct.height} securitisation allocation row(s) had "
"allocation_pct outside (0, 1] or null; rows dropped."
),
severity=ErrorSeverity.ERROR,
regulatory_reference=CRR_ART_244,
)
)
raw = raw.filter(
(pl.col("allocation_pct").is_not_null())
& (pl.col("allocation_pct") > 0.0)
& (pl.col("allocation_pct") <= 1.0)
)
if raw.height == 0:
return data, empty_resolved_lookup(), errors
# ------------------------------------------------------------------
# Step 2: SEC003 -- orphan exposure_reference (unknown to any of
# loans / contingents / facilities). Each row is checked against
# the source table matching its exposure_type to keep the lookup
# surface narrow.
# ------------------------------------------------------------------
known_refs = _collect_known_references(data)
raw = raw.with_columns(
pl.struct(["exposure_reference", "exposure_type"])
.map_elements(
lambda row: (row["exposure_reference"], row["exposure_type"]) in known_refs,
return_dtype=pl.Boolean,
)
.alias("_is_known"),
)
unknown = raw.filter(~pl.col("_is_known"))
if unknown.height > 0:
errors.append(
securitisation_warning(
code=ERROR_SEC_UNKNOWN_REFERENCE,
message=(
f"{unknown.height} securitisation allocation row(s) referenced "
"an exposure that does not exist in loans / contingents / "
"facilities; rows dropped."
),
severity=ErrorSeverity.WARNING,
regulatory_reference=CRR_ART_244,
)
)
raw = raw.filter(pl.col("_is_known")).drop("_is_known")
if raw.height == 0:
return data, empty_resolved_lookup(), errors
# ------------------------------------------------------------------
# Step 3: SEC004 -- duplicate (exposure_reference, pool_reference).
# Keep first, drop subsequent.
# ------------------------------------------------------------------
before_dedup = raw.height
raw = raw.unique(
subset=["exposure_reference", "exposure_type", "pool_reference"],
keep="first",
)
dropped = before_dedup - raw.height
if dropped > 0:
errors.append(
securitisation_warning(
code=ERROR_SEC_DUPLICATE,
message=(
f"{dropped} duplicate (exposure_reference, pool_reference) "
"securitisation allocation row(s) dropped; first row kept."
),
severity=ErrorSeverity.WARNING,
regulatory_reference=CRR_ART_244,
)
)
# ------------------------------------------------------------------
# Step 4: per-exposure aggregation. Group into struct list and
# compute total_allocated_pct.
# ------------------------------------------------------------------
aggregated = (
raw.lazy()
.group_by(["exposure_reference", "exposure_type"])
.agg(
[
pl.struct(
[
pl.col("pool_reference"),
pl.col("allocation_pct"),
]
).alias("securitisation_pool_allocations"),
pl.col("allocation_pct").sum().alias("total_allocated_pct"),
]
)
).collect()
# ------------------------------------------------------------------
# Step 5: SEC001 -- per-exposure sum > 1. Drop the allocations
# entirely for those rows; the exposure is treated as fully
# on-balance-sheet (residual_pct = 1.0) with audit_status =
# "over_allocated" so the audit row still surfaces the issue.
# ------------------------------------------------------------------
# Use a small tolerance to absorb floating-point summation noise --
# ``0.4 + 0.3 + 0.3`` is not exactly 1.0 in IEEE-754.
_SUM_TOLERANCE = 1e-9
over = aggregated.filter(pl.col("total_allocated_pct") > 1.0 + _SUM_TOLERANCE)
if over.height > 0:
errors.append(
securitisation_warning(
code=ERROR_SEC_OVER_ALLOCATED,
message=(
f"{over.height} exposure(s) had securitisation allocations "
"summing to > 1.0; all pool slices dropped, exposure(s) "
"kept fully on-balance-sheet."
),
severity=ErrorSeverity.ERROR,
regulatory_reference=CRR_ART_244,
)
)
# ------------------------------------------------------------------
# Step 6: SEC005 -- per-exposure sum == 1 (residual = 0). Inform-
# ational only -- the exposure flows through the pipeline with
# zero on-balance-sheet contribution.
# ------------------------------------------------------------------
fully = aggregated.filter(
(pl.col("total_allocated_pct") >= 1.0 - _SUM_TOLERANCE)
& (pl.col("total_allocated_pct") <= 1.0 + _SUM_TOLERANCE)
)
if fully.height > 0:
errors.append(
securitisation_warning(
code=ERROR_SEC_FULLY_SECURITISED,
message=(
f"{fully.height} exposure(s) fully securitised "
"(residual = 0); zero on-balance-sheet contribution."
),
severity=ErrorSeverity.WARNING,
regulatory_reference=CRR_ART_244,
)
)
# ------------------------------------------------------------------
# Step 7: build the resolved lookup. Over-allocated rows keep
# residual_pct = 1.0 and an empty pool_allocations list so the
# aggregator does not double-count them.
# ------------------------------------------------------------------
is_over = pl.col("total_allocated_pct") > 1.0 + _SUM_TOLERANCE
is_fully = (pl.col("total_allocated_pct") >= 1.0 - _SUM_TOLERANCE) & (
pl.col("total_allocated_pct") <= 1.0 + _SUM_TOLERANCE
)
empty_struct_list = pl.lit([]).cast(
pl.List(
pl.Struct(
{
"pool_reference": pl.String,
"allocation_pct": pl.Float64,
}
)
)
)
resolved = aggregated.with_columns(
[
pl.when(is_over)
.then(pl.lit(1.0))
.otherwise((pl.lit(1.0) - pl.col("total_allocated_pct")).clip(lower_bound=0.0))
.alias("securitisation_residual_pct"),
pl.when(is_over)
.then(empty_struct_list)
.otherwise(pl.col("securitisation_pool_allocations"))
.alias("securitisation_pool_allocations"),
pl.when(is_over)
.then(pl.lit("over_allocated"))
.when(is_fully)
.then(pl.lit("fully_securitised"))
.otherwise(pl.lit("ok"))
.alias("audit_status"),
]
).select(list(RESOLVED_SECURITISATION_SCHEMA.keys()))
logger.info(
"securitisation_allocator resolved %d exposure(s); %d error(s)",
resolved.height,
len(errors),
)
return data, resolved.lazy(), errors
CRR Art. 153 — Risk-weighted exposure amounts for exposures to corporates, institutions and central governments and central banks¶
apply_irb_formulas — src/rwa_calc/engine/irb/formulas.py:532
@cites("CRR Art. 151")
@cites("CRR Art. 153")
@cites("CRR Art. 154")
def apply_irb_formulas(
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""
Apply IRB formulas to exposures using pure Polars expressions.
Uses polars-normal-stats for statistical functions (normal_cdf, normal_ppf),
enabling full lazy evaluation, query optimization, and streaming.
Expects columns: pd, lgd, ead_final, exposure_class
Optional: maturity, turnover_m (for SME correlation adjustment)
Adds columns: pd_floored, lgd_floored, correlation, k, maturity_adjustment,
scaling_factor, risk_weight, rwa, expected_loss
Args:
exposures: LazyFrame with IRB exposures
config: Calculation configuration
Returns:
LazyFrame with IRB calculations added
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
scaling_factor = scalar_value(resolved_pack.scalar_param("irb_scaling_factor"))
# Ensure calculator-internal derived columns exist (maturity / turnover_m
# are produced by ``prepare_columns`` on the namespace path and are not
# crm_exit contract columns).
schema = exposures.collect_schema()
schema_names = schema.names()
if "maturity" not in schema_names:
exposures = exposures.with_columns(pl.lit(2.5).alias("maturity"))
if "turnover_m" not in schema_names:
exposures = exposures.with_columns(pl.lit(None).cast(pl.Float64).alias("turnover_m"))
# Step 1: Apply per-exposure-class PD floor (CRR: uniform, Basel 3.1: differentiated).
# fill_nan(None) first: a NaN PD passes straight through max_horizontal/clip and
# would poison K -> rwa; treating it as null routes it to the regulatory floor.
pd_floor_expr = _pd_floor_expression(config, pack=resolved_pack)
exposures = exposures.with_columns(
pl.max_horizontal(pl.col("pd").fill_nan(None), pd_floor_expr).alias("pd_floored")
)
# Step 2: Apply LGD floor (Basel 3.1 A-IRB only, CRR has no LGD floors)
# LGD floors only apply to A-IRB own-estimate LGDs (CRE30.41).
# F-IRB supervisory LGDs are regulatory values and don't need flooring.
if resolved_pack.feature("airb_lgd_floor"):
if "collateral_type" in schema_names:
lgd_floor_expr = _lgd_floor_expression_with_collateral(
config,
has_seniority=True,
has_exposure_class=True,
pack=resolved_pack,
)
else:
lgd_floor_expr = _lgd_floor_expression(
config,
has_seniority=True,
has_exposure_class=True,
pack=resolved_pack,
)
# Art. 161(5)(b) / 164(4)(c) LGD* blend for (partially) secured rows
blended_expr = _lgd_floor_blended_expression(config, pack=resolved_pack)
lgd_floor_expr = (
pl.when(blended_expr.is_not_null()).then(blended_expr).otherwise(lgd_floor_expr)
)
is_airb = pl.col("is_airb").fill_null(False) if "is_airb" in schema_names else pl.lit(False)
# fill_nan(None): treat a NaN own-estimate LGD as null so the A-IRB
# regulatory floor governs (max_horizontal does not scrub NaN).
floored_lgd = pl.max_horizontal(pl.col("lgd").fill_nan(None), lgd_floor_expr)
exposures = exposures.with_columns(
pl.when(is_airb).then(floored_lgd).otherwise(pl.col("lgd")).alias("lgd_floored")
)
else:
exposures = exposures.with_columns(pl.col("lgd").alias("lgd_floored"))
# Step 3: Calculate correlation using pure Polars expressions
# B31 uses GBP-native thresholds (Art. 153(4)); CRR converts GBP→EUR via rate
eur_gbp_rate = float(config.eur_gbp_rate)
sme_turnover_m = (
float(regulatory_threshold(resolved_pack, "sme_turnover_threshold", config.eur_gbp_rate))
/ 1_000_000
)
exposures = exposures.with_columns(
_polars_correlation_expr(
eur_gbp_rate=eur_gbp_rate,
is_b31=resolved_pack.feature("irb_correlation_sme_gbp_native"),
sme_turnover_threshold_m=sme_turnover_m,
).alias("correlation")
)
# Step 4: Calculate K using pure Polars with polars-normal-stats
exposures = exposures.with_columns(_polars_capital_k_expr().alias("k"))
# Step 5: Calculate maturity adjustment (only for non-retail)
is_retail = (
pl.col("exposure_class")
.cast(pl.String)
.fill_null("CORPORATE")
.str.to_uppercase()
.str.contains("RETAIL")
)
exposures = exposures.with_columns(
pl.when(is_retail)
.then(pl.lit(1.0))
.otherwise(_polars_maturity_adjustment_expr())
.alias("maturity_adjustment")
)
# Step 6-9: Final calculations (pure Polars expressions)
exposures = exposures.with_columns(
[
pl.lit(scaling_factor).alias("scaling_factor"),
(
pl.col("k")
* 12.5
* scaling_factor
* pl.col("ead_final")
* pl.col("maturity_adjustment")
).alias("rwa"),
(pl.col("k") * 12.5 * scaling_factor * pl.col("maturity_adjustment")).alias(
"risk_weight"
),
(pl.col("pd_floored") * pl.col("lgd_floored") * pl.col("ead_final")).alias(
"expected_loss"
),
]
)
# Step 10: Override for defaulted exposures (CRR Art. 153(1)(ii) / 154(1)(i))
# Delegates to the single source of truth in adjustments.py to avoid divergence.
from rwa_calc.engine.irb.adjustments import apply_defaulted_treatment
exposures = apply_defaulted_treatment(exposures)
return exposures
_correlation_expr_from_pd — src/rwa_calc/engine/irb/formulas.py:681
@cites("CRR Art. 153(2)")
def _correlation_expr_from_pd(
pd_expr: pl.Expr,
sme_threshold: float = 50.0,
eur_gbp_rate: float = 0.8732,
is_b31: bool = False,
sme_turnover_threshold_m: float = 44.0,
) -> pl.Expr:
"""
Shared correlation expression accepting an arbitrary PD expression.
Supports all exposure classes with proper correlation formulas:
- Corporate/Institution/Sovereign: PD-dependent (decay=50)
- Retail mortgage: Fixed 0.15
- QRRE: Fixed 0.04
- Other retail: PD-dependent (decay=35)
Includes:
- SME firm size adjustment for corporates
- FI scalar (1.25x) for large/unregulated financial sector entities (CRR Art. 153(2))
Under Basel 3.1 (PRA PS1/26 Art. 153(4)), the SME correlation adjustment uses
native GBP thresholds (44m/4.4m/39.6) directly on GBP turnover — no FX conversion.
Under CRR, GBP turnover is converted to EUR via eur_gbp_rate, then clipped to
EUR 5m-50m with denominator 45.
Reads exposure_class, turnover_m, requires_fi_scalar columns from the LazyFrame.
Args:
pd_expr: Polars expression for the PD value to use
sme_threshold: SME threshold in EUR millions (default 50.0, CRR only)
eur_gbp_rate: EUR/GBP exchange rate for converting GBP turnover to EUR (CRR only)
is_b31: If True, use GBP-native parameters per PRA PS1/26 Art. 153(4)
sme_turnover_threshold_m: Basel 3.1 SME turnover threshold in GBP millions
(default 44.0). Floor and range are derived: floor = threshold * 0.1,
range = threshold - floor.
"""
# Basel 3.1 SME correlation parameters derived from threshold
_b31_sme_threshold_m = sme_turnover_threshold_m
_b31_sme_floor_m = sme_turnover_threshold_m * 0.1
_b31_sme_range = sme_turnover_threshold_m - _b31_sme_floor_m
exp_class = pl.col("exposure_class").cast(pl.String).fill_null("CORPORATE").str.to_uppercase()
# Pre-calculate decay denominators (constants)
corporate_denom = 1.0 - math.exp(-50.0)
retail_denom = 1.0 - math.exp(-35.0)
# f(PD) for corporate (decay = 50)
f_pd_corp = (1.0 - (-50.0 * pd_expr).exp()) / corporate_denom
# f(PD) for retail (decay = 35)
f_pd_retail = (1.0 - (-35.0 * pd_expr).exp()) / retail_denom
# Corporate correlation: 0.12 × f(PD) + 0.24 × (1 - f(PD))
r_corporate = 0.12 * f_pd_corp + 0.24 * (1.0 - f_pd_corp)
# Retail other correlation: 0.03 × f(PD) + 0.16 × (1 - f(PD))
r_retail_other = 0.03 * f_pd_retail + 0.16 * (1.0 - f_pd_retail)
# SME adjustment for corporates: reduce correlation based on turnover
# Cast to Float64 first to handle null dtype
turnover_float = pl.col("turnover_m").cast(pl.Float64)
if is_b31:
# Basel 3.1: use GBP turnover directly with PRA-mandated thresholds.
# turnover_m is sourced from sme_size_metric_gbp (= coalesce(annual_
# revenue, total_assets)) so the S value automatically picks up the
# assets fallback per PS1/26 Art. 153(4) third subparagraph.
s_clamped = turnover_float.clip(_b31_sme_floor_m, _b31_sme_threshold_m)
sme_adjustment = 0.04 * (1.0 - (s_clamped - _b31_sme_floor_m) / _b31_sme_range)
has_valid_turnover = turnover_float.is_not_null() & turnover_float.is_finite()
is_sme = has_valid_turnover & (turnover_float < _b31_sme_threshold_m)
else:
# CRR: convert GBP turnover to EUR, then apply EUR thresholds.
# turnover_m is sourced from sme_size_metric_gbp (= coalesce(annual_
# revenue, total_assets)) so the S value automatically picks up the
# assets fallback per CRR Art. 153(4) third subparagraph.
# turnover_eur = turnover_gbp / eur_gbp_rate
# s = max(5, min(turnover_eur, 50))
# adjustment = 0.04 × (1 - (s - 5) / 45)
turnover_eur = turnover_float / eur_gbp_rate
s_clamped = turnover_eur.clip(5.0, sme_threshold)
sme_adjustment = 0.04 * (1.0 - (s_clamped - 5.0) / 45.0)
has_valid_turnover = turnover_eur.is_not_null() & turnover_eur.is_finite()
is_sme = has_valid_turnover & (turnover_eur < sme_threshold)
# Corporate with SME adjustment (when turnover < threshold and is corporate)
is_corporate = exp_class.str.contains("CORPORATE")
r_corporate_with_sme = (
pl.when(is_corporate & is_sme).then(r_corporate - sme_adjustment).otherwise(r_corporate)
)
# Build base correlation based on exposure class
base_correlation = (
pl.when(exp_class.str.contains("MORTGAGE") | exp_class.str.contains("RESIDENTIAL"))
.then(pl.lit(0.15))
.when(exp_class.str.contains("QRRE"))
.then(pl.lit(0.04))
.when(exp_class.str.contains("RETAIL"))
.then(r_retail_other)
.otherwise(r_corporate_with_sme)
)
# Apply FI scalar (1.25x) for large/unregulated financial sector entities
# Per CRR Article 153(2)
fi_scalar = (
pl.when(pl.col("requires_fi_scalar").fill_null(False) == True) # noqa: E712
.then(pl.lit(1.25))
.otherwise(pl.lit(1.0))
)
return base_correlation * fi_scalar
_capital_k_expr_from_params — src/rwa_calc/engine/irb/formulas.py:823
@cites("CRR Art. 153(1)")
def _capital_k_expr_from_params(
pd_expr: pl.Expr,
lgd_expr: pl.Expr,
correlation_expr: pl.Expr,
) -> pl.Expr:
"""
Shared K formula accepting arbitrary PD, LGD, and correlation expressions.
K = LGD × N[(1-R)^(-0.5) × G(PD) + (R/(1-R))^(0.5) × G(0.999)] - PD × LGD
Uses polars-normal-stats for normal_cdf and normal_ppf functions.
Args:
pd_expr: Polars expression for PD (will be clipped to [1e-10, 0.9999])
lgd_expr: Polars expression for LGD
correlation_expr: Polars expression for asset correlation
"""
pd_safe = pd_expr.clip(1e-10, 0.9999)
# G(PD) = inverse normal CDF of PD
g_pd = normal_ppf(pd_safe)
# Calculate conditional default probability terms
one_minus_r = 1.0 - correlation_expr
term1 = (1.0 / one_minus_r).sqrt() * g_pd
term2 = (correlation_expr / one_minus_r).sqrt() * G_999
# Conditional PD = N(term1 + term2)
conditional_pd = normal_cdf(term1 + term2)
# K = LGD × conditional_pd - PD × LGD
k = lgd_expr * conditional_pd - pd_safe * lgd_expr
# Floor at 0
return pl.max_horizontal(k, pl.lit(0.0))
_double_default_multiplier_expr — src/rwa_calc/engine/irb/formulas.py:942
@cites("CRR Art. 153(3)")
def _double_default_multiplier_expr(guarantor_pd_expr: pl.Expr) -> pl.Expr:
"""
Double default multiplier per CRR Art. 153(3) / Basel II para 284.
K_dd = K_obligor × (0.15 + 160 × PD_g)
The multiplier (0.15 + 160 × PD_g) reduces the capital charge by accounting
for the joint probability that both obligor and guarantor default. For a
high-quality guarantor (PD_g = 0.03%), the multiplier ≈ 0.198, providing
~80% capital relief vs standard substitution.
Args:
guarantor_pd_expr: Polars expression for the guarantor's PD (floored)
Returns:
Expression computing the double default multiplier (0.15 + 160 × PD_g)
"""
return pl.lit(0.15) + pl.lit(160.0) * guarantor_pd_expr
calculate_double_default_k — src/rwa_calc/engine/irb/formulas.py:963
@cites("CRR Art. 153(3)")
def calculate_double_default_k(
k_obligor: float,
guarantor_pd: float,
) -> float:
"""
Scalar double default K calculation.
K_dd = K_obligor × (0.15 + 160 × PD_g)
Args:
k_obligor: Standard IRB K for the obligor (pre-guarantee)
guarantor_pd: PD of the protection provider (floored)
Returns:
Capital requirement under double default treatment
"""
multiplier = 0.15 + 160.0 * guarantor_pd
return k_obligor * multiplier
calculate_correlation — src/rwa_calc/engine/irb/formulas.py:1152
@cites("CRR Art. 153(1)")
def calculate_correlation(
pd: float,
exposure_class: str,
turnover_m: float | None = None,
sme_threshold: float = 50.0,
apply_fi_scalar: bool = False,
eur_gbp_rate: float = 0.8732,
is_b31: bool = False,
) -> float:
"""
Scalar correlation calculation.
Wrapper around _polars_correlation_expr() - uses the same implementation
as vectorized processing.
Args:
pd: Probability of default
exposure_class: Exposure class string
turnover_m: Turnover in GBP millions (for SME adjustment)
sme_threshold: SME threshold in EUR millions (default 50.0, CRR only)
apply_fi_scalar: Whether to apply 1.25x FI scalar (CRR Art. 153(2))
for large/unregulated financial sector entities
eur_gbp_rate: EUR/GBP exchange rate for converting GBP turnover to EUR (CRR only)
is_b31: If True, use GBP-native parameters per PRA PS1/26 Art. 153(4)
Returns:
Asset correlation value
"""
return _run_scalar_via_vectorized(
{
"pd_floored": pd,
"exposure_class": exposure_class,
"turnover_m": turnover_m,
"requires_fi_scalar": apply_fi_scalar,
"eur_gbp_rate": eur_gbp_rate,
"is_b31": is_b31,
},
"correlation",
)
calculate_k — src/rwa_calc/engine/irb/formulas.py:1194
@cites("CRR Art. 153(1)")
def calculate_k(pd: float, lgd: float, correlation: float) -> float:
"""Scalar capital requirement calculation.
Wrapper around _polars_capital_k_expr() - uses the same implementation
as vectorized processing.
Args:
pd: Probability of default (floored)
lgd: Loss given default (floored)
correlation: Asset correlation
Returns:
Capital requirement K value
"""
# Handle edge cases that vectorized expression clips
if pd >= 1.0:
return lgd
if pd <= 0:
return 0.0
return _run_scalar_via_vectorized(
{
"pd_floored": pd,
"lgd_floored": lgd,
"correlation": correlation,
},
"k",
)
calculate_correlation — src/rwa_calc/engine/irb/transforms.py:423
@cites("CRR Art. 153(1)")
def calculate_correlation(
lf: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""
Calculate asset correlation using pure Polars expressions.
Supports:
- Corporate/Institution/Sovereign: PD-dependent (0.12-0.24)
- Retail mortgage: Fixed 0.15
- QRRE: Fixed 0.04
- Other retail: PD-dependent (0.03-0.16)
- SME adjustment for corporates (turnover converted from GBP to EUR)
- FI scalar (1.25x) for large/unregulated financial sector entities
Args:
lf: IRB exposures frame
config: Calculation configuration
Returns:
LazyFrame with correlation column
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
# B31 uses GBP-native thresholds (Art. 153(4)); CRR converts GBP→EUR via rate
eur_gbp_rate = float(config.eur_gbp_rate)
sme_turnover_m = (
float(regulatory_threshold(resolved_pack, "sme_turnover_threshold", config.eur_gbp_rate))
/ 1_000_000
)
return lf.with_columns(
_polars_correlation_expr(
eur_gbp_rate=eur_gbp_rate,
is_b31=resolved_pack.feature("irb_correlation_sme_gbp_native"),
sme_turnover_threshold_m=sme_turnover_m,
).alias("correlation")
)
calculate_k — src/rwa_calc/engine/irb/transforms.py:464
@cites("CRR Art. 153(1)")
def calculate_k(lf: pl.LazyFrame, config: CalculationConfig) -> pl.LazyFrame:
"""
Calculate capital requirement (K) using pure Polars with polars-normal-stats.
K = LGD × N[(1-R)^(-0.5) × G(PD) + (R/(1-R))^(0.5) × G(0.999)] - PD × LGD
Args:
lf: IRB exposures frame
config: Calculation configuration
Returns:
LazyFrame with k column
"""
return lf.with_columns(_polars_capital_k_expr().alias("k"))
calculate_branch — src/rwa_calc/engine/slotting/calculator.py:92
@cites("CRR Art. 153(5)")
def calculate_branch(
self,
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
errors: list[CalculationError] | None = None,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""
Calculate Slotting RWA and Expected Loss on pre-filtered slotting-only rows.
Computes risk weights (Art. 153(5)), RWA, supporting factors (Art. 501/501a),
expected loss rates (Art. 158(6) Table B), and EL shortfall/excess for the
portfolio EL summary.
Args:
exposures: Pre-filtered slotting rows only
config: Calculation configuration
errors: Optional error accumulator for data quality warnings
Returns:
LazyFrame with slotting RWA, expected_loss, el_shortfall, el_excess
"""
exposures = (
exposures.pipe(prepare_columns, config)
.pipe(apply_slotting_weights, config, pack=pack)
# RWSM (Art. 235(1)): the covered __G_ leg takes the guarantor's
# SA RW when beneficial — BEFORE calculate_rwa (so rwa reflects
# the substituted RW) and BEFORE supporting factors / the floor
# (the F8 guarantee_benefit_rw snapshot basis).
.pipe(apply_guarantee_substitution, config, pack=pack)
.pipe(calculate_rwa)
)
# Apply supporting factors (CRR Art. 501/501a) — same pattern as IRB
exposures = self._apply_supporting_factors(exposures, config, errors=errors, pack=pack)
exposures = (
exposures.pipe(apply_el_rates, config, pack=pack)
# Art. 235(1A): the substituted covered part carries no slotting
# EL — zero it before the Art. 159 shortfall pool sees it.
.pipe(zero_covered_expected_loss, config, pack=pack)
.pipe(compute_el_shortfall_excess, errors=errors)
)
# Standardize output for aggregator
schema = exposures.collect_schema()
rwa_col = "rwa_final" if "rwa_final" in schema.names() else "rwa"
return exposures.with_columns(
pl.col("approach").alias("approach_applied"),
pl.col(rwa_col).alias("rwa_final"),
)
apply_slotting_weights — src/rwa_calc/engine/slotting/transforms.py:159
@cites("CRR Art. 153(5)")
def apply_slotting_weights(
lf: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Apply slotting risk weights based on framework, category, HVCRE flag, and maturity."""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
is_crr = not resolved_pack.feature("slotting_revised_tables")
if is_crr:
rw_expr = lookup_rw(
col("slotting_category"),
is_crr=True,
is_hvcre=col("is_hvcre"),
is_short=col("is_short_maturity"),
)
else:
rw_expr = lookup_rw(
col("slotting_category"),
is_crr=False,
is_hvcre=col("is_hvcre"),
is_short=col("is_short_maturity"),
is_preop=col("is_pre_operational"),
)
return lf.with_columns(risk_weight=rw_expr)
_build_is_defaulted_expr — src/rwa_calc/engine/stages/classify/attributes.py:595
@cites("CRR Art. 178")
@cites("CRR Art. 153")
def _build_is_defaulted_expr() -> pl.Expr:
"""Build per-exposure ``is_defaulted`` flag.
Combines two explicit default signals so detection works at any
granularity:
- counterparty-level ``cp_default_status`` (propagates to all that
counterparty's exposures);
- explicit row-level ``is_defaulted`` carried on the loan/contingent
parquet (lets a single-default exposure on an otherwise non-defaulted
counterparty trigger the Art. 153(1)(ii) / 154(1)(i) defaulted
treatment).
Either one being true sets ``is_defaulted=True``.
``beel`` is deliberately **not** a trigger. PS1/26 Art. 181(1)(h)(ii)
and CRR Art. 158(5) define BEEL only for defaulted exposures, but
firms whose A-IRB models emit a BEEL-style value alongside LGD on
performing exposures would otherwise see those rows silently
reclassified as defaulted. The post-classification step
``_collect_beel_on_non_defaulted_warnings`` flags the contradictory
combination (``is_defaulted=False ∧ beel>0``) as a DQ008 warning so
the input contradiction is visible without changing routing.
"""
cp_default = pl.col("cp_default_status") == True # noqa: E712
row_default = pl.col("is_defaulted").fill_null(False)
return (cp_default | row_default).alias("is_defaulted")
classify_exposure_subtypes — src/rwa_calc/engine/stages/classify/subtypes.py:64
@cites("CRR Art. 153(2)")
@cites("CRR Art. 142(1)(4)")
@cites("CRR Art. 154(4)")
@cites("PS1/26, paragraph 153")
@cites("PS1/26, paragraph 147")
def classify_exposure_subtypes(
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""
Merge SME, retail, and QRRE classification into a single .with_columns().
Works because they operate on non-overlapping initial exposure_class values:
SME only touches "corporate", retail only touches "retail_other",
QRRE specialises qualifying revolving retail.
Also derives ``requires_fi_scalar`` — the gate for the 1.25x asset-value
correlation multiplier (CRR Art. 153(2) / PS1/26 Art. 153(2)). This is a
MANDATORY treatment for large financial sector entities, not a user
election, so it is DERIVED from the entity-type flag and total assets:
requires_fi_scalar = apply_fi_scalar
OR (is_financial_sector_entity
AND total_assets >= threshold)
The threshold is the LFSE size test (CRR Art. 142(1)(4): EUR 70bn on an
individual/consolidated basis, converted GBP via the FX seam; PS1/26 IRB
Part glossary: GBP 79bn native, at the highest level of consolidation).
``total_assets`` is a GBP figure, mirroring the SME balance-sheet gate.
The user-supplied ``apply_fi_scalar`` is retained as an authoritative
True-OVERRIDE (a firm may know an entity is a large or UNREGULATED FSE
even when size data says otherwise) — it can never SUPPRESS a derived
True. A null ``total_assets`` on a flagged FSE leaves largeness
undetermined: the scalar is NOT applied (the whole-FSE population mostly
sits below the threshold), and ``audit.collect_input_warnings`` emits
CLS009 so the data gap is never a silent under-statement. The unregulated
FSE limb (Art. 142(1)(5), size-independent) needs a regulated-status input
the schema does not carry and is deferred to a schema-enablement change;
``apply_fi_scalar`` is the interim override for known unregulated FSEs.
Sets: exposure_class (updated), is_sme, requires_fi_scalar, is_hvcre
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
qrre_max_limit = float(
regulatory_threshold(resolved_pack, "qrre_max_limit", config.eur_gbp_rate)
)
lfse_total_assets_threshold = float(
regulatory_threshold(resolved_pack, "lfse_total_assets_threshold", config.eur_gbp_rate)
)
is_sme_by_size = is_sme_by_size_expr(config, pack=resolved_pack)
# PRA PS1/26 Art. 124(3) / Art. 124K: ADC exposures retain the CORPORATE
# class and route to the 150% Art. 124K(1) ADC RW — they must not be
# reclassified to CORPORATE_SME. ``is_adc`` is always present after
# ``_derive_independent_flags``.
is_adc = pl.col("is_adc").fill_null(False)
# Conditions reused across expressions. ``is_sme_by_size`` evaluates
# CRR Art. 4(1)(128D) / Commission Rec 2003/361/EC using turnover when
# present and total assets as a fallback. Art. 501 supporting factor
# eligibility is handled separately in sa/supporting_factors.py and
# remains turnover-only per Art. 501(2)(c).
is_corporate_sme = (
(pl.col("exposure_class") == ExposureClass.CORPORATE.value) & is_sme_by_size & ~is_adc
)
is_retail_sme = (
(pl.col("exposure_class") == ExposureClass.RETAIL_OTHER.value)
& (pl.col("qualifies_as_retail") == False) # noqa: E712
& is_sme_by_size
)
# Specialised lending is a corporate sub-type (Art. 112(1)(g)) and is
# flagged as SME when the counterparty meets the size test. The
# exposure_class must remain SPECIALISED_LENDING so approach assignment
# routes it to the slotting calculator; only the is_sme flag is set.
# Art. 501 supporting-factor eligibility is gated separately on
# turnover non-null in sa/supporting_factors.py.
is_sl_sme = (
pl.col("exposure_class") == ExposureClass.SPECIALISED_LENDING.value
) & is_sme_by_size
# QRRE qualification (CRR Art. 154(4)(a)-(c) / PS1/26 Art. 147(5A)(a)-(c)):
# (a) the exposures are to individuals (natural persons);
# (b) they are revolving, UNSECURED, and — to the extent they are not
# drawn — immediately and unconditionally cancellable; and
# (c) the largest per-individual aggregate nominal exposure across the
# sub-portfolio is <= the limit (EUR 100k CRR / GBP 90k B31).
# The same conditions apply under both regimes (only the (c) limit value
# differs, resolved from the pack), so the gates are NOT regime-Featured.
# Conditions (5A)(d) low loss-rate volatility and (5A)(e) consistency with
# the sub-portfolio's underlying risk characteristics are supervisory,
# portfolio-level attestations — not per-exposure inputs — and are out of
# scope for row-level classification.
#
# (a) individuals; (b) unsecured + unconditionally-cancellable-when-undrawn.
# Each is a reusable module-level predicate (also read by the CLS010
# demotion-warning collector in ``audit.py``) — see the helpers below.
is_qrre_individual = natural_person_expr()
is_qrre_unsecured = qrre_unsecured_expr()
is_qrre_cancellable = qrre_undrawn_cancellable_expr()
# CRR Art. 154(4)(c) / PS1/26 Art. 147(5A)(c) cap the *aggregate* nominal
# exposure to any single individual across the QRRE sub-portfolio at the
# limit (EUR 100k / GBP 90k), not each facility individually. Aggregate
# ``facility_limit`` (the committed/nominal basis) per
# ``counterparty_reference`` before comparing. The driver columns
# (``is_revolving`` / ``facility_limit`` / ``is_secured`` / ``risk_type`` /
# ``undrawn_amount``) are hierarchy_exit contract columns — always present,
# null-gated by value.
#
# The QRRE sub-portfolio is the qualifying revolving retail population.
# Only those rows contribute to the per-individual aggregate; non-QRRE
# facilities (e.g. a term loan to the same obligor) are masked to 0.
is_qrre_candidate = (
(pl.col("exposure_class") == ExposureClass.RETAIL_OTHER.value)
& (pl.col("qualifies_as_retail") == True) # noqa: E712
& (pl.col("is_revolving") == True) # noqa: E712
& is_qrre_individual
& is_qrre_unsecured
& is_qrre_cancellable
)
facility_limit = pl.col("facility_limit").fill_null(float("inf"))
candidate_limit = pl.when(is_qrre_candidate).then(facility_limit).otherwise(pl.lit(0.0))
# Guard the nullable ``counterparty_reference`` partition: a null key
# would otherwise pool all unmapped rows into a single bucket (see
# ``partition_by_nullable`` / ``NULLABLE_PARTITION_KEYS``). Null-keyed
# rows fall back to their own per-row candidate limit.
obligor_aggregate_limit = partition_by_nullable(
candidate_limit.sum().over("counterparty_reference"),
"counterparty_reference",
candidate_limit,
)
is_qrre = is_qrre_candidate & (obligor_aggregate_limit <= qrre_max_limit)
# FI scalar (1.25x correlation) — mandatory for large FSEs (Art. 153(2)).
# An FSE is "large" when total assets meet the Art. 142(1)(4) / PS1/26
# glossary threshold. Null total_assets -> the >= test is null -> False:
# size undetermined, no scalar (CLS009 flags the gap in audit.py). The
# user flag is OR-ed in as an authoritative override that can never
# suppress a derived True.
is_large_fse = pl.col("cp_is_financial_sector_entity").fill_null(False) & (
pl.col("cp_total_assets") >= lfse_total_assets_threshold
).fill_null(False)
requires_fi_scalar = pl.col("cp_apply_fi_scalar").fill_null(False) | is_large_fse
return exposures.with_columns(
[
# --- exposure_class update (SME + retail + QRRE combined) ---
# Priority order: mortgage, QRRE, SME retail, non-qualifying retail,
# corporate SME, keep current.
pl.when(
# Retail mortgage — stays RETAIL_MORTGAGE regardless of threshold
(pl.col("is_mortgage") == True) # noqa: E712
& (
(pl.col("exposure_class") == ExposureClass.RETAIL_OTHER.value)
| (pl.col("cp_entity_type") == "individual")
)
)
.then(pl.lit(ExposureClass.RETAIL_MORTGAGE.value))
.when(
# QRRE: qualifying revolving retail under QRRE limit (Art. 147(5))
is_qrre
)
.then(pl.lit(ExposureClass.RETAIL_QRRE.value))
.when(
# SME retail that doesn't qualify → CORPORATE_SME
is_retail_sme
)
.then(pl.lit(ExposureClass.CORPORATE_SME.value))
.when(
# Other retail that doesn't qualify → CORPORATE
(pl.col("exposure_class") == ExposureClass.RETAIL_OTHER.value)
& (pl.col("qualifies_as_retail") == False) # noqa: E712
)
.then(pl.lit(ExposureClass.CORPORATE.value))
.when(
# Corporate with SME revenue → CORPORATE_SME
is_corporate_sme
)
.then(pl.lit(ExposureClass.CORPORATE_SME.value))
.otherwise(pl.col("exposure_class"))
.alias("exposure_class"),
# --- is_sme flag ---
# True for: corporate SME, retail reclassified to CORPORATE_SME,
# or specialised lending with SME counterparty (keeps SPECIALISED_LENDING class).
(is_corporate_sme | is_retail_sme | is_sl_sme).alias("is_sme"),
# --- FI scalar: derived (large FSE) OR user override (Art. 153(2)) ---
requires_fi_scalar.alias("requires_fi_scalar"),
# --- HVCRE flag (from specialised lending join, null → False) ---
pl.col("is_hvcre").fill_null(False).alias("is_hvcre"),
]
)
CRR Art. 154 — Risk-weighted exposure amounts for retail exposures¶
apply_irb_formulas — src/rwa_calc/engine/irb/formulas.py:533
@cites("CRR Art. 151")
@cites("CRR Art. 153")
@cites("CRR Art. 154")
def apply_irb_formulas(
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""
Apply IRB formulas to exposures using pure Polars expressions.
Uses polars-normal-stats for statistical functions (normal_cdf, normal_ppf),
enabling full lazy evaluation, query optimization, and streaming.
Expects columns: pd, lgd, ead_final, exposure_class
Optional: maturity, turnover_m (for SME correlation adjustment)
Adds columns: pd_floored, lgd_floored, correlation, k, maturity_adjustment,
scaling_factor, risk_weight, rwa, expected_loss
Args:
exposures: LazyFrame with IRB exposures
config: Calculation configuration
Returns:
LazyFrame with IRB calculations added
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
scaling_factor = scalar_value(resolved_pack.scalar_param("irb_scaling_factor"))
# Ensure calculator-internal derived columns exist (maturity / turnover_m
# are produced by ``prepare_columns`` on the namespace path and are not
# crm_exit contract columns).
schema = exposures.collect_schema()
schema_names = schema.names()
if "maturity" not in schema_names:
exposures = exposures.with_columns(pl.lit(2.5).alias("maturity"))
if "turnover_m" not in schema_names:
exposures = exposures.with_columns(pl.lit(None).cast(pl.Float64).alias("turnover_m"))
# Step 1: Apply per-exposure-class PD floor (CRR: uniform, Basel 3.1: differentiated).
# fill_nan(None) first: a NaN PD passes straight through max_horizontal/clip and
# would poison K -> rwa; treating it as null routes it to the regulatory floor.
pd_floor_expr = _pd_floor_expression(config, pack=resolved_pack)
exposures = exposures.with_columns(
pl.max_horizontal(pl.col("pd").fill_nan(None), pd_floor_expr).alias("pd_floored")
)
# Step 2: Apply LGD floor (Basel 3.1 A-IRB only, CRR has no LGD floors)
# LGD floors only apply to A-IRB own-estimate LGDs (CRE30.41).
# F-IRB supervisory LGDs are regulatory values and don't need flooring.
if resolved_pack.feature("airb_lgd_floor"):
if "collateral_type" in schema_names:
lgd_floor_expr = _lgd_floor_expression_with_collateral(
config,
has_seniority=True,
has_exposure_class=True,
pack=resolved_pack,
)
else:
lgd_floor_expr = _lgd_floor_expression(
config,
has_seniority=True,
has_exposure_class=True,
pack=resolved_pack,
)
# Art. 161(5)(b) / 164(4)(c) LGD* blend for (partially) secured rows
blended_expr = _lgd_floor_blended_expression(config, pack=resolved_pack)
lgd_floor_expr = (
pl.when(blended_expr.is_not_null()).then(blended_expr).otherwise(lgd_floor_expr)
)
is_airb = pl.col("is_airb").fill_null(False) if "is_airb" in schema_names else pl.lit(False)
# fill_nan(None): treat a NaN own-estimate LGD as null so the A-IRB
# regulatory floor governs (max_horizontal does not scrub NaN).
floored_lgd = pl.max_horizontal(pl.col("lgd").fill_nan(None), lgd_floor_expr)
exposures = exposures.with_columns(
pl.when(is_airb).then(floored_lgd).otherwise(pl.col("lgd")).alias("lgd_floored")
)
else:
exposures = exposures.with_columns(pl.col("lgd").alias("lgd_floored"))
# Step 3: Calculate correlation using pure Polars expressions
# B31 uses GBP-native thresholds (Art. 153(4)); CRR converts GBP→EUR via rate
eur_gbp_rate = float(config.eur_gbp_rate)
sme_turnover_m = (
float(regulatory_threshold(resolved_pack, "sme_turnover_threshold", config.eur_gbp_rate))
/ 1_000_000
)
exposures = exposures.with_columns(
_polars_correlation_expr(
eur_gbp_rate=eur_gbp_rate,
is_b31=resolved_pack.feature("irb_correlation_sme_gbp_native"),
sme_turnover_threshold_m=sme_turnover_m,
).alias("correlation")
)
# Step 4: Calculate K using pure Polars with polars-normal-stats
exposures = exposures.with_columns(_polars_capital_k_expr().alias("k"))
# Step 5: Calculate maturity adjustment (only for non-retail)
is_retail = (
pl.col("exposure_class")
.cast(pl.String)
.fill_null("CORPORATE")
.str.to_uppercase()
.str.contains("RETAIL")
)
exposures = exposures.with_columns(
pl.when(is_retail)
.then(pl.lit(1.0))
.otherwise(_polars_maturity_adjustment_expr())
.alias("maturity_adjustment")
)
# Step 6-9: Final calculations (pure Polars expressions)
exposures = exposures.with_columns(
[
pl.lit(scaling_factor).alias("scaling_factor"),
(
pl.col("k")
* 12.5
* scaling_factor
* pl.col("ead_final")
* pl.col("maturity_adjustment")
).alias("rwa"),
(pl.col("k") * 12.5 * scaling_factor * pl.col("maturity_adjustment")).alias(
"risk_weight"
),
(pl.col("pd_floored") * pl.col("lgd_floored") * pl.col("ead_final")).alias(
"expected_loss"
),
]
)
# Step 10: Override for defaulted exposures (CRR Art. 153(1)(ii) / 154(1)(i))
# Delegates to the single source of truth in adjustments.py to avoid divergence.
from rwa_calc.engine.irb.adjustments import apply_defaulted_treatment
exposures = apply_defaulted_treatment(exposures)
return exposures
classify_exposure_subtypes — src/rwa_calc/engine/stages/classify/subtypes.py:66
@cites("CRR Art. 153(2)")
@cites("CRR Art. 142(1)(4)")
@cites("CRR Art. 154(4)")
@cites("PS1/26, paragraph 153")
@cites("PS1/26, paragraph 147")
def classify_exposure_subtypes(
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""
Merge SME, retail, and QRRE classification into a single .with_columns().
Works because they operate on non-overlapping initial exposure_class values:
SME only touches "corporate", retail only touches "retail_other",
QRRE specialises qualifying revolving retail.
Also derives ``requires_fi_scalar`` — the gate for the 1.25x asset-value
correlation multiplier (CRR Art. 153(2) / PS1/26 Art. 153(2)). This is a
MANDATORY treatment for large financial sector entities, not a user
election, so it is DERIVED from the entity-type flag and total assets:
requires_fi_scalar = apply_fi_scalar
OR (is_financial_sector_entity
AND total_assets >= threshold)
The threshold is the LFSE size test (CRR Art. 142(1)(4): EUR 70bn on an
individual/consolidated basis, converted GBP via the FX seam; PS1/26 IRB
Part glossary: GBP 79bn native, at the highest level of consolidation).
``total_assets`` is a GBP figure, mirroring the SME balance-sheet gate.
The user-supplied ``apply_fi_scalar`` is retained as an authoritative
True-OVERRIDE (a firm may know an entity is a large or UNREGULATED FSE
even when size data says otherwise) — it can never SUPPRESS a derived
True. A null ``total_assets`` on a flagged FSE leaves largeness
undetermined: the scalar is NOT applied (the whole-FSE population mostly
sits below the threshold), and ``audit.collect_input_warnings`` emits
CLS009 so the data gap is never a silent under-statement. The unregulated
FSE limb (Art. 142(1)(5), size-independent) needs a regulated-status input
the schema does not carry and is deferred to a schema-enablement change;
``apply_fi_scalar`` is the interim override for known unregulated FSEs.
Sets: exposure_class (updated), is_sme, requires_fi_scalar, is_hvcre
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
qrre_max_limit = float(
regulatory_threshold(resolved_pack, "qrre_max_limit", config.eur_gbp_rate)
)
lfse_total_assets_threshold = float(
regulatory_threshold(resolved_pack, "lfse_total_assets_threshold", config.eur_gbp_rate)
)
is_sme_by_size = is_sme_by_size_expr(config, pack=resolved_pack)
# PRA PS1/26 Art. 124(3) / Art. 124K: ADC exposures retain the CORPORATE
# class and route to the 150% Art. 124K(1) ADC RW — they must not be
# reclassified to CORPORATE_SME. ``is_adc`` is always present after
# ``_derive_independent_flags``.
is_adc = pl.col("is_adc").fill_null(False)
# Conditions reused across expressions. ``is_sme_by_size`` evaluates
# CRR Art. 4(1)(128D) / Commission Rec 2003/361/EC using turnover when
# present and total assets as a fallback. Art. 501 supporting factor
# eligibility is handled separately in sa/supporting_factors.py and
# remains turnover-only per Art. 501(2)(c).
is_corporate_sme = (
(pl.col("exposure_class") == ExposureClass.CORPORATE.value) & is_sme_by_size & ~is_adc
)
is_retail_sme = (
(pl.col("exposure_class") == ExposureClass.RETAIL_OTHER.value)
& (pl.col("qualifies_as_retail") == False) # noqa: E712
& is_sme_by_size
)
# Specialised lending is a corporate sub-type (Art. 112(1)(g)) and is
# flagged as SME when the counterparty meets the size test. The
# exposure_class must remain SPECIALISED_LENDING so approach assignment
# routes it to the slotting calculator; only the is_sme flag is set.
# Art. 501 supporting-factor eligibility is gated separately on
# turnover non-null in sa/supporting_factors.py.
is_sl_sme = (
pl.col("exposure_class") == ExposureClass.SPECIALISED_LENDING.value
) & is_sme_by_size
# QRRE qualification (CRR Art. 154(4)(a)-(c) / PS1/26 Art. 147(5A)(a)-(c)):
# (a) the exposures are to individuals (natural persons);
# (b) they are revolving, UNSECURED, and — to the extent they are not
# drawn — immediately and unconditionally cancellable; and
# (c) the largest per-individual aggregate nominal exposure across the
# sub-portfolio is <= the limit (EUR 100k CRR / GBP 90k B31).
# The same conditions apply under both regimes (only the (c) limit value
# differs, resolved from the pack), so the gates are NOT regime-Featured.
# Conditions (5A)(d) low loss-rate volatility and (5A)(e) consistency with
# the sub-portfolio's underlying risk characteristics are supervisory,
# portfolio-level attestations — not per-exposure inputs — and are out of
# scope for row-level classification.
#
# (a) individuals; (b) unsecured + unconditionally-cancellable-when-undrawn.
# Each is a reusable module-level predicate (also read by the CLS010
# demotion-warning collector in ``audit.py``) — see the helpers below.
is_qrre_individual = natural_person_expr()
is_qrre_unsecured = qrre_unsecured_expr()
is_qrre_cancellable = qrre_undrawn_cancellable_expr()
# CRR Art. 154(4)(c) / PS1/26 Art. 147(5A)(c) cap the *aggregate* nominal
# exposure to any single individual across the QRRE sub-portfolio at the
# limit (EUR 100k / GBP 90k), not each facility individually. Aggregate
# ``facility_limit`` (the committed/nominal basis) per
# ``counterparty_reference`` before comparing. The driver columns
# (``is_revolving`` / ``facility_limit`` / ``is_secured`` / ``risk_type`` /
# ``undrawn_amount``) are hierarchy_exit contract columns — always present,
# null-gated by value.
#
# The QRRE sub-portfolio is the qualifying revolving retail population.
# Only those rows contribute to the per-individual aggregate; non-QRRE
# facilities (e.g. a term loan to the same obligor) are masked to 0.
is_qrre_candidate = (
(pl.col("exposure_class") == ExposureClass.RETAIL_OTHER.value)
& (pl.col("qualifies_as_retail") == True) # noqa: E712
& (pl.col("is_revolving") == True) # noqa: E712
& is_qrre_individual
& is_qrre_unsecured
& is_qrre_cancellable
)
facility_limit = pl.col("facility_limit").fill_null(float("inf"))
candidate_limit = pl.when(is_qrre_candidate).then(facility_limit).otherwise(pl.lit(0.0))
# Guard the nullable ``counterparty_reference`` partition: a null key
# would otherwise pool all unmapped rows into a single bucket (see
# ``partition_by_nullable`` / ``NULLABLE_PARTITION_KEYS``). Null-keyed
# rows fall back to their own per-row candidate limit.
obligor_aggregate_limit = partition_by_nullable(
candidate_limit.sum().over("counterparty_reference"),
"counterparty_reference",
candidate_limit,
)
is_qrre = is_qrre_candidate & (obligor_aggregate_limit <= qrre_max_limit)
# FI scalar (1.25x correlation) — mandatory for large FSEs (Art. 153(2)).
# An FSE is "large" when total assets meet the Art. 142(1)(4) / PS1/26
# glossary threshold. Null total_assets -> the >= test is null -> False:
# size undetermined, no scalar (CLS009 flags the gap in audit.py). The
# user flag is OR-ed in as an authoritative override that can never
# suppress a derived True.
is_large_fse = pl.col("cp_is_financial_sector_entity").fill_null(False) & (
pl.col("cp_total_assets") >= lfse_total_assets_threshold
).fill_null(False)
requires_fi_scalar = pl.col("cp_apply_fi_scalar").fill_null(False) | is_large_fse
return exposures.with_columns(
[
# --- exposure_class update (SME + retail + QRRE combined) ---
# Priority order: mortgage, QRRE, SME retail, non-qualifying retail,
# corporate SME, keep current.
pl.when(
# Retail mortgage — stays RETAIL_MORTGAGE regardless of threshold
(pl.col("is_mortgage") == True) # noqa: E712
& (
(pl.col("exposure_class") == ExposureClass.RETAIL_OTHER.value)
| (pl.col("cp_entity_type") == "individual")
)
)
.then(pl.lit(ExposureClass.RETAIL_MORTGAGE.value))
.when(
# QRRE: qualifying revolving retail under QRRE limit (Art. 147(5))
is_qrre
)
.then(pl.lit(ExposureClass.RETAIL_QRRE.value))
.when(
# SME retail that doesn't qualify → CORPORATE_SME
is_retail_sme
)
.then(pl.lit(ExposureClass.CORPORATE_SME.value))
.when(
# Other retail that doesn't qualify → CORPORATE
(pl.col("exposure_class") == ExposureClass.RETAIL_OTHER.value)
& (pl.col("qualifies_as_retail") == False) # noqa: E712
)
.then(pl.lit(ExposureClass.CORPORATE.value))
.when(
# Corporate with SME revenue → CORPORATE_SME
is_corporate_sme
)
.then(pl.lit(ExposureClass.CORPORATE_SME.value))
.otherwise(pl.col("exposure_class"))
.alias("exposure_class"),
# --- is_sme flag ---
# True for: corporate SME, retail reclassified to CORPORATE_SME,
# or specialised lending with SME counterparty (keeps SPECIALISED_LENDING class).
(is_corporate_sme | is_retail_sme | is_sl_sme).alias("is_sme"),
# --- FI scalar: derived (large FSE) OR user override (Art. 153(2)) ---
requires_fi_scalar.alias("requires_fi_scalar"),
# --- HVCRE flag (from specialised lending join, null → False) ---
pl.col("is_hvcre").fill_null(False).alias("is_hvcre"),
]
)
qrre_unsecured_expr — src/rwa_calc/engine/stages/classify/subtypes.py:264
@cites("CRR Art. 154(4)")
@cites("PS1/26, paragraph 147")
def qrre_unsecured_expr() -> pl.Expr:
"""Return the Art. 147(5A)(b) / Art. 154(4)(b) "unsecured" QRRE predicate.
A revolving retail facility flagged ``is_secured`` is NOT a QRRE. A null
attestation resolves to unsecured (``fill_null(False)``) — consistent with
how the pipeline treats absent collateral everywhere else, and with the
reality that revolving retail credit is unsecured by nature. The classifier
runs before CRMProcessor, so general (non-property) collateral is not yet
allocated; this is a firm attestation rather than a pledge-presence join
(which would replicate CRM's multi-level beneficiary cascade at classify
time). The Art. 147(5A) second-sub-paragraph wage-account derogation is
applied via input semantics — see ``FACILITY_SCHEMA.is_secured``.
"""
return ~pl.col("is_secured").fill_null(False)
qrre_undrawn_cancellable_expr — src/rwa_calc/engine/stages/classify/subtypes.py:282
@cites("CRR Art. 154(4)")
@cites("PS1/26, paragraph 147")
def qrre_undrawn_cancellable_expr() -> pl.Expr:
"""Return the Art. 147(5A)(b) / Art. 154(4)(b) cancellability QRRE predicate.
QRRE must be, "to the extent they are not drawn, immediately and
unconditionally cancellable". A row carrying an undrawn commitment
(``undrawn_amount > 0``) must have the CCF unconditionally-cancellable
(LR / low-risk) ``risk_type``; a fully-drawn row has nothing undrawn to
cancel and satisfies the limb trivially. Reuses the CCF machinery's UC
signal (``risk_type`` == LR, engine/ccf.py) rather than minting a duplicate
flag. A null/non-LR ``risk_type`` on an undrawn row -> not cancellable ->
not QRRE, mirroring the CCF null convention (a null risk_type resolves to
the MR-equivalent CCF, never the LR benefit — no divergence). A null
``undrawn_amount`` propagates (never QRRE), which is the conservative
direction — no ``fill_null(0.0)`` on the Float column.
"""
has_undrawn_commitment = pl.col("undrawn_amount") > 0.0
is_uncond_cancellable = (
pl.col("risk_type")
.cast(pl.Utf8, strict=False)
.fill_null("")
.str.to_lowercase()
.is_in(["lr", "low_risk"])
)
return ~has_undrawn_commitment | is_uncond_cancellable
CRR Art. 155 — Risk-weighted exposure amounts for equity exposures¶
get_equity_result_bundle — src/rwa_calc/engine/equity/calculator.py:263
@cites("CRR Art. 133")
@cites("CRR Art. 155")
def get_equity_result_bundle(
self,
data: CRMAdjustedBundle,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> EquityResultBundle:
"""
Calculate equity RWA and return as a bundle.
Args:
data: CRM-adjusted exposures
config: Calculation configuration
Returns:
EquityResultBundle with results and audit trail
"""
errors: list[CalculationError] = []
exposures = data.equity_exposures
if exposures is None:
empty_frame = pl.LazyFrame(
{
"exposure_reference": pl.Series([], dtype=pl.String),
"equity_type": pl.Series([], dtype=pl.String),
"ead_final": pl.Series([], dtype=pl.Float64),
"risk_weight": pl.Series([], dtype=pl.Float64),
"rwa": pl.Series([], dtype=pl.Float64),
}
)
return EquityResultBundle(
results=empty_frame,
calculation_audit=empty_frame,
approach=EquityApproach.SA,
errors=[],
)
approach = self._determine_approach(config, pack=pack)
exposures = self._prepare_columns(exposures, config)
exposures = self._resolve_look_through_rw(exposures, data.ciu_holdings, config, pack=pack)
# Art. 155(3) PD/LGD computes RWEA inside the branch and bypasses both
# the IRB Simple transitional floor and _calculate_rwa.
if approach == EquityApproach.PD_LGD:
exposures = self._apply_equity_weights_pd_lgd(exposures, config, pack=pack)
else:
if approach == EquityApproach.IRB_SIMPLE:
exposures = self._apply_equity_weights_irb_simple(exposures, config)
else:
exposures = self._apply_equity_weights_sa(exposures, config, pack=pack)
exposures = self._apply_transitional_floor(exposures, config, pack=pack)
exposures = self._calculate_rwa(exposures)
audit = self._build_audit(exposures, approach)
return EquityResultBundle(
results=exposures,
calculation_audit=audit,
approach=approach,
errors=errors,
)
_determine_approach — src/rwa_calc/engine/equity/calculator.py:329
@cites("CRR Art. 155(3)")
def _determine_approach(
self,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> EquityApproach:
"""
Determine SA, IRB_SIMPLE, or PD_LGD based on config.
Under Basel 3.1 (CRE20.58-62): IRB for equity is removed — all equity
exposures must use SA treatment. The IRB Simple Risk Weight Method
(Art. 155: 190%/290%/370%) and the PD/LGD approach (Art. 155(3)) are no
longer available; the ``equity_pd_lgd`` flag is ignored.
Under CRR: If the firm has IRB permissions (FIRB or AIRB) for any
exposure class, equity uses either the Art. 155(3) PD/LGD approach (when
``config.equity_pd_lgd`` is True) or the Art. 155(2) IRB Simple approach.
If SA-only, use Article 133 SA approach.
Args:
config: Calculation configuration
Returns:
EquityApproach.SA (Art. 133), EquityApproach.IRB_SIMPLE (Art. 155(2)),
or EquityApproach.PD_LGD (Art. 155(3))
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
# Basel 3.1: IRB equity removed — all equity uses SA (CRE20.58-62).
# The equity_pd_lgd flag is ignored under Basel 3.1.
if not resolved_pack.feature("equity_irb_approaches_available"):
return EquityApproach.SA
# CRR: Check if firm has any IRB permissions beyond SA
# If permissions dict is empty, it's SA-only
# irb_permissions is derived non-None in CalculationConfig.__post_init__.
if not config.irb_permissions.permissions: # ty: ignore[unresolved-attribute]
return EquityApproach.SA
# Check if any exposure class has FIRB or AIRB permission
for _exposure_class, approaches in config.irb_permissions.permissions.items(): # ty: ignore[unresolved-attribute]
if ApproachType.FIRB in approaches or ApproachType.AIRB in approaches:
# Art. 155(3): PD/LGD approach when the firm has elected it
if config.equity_pd_lgd:
return EquityApproach.PD_LGD
return EquityApproach.IRB_SIMPLE
return EquityApproach.SA
_equity_holding_higher_of_rw — src/rwa_calc/engine/equity/calculator.py:547
@cites("CRR Art. 155(2)")
@cites("PS1/26, paragraph 4.8")
@cites("PS1/26, paragraph 4.9")
def _equity_holding_higher_of_rw(
self, config: CalculationConfig, *, pack: ResolvedRulepack | None = None
) -> float | None:
"""Rules 4.7-4.8 higher-of RW for EQUITY-class CIU look-through holdings.
Returns ``max(legacy Art. 155(2) "other equity" simple RW, Rule 4.2/4.3
transitional SA RW)`` when the Basel 3.1 equity transitional regime is
active for the reporting date, else ``None`` (no override — holdings keep
the _DEFAULT_HOLDING_RW fallback).
The transitional regime only applies to firms that held IRB equity
permission, so ``equity_transitional.enabled`` (plus a transitional RW
existing for the reporting date) is the gate.
Per Rule 4.9-4.10, a firm that has irrevocably opted out of the
transitional regime (``equity_transitional.opt_out``) suppresses the
higher-of: ``None`` is returned so the holding falls back to the
``_DEFAULT_HOLDING_RW`` standard treatment. The opt-out applies jointly
with the direct-equity transitional floor (Rule 4.9).
References:
- CRR Art. 155(2): IRB simple method equity RW ("other" = 370%).
- PRA PS1/26 Rule 4.8: higher-of(Art. 155(2) simple, Rule 4.2/4.3 band).
- PRA PS1/26 Rule 4.9-4.10: irrevocable joint opt-out suppresses higher-of.
"""
if config.equity_transitional.opt_out:
return None
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
transitional_rw = _equity_transitional_rw(
resolved_pack, config.reporting_date, is_higher_risk=False
)
if transitional_rw is None:
return None
legacy_simple_rw = _IRB_RW[EquityType.OTHER]
return max(legacy_simple_rw, float(transitional_rw))
_apply_equity_weights_irb_simple — src/rwa_calc/engine/equity/calculator.py:749
@cites("CRR Art. 155(2)")
def _apply_equity_weights_irb_simple(
self,
exposures: pl.LazyFrame,
config: CalculationConfig,
) -> pl.LazyFrame:
"""
Apply Article 155 (IRB Simple) equity risk weights and Art. 158(7) EL.
Risk weights (Art. 155(2)) and paired expected-loss rates (Art. 158(7)):
- Central bank: 0% RW, 0.0% EL
- Private equity (diversified portfolio): 190% RW, 0.8% EL
- Exchange-traded / listed: 290% RW, 0.8% EL
- All other equity: 370% RW, 2.4% EL
The Art. 158(7) expected-loss amount is ``EL rate x ead_final`` and shares
the RW when-chain's bucket predicates so each EL rate pairs with its RW.
It is a disclosure quantity (COREP C08 / Pillar 3 IRB EL) — Art. 155(2)
does not gross the equity RWA up by EL, and Art. 159 subtracts only the
Art. 158(5),(6),(10) EL amounts from provisions, so equity simple EL does
not enter the EL-vs-provisions shortfall/excess machinery.
Before assigning risk weights this nets non-trading-book short positions
against long positions in the same individual stock per Art. 155(2)
(see ``_net_short_positions``).
"""
exposures = self._net_short_positions(exposures)
# Shared bucket predicates so the RW and EL when-chains stay in lockstep
# (each Art. 158(7) EL rate must pair with its Art. 155(2) RW bucket).
eq_type = pl.col("equity_type").str.to_lowercase()
is_central_bank = eq_type == "central_bank"
is_diversified_pe = (eq_type == "private_equity_diversified") | (
(eq_type == "private_equity") & (pl.col("is_diversified_portfolio") == True) # noqa: E712
)
is_exchange_traded = pl.col("is_exchange_traded") == True # noqa: E712
is_listed = eq_type == "listed"
is_exchange_traded_type = eq_type == "exchange_traded"
risk_weight = (
pl.when(is_central_bank)
.then(pl.lit(_IRB_RW[EquityType.CENTRAL_BANK]))
.when(is_diversified_pe)
.then(pl.lit(_IRB_RW[EquityType.PRIVATE_EQUITY_DIVERSIFIED]))
.when(is_exchange_traded)
.then(pl.lit(_IRB_RW[EquityType.EXCHANGE_TRADED]))
.when(is_listed)
.then(pl.lit(_IRB_RW[EquityType.LISTED]))
.when(is_exchange_traded_type)
.then(pl.lit(_IRB_RW[EquityType.EXCHANGE_TRADED]))
# CRR Art. 155(2)(c): "all other equity" 370% — including
# government_supported, which has no Art. 155 carve-out.
.otherwise(pl.lit(_IRB_RW[EquityType.OTHER]))
)
# Art. 158(7) EL rate, matched bucket-for-bucket to the RW chain above.
el_rate = (
pl.when(is_central_bank)
.then(pl.lit(_IRB_SIMPLE_EL[EquityType.CENTRAL_BANK]))
.when(is_diversified_pe)
.then(pl.lit(_IRB_SIMPLE_EL[EquityType.PRIVATE_EQUITY_DIVERSIFIED]))
.when(is_exchange_traded)
.then(pl.lit(_IRB_SIMPLE_EL[EquityType.EXCHANGE_TRADED]))
.when(is_listed)
.then(pl.lit(_IRB_SIMPLE_EL[EquityType.LISTED]))
.when(is_exchange_traded_type)
.then(pl.lit(_IRB_SIMPLE_EL[EquityType.EXCHANGE_TRADED]))
.otherwise(pl.lit(_IRB_SIMPLE_EL[EquityType.OTHER]))
)
return exposures.with_columns(
risk_weight.alias("risk_weight"),
(el_rate * pl.col("ead_final")).alias("expected_loss"),
# Art. 155(2) simple-RW method tag — the discriminator Pillar 3
# CR10.5 filters on (reporting_approach_origin == "equity" AND
# equity_method == "irb_simple").
pl.lit(EquityApproach.IRB_SIMPLE.value).alias("equity_method"),
)
_net_short_positions — src/rwa_calc/engine/equity/calculator.py:828
@cites("CRR Art. 155(2)")
def _net_short_positions(self, exposures: pl.LazyFrame) -> pl.LazyFrame:
"""Net non-trading-book short positions against longs (CRR Art. 155(2)).
Under the IRB Simple Risk Weight Method, short cash positions and
derivatives held in the non-trading book may offset long positions in
the *same individual stock* provided the offsetting short is an explicit
hedge covering at least one year. Other short positions are treated as
long with the relevant RW applied to their absolute value.
Mechanics (LazyFrame-first, column-absence defensive):
- Eligibility requires the optional inputs ``position_value`` and
``issuer_reference``; absent either, ``exposures`` is returned
unchanged so production frames behave exactly as before.
- A row is netting-eligible when it carries a non-null
``issuer_reference`` and ``is_explicitly_hedged`` is True (the boolean
encodes "explicit hedge >= 1 year", ``CRR_EQUITY_NETTING_MIN_HEDGE_YEARS``).
- Net long per issuer = ``max(0, sum(signed position_value))`` over the
eligible rows. The surviving long row(s) carry the netted EAD pro-rata
to their gross long value; absorbed shorts (and any rows whose group
nets to <= 0) collapse to ``ead_final`` 0. Net-short residual is
floored at 0 (out of scope here).
- Ineligible rows keep their existing ``ead_final`` (the absolute-value
``fair_value``/``carrying_value``/``ead`` chain).
"""
schema_names = exposures.collect_schema().names()
if "position_value" not in schema_names or "issuer_reference" not in schema_names:
return exposures
is_hedged = (
pl.col("is_explicitly_hedged").fill_null(False)
if "is_explicitly_hedged" in schema_names
else pl.lit(False)
)
# Eligible: a hedged position on a known issuer with a signed value.
eligible = (
pl.col("issuer_reference").is_not_null()
& pl.col("position_value").is_not_null()
& is_hedged
)
signed = pl.col("position_value").fill_null(0.0)
gross_long = pl.when(eligible & (signed > 0)).then(signed).otherwise(pl.lit(0.0))
# Per-issuer windowed aggregates over eligible rows only.
net_long_per_issuer = (
pl.when(eligible)
.then(signed)
.otherwise(pl.lit(0.0))
.sum()
.over("issuer_reference")
.clip(lower_bound=0.0)
)
gross_long_per_issuer = gross_long.sum().over("issuer_reference")
# Distribute the issuer's net long across its long rows pro-rata to
# their gross long value; eligible shorts (and longs in a net-short or
# fully-netted group) collapse to 0. Ineligible rows are untouched.
share = (
pl.when(gross_long_per_issuer > 0)
.then(gross_long / gross_long_per_issuer)
.otherwise(pl.lit(0.0))
)
netted_ead = net_long_per_issuer * share
return exposures.with_columns(
pl.when(eligible).then(netted_ead).otherwise(pl.col("ead_final")).alias("ead_final"),
)
_apply_equity_weights_pd_lgd — src/rwa_calc/engine/equity/calculator.py:896
@cites("CRR Art. 155(3)")
@cites("CRR Art. 165")
def _apply_equity_weights_pd_lgd(
self,
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""
Apply the Article 155(3) PD/LGD equity approach.
Risk-weighted exposure amounts are calculated with the corporate IRB K
formula (Art. 153(1)) using supervisory parameters from Art. 165:
- PD floor (Art. 165(1)): by equity sub-type —
exchange-traded long-term / non-exchange regular cash flow -> 0.09%,
exchange-traded (incl. short positions) -> 0.40%,
all other equity -> 1.25%.
- LGD (Art. 165(2)): 65% for sufficiently-diversified private equity
(equity_type == "private_equity_diversified"), else 90%.
- M (Art. 165(3)): fixed at 5 years.
- Scaling (Art. 153): 1.06 for CRR.
RWEA = K x 12.5 x scaling x MA x EAD, EL = PD x LGD x EAD. Per Art. 155(3)
the result is capped at the individual-exposure level so that
``EL x 12.5 + RWEA <= EAD x 12.5`` (equivalently RWEA <= EAD x 12.5 - EL x 12.5,
clamped at 0). A 1.5x scaling is applied to the risk weights where the
institution lacks Art. 178 default-definition data
(has_default_definition_info == False).
The IRB Simple transitional floor (PRA Rules 4.1-4.10) does NOT apply —
it is Simple-approach machinery.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
scaling_factor = scalar_value(resolved_pack.scalar_param("irb_scaling_factor"))
maturity = scalar_value(resolved_pack.scalar_param("equity_pd_lgd_maturity"))
equity_lgd = formula_float_map(resolved_pack.formula("equity_pd_lgd_lgd"))
lgd_diversified = equity_lgd["private_equity_diversified"]
lgd_other = equity_lgd["other"]
no_default_info_scaling = scalar_value(
resolved_pack.scalar_param("equity_pd_lgd_no_default_info_scaling")
)
equity_pd_floors = formula_float_map(resolved_pack.formula("equity_pd_floors"))
pd_floor_exchange_traded = equity_pd_floors["exchange_traded"]
pd_floor_other = equity_pd_floors["other"]
eq_type = pl.col("equity_type").str.to_lowercase()
is_exchange_traded = pl.col("is_exchange_traded").fill_null(False)
# Art. 165(1): PD floor by equity sub-type. Exchange-traded equity uses
# the 0.40% Art. 165(1)(c) floor; all other equity uses 1.25% (165(1)(d)).
pd_floored = (
pl.when(is_exchange_traded | (eq_type == "exchange_traded") | (eq_type == "listed"))
.then(pl.lit(pd_floor_exchange_traded))
.otherwise(pl.lit(pd_floor_other))
)
# Art. 165(2): supervisory LGD — 65% diversified PE, else 90%.
lgd = (
pl.when(eq_type == "private_equity_diversified")
.then(pl.lit(lgd_diversified))
.otherwise(pl.lit(lgd_other))
)
# Corporate IRB K formula inputs (Art. 153(1)). The shared expressions
# read exposure_class, turnover_m, requires_fi_scalar, maturity and
# has_one_day_maturity_floor — set them to the corporate-equity defaults.
exposures = exposures.with_columns(
pl.lit(ExposureClass.CORPORATE.value.upper()).alias("exposure_class"),
pl.lit(None).cast(pl.Float64).alias("turnover_m"),
pl.lit(False).alias("requires_fi_scalar"),
pl.lit(maturity).alias("maturity"),
pl.lit(False).alias("has_one_day_maturity_floor"),
pd_floored.alias("pd_floored"),
lgd.alias("lgd"),
)
correlation = _correlation_expr_from_pd(
pl.col("pd_floored"),
eur_gbp_rate=float(config.eur_gbp_rate),
is_b31=resolved_pack.feature("irb_correlation_sme_gbp_native"),
)
exposures = exposures.with_columns(correlation.alias("correlation"))
k = _capital_k_expr_from_params(pl.col("pd_floored"), pl.col("lgd"), pl.col("correlation"))
ma = _maturity_adjustment_expr_from_pd(pl.col("pd_floored"))
exposures = exposures.with_columns(
k.alias("k"),
ma.alias("maturity_adjustment"),
pl.lit(scaling_factor).alias("scaling_factor"),
)
# Art. 155(3): 1.5x scaling where the firm lacks Art. 178 default data.
no_default_info = ~pl.col("has_default_definition_info").fill_null(False)
rw_scaling = (
pl.when(no_default_info).then(pl.lit(no_default_info_scaling)).otherwise(pl.lit(1.0))
)
# Base risk weight (Art. 153(1)): K x 12.5 x scaling x MA, then 1.5x where applicable.
risk_weight = (
pl.col("k") * 12.5 * pl.col("scaling_factor") * pl.col("maturity_adjustment")
) * rw_scaling
exposures = exposures.with_columns(
risk_weight.alias("risk_weight"),
(pl.col("pd_floored") * pl.col("lgd") * pl.col("ead_final")).alias("expected_loss"),
)
# Uncapped RWEA = RW x EAD.
rwea = pl.col("risk_weight") * pl.col("ead_final")
# Art. 155(3) cap: EL x 12.5 + RWEA <= EAD x 12.5, i.e.
# RWEA <= EAD x 12.5 - EL x 12.5, clamped at 0.
rwea_cap = (pl.col("ead_final") * 12.5 - pl.col("expected_loss") * 12.5).clip(
lower_bound=0.0
)
rwea_capped = pl.min_horizontal(rwea, rwea_cap)
return exposures.with_columns(
(rwea > rwea_cap).alias("equity_pd_lgd_cap_binds"),
rwea_capped.alias("rwa"),
rwea_capped.alias("rwa_final"),
# Art. 155(3) PD/LGD method tag — kept OUT of Pillar 3 CR10.5, which
# discloses only the Art. 155(2) simple-RW method.
pl.lit(EquityApproach.PD_LGD.value).alias("equity_method"),
)
CRR Art. 160 — Probability of default (PD)¶
_pd_floor_expression — src/rwa_calc/engine/irb/formulas.py:110
@cites("CRR Art. 160")
@cites("CRR Art. 163")
@cites("PS1/26, paragraph 163")
def _pd_floor_expression(
config: CalculationConfig,
*,
has_transactor_col: bool = True,
exposure_class_col: str = "exposure_class",
transactor_col: str = "is_qrre_transactor",
pack: ResolvedRulepack | None = None,
) -> pl.Expr:
"""
Build Polars expression for per-exposure-class PD floor.
Under CRR the 0.03% floor has two separate homes and one gap:
- Art. 160(1): corporates and institutions ("The PD of an exposure to a
corporate or an institution shall be at least 0,03 %").
- Art. 163(1): retail (its own sub-section article).
- Central governments / central banks: NO floor — neither article reaches
them, so the pack's ``sovereign`` floor is 0 (P1.277).
Under Basel 3.1 (CRE30.55): Differentiated floors:
- Corporate/SME: 0.05%
- Retail mortgage: 0.10% (Art. 163(1)(b))
- QRRE transactors: 0.05%, revolvers: 0.10% (Art. 163(1)(c))
- Retail other: 0.05%
Args:
config: Calculation configuration
has_transactor_col: Whether the LazyFrame has the transactor column.
When True (pipeline path), uses per-row transactor/revolver distinction.
When False (isolated expressions), defaults to conservative revolver floor.
exposure_class_col: Name of the column to read the exposure class from.
Defaults to ``exposure_class`` (the borrower's class). For guarantor
PD substitution (CRR Art. 161(3) / B31 CRE22.70-85, Art. 160(4)),
pass ``guarantor_exposure_class`` so the floor reads the guarantor's
own class — the guaranteed portion is treated as a direct exposure
to the guarantor, so the guarantor's class floor governs.
transactor_col: Name of the QRRE transactor flag column. For guarantor
PD floors this is normally not relevant (guarantors are typically
not QRRE), but the parameter is exposed for symmetry with
``exposure_class_col``.
Required columns (no presence guard — see below):
- ``exposure_class_col``: always dereferenced.
- ``transactor_col``: dereferenced only when ``has_transactor_col`` is
True (the default). Isolated / hand-built frames that do not carry it
must pass ``has_transactor_col=False``, which selects the conservative
revolver floor.
Both columns are declared on every edge contract that feeds this builder —
``classifier_exit``, ``crm_exit`` and ``irb_branch`` carry ``exposure_class``,
``is_qrre_transactor`` and ``guarantor_exposure_class`` — so safety comes from
the producer seal rather than from a runtime presence check. A presence guard
here would be worse than a loud failure: falling back to a scalar floor would
silently reinstate the pre-P1.277 behaviour of ignoring the exposure class
(and with it the absent CGCB floor).
Returns a Polars expression evaluating to the per-row PD floor value.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
floors = formula_float_map(resolved_pack.formula("pd_floors"))
# Per-exposure-class floors (CRR Art. 160(1) / 163(1); B31 differentiated)
exp_class = pl.col(exposure_class_col).cast(pl.String).fill_null("CORPORATE").str.to_uppercase()
# QRRE transactor/revolver distinction (CRE30.55):
# Transactors (repay in full each period) get 0.03% floor;
# revolvers (carry balance) get 0.10% floor.
if has_transactor_col:
qrre_floor = (
pl.when(pl.col(transactor_col).fill_null(False))
.then(pl.lit(floors["retail_qrre_transactor"]))
.otherwise(pl.lit(floors["retail_qrre_revolver"]))
)
else:
# Conservative default: revolver floor (0.10% under Basel 3.1)
qrre_floor = pl.lit(floors["retail_qrre_revolver"])
sovereign_value = ExposureClass.CENTRAL_GOVT_CENTRAL_BANK.value.upper()
institution_value = ExposureClass.INSTITUTION.value.upper()
return (
pl.when(exp_class.str.contains("QRRE"))
.then(qrre_floor)
.when(exp_class.str.contains("MORTGAGE") | exp_class.str.contains("RESIDENTIAL"))
.then(pl.lit(floors["retail_mortgage"]))
.when(exp_class.str.contains("RETAIL"))
.then(pl.lit(floors["retail_other"]))
.when(exp_class == "CORPORATE_SME")
.then(pl.lit(floors["corporate_sme"]))
.when(exp_class == sovereign_value)
.then(pl.lit(floors["sovereign"]))
.when(exp_class == institution_value)
.then(pl.lit(floors["institution"]))
.otherwise(pl.lit(floors["corporate"]))
)
derive_purchased_receivables_pd — src/rwa_calc/engine/stages/classify/subtypes.py:315
@cites("CRR Art. 160(2)")
@cites("CRR Art. 160(6)")
@cites("PS1/26, paragraph 160")
def derive_purchased_receivables_pd(
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""
Derive the Art. 160(2)/(6) top-down PD for purchased corporate receivables.
Where an institution "is not able to estimate PDs or an institution's PD
estimates do not meet the requirements set out in Section 6", the PD is
prescribed rather than modelled:
- Art. 160(2)(a) — senior claims: ``PD = EL / LGD`` for those receivables.
- Art. 160(2)(b) — subordinated claims: ``PD = EL`` (no division).
- Art. 160(6) first sentence — dilution risk: ``PD = EL`` for dilution risk,
taken from the separate ``el_dilution_estimate`` input.
The (a) denominator is not a free choice. CRR Art. 161(1)(e)/(f)/(g) fix the
supervisory purchased-receivables LGDs for exactly the population that cannot
estimate PDs, and PS1/26 Art. 161(1)(e)-(g) with Art. 161(2)(a) bind the same
values to "where PD is determined in accordance with point (a) of Article
160(2)" for Foundation *and* Advanced IRB alike. So the denominator is the
subtype's supervisory LGD read from the same pack table the LGD side uses —
never a firm-supplied LGD, which removes any divide-by-null/zero surface.
Runs before the approach ladder because the IRB gate is
``internal_pd.is_not_null()``: without this the pool has no PD at all and
falls to the Standardised Approach.
Null semantics (conservative): a null, zero or negative EL estimate derives
nothing, leaving ``internal_pd`` / ``pd`` exactly as they were — an absent
estimate must never become PD 0%. A firm-supplied PD always wins, because
Art. 160(2) applies only where the institution cannot produce one.
The derived PD is capped at 1.0 — ``EL / LGD`` is unbounded above (an EL rate
of 60% over a 45% LGD gives 1.33) but a PD is a probability, and 100% is the
Art. 160(3) value for a defaulted obligor. No floor is applied here: the
Art. 160(1) 0.03% (PS1/26 0.05%) input floor is applied downstream by
``engine/irb/transforms.py::apply_pd_floor`` for every PD alike.
Class scope: purchased *corporate* receivables only — CORPORATE /
CORPORATE_SME on ``exposure_class_irb``. Art. 160(2) and Art. 160(6) both name
the corporate population, and the (a) denominator is a corporate supervisory
LGD (Art. 161(1)(e)); retail IRB is own-estimate only, with no supervisory LGD
and no Art. 163 senior EL/LGD limb to authorise the division. A retail row
carrying a subtype therefore derives nothing and keeps its existing route.
Regime scope: both. PS1/26 Art. 160(2)(a)-(c) and 160(6) carry the CRR text
over, so there is no regime Feature — only the pack's regime-keyed LGD values.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
lgd_table = firb_supervisory_lgd_values(resolved_pack)
# Art. 161(1)(e): the senior supervisory LGD — a cited pack value, always
# non-zero, so the Art. 160(2)(a) division is total.
senior_lgd = float(lgd_table["purchased_receivables_senior"])
subtype = pl.col("purchased_receivables_subtype")
# Art. 160(2) and Art. 160(6) both read "purchased CORPORATE receivables", and
# the Art. 161(1)(e)-(g) LGDs that supply the (a) denominator are likewise
# corporate rates. Retail IRB is own-estimate only — Art. 163 has no senior
# EL/LGD limb and no supervisory retail LGD exists — so without this gate a
# retail row carrying a subtype would divide its EL by the CORPORATE senior
# LGD and manufacture an unauthorised retail PD. Gating on the IRB class keeps
# the derivation inside the population the article names (``exposure_class_irb``
# is already synced by ``sync_irb_exposure_class``, which runs immediately
# before this transform in the classifier).
is_corporate = pl.col("exposure_class_irb").is_in(
[ExposureClass.CORPORATE.value, ExposureClass.CORPORATE_SME.value]
)
# A usable estimate is strictly positive: 0.0 is "not supplied", not "no loss".
default_risk_el = pl.when(pl.col("el_estimate") > 0.0).then(pl.col("el_estimate"))
dilution_el = pl.when(pl.col("el_dilution_estimate") > 0.0).then(pl.col("el_dilution_estimate"))
top_down_pd = (
pl.when(~is_corporate)
.then(pl.lit(None, dtype=pl.Float64))
.when(subtype == "senior")
.then(default_risk_el / pl.lit(senior_lgd))
.when(subtype == "subordinated")
.then(default_risk_el)
.when(subtype == "dilution_risk")
.then(dilution_el)
.otherwise(pl.lit(None, dtype=pl.Float64))
.clip(upper_bound=1.0)
)
# coalesce, not fill_null: the firm's own PD outranks the derivation, and a
# null derivation leaves the column untouched (no Float null ever filled).
derived_pd = pl.coalesce([pl.col("internal_pd"), top_down_pd])
return exposures.with_columns(
[derived_pd.alias("internal_pd"), pl.coalesce([pl.col("pd"), top_down_pd]).alias("pd")]
)
derive_purchased_receivables_pd — src/rwa_calc/engine/stages/classify/subtypes.py:316
@cites("CRR Art. 160(2)")
@cites("CRR Art. 160(6)")
@cites("PS1/26, paragraph 160")
def derive_purchased_receivables_pd(
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""
Derive the Art. 160(2)/(6) top-down PD for purchased corporate receivables.
Where an institution "is not able to estimate PDs or an institution's PD
estimates do not meet the requirements set out in Section 6", the PD is
prescribed rather than modelled:
- Art. 160(2)(a) — senior claims: ``PD = EL / LGD`` for those receivables.
- Art. 160(2)(b) — subordinated claims: ``PD = EL`` (no division).
- Art. 160(6) first sentence — dilution risk: ``PD = EL`` for dilution risk,
taken from the separate ``el_dilution_estimate`` input.
The (a) denominator is not a free choice. CRR Art. 161(1)(e)/(f)/(g) fix the
supervisory purchased-receivables LGDs for exactly the population that cannot
estimate PDs, and PS1/26 Art. 161(1)(e)-(g) with Art. 161(2)(a) bind the same
values to "where PD is determined in accordance with point (a) of Article
160(2)" for Foundation *and* Advanced IRB alike. So the denominator is the
subtype's supervisory LGD read from the same pack table the LGD side uses —
never a firm-supplied LGD, which removes any divide-by-null/zero surface.
Runs before the approach ladder because the IRB gate is
``internal_pd.is_not_null()``: without this the pool has no PD at all and
falls to the Standardised Approach.
Null semantics (conservative): a null, zero or negative EL estimate derives
nothing, leaving ``internal_pd`` / ``pd`` exactly as they were — an absent
estimate must never become PD 0%. A firm-supplied PD always wins, because
Art. 160(2) applies only where the institution cannot produce one.
The derived PD is capped at 1.0 — ``EL / LGD`` is unbounded above (an EL rate
of 60% over a 45% LGD gives 1.33) but a PD is a probability, and 100% is the
Art. 160(3) value for a defaulted obligor. No floor is applied here: the
Art. 160(1) 0.03% (PS1/26 0.05%) input floor is applied downstream by
``engine/irb/transforms.py::apply_pd_floor`` for every PD alike.
Class scope: purchased *corporate* receivables only — CORPORATE /
CORPORATE_SME on ``exposure_class_irb``. Art. 160(2) and Art. 160(6) both name
the corporate population, and the (a) denominator is a corporate supervisory
LGD (Art. 161(1)(e)); retail IRB is own-estimate only, with no supervisory LGD
and no Art. 163 senior EL/LGD limb to authorise the division. A retail row
carrying a subtype therefore derives nothing and keeps its existing route.
Regime scope: both. PS1/26 Art. 160(2)(a)-(c) and 160(6) carry the CRR text
over, so there is no regime Feature — only the pack's regime-keyed LGD values.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
lgd_table = firb_supervisory_lgd_values(resolved_pack)
# Art. 161(1)(e): the senior supervisory LGD — a cited pack value, always
# non-zero, so the Art. 160(2)(a) division is total.
senior_lgd = float(lgd_table["purchased_receivables_senior"])
subtype = pl.col("purchased_receivables_subtype")
# Art. 160(2) and Art. 160(6) both read "purchased CORPORATE receivables", and
# the Art. 161(1)(e)-(g) LGDs that supply the (a) denominator are likewise
# corporate rates. Retail IRB is own-estimate only — Art. 163 has no senior
# EL/LGD limb and no supervisory retail LGD exists — so without this gate a
# retail row carrying a subtype would divide its EL by the CORPORATE senior
# LGD and manufacture an unauthorised retail PD. Gating on the IRB class keeps
# the derivation inside the population the article names (``exposure_class_irb``
# is already synced by ``sync_irb_exposure_class``, which runs immediately
# before this transform in the classifier).
is_corporate = pl.col("exposure_class_irb").is_in(
[ExposureClass.CORPORATE.value, ExposureClass.CORPORATE_SME.value]
)
# A usable estimate is strictly positive: 0.0 is "not supplied", not "no loss".
default_risk_el = pl.when(pl.col("el_estimate") > 0.0).then(pl.col("el_estimate"))
dilution_el = pl.when(pl.col("el_dilution_estimate") > 0.0).then(pl.col("el_dilution_estimate"))
top_down_pd = (
pl.when(~is_corporate)
.then(pl.lit(None, dtype=pl.Float64))
.when(subtype == "senior")
.then(default_risk_el / pl.lit(senior_lgd))
.when(subtype == "subordinated")
.then(default_risk_el)
.when(subtype == "dilution_risk")
.then(dilution_el)
.otherwise(pl.lit(None, dtype=pl.Float64))
.clip(upper_bound=1.0)
)
# coalesce, not fill_null: the firm's own PD outranks the derivation, and a
# null derivation leaves the column untouched (no Float null ever filled).
derived_pd = pl.coalesce([pl.col("internal_pd"), top_down_pd])
return exposures.with_columns(
[derived_pd.alias("internal_pd"), pl.coalesce([pl.col("pd"), top_down_pd]).alias("pd")]
)
CRR Art. 161 — Loss Given Default (LGD)¶
apply_firb_supervisory_lgd_no_collateral — src/rwa_calc/engine/crm/collateral.py:589
@cites("CRR Art. 161")
def apply_firb_supervisory_lgd_no_collateral(
exposures: pl.LazyFrame,
config: CalculationConfig | None = None,
*,
pack: ResolvedRulepack | None = None,
is_basel_3_1: bool = False,
) -> pl.LazyFrame:
"""
Apply F-IRB supervisory LGD when no collateral is available.
For F-IRB exposures without collateral, uses supervisory LGD values:
- CRR Art. 161(1)(a): Senior unsecured 45%, Subordinated 75%
- Basel 3.1 Art. 161(1)(a)/(aa): FSE senior 45%, non-FSE senior 40%, Sub 75%
For A-IRB exposures under Basel 3.1:
- LGD Modelling + insufficient data (Art. 169B): own lgd_unsecured as LGDU
- Foundation election: supervisory LGDU (same as F-IRB)
- LGD Modelling + sufficient data: keep modelled LGD unchanged
Under CRR, A-IRB exposures always keep their modelled LGD.
Args:
exposures: Exposures with lgd_pre_crm
config: CalculationConfig (optional, for AIRB collateral method)
pack: Resolved rulepack; production threads the run's pack.
is_basel_3_1: No-config bootstrap regime hint for _resolve_pack_for_lgd
(direct unit-test path only). The regime BRANCHES read cited Features
off the resolved pack, not this flag (S9h).
Returns:
Exposures with lgd_post_crm set for F-IRB (and qualifying A-IRB)
"""
resolved_pack = _resolve_pack_for_lgd(pack, config, is_basel_3_1)
# S9h: read the regime branches as honest cited Features off the same resolved
# pack that supplies the LGD values. firb_fse_senior_lgd_split gates the FSE
# 45/40 split; airb_lgd_collateral_method_applicable gates the B31 Art. 169A/169B
# AIRB collateral-method branches (CRR AIRB is free-form).
fse_senior_lgd_split = resolved_pack.feature("firb_fse_senior_lgd_split")
airb_collateral_method_applies = resolved_pack.feature("airb_lgd_collateral_method_applicable")
lgd_values = supervisory_lgd_values(resolved_pack)
lgd_senior = lgd_values["unsecured"]
lgd_subordinated = subordinated_unsecured_lgd(resolved_pack)
# Add collateral-related columns with zero values for consistency
exposures = exposures.with_columns(
[
pl.lit(0.0).alias("total_collateral_for_lgd"),
pl.lit(0.0).alias("collateral_coverage_pct"),
]
)
# Determine LGD based on seniority for F-IRB
schema_names = set(exposures.collect_schema().names())
is_subordinated = (
pl.col("seniority").fill_null("").str.to_lowercase().is_in(["subordinated", "junior"])
if "seniority" in schema_names
else pl.lit(False)
)
# Under Basel 3.1, FSE senior unsecured = 45% (Art. 161(1)(a));
# non-FSE senior unsecured = 40% (Art. 161(1)(aa)).
# Under CRR, all senior unsecured = 45% (no FSE distinction).
if fse_senior_lgd_split and "cp_is_financial_sector_entity" in schema_names:
lgd_senior_fse = lgd_values["unsecured_fse"]
lgd_senior_expr = (
pl.when(pl.col("cp_is_financial_sector_entity").fill_null(False))
.then(pl.lit(lgd_senior_fse))
.otherwise(pl.lit(lgd_senior))
)
else:
lgd_senior_expr = pl.lit(lgd_senior)
# --- Determine AIRB treatment (Art. 169A/169B) ---
airb_method = config.airb_collateral_method if config else None
is_airb = pl.col("approach") == ApproachType.AIRB.value
if airb_collateral_method_applies and airb_method == AIRBCollateralMethod.FOUNDATION:
# AIRB Foundation election: use supervisory LGDU (same as FIRB)
uses_formula = (pl.col("approach") == ApproachType.FIRB.value) | is_airb
elif (
airb_collateral_method_applies
and airb_method == AIRBCollateralMethod.LGD_MODELLING
and "has_sufficient_collateral_data" in schema_names
):
# Art. 169B: AIRB with insufficient data → use own lgd_unsecured
_is_169b = is_airb & (
pl.col("has_sufficient_collateral_data").fill_null(True) == False # noqa: E712
)
own_lgdu = (
pl.coalesce(pl.col("lgd_unsecured"), pl.col("lgd_pre_crm"))
if "lgd_unsecured" in schema_names
else pl.col("lgd_pre_crm")
)
# Build the expression: FIRB uses supervisory, AIRB 169B uses own, AIRB full keeps modelled
exposures = exposures.with_columns(
[
pl.when((pl.col("approach") == ApproachType.FIRB.value) & is_subordinated)
.then(pl.lit(lgd_subordinated))
.when(pl.col("approach") == ApproachType.FIRB.value)
.then(lgd_senior_expr)
.when(_is_169b & is_subordinated)
.then(pl.lit(lgd_subordinated))
.when(_is_169b)
.then(own_lgdu)
.otherwise(pl.col("lgd_pre_crm"))
.alias("lgd_post_crm"),
]
)
return exposures
else:
# CRR or no method: standard FIRB/AIRB split
uses_formula = pl.col("approach") == ApproachType.FIRB.value
exposures = exposures.with_columns(
[
pl.when(uses_formula & is_subordinated)
.then(pl.lit(lgd_subordinated)) # Subordinated (same both frameworks)
.when(uses_formula)
.then(lgd_senior_expr) # Senior unsecured (FSE-aware under B31)
.otherwise(pl.col("lgd_pre_crm")) # A-IRB or SA: keep existing
.alias("lgd_post_crm"),
]
)
return exposures
_parametric_irb_risk_weight_expr — src/rwa_calc/engine/irb/formulas.py:989
@cites("CRR Art. 161")
def _parametric_irb_risk_weight_expr(
pd_expr: pl.Expr,
lgd: float | pl.Expr,
scaling_factor: float = 1.0,
eur_gbp_rate: float = 0.8732,
is_b31: bool = False,
sme_turnover_threshold_m: float = 44.0,
) -> pl.Expr:
"""
Compute IRB risk weight from arbitrary PD expression and LGD.
Used for Basel 3.1 parameter substitution (CRE22.70-85): when an IRB
exposure is guaranteed by an F-IRB counterparty, the guaranteed portion
uses the guarantor's PD and F-IRB supervisory LGD instead of the
borrower's parameters.
Reads exposure_class, turnover_m, maturity, requires_fi_scalar columns
from the LazyFrame. PD and LGD are substituted externally.
Args:
pd_expr: Polars expression for the substituted PD (e.g. guarantor PD, floored)
lgd: F-IRB supervisory LGD — either a fixed scalar (uniform LGD across
all rows) or a Polars expression (per-row LGD selection, e.g. when
seniority/FSE drives Art. 161(1)(a)/(aa)/(b) routing).
scaling_factor: 1.06 for CRR, 1.0 for Basel 3.1
eur_gbp_rate: EUR/GBP rate for SME turnover conversion (CRR only)
is_b31: If True, use GBP-native SME parameters per PRA PS1/26 Art. 153(4)
sme_turnover_threshold_m: Basel 3.1 SME turnover threshold in GBP millions
Returns:
Expression computing risk_weight = K × 12.5 × scaling × MA
"""
correlation = _correlation_expr_from_pd(
pd_expr,
eur_gbp_rate=eur_gbp_rate,
is_b31=is_b31,
sme_turnover_threshold_m=sme_turnover_threshold_m,
)
lgd_expr = lgd if isinstance(lgd, pl.Expr) else pl.lit(lgd)
k = _capital_k_expr_from_params(pd_expr, lgd_expr, correlation)
ma = _maturity_adjustment_expr_from_pd(pd_expr)
# Retail: no maturity adjustment (MA = 1.0)
exp_class = pl.col("exposure_class").cast(pl.String).fill_null("CORPORATE").str.to_uppercase()
is_retail = (
exp_class.str.contains("RETAIL")
| exp_class.str.contains("MORTGAGE")
| exp_class.str.contains("QRRE")
)
ma = pl.when(is_retail).then(pl.lit(1.0)).otherwise(ma)
return k * 12.5 * scaling_factor * ma
apply_guarantee_substitution — src/rwa_calc/engine/irb/guarantee.py:55
@cites("CRR Art. 161(3)")
def apply_guarantee_substitution(
lf: pl.LazyFrame, config: CalculationConfig, *, pack: ResolvedRulepack | None = None
) -> pl.LazyFrame:
"""
Apply guarantee substitution for IRB exposures with unfunded credit protection.
Three methods depending on framework and guarantor approach:
1. **SA risk weight substitution** (CRR Art. 215-217, Basel 3.1 SA guarantors):
Guaranteed portion uses guarantor's SA risk weight.
2. **Parameter substitution** (Basel 3.1 CRE22.70-85, IRB guarantors):
Guaranteed portion recalculated using guarantor's PD and F-IRB supervisory
LGD through the full IRB formula (K × 12.5 × scaling × MA).
3. **Double default** (CRR Art. 153(3), 202-203, CRR only):
K_dd = K_obligor × (0.15 + 160 × PD_guarantor). Requires A-IRB permission,
corporate underlying, and eligible guarantor with internal PD. Provides
lower capital charge than substitution for high-quality guarantors.
The final RWA blends:
- Unguaranteed portion: borrower's IRB RWA (pro-rated)
- Guaranteed portion: guarantor's equivalent RWA (method-dependent)
Args:
lf: LazyFrame with IRB formula results
config: Calculation configuration
Returns:
LazyFrame with guarantee-adjusted RWA
"""
schema = lf.collect_schema()
cols = schema.names()
# Run-level sentinel gate: guarantor_entity_type is the one crm_exit
# column still CONDITIONAL (inject=False) — present iff the CRM guarantee
# sub-step ran. Keying on it keeps this machinery (and its derived audit
# columns: rwa_irb_original, guarantor_rw*, guarantee_status, ...) off
# unguaranteed runs; see contracts/edges.py. The guaranteed_portion check
# covers direct (non-pipeline) invocation.
if "guaranteed_portion" not in cols or "guarantor_entity_type" not in cols:
return lf
has_expected_loss = "expected_loss" in cols
has_guarantor_pd = "guarantor_pd" in cols
# PD substitution applies whenever the guarantor has an internal PD.
# Per-row routing (IRB-derived RW vs SA-derived RW) is decided inside
# _apply_parameter_substitution by guarantor_approach, which is itself
# beneficiary-aware (set in engine/crm/guarantees.py). This covers both
# CRR Art. 161(3) and Basel 3.1 CRE22.70-85 — only the F-IRB LGD differs.
use_parameter_substitution = has_guarantor_pd
# Store original IRB values before substitution (pre-CRM values)
store_originals = [
pl.col("rwa").alias("rwa_irb_original"),
pl.col("risk_weight").alias("risk_weight_irb_original"),
pl.col("risk_weight").alias("pre_crm_risk_weight"),
pl.col("rwa").alias("pre_crm_rwa"),
]
if has_expected_loss:
store_originals.append(pl.col("expected_loss").alias("expected_loss_irb_original"))
lf = lf.with_columns(store_originals)
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
# --- Compute SA risk weight for guarantor (used for SA guarantors) ---
lf = _compute_guarantor_rw_sa(lf, cols, config, pack=resolved_pack)
# --- Basel 3.1 parameter substitution for IRB guarantors (CRE22.70-85) ---
lf = _apply_parameter_substitution(
lf, cols, config, use_parameter_substitution, pack=resolved_pack
)
# --- Double default treatment (CRR Art. 153(3), 202-203) ---
lf = _apply_double_default(lf, cols, config, has_guarantor_pd, pack=resolved_pack)
# --- Blend RWA and adjust expected loss ---
ead_col = "ead_final" if "ead_final" in cols else "ead"
# The Art. 193(1) benefit test on the IRB leg (guarantor RW < borrower IRB
# RW). Art. 193(1) binds the EXPECTED LOSS amount as well as the RWEA, and
# Art. 193(3)'s "may amend" reaches the IRB approach explicitly — which is
# why the basis here is Art. 193 and NOT Art. 113(3) (Chapter 2, SA-only) or
# Art. 213 (an eligibility gate on the protection contract, gated upstream).
# Declining rather than applying-and-capping, and the strict ``<``, are both
# elections the text does not force: the single recorded basis for this flag
# and every consumer of it is
# ``engine/sa/rw_adjustments.py::apply_guarantee_substitution``.
lf = lf.with_columns(
[
pl.when(
(pl.col("guaranteed_portion").fill_null(0) > 0)
& (pl.col("guarantor_rw").is_not_null())
& (pl.col("guarantor_rw") < pl.col("risk_weight_irb_original"))
)
.then(pl.lit(True))
.otherwise(pl.lit(False))
.alias("is_guarantee_beneficial"),
]
)
# Redistribute non-beneficial guarantee portions to beneficial guarantors.
# For multi-guarantor exposures, non-beneficial guarantors' EAD is reallocated
# to the most beneficial (lowest RW) guarantors using greedy fill.
from rwa_calc.engine.crm.guarantees import redistribute_non_beneficial
lf = redistribute_non_beneficial(lf)
# Calculate blended RWA using substitution approach. The predicate and the
# retained share are named once and reused below, so the post-model-adjustment
# DISCLOSURE carriers cannot drift from the RWA they decompose.
substituted = (
(pl.col("guaranteed_portion").fill_null(0) > 0)
& (pl.col("guarantor_rw").is_not_null())
& (pl.col("is_guarantee_beneficial"))
)
retained_share = (
pl.when(pl.col(ead_col) > 0)
.then(pl.col("unguaranteed_portion") / pl.col(ead_col))
.otherwise(pl.lit(1.0))
.fill_null(1.0)
)
substituted_rwa = pl.col("rwa_irb_original") * retained_share + pl.col(
"guaranteed_portion"
) * pl.col("guarantor_rw")
blend_exprs = [
pl.when(substituted)
.then(substituted_rwa)
.otherwise(pl.col("rwa_irb_original"))
.alias("rwa")
]
# PUT THE POST-MODEL-ADJUSTMENT DISCLOSURE CARRIERS ON THE SUBSTITUTED BASIS.
# ``apply_post_model_adjustments`` runs BEFORE this function, so its four
# carriers are all measured on the BORROWER basis. PS1/26 Annex II §3.3.1
# defines OF 08.01 col 0260 as ``0251 + 0252 + 0253 + 0254`` and the published
# rules state it as live ERROR checks (``boe_b0751`` / ``boe_b0763``). Writing
# out the blend shows why leaving them alone cannot satisfy it:
# rwa = (rwa_pre_adjustments + SUM adj) * share + gp * guarantor_rw
# so the identity holds only when col 0251 is
# ``rwa_pre_adjustments * share + gp * guarantor_rw`` and each adjustment is
# ``adj * share``. Leaving them on the borrower basis overstated the
# adjustments by ``adj * (1 - share)`` and left col 0251 exceeding col 0260 by
# exactly the Art. 235 relief (reproduced at 326,708.85 on one guaranteed leg).
# Scaling is substantively right independently of the identity: only the
# RETAINED share of a mortgage-floor or unrecognised-exposure overlay survives
# into the reported RWEA, and the substituted part carries no model overlay at
# all — which is why the whole ``gp x guarantor_rw`` term lands in the col 0251
# BASE. Art. 235 relief has no home among the three named adjustment columns
# and is NOT forced into one. Disclosure carriers only: ``rwa`` above is final
# and is not read here, so no RWA number moves.
rebased_disclosure = {
"rwa_pre_adjustments": pl.col("rwa_pre_adjustments") * retained_share
+ pl.col("guaranteed_portion") * pl.col("guarantor_rw"),
"post_model_adjustment_rwa": pl.col("post_model_adjustment_rwa") * retained_share,
"mortgage_rw_floor_adjustment": pl.col("mortgage_rw_floor_adjustment") * retained_share,
"unrecognised_exposure_adjustment": pl.col("unrecognised_exposure_adjustment")
* retained_share,
}
blend_exprs.extend(
pl.when(substituted).then(expr).otherwise(pl.col(name)).alias(name)
for name, expr in rebased_disclosure.items()
if name in cols
)
lf = lf.with_columns(blend_exprs)
# Calculate blended risk weight for reporting. Guard the divisor so a
# zero-EAD guaranteed row yields a finite 0.0 rather than 0/0 -> NaN (or
# x/0 -> inf); .fill_null does not catch a non-finite quotient.
lf = lf.with_columns(
[
pl.when(pl.col(ead_col) > 0)
.then(pl.col("rwa") / pl.col(ead_col))
.otherwise(pl.lit(0.0))
.fill_null(0.0)
.alias("risk_weight"),
]
)
# Adjust expected loss for guaranteed portion
if has_expected_loss:
lf = _adjust_expected_loss(
lf, config, ead_col, use_parameter_substitution, pack=resolved_pack
)
# Track guarantee status and method for reporting
lf = _add_guarantee_status_columns(lf)
# Drop internal tracking columns
lf = lf.drop("_is_pd_substitution", "_is_dd_applied", "guarantor_rw_sa")
return lf
apply_firb_lgd — src/rwa_calc/engine/irb/transforms.py:127
@cites("CRR Art. 161")
@cites("PS1/26, paragraph 161")
def apply_firb_lgd(
lf: pl.LazyFrame, config: CalculationConfig, *, pack: ResolvedRulepack | None = None
) -> pl.LazyFrame:
"""
Apply F-IRB supervisory LGD for Foundation IRB exposures.
CRR Art. 161(1)(a): Senior unsecured 45%, subordinated 75%
Basel 3.1 Art. 161(1)(a)/(aa): FSE senior 45%, non-FSE senior 40%, sub 75%
For F-IRB exposures with collateral, the CRM processor calculates
the effective LGD (lgd_post_crm) based on collateral type and coverage.
This function uses lgd_post_crm as the input LGD for risk weight calculation.
A-IRB exposures retain their own LGD estimates.
Args:
lf: IRB exposures frame
config: Calculation configuration
Returns:
LazyFrame with F-IRB LGD applied
"""
# Use framework-appropriate supervisory LGD values (rulepack-sourced).
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
lgd_table = firb_supervisory_lgd_values(resolved_pack)
default_lgd = float(lgd_table["unsecured_senior"])
sub_lgd = float(lgd_table["subordinated"])
# PRA PS1/26 / CRR Art. 161(1)(e)/(f)/(g): purchased-receivables sub-type LGDs.
# Takes precedence over the seniority-based selector when populated.
pr_senior_lgd = float(lgd_table["purchased_receivables_senior"])
pr_sub_lgd = float(lgd_table["purchased_receivables_subordinated"])
pr_dilution_lgd = float(lgd_table["dilution_risk"])
# Under Basel 3.1, FSE senior unsecured = 45% (Art. 161(1)(a));
# non-FSE = 40% (Art. 161(1)(aa)). Under CRR, all = 45% (no FSE split).
if resolved_pack.feature("firb_fse_senior_lgd_split"):
fse_lgd = float(lgd_table["unsecured_senior_fse"])
default_lgd_expr = (
pl.when(pl.col("cp_is_financial_sector_entity").fill_null(False))
.then(pl.lit(fse_lgd))
.otherwise(pl.lit(default_lgd))
)
else:
default_lgd_expr = pl.lit(default_lgd)
# Build the seniority-based supervisory LGD expression (used both as the
# F-IRB fallback for null lgd and as the override base for purchased
# receivables routing below).
seniority_based_lgd_expr = (
pl.when(pl.col("seniority").fill_null("senior").str.to_lowercase().str.contains("sub"))
.then(pl.lit(sub_lgd))
.otherwise(default_lgd_expr)
)
# Art. 161(1)(e)/(f)/(g) routing: when purchased_receivables_subtype is set
# the engine MUST dispatch via the subtype (not via seniority), because
# subordinated purchased receivables (100%) and dilution risk (100% B3.1
# / 75% CRR) deviate from the standard subordinated (75%) and senior
# (40%/45%) supervisory LGDs respectively.
pr_subtype = pl.col("purchased_receivables_subtype")
firb_lgd_expr = (
pl.when(pr_subtype == "senior")
.then(pl.lit(pr_senior_lgd))
.when(pr_subtype == "subordinated")
.then(pl.lit(pr_sub_lgd))
.when(pr_subtype == "dilution_risk")
.then(pl.lit(pr_dilution_lgd))
.otherwise(seniority_based_lgd_expr)
)
# Guarantee the P1.215 A-IRB own-estimate LGD carrier exists as a typed null
# so the coalesce below never errors on lending-only / direct-call frames
# (it is a CCR_EXIT_EDGE-only column). ensure_columns uses pl.lit().cast() —
# no fill_null — so the check-11 baseline is untouched.
lf = ensure_columns(
lf, {"ccr_modelled_lgd": ColumnSpec(pl.Float64, default=None, required=False)}
)
# The Art. 161(1)(e)-(g) subtype LGDs bind on BOTH IRB approaches for the
# Art. 160(2)/(6) top-down population — they are not a Foundation-only rate:
# - CRR Art. 161(1) opens "Institutions shall use the following LGD values"
# with no approach qualifier, and Art. 161(2) is its only escape, open
# where the institution "can decompose its EL estimates for purchased
# corporate receivables into PDs and LGDs" reliably.
# - PS1/26 Art. 161(2)(a) is explicit the other way round: an institution
# using the Advanced IRB Approach "shall apply" points (e)/(f)/(g) of
# paragraph 1 where PD is determined under Art. 160(2)(a)/(b) or the
# first sentence of Art. 160(6).
# A row carrying a subtype with no own AND no modelled LGD is by construction
# that population: the decomposition escape presupposes an LGD estimate, so a
# firm holding one supplies it and keeps it (CRR Art. 161(2) / PS1/26
# Art. 161(2)(b)(i)-(ii)). Gating this on ``approach == FIRB`` let an A-IRB
# row reach the generic senior-unsecured value — 45%/40% in place of the 100%
# subordinated and 75%/100% dilution rates, i.e. anti-conservative.
supervisory_subtype_applies = (
pr_subtype.is_not_null() & pl.col("lgd").is_null() & pl.col("ccr_modelled_lgd").is_null()
)
lf = lf.with_columns(
[
# FIRB rows with a cleared LGD take the supervisory value (the FIRB
# branch is checked first, so ``firb_clear_expr`` wins and the
# coalesce never applies to them). A-IRB rows keep their own LGD;
# a synthetic CCR/SFT A-IRB row carries it on ``ccr_modelled_lgd``
# (P1.215) rather than the lending ``lgd``, so coalesce those before
# the supervisory default-fill (CRR Art. 143 own-estimate LGD).
pl.when(
((pl.col("approach") == ApproachType.FIRB.value) & pl.col("lgd").is_null())
| supervisory_subtype_applies
)
.then(firb_lgd_expr)
.otherwise(
pl.coalesce([pl.col("lgd"), pl.col("ccr_modelled_lgd")]).fill_null(default_lgd)
)
.alias("lgd"),
]
)
# For lgd_input, use lgd_post_crm (from CRM processor).
# This ensures collateral-adjusted LGD is used for F-IRB risk weight calculation.
# Purchased-receivables sub-type LGDs (Art. 161(1)(e)/(f)/(g)) override the
# CRM-derived lgd_post_crm because they are unsecured supervisory rates that
# do not benefit from generic seniority/collateral adjustments.
lgd_input_expr = (
pl.when((pl.col("approach") == ApproachType.FIRB.value) & pr_subtype.is_not_null())
.then(pl.col("lgd"))
.when(pl.col("approach") == ApproachType.FIRB.value)
.then(pl.col("lgd_post_crm"))
.otherwise(pl.col("lgd"))
)
return lf.with_columns([lgd_input_expr.alias("lgd_input")])
CRR Art. 162 — Maturity¶
_maturity_adjustment_expr_from_pd — src/rwa_calc/engine/irb/formulas.py:872
@cites("CRR Art. 162(2)")
@cites("CRR Art. 162(3)")
def _maturity_adjustment_expr_from_pd(
pd_expr: pl.Expr,
maturity_floor: float = 1.0,
maturity_cap: float = 5.0,
) -> pl.Expr:
"""
Shared maturity adjustment expression accepting an arbitrary PD expression.
b = (0.11852 - 0.05478 × ln(PD))²
MA = (1 + (M - 2.5) × b) / (1 - 1.5 × b)
Retail exposures should have MA=1.0 applied externally (this function
does not check exposure class).
Maturity is clipped to ``[maturity_floor, maturity_cap]`` (default
[1y, 5y] per CRR Art. 162(2)) except where the input column
``has_one_day_maturity_floor`` is True. For carve-out rows the 1-year
floor is suppressed and the actual maturity (down to 1 day) flows
through to the formula. The 5-year cap is always applied.
Callers must ensure ``has_one_day_maturity_floor`` exists on the frame
(defaulting to False); the expression does no schema introspection.
References:
CRR Art. 153(1)(iii) — maturity adjustment formula
CRR Art. 162(2) — 1y floor / 5y cap
CRR Art. 162(3) — carve-out from the 1y floor for daily-margined SFTs
and derivatives, margin lending, and short-term self-liquidating
trade transactions
BCBS CRE32.46 / CRE32.50 — equivalent Basel 3.1 references
Args:
pd_expr: Polars expression for PD
maturity_floor: Minimum maturity in years (default 1.0). Suppressed
for rows with ``has_one_day_maturity_floor=True``.
maturity_cap: Maximum maturity in years (default 5.0). Always applied.
"""
has_carve_out = pl.col("has_one_day_maturity_floor").fill_null(False)
m_capped = pl.col("maturity").clip(upper_bound=maturity_cap)
m = pl.when(has_carve_out).then(m_capped).otherwise(m_capped.clip(lower_bound=maturity_floor))
# Safe PD for log calculation
pd_safe = pd_expr.clip(lower_bound=1e-10)
# b = (0.11852 - 0.05478 × ln(PD))²
b = (0.11852 - 0.05478 * pd_safe.log()) ** 2
# MA = (1 + (M - 2.5) × b) / (1 - 1.5 × b)
return (1.0 + (m - 2.5) * b) / (1.0 - 1.5 * b)
_maturity_adjustment_expr_from_pd — src/rwa_calc/engine/irb/formulas.py:873
@cites("CRR Art. 162(2)")
@cites("CRR Art. 162(3)")
def _maturity_adjustment_expr_from_pd(
pd_expr: pl.Expr,
maturity_floor: float = 1.0,
maturity_cap: float = 5.0,
) -> pl.Expr:
"""
Shared maturity adjustment expression accepting an arbitrary PD expression.
b = (0.11852 - 0.05478 × ln(PD))²
MA = (1 + (M - 2.5) × b) / (1 - 1.5 × b)
Retail exposures should have MA=1.0 applied externally (this function
does not check exposure class).
Maturity is clipped to ``[maturity_floor, maturity_cap]`` (default
[1y, 5y] per CRR Art. 162(2)) except where the input column
``has_one_day_maturity_floor`` is True. For carve-out rows the 1-year
floor is suppressed and the actual maturity (down to 1 day) flows
through to the formula. The 5-year cap is always applied.
Callers must ensure ``has_one_day_maturity_floor`` exists on the frame
(defaulting to False); the expression does no schema introspection.
References:
CRR Art. 153(1)(iii) — maturity adjustment formula
CRR Art. 162(2) — 1y floor / 5y cap
CRR Art. 162(3) — carve-out from the 1y floor for daily-margined SFTs
and derivatives, margin lending, and short-term self-liquidating
trade transactions
BCBS CRE32.46 / CRE32.50 — equivalent Basel 3.1 references
Args:
pd_expr: Polars expression for PD
maturity_floor: Minimum maturity in years (default 1.0). Suppressed
for rows with ``has_one_day_maturity_floor=True``.
maturity_cap: Maximum maturity in years (default 5.0). Always applied.
"""
has_carve_out = pl.col("has_one_day_maturity_floor").fill_null(False)
m_capped = pl.col("maturity").clip(upper_bound=maturity_cap)
m = pl.when(has_carve_out).then(m_capped).otherwise(m_capped.clip(lower_bound=maturity_floor))
# Safe PD for log calculation
pd_safe = pd_expr.clip(lower_bound=1e-10)
# b = (0.11852 - 0.05478 × ln(PD))²
b = (0.11852 - 0.05478 * pd_safe.log()) ** 2
# MA = (1 + (M - 2.5) × b) / (1 - 1.5 × b)
return (1.0 + (m - 2.5) * b) / (1.0 - 1.5 * b)
calculate_maturity_adjustment — src/rwa_calc/engine/irb/formulas.py:1225
@cites("CRR Art. 162")
def calculate_maturity_adjustment(
pd: float,
maturity: float,
has_one_day_maturity_floor: bool = False,
maturity_floor: float = 1.0,
maturity_cap: float = 5.0,
) -> float:
"""Scalar maturity adjustment calculation.
Wrapper around _polars_maturity_adjustment_expr() - uses the same
implementation as vectorized processing.
Args:
pd: Probability of default (floored)
maturity: Effective maturity in years
has_one_day_maturity_floor: If True, the 1-year M floor is suppressed
(CRR Art. 162(3) carve-out). The 5-year cap still applies.
maturity_floor: Minimum maturity (default 1.0). Suppressed when
``has_one_day_maturity_floor=True``.
maturity_cap: Maximum maturity (default 5.0). Always applied.
Returns:
Maturity adjustment factor
"""
pd_safe = max(pd, 1e-10)
return _run_scalar_via_vectorized(
{
"pd_floored": pd_safe,
"maturity": maturity,
"has_one_day_maturity_floor": has_one_day_maturity_floor,
},
"maturity_adjustment",
)
prepare_columns — src/rwa_calc/engine/irb/transforms.py:263
@cites("CRR Art. 162(1)")
def prepare_columns(
lf: pl.LazyFrame, config: CalculationConfig, *, pack: ResolvedRulepack | None = None
) -> pl.LazyFrame:
"""
Ensure all required columns exist with defaults.
Single schema check followed by one with_columns() for all defaults.
Args:
lf: IRB exposures frame
config: Calculation configuration
pack: Resolved rulepack (falls back to ``config`` when omitted) — supplies
the maturity-treatment regime Features.
Returns:
LazyFrame with all required columns
"""
# Maturity priority chain (highest wins) — see ``_build_maturity_exprs``:
# 1. effective_maturity input populated → firm override, clipped [1 day, 5y]
# 2. has_one_day_maturity_floor flag → M = 1/365 (Art. 162(3) carve-out:
# daily-margined SFTs/derivatives/margin lending, short-term trade)
# 3. Basel 3.1 revolving + facility_termination_date (Art. 162(2A)(k))
# 4. maturity_date standard derivation, clipped [1y, 5y]
# 5. Fallback default 2.5y
# The two CRR Art. 162(1) fixed F-IRB supervisory values are applied to the
# base chain (4/3) but are superseded by the two explicit overrides above:
# repo-style M = 0.5y always (regime Feature), and M = 2.5y for all other
# F-IRB exposures under the Art. 143 election (config.firb_fixed_maturity).
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
# Guarantee the CCR maturity-chain inputs exist as typed nulls so the
# carrier rung / FIRB-CCR_SFT gate never error on lending-only or
# direct-call frames (both absent from pad_crm_exit_defaults; the carrier
# is a CCR_EXIT_EDGE-only column). ensure_columns uses pl.lit().cast() —
# no fill_null — so the check-11 baseline is untouched. A typed null leaves
# the carrier rung inert (is_not_null() False) and lending rows untouched
# (risk_type null → eq_missing("CCR_SFT") False).
lf = ensure_columns(
lf,
{
"approach": ColumnSpec(pl.String, default=ApproachType.FIRB.value, required=False),
"risk_type": ColumnSpec(pl.String, default=None, required=False),
"ccr_effective_maturity": ColumnSpec(pl.Float64, default=None, required=False),
},
)
names = set(lf.collect_schema().names())
exprs = _prepare_columns_exprs(config, names, pack=resolved_pack)
if exprs:
return lf.with_columns(exprs)
return lf
calculate_maturity_adjustment — src/rwa_calc/engine/irb/transforms.py:481
@cites("CRR Art. 162")
def calculate_maturity_adjustment(lf: pl.LazyFrame, config: CalculationConfig) -> pl.LazyFrame:
"""
Calculate maturity adjustment for non-retail exposures.
MA = (1 + (M - 2.5) × b) / (1 - 1.5 × b)
where b = (0.11852 - 0.05478 × ln(PD))²
Retail exposures get MA = 1.0.
Reads ``has_one_day_maturity_floor`` to gate the 1-year M floor
(CRR Art. 162(3) carve-out); defaulted to False if absent.
Args:
lf: IRB exposures frame
config: Calculation configuration
Returns:
LazyFrame with maturity_adjustment column
"""
is_retail = (
pl.col("exposure_class")
.cast(pl.String)
.fill_null("CORPORATE")
.str.to_uppercase()
.str.contains("RETAIL")
)
return lf.with_columns(
pl.when(is_retail)
.then(pl.lit(1.0))
.otherwise(_polars_maturity_adjustment_expr())
.alias("maturity_adjustment")
)
_derive_ccr_sft_maturity_years — src/rwa_calc/engine/sft/fccm.py:232
@cites("CRR Art. 162")
@cites("PS1/26, paragraph 162")
def _derive_ccr_sft_maturity_years(
*,
remaining_years: float | None,
under_mna: bool,
qualifies_one_day_floor: bool,
qualifies_mna_intermediate_floor: bool,
pack: ResolvedRulepack,
) -> float | None:
"""Return the Art. 162 effective maturity M for one SFT netting set, or None.
The carrier is the FULL M = ``clip(remaining_years, floor, 5.0)`` — the floor
is a MINIMUM on the remaining maturity (Art. 162(2)(d)/(3)), never a fixed
replacement value. For a long-dated MNA exposure the floor does not bite and
M = ``remaining_years``. Returns ``None`` (the date-derived 1-year catch-all,
Art. 162(2)(f) / PS1/26 162(2A)(f)) when the row is not under a master netting
agreement or carries no maturity.
Floor precedence (all sub-1y floors require the MNA precondition):
- not under an MNA, or ``remaining_years is None`` -> ``None`` (1y catch-all).
- ``qualifies_one_day_floor`` (the three conjunctive Art. 162(3) conditions —
daily re-margin AND revaluation AND prompt-liquidation docs) -> the one-day
(~1/365 y) floor.
- else the 5BD repo/SFT floor (Art. 162(2)(d) / PS1/26 162(2A)(d)). Under B31
the intermediate floor additionally requires the 162(2A)(c)/(d) daily
documentation condition (gated by the
``mna_intermediate_floor_requires_daily_condition`` feature); without it the
row falls to the 1-year catch-all (``None``). Under CRR the floor applies on
MNA alone (the feature is off).
Floors / feature are read from the RUN ``pack`` (not the module ``_PACK``) so
the derivation is regime-correct.
Args:
remaining_years: Exact /365 fractional years to maturity, or None.
under_mna: Art. 162(2) master-netting-agreement precondition.
qualifies_one_day_floor: All three Art. 162(3) conditions hold.
qualifies_mna_intermediate_floor: The B31 162(2A)(c)/(d) daily condition.
pack: The resolved run rulepack supplying the cited maturity floors / gate.
Returns:
M as a float, or ``None`` for the date-derived 1-year catch-all.
"""
if not under_mna or remaining_years is None:
return None
cap = 5.0
if qualifies_one_day_floor:
floor = float(pack.scalar_param("one_day_maturity_floor_years").value)
else:
requires_daily = pack.feature("mna_intermediate_floor_requires_daily_condition")
intermediate_available = (not requires_daily) or qualifies_mna_intermediate_floor
if not intermediate_available:
return None
floor = float(pack.scalar_param("irb_maturity_floor_repo_sft_years").value)
return min(max(remaining_years, floor), cap)
CRR Art. 163 — Probability of default (PD)¶
_pd_floor_expression — src/rwa_calc/engine/irb/formulas.py:111
@cites("CRR Art. 160")
@cites("CRR Art. 163")
@cites("PS1/26, paragraph 163")
def _pd_floor_expression(
config: CalculationConfig,
*,
has_transactor_col: bool = True,
exposure_class_col: str = "exposure_class",
transactor_col: str = "is_qrre_transactor",
pack: ResolvedRulepack | None = None,
) -> pl.Expr:
"""
Build Polars expression for per-exposure-class PD floor.
Under CRR the 0.03% floor has two separate homes and one gap:
- Art. 160(1): corporates and institutions ("The PD of an exposure to a
corporate or an institution shall be at least 0,03 %").
- Art. 163(1): retail (its own sub-section article).
- Central governments / central banks: NO floor — neither article reaches
them, so the pack's ``sovereign`` floor is 0 (P1.277).
Under Basel 3.1 (CRE30.55): Differentiated floors:
- Corporate/SME: 0.05%
- Retail mortgage: 0.10% (Art. 163(1)(b))
- QRRE transactors: 0.05%, revolvers: 0.10% (Art. 163(1)(c))
- Retail other: 0.05%
Args:
config: Calculation configuration
has_transactor_col: Whether the LazyFrame has the transactor column.
When True (pipeline path), uses per-row transactor/revolver distinction.
When False (isolated expressions), defaults to conservative revolver floor.
exposure_class_col: Name of the column to read the exposure class from.
Defaults to ``exposure_class`` (the borrower's class). For guarantor
PD substitution (CRR Art. 161(3) / B31 CRE22.70-85, Art. 160(4)),
pass ``guarantor_exposure_class`` so the floor reads the guarantor's
own class — the guaranteed portion is treated as a direct exposure
to the guarantor, so the guarantor's class floor governs.
transactor_col: Name of the QRRE transactor flag column. For guarantor
PD floors this is normally not relevant (guarantors are typically
not QRRE), but the parameter is exposed for symmetry with
``exposure_class_col``.
Required columns (no presence guard — see below):
- ``exposure_class_col``: always dereferenced.
- ``transactor_col``: dereferenced only when ``has_transactor_col`` is
True (the default). Isolated / hand-built frames that do not carry it
must pass ``has_transactor_col=False``, which selects the conservative
revolver floor.
Both columns are declared on every edge contract that feeds this builder —
``classifier_exit``, ``crm_exit`` and ``irb_branch`` carry ``exposure_class``,
``is_qrre_transactor`` and ``guarantor_exposure_class`` — so safety comes from
the producer seal rather than from a runtime presence check. A presence guard
here would be worse than a loud failure: falling back to a scalar floor would
silently reinstate the pre-P1.277 behaviour of ignoring the exposure class
(and with it the absent CGCB floor).
Returns a Polars expression evaluating to the per-row PD floor value.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
floors = formula_float_map(resolved_pack.formula("pd_floors"))
# Per-exposure-class floors (CRR Art. 160(1) / 163(1); B31 differentiated)
exp_class = pl.col(exposure_class_col).cast(pl.String).fill_null("CORPORATE").str.to_uppercase()
# QRRE transactor/revolver distinction (CRE30.55):
# Transactors (repay in full each period) get 0.03% floor;
# revolvers (carry balance) get 0.10% floor.
if has_transactor_col:
qrre_floor = (
pl.when(pl.col(transactor_col).fill_null(False))
.then(pl.lit(floors["retail_qrre_transactor"]))
.otherwise(pl.lit(floors["retail_qrre_revolver"]))
)
else:
# Conservative default: revolver floor (0.10% under Basel 3.1)
qrre_floor = pl.lit(floors["retail_qrre_revolver"])
sovereign_value = ExposureClass.CENTRAL_GOVT_CENTRAL_BANK.value.upper()
institution_value = ExposureClass.INSTITUTION.value.upper()
return (
pl.when(exp_class.str.contains("QRRE"))
.then(qrre_floor)
.when(exp_class.str.contains("MORTGAGE") | exp_class.str.contains("RESIDENTIAL"))
.then(pl.lit(floors["retail_mortgage"]))
.when(exp_class.str.contains("RETAIL"))
.then(pl.lit(floors["retail_other"]))
.when(exp_class == "CORPORATE_SME")
.then(pl.lit(floors["corporate_sme"]))
.when(exp_class == sovereign_value)
.then(pl.lit(floors["sovereign"]))
.when(exp_class == institution_value)
.then(pl.lit(floors["institution"]))
.otherwise(pl.lit(floors["corporate"]))
)
apply_pd_floor — src/rwa_calc/engine/irb/transforms.py:320
@cites("CRR Art. 163")
@cites("PS1/26, paragraph 163")
def apply_pd_floor(
lf: pl.LazyFrame, config: CalculationConfig, *, pack: ResolvedRulepack | None = None
) -> pl.LazyFrame:
"""
Apply PD floor based on configuration.
CRR (Art. 163): 0.03% for all classes
Basel 3.1 (CRE30.55): Differentiated by class
- Corporate/SME: 0.05%
- Retail mortgage: 0.05%
- QRRE revolvers: 0.10%, transactors: 0.03%
- Retail other: 0.05%
Args:
lf: IRB exposures frame
config: Calculation configuration
pack: Resolved rulepack (falls back to ``config`` when omitted)
Returns:
LazyFrame with pd_floored column
"""
pd_floor_expr = _pd_floor_expression(config, pack=pack)
# fill_nan(None) so a NaN PD is treated as null and raised to the floor
# (max_horizontal/clip do not scrub NaN); see apply_all_formulas.
return lf.with_columns(
pl.max_horizontal(pl.col("pd").fill_nan(None), pd_floor_expr).alias("pd_floored")
)
CRR Art. 164 — Loss Given Default (LGD)¶
check_retail_re_portfolio_lgd_floors — src/rwa_calc/engine/aggregator/_lgd_floor_check.py:46
@cites("CRR Art. 164")
def check_retail_re_portfolio_lgd_floors(
combined: pl.DataFrame,
pack: ResolvedRulepack,
) -> list[CalculationError]:
"""Return one IRB007 WARNING per A-IRB retail-RE sub-portfolio below its LGD floor.
``combined`` is the aggregator's already-materialised merged per-approach
results frame (``combined_df``) — reused so no extra collect is needed. The
population is the A-IRB retail-mortgage book minus central-government-
guaranteed legs (Art. 164(4)); it is split into residential
(``property_type != "commercial"``, null -> residential) and commercial
(``property_type == "commercial"``) sub-portfolios, and each is tested for
EAD-weighted-average own-estimate LGD below its floor (residential 10% /
commercial 15%, read from the resolved pack). An empty sub-portfolio or a
zero total EAD raises no warning, so at most two warnings are returned.
"""
# Columns the population predicate and the EW-avg aggregation require. When
# any is absent the check is skipped (returns no warnings) rather than
# raising, so it stays inert on frames without the A-IRB/guarantee
# provenance columns. Kept function-local — these are internal result-frame
# column names, not a validation string-enum for data/schemas.py.
required_columns = (
"is_airb",
"exposure_class",
"property_type",
"lgd",
"ead_final",
"is_guaranteed",
"guarantor_exposure_class",
)
if not set(required_columns) <= set(combined.columns):
return []
resi_floor = float(pack.scalar("retail_residential_re_portfolio_lgd_floor"))
comm_floor = float(pack.scalar("retail_commercial_re_portfolio_lgd_floor"))
# Art. 164(4) population: A-IRB retail mortgages, minus the Art. 164(4)
# central-government-guarantee carve-out (that leg substitutes a 0%-RW
# obligor, so its own-estimate LGD is not the binding floor input).
in_population = (
pl.col("is_airb")
& (pl.col("exposure_class") == "retail_mortgage")
& ~(
pl.col("is_guaranteed")
& (pl.col("guarantor_exposure_class") == "central_govt_central_bank")
)
)
# Residential vs commercial bucket; a null property_type falls to residential.
bucket = (
pl.when(pl.col("property_type") == "commercial")
.then(pl.lit("commercial"))
.otherwise(pl.lit("residential"))
)
# Eager group_by/agg on the already-materialised frame (no new collect).
per_bucket = (
combined.filter(in_population)
.with_columns(bucket.alias("_re_bucket"))
.group_by("_re_bucket")
.agg(
(pl.col("lgd") * pl.col("ead_final")).sum().alias("_lgd_ead"),
pl.col("ead_final").sum().alias("_ead"),
pl.len().alias("_n"),
)
)
floors = {"residential": resi_floor, "commercial": comm_floor}
warnings: list[CalculationError] = []
for row in per_bucket.iter_rows(named=True):
total_ead = row["_ead"] or 0.0
if total_ead <= 0.0:
continue
ew_avg_lgd = row["_lgd_ead"] / total_ead
floor = floors[row["_re_bucket"]]
if ew_avg_lgd < floor:
warnings.append(
_portfolio_lgd_floor_warning(
row["_re_bucket"], ew_avg_lgd, floor, total_ead, int(row["_n"])
)
)
return warnings
_lgd_floor_expression — src/rwa_calc/engine/irb/formulas.py:208
@cites("CRR Art. 164")
@cites("PS1/26, paragraph 164")
def _lgd_floor_expression(
config: CalculationConfig,
*,
has_seniority: bool = False,
has_exposure_class: bool = False,
pack: ResolvedRulepack | None = None,
) -> pl.Expr:
"""
Build Polars expression for LGD floor (no collateral_type column).
Under CRR: No LGD floors (returns 0.0).
Under Basel 3.1: Differentiated floors for A-IRB by exposure class:
Corporate (Art. 161(5)): 25% unsecured (senior & subordinated alike)
Retail (Art. 164(4)):
- retail_mortgage: 5% (assumed RRE-secured)
- retail_qrre: 50% (Art. 164(4)(b)(i))
- retail_other: 30% (Art. 164(4)(b)(ii))
Without exposure_class, falls back to seniority-based logic (conservative).
Without either, defaults to 25% unsecured floor.
Returns a Polars expression evaluating to the per-row LGD floor value.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
if not resolved_pack.feature("airb_lgd_floor"):
return pl.lit(0.0)
floors = formula_float_map(resolved_pack.formula("lgd_floors"))
if has_exposure_class:
# Route by exposure class — retail gets Art. 164(4) floors
exp_class = pl.col("exposure_class").cast(pl.String).str.to_lowercase()
return (
pl.when(exp_class.is_in(["retail_mortgage"]))
.then(pl.lit(floors["retail_rre"])) # 5% Art. 164(4)(a)
.when(exp_class.is_in(["retail_qrre"]))
.then(pl.lit(floors["retail_qrre_unsecured"])) # 50% Art. 164(4)(b)(i)
.when(exp_class.is_in(["retail_other"]))
.then(pl.lit(floors["retail_other_unsecured"])) # 30% Art. 164(4)(b)(ii)
.otherwise(pl.lit(floors["unsecured"])) # 25% Art. 161(5)
)
if has_seniority:
# Fallback without exposure_class: corporate A-IRB applies a single 25%
# unsecured floor regardless of seniority (Art. 161(5)). The 50%
# subordinated_unsecured value is the F-IRB supervisory LGD per
# Art. 161(1)(b), not an A-IRB floor — do not branch on seniority here.
return pl.lit(floors["unsecured"])
# Default to unsecured floor (25%) — most conservative for senior
return pl.lit(floors["unsecured"])
apply_lgd_floor — src/rwa_calc/engine/irb/transforms.py:351
@cites("CRR Art. 164")
@cites("PS1/26, paragraph 164")
def apply_lgd_floor(
lf: pl.LazyFrame, config: CalculationConfig, *, pack: ResolvedRulepack | None = None
) -> pl.LazyFrame:
"""
Apply LGD floor for Basel 3.1 A-IRB exposures.
Uses lgd_input (which contains collateral-adjusted LGD for F-IRB)
as the base for flooring.
CRR: No LGD floor (A-IRB models LGD freely)
Basel 3.1: Differentiated floors by collateral type and exposure class:
- Corporate unsecured (senior & subordinated): 25% (Art. 161(5)(a))
- Retail QRRE unsecured: 50% (Art. 164(4)(b)(i))
- Financial: 0%, Receivables: 10%
- RRE: 10%, CRE: 10%, Other physical: 15%
- Secured / PARTIALLY secured (all classes bar retail_mortgage): the
Art. 230/231 LGD* blend of those LGDS values with the class LGDU
(Art. 161(5)(b) corporates & institutions, Art. 164(4)(c) retail)
LGD floors only apply to A-IRB own-estimate LGDs. F-IRB supervisory
LGDs are regulatory values and don't need flooring.
Args:
lf: IRB exposures frame
config: Calculation configuration
pack: Resolved rulepack (falls back to ``config`` when omitted)
Returns:
LazyFrame with lgd_floored column
"""
schema = lf.collect_schema()
schema_names = schema.names()
lgd_col = "lgd_input" if "lgd_input" in schema_names else "lgd"
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
if resolved_pack.feature("airb_lgd_floor"):
if "collateral_type" in schema_names:
lgd_floor_expr = _lgd_floor_expression_with_collateral(
config,
has_seniority=True,
has_exposure_class=True,
pack=resolved_pack,
)
else:
lgd_floor_expr = _lgd_floor_expression(
config,
has_seniority=True,
has_exposure_class=True,
pack=resolved_pack,
)
# Art. 161(5)(b) / 164(4)(c) LGD* blend: applies to every class except
# retail_mortgage once recognised collateral is present; falls back to
# the single-type / flat floor otherwise
blended_expr = _lgd_floor_blended_expression(config, pack=resolved_pack)
lgd_floor_expr = (
pl.when(blended_expr.is_not_null()).then(blended_expr).otherwise(lgd_floor_expr)
)
# LGD floors only apply to A-IRB (CRE30.41); F-IRB uses supervisory LGD
is_airb = pl.col("is_airb").fill_null(False) if "is_airb" in schema_names else pl.lit(False)
# fill_nan(None): a NaN own-estimate LGD passes through max_horizontal;
# treat it as null so the A-IRB regulatory LGD floor governs (conservative).
floored_lgd = pl.max_horizontal(pl.col(lgd_col).fill_nan(None), lgd_floor_expr)
return lf.with_columns(
pl.when(is_airb).then(floored_lgd).otherwise(pl.col(lgd_col)).alias("lgd_floored")
)
return lf.with_columns(pl.col(lgd_col).alias("lgd_floored"))
CRR Art. 165 — Equity exposures subject to the PD/LGD method¶
_apply_equity_weights_pd_lgd — src/rwa_calc/engine/equity/calculator.py:897
@cites("CRR Art. 155(3)")
@cites("CRR Art. 165")
def _apply_equity_weights_pd_lgd(
self,
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""
Apply the Article 155(3) PD/LGD equity approach.
Risk-weighted exposure amounts are calculated with the corporate IRB K
formula (Art. 153(1)) using supervisory parameters from Art. 165:
- PD floor (Art. 165(1)): by equity sub-type —
exchange-traded long-term / non-exchange regular cash flow -> 0.09%,
exchange-traded (incl. short positions) -> 0.40%,
all other equity -> 1.25%.
- LGD (Art. 165(2)): 65% for sufficiently-diversified private equity
(equity_type == "private_equity_diversified"), else 90%.
- M (Art. 165(3)): fixed at 5 years.
- Scaling (Art. 153): 1.06 for CRR.
RWEA = K x 12.5 x scaling x MA x EAD, EL = PD x LGD x EAD. Per Art. 155(3)
the result is capped at the individual-exposure level so that
``EL x 12.5 + RWEA <= EAD x 12.5`` (equivalently RWEA <= EAD x 12.5 - EL x 12.5,
clamped at 0). A 1.5x scaling is applied to the risk weights where the
institution lacks Art. 178 default-definition data
(has_default_definition_info == False).
The IRB Simple transitional floor (PRA Rules 4.1-4.10) does NOT apply —
it is Simple-approach machinery.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
scaling_factor = scalar_value(resolved_pack.scalar_param("irb_scaling_factor"))
maturity = scalar_value(resolved_pack.scalar_param("equity_pd_lgd_maturity"))
equity_lgd = formula_float_map(resolved_pack.formula("equity_pd_lgd_lgd"))
lgd_diversified = equity_lgd["private_equity_diversified"]
lgd_other = equity_lgd["other"]
no_default_info_scaling = scalar_value(
resolved_pack.scalar_param("equity_pd_lgd_no_default_info_scaling")
)
equity_pd_floors = formula_float_map(resolved_pack.formula("equity_pd_floors"))
pd_floor_exchange_traded = equity_pd_floors["exchange_traded"]
pd_floor_other = equity_pd_floors["other"]
eq_type = pl.col("equity_type").str.to_lowercase()
is_exchange_traded = pl.col("is_exchange_traded").fill_null(False)
# Art. 165(1): PD floor by equity sub-type. Exchange-traded equity uses
# the 0.40% Art. 165(1)(c) floor; all other equity uses 1.25% (165(1)(d)).
pd_floored = (
pl.when(is_exchange_traded | (eq_type == "exchange_traded") | (eq_type == "listed"))
.then(pl.lit(pd_floor_exchange_traded))
.otherwise(pl.lit(pd_floor_other))
)
# Art. 165(2): supervisory LGD — 65% diversified PE, else 90%.
lgd = (
pl.when(eq_type == "private_equity_diversified")
.then(pl.lit(lgd_diversified))
.otherwise(pl.lit(lgd_other))
)
# Corporate IRB K formula inputs (Art. 153(1)). The shared expressions
# read exposure_class, turnover_m, requires_fi_scalar, maturity and
# has_one_day_maturity_floor — set them to the corporate-equity defaults.
exposures = exposures.with_columns(
pl.lit(ExposureClass.CORPORATE.value.upper()).alias("exposure_class"),
pl.lit(None).cast(pl.Float64).alias("turnover_m"),
pl.lit(False).alias("requires_fi_scalar"),
pl.lit(maturity).alias("maturity"),
pl.lit(False).alias("has_one_day_maturity_floor"),
pd_floored.alias("pd_floored"),
lgd.alias("lgd"),
)
correlation = _correlation_expr_from_pd(
pl.col("pd_floored"),
eur_gbp_rate=float(config.eur_gbp_rate),
is_b31=resolved_pack.feature("irb_correlation_sme_gbp_native"),
)
exposures = exposures.with_columns(correlation.alias("correlation"))
k = _capital_k_expr_from_params(pl.col("pd_floored"), pl.col("lgd"), pl.col("correlation"))
ma = _maturity_adjustment_expr_from_pd(pl.col("pd_floored"))
exposures = exposures.with_columns(
k.alias("k"),
ma.alias("maturity_adjustment"),
pl.lit(scaling_factor).alias("scaling_factor"),
)
# Art. 155(3): 1.5x scaling where the firm lacks Art. 178 default data.
no_default_info = ~pl.col("has_default_definition_info").fill_null(False)
rw_scaling = (
pl.when(no_default_info).then(pl.lit(no_default_info_scaling)).otherwise(pl.lit(1.0))
)
# Base risk weight (Art. 153(1)): K x 12.5 x scaling x MA, then 1.5x where applicable.
risk_weight = (
pl.col("k") * 12.5 * pl.col("scaling_factor") * pl.col("maturity_adjustment")
) * rw_scaling
exposures = exposures.with_columns(
risk_weight.alias("risk_weight"),
(pl.col("pd_floored") * pl.col("lgd") * pl.col("ead_final")).alias("expected_loss"),
)
# Uncapped RWEA = RW x EAD.
rwea = pl.col("risk_weight") * pl.col("ead_final")
# Art. 155(3) cap: EL x 12.5 + RWEA <= EAD x 12.5, i.e.
# RWEA <= EAD x 12.5 - EL x 12.5, clamped at 0.
rwea_cap = (pl.col("ead_final") * 12.5 - pl.col("expected_loss") * 12.5).clip(
lower_bound=0.0
)
rwea_capped = pl.min_horizontal(rwea, rwea_cap)
return exposures.with_columns(
(rwea > rwea_cap).alias("equity_pd_lgd_cap_binds"),
rwea_capped.alias("rwa"),
rwea_capped.alias("rwa_final"),
# Art. 155(3) PD/LGD method tag — kept OUT of Pillar 3 CR10.5, which
# discloses only the Art. 155(2) simple-RW method.
pl.lit(EquityApproach.PD_LGD.value).alias("equity_method"),
)
CRR Art. 166 — Exposures to corporates, institutions, central governments and central banks and retail exposures¶
_firb_ccf_for_col — src/rwa_calc/engine/ccf.py:212
@cites("CRR Art. 166")
def _firb_ccf_for_col(risk_type_col: str = "risk_type") -> pl.Expr:
"""Polars expression for CRR F-IRB CCFs (Art. 166(8) + (10)).
Implements both F-IRB CCF clauses of CRR Article 166:
Art. 166(8) bespoke CCFs (is_obs_commitment=True, matching commitment):
(a) UCC credit lines (LR) -> 0%; (b) short-term trade LCs
(MLR + is_short_term_trade_lc) -> 20%; (d) other credit lines /
NIFs / RUFs (MR/MLR/OC commitments) -> 75%.
Art. 166(10) residual fallback (is_obs_commitment=False):
(a) full risk -> 100%; (b) medium -> 50%; (c) medium/low -> 20%;
(d) low -> 0%.
FR/FRC and LR converge under either path; the Art. 166(8)(b) trade-LC
carve-out wins over the issued/commitment split. Values come from the
rulepack (``firb_obs_fallback_ccf`` lookup + the bespoke scalars).
"""
canonical = _normalize_risk_type(risk_type_col)
is_commitment = pl.col("is_obs_commitment").fill_null(True)
is_trade_lc = pl.col("is_short_term_trade_lc").fill_null(False)
is_mlr = canonical == "MLR"
# MR_ISSUED (CRR Annex I Row 3 issued OBS items) mirrors MR exactly: it
# rides the same Art. 166(8)(d) commitment / Art. 166(10)(b) issued split,
# so it never diverges to the otherwise default (P2.30).
is_mr_or_oc = canonical.is_in(["MR", "MR_ISSUED", "OC"])
return (
# FR/FRC -> 100% under both Art. 166(8) general and Art. 166(10)(a)
pl.when(canonical.is_in(["FR", "FRC"]))
.then(pl.lit(_FIRB_OBS_FALLBACK_MAP["FR"]))
# LR -> 0% under both Art. 166(8)(a) and Art. 166(10)(d)
.when(canonical == "LR")
.then(pl.lit(_FIRB_OBS_FALLBACK_MAP["LR"]))
# Art. 166(8)(b): short-term trade LC carve-out wins over both buckets
.when(is_mlr & is_trade_lc)
.then(pl.lit(_FIRB_TRADE_LC_CCF))
# Art. 166(8)(d): credit lines / NIFs / RUFs -> 75%
.when(is_commitment & (is_mr_or_oc | is_mlr))
.then(pl.lit(_FIRB_CREDIT_LINE_CCF))
# Art. 166(10)(b): MR / OC issued items -> 50%
.when(is_mr_or_oc)
.then(pl.lit(_FIRB_OBS_FALLBACK_MAP["MR"]))
# Art. 166(10)(c): MLR issued items -> 20%
.when(is_mlr)
.then(pl.lit(_FIRB_OBS_FALLBACK_MAP["MLR"]))
# Conservative MR-equivalent fallback for unrecognised risk_type values
.otherwise(pl.lit(_SA_CCF_DEFAULT))
)
apply_ccf — src/rwa_calc/engine/ccf.py:288
@cites("CRR Art. 111")
@cites("CRR Art. 166")
def apply_ccf(
self,
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""
Apply CCF to calculate EAD for off-balance sheet exposures.
CCF determination follows CRR Art. 111 categories based on risk_type:
- SA: FR=100%, MR=50%, MLR=20%, LR=0%
- F-IRB Art. 166(8)(d): MR/MLR/OC commitments (credit lines / NIFs / RUFs)
when ``is_obs_commitment=True`` -> 75%
- F-IRB Art. 166(10) fallback: issued OBS items (``is_obs_commitment=False``)
-> 100% FR / 50% MR / 20% MLR / 0% LR
- F-IRB Art. 166(8)(b): MLR with ``is_short_term_trade_lc=True`` -> 20%
- A-IRB CRR: Uses ccf_modelled if provided, otherwise falls back to SA
- A-IRB B31: Own CCF only for revolving (non-100% SA); else SA CCF (Art. 166D)
- Art. 111(1)(c): When underlying_risk_type is specified, CCF is capped
at the lower of the commitment's CCF and the underlying OBS item's CCF
Args:
exposures: Exposures with nominal_amount, risk_type, and approach columns
config: Calculation configuration
Returns:
LazyFrame with ead_from_ccf and ccf columns added
"""
schema = exposures.collect_schema()
names = schema.names()
original_has_risk_type = "risk_type" in names
original_has_underlying = "underlying_risk_type" in names
original_has_interest = "interest" in names
has_provision_cols = "nominal_after_provision" in names and "provision_on_drawn" in names
exposures, added_cols = self._ensure_columns(exposures, names, has_provision_cols)
exposures = self._compute_ccf(exposures, config, pack=pack)
exposures = self._compute_ead(exposures, has_provision_cols, config, pack=pack)
exposures = self._build_audit_trail(
exposures, original_has_risk_type, original_has_underlying, original_has_interest
)
# Clean up temp and default-populated columns
return exposures.drop(
"_sa_ccf_from_risk_type",
"_firb_ccf_from_risk_type",
"_nominal_is_zero",
*added_cols,
)
CRR Art. 178 — Default of an obligor¶
_build_is_defaulted_expr — src/rwa_calc/engine/stages/classify/attributes.py:594
@cites("CRR Art. 178")
@cites("CRR Art. 153")
def _build_is_defaulted_expr() -> pl.Expr:
"""Build per-exposure ``is_defaulted`` flag.
Combines two explicit default signals so detection works at any
granularity:
- counterparty-level ``cp_default_status`` (propagates to all that
counterparty's exposures);
- explicit row-level ``is_defaulted`` carried on the loan/contingent
parquet (lets a single-default exposure on an otherwise non-defaulted
counterparty trigger the Art. 153(1)(ii) / 154(1)(i) defaulted
treatment).
Either one being true sets ``is_defaulted=True``.
``beel`` is deliberately **not** a trigger. PS1/26 Art. 181(1)(h)(ii)
and CRR Art. 158(5) define BEEL only for defaulted exposures, but
firms whose A-IRB models emit a BEEL-style value alongside LGD on
performing exposures would otherwise see those rows silently
reclassified as defaulted. The post-classification step
``_collect_beel_on_non_defaulted_warnings`` flags the contradictory
combination (``is_defaulted=False ∧ beel>0``) as a DQ008 warning so
the input contradiction is visible without changing routing.
"""
cp_default = pl.col("cp_default_status") == True # noqa: E712
row_default = pl.col("is_defaulted").fill_null(False)
return (cp_default | row_default).alias("is_defaulted")
CRR Art. 193 — Principles for recognising the effect of credit risk mitigation techniques¶
apply_guarantee_substitution — src/rwa_calc/engine/sa/rw_adjustments.py:195
@cites("CRR Art. 193")
@cites("CRR Art. 235")
def apply_guarantee_substitution(
lf: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Apply guarantee substitution for unfunded credit protection.
For guaranteed portions the risk weight is substituted with the guarantor's
risk weight, and the row's RWA is the Art. 235(1) blend of the two:
``max(0, E - GA) x r + GA x g``.
THE DECLINE AND ITS AUTHORITY. This is the canonical account for the whole
``is_guarantee_beneficial`` machinery — its IRB twin
(``engine/irb/guarantee.py``) and the three reporting consumers that gate on
it (``engine/aggregator/aggregator.py::_beneficial_gate``,
``reporting/corep/crm_substitution.py::_decline_gate``,
``reporting/corep/c07.py::_protection_exprs``) all point here rather than
restating it.
THE BASIS IS ART. 193, NOT ART. 213. Art. 213 ("Requirements common to
guarantees and credit derivatives") is an ELIGIBILITY gate on the protection
CONTRACT — the protection is direct, its extent clearly defined and
incontrovertible, no clause permitting unilateral cancellation / cost
escalation on credit deterioration / obstruction of timely payout / reduction
of protection maturity, legally effective and enforceable — plus
concentration-risk systems (213(2)) and contractual/statutory fulfilment
(213(3)). It says nothing about whether a recognised guarantee must be
APPLIED, yet this code recorded "recognition is permissive (Art. 213)" in six
places. The two provisions that actually carry the decline both sit in
Art. 193, "Principles for recognising the effect of credit risk mitigation
techniques":
- Art. 193(1), MANDATORY, about the OUTCOME: "No exposure in respect of which
an institution obtains credit risk mitigation shall produce a higher
risk-weighted exposure amount or expected loss amount than an otherwise
identical exposure in respect of which an institution has no credit risk
mitigation." The unit is THE EXPOSURE, not the portfolio, and it binds the
EXPECTED LOSS amount as well as the RWEA — which is what makes it the right
authority for the IRB leg too.
- Art. 193(3), PERMISSIVE, about the MECHANISM: "Where the provisions in
Sections 2 and 3 are met, institutions MAY amend the calculation of
risk-weighted exposure amounts under the Standardised Approach and the
calculation of risk-weighted exposure amounts and expected loss amounts
under the IRB Approach in accordance with the provisions of Sections 4, 5
and 6." Art. 213 sits IN Section 2 — it is one of the preconditions 193(3)
makes the election conditional on, which is exactly why it kept being
mistaken for the election itself.
Art. 113(3) ("Where an exposure is subject to credit protection the risk
weight applicable to that item may be amended in accordance with Chapter 4")
says the same thing per ITEM, and is the hook Art. 235(1) opens with — "For
the purposes of Article 113(3) institutions shall calculate ..." — so that
``shall`` governs HOW you compute if you amend, not WHETHER you amend. But
113(3) lives in Chapter 2 and speaks only to the SA risk weight; it does not
reach the IRB path. Art. 193(3) covers both, so it is the citation of record.
THE ARGUMENT THAT NEEDS NO CITATION. The Art. 235(1) formula carries no
``min`` against ``E x r``. Applied mechanically with ``g > r`` it returns a
HIGHER RWEA than the identical unprotected exposure — a credit risk
mitigation chapter that penalises mitigation. That reading cannot be right,
which is precisely why Art. 193(1) exists.
TWO ELECTIONS THE TEXT DOES NOT FORCE, recorded because nothing else records
them:
(1) DECLINE vs APPLY-AND-CAP — AND ITS CAPITAL-NEUTRALITY IS CRR-CONDITIONAL,
WHICH IS THE WHOLE POINT. Art. 193(1) mandates the OUTCOME, not the
mechanism, so under CRR there are exactly TWO compliant implementations:
decline the guarantee, or apply Art. 235(1) and then cap the RWEA at the
unmitigated amount. They give IDENTICAL CAPITAL — but only BECAUSE the
Art. 193(1) cap is MANDATORY. That is what collapses the two onto one
number and leaves the choice affecting DISCLOSURE alone: under
apply-and-cap Art. 235 HAS fired, the covered part HAS been assigned to
the guarantor's class, and the C 07.00 / C 08.01 outflow and inflow would
both be reported. We decline; that is an implementation election, not a
reading of the text. Remove the mandatory cap and a THIRD option appears
— apply Art. 235(1) UNCAPPED, returning a HIGHER RWEA — at which point
the election stops being capital-neutral and our decline stops being the
only permissible behaviour. Whether that coincidence survives into
PS1/26 is UNVERIFIED (below); if it does not, this election acquires a
capital consequence and needs a recorded policy basis rather than a code
comment. Note the scope: this is the ENGINE gate, which moves RWA. The
reporting gates that consume its flag
(``engine/aggregator/aggregator.py::_beneficial_gate``,
``reporting/corep/crm_substitution.py::_decline_gate``) move no RWA at
all, so THEIR capital-neutrality is unconditional and nothing here
qualifies it.
(2) THE STRICT ``<``. The benefit test below is
``guarantor_rw < pre_crm_risk_weight`` (the IRB twin is identical against
``risk_weight_irb_original``). At EQUALITY Art. 193(1) is SILENT — equal
is not "higher" — so an equally-weighted guarantor is declined BY CHOICE:
zero capital effect, and a real effect on which sheet the exposure
appears on.
A PS1/26 GAP, STATED RATHER THAN GLOSSED. PS1/26 Art. 113(3) is word for word
the CRR text with "the Credit Risk Mitigation (CRR) Part" substituted for
"Chapter 4", so the permissive hook carries into 2027. A PS1/26 equivalent of
Art. 193(1) has NOT been verified: the PRA renumbers (in ``ps126app1.pdf``
"Article 193" is the CRR Art. 161 IRB-parameter-substitution rule — read the
``[Note: This rule corresponds to Article NNN of CRR ...]`` line, never the
number), and the PRA Credit Risk Mitigation (CRR) Part is not in
``docs/assets/`` at all. The consequence matters: under CRR the decline is
COMPELLED, so doing neither it nor apply-and-cap is a breach; if the PRA CRM
Part turns out to lack the equivalent, from 1 Jan 2027 this becomes a pure
ELECTION — and an election needs a recorded policy basis in a way that
compliance with a mandatory cap does not. No PS1/26 citation is asserted
here, because none has been read.
References:
CRR Art. 193(1), (3): CRM recognition principles — the no-worse outcome
cap, and the election to amend the calculation at all.
CRR Art. 113(3): the SA per-item permissive hook Art. 235(1) hangs off.
CRR Art. 235: SA risk-weight substitution formula.
CRR Art. 213-217: eligibility of the protection itself, gated upstream in
``engine/crm/guarantees.py``.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
exposures = lf
cols = exposures.collect_schema().names()
# Run-level sentinel gate: guarantor_entity_type is the one crm_exit
# column still CONDITIONAL (inject=False) — present iff the CRM
# guarantee sub-step ran. Keying on it keeps this machinery (and its
# derived audit columns: pre_crm_risk_weight, guarantor_rw,
# is_guarantee_beneficial, guarantee_status, guarantee_benefit_rw)
# off unguaranteed runs; see contracts/edges.py. The
# guaranteed_portion check covers direct (non-pipeline) invocation.
if "guaranteed_portion" not in cols or "guarantor_entity_type" not in cols:
return exposures
# Ensure defensive column fallbacks (guarantor_exposure_class,
# guarantor_country_code, guarantor_is_ccp_client_cleared). In
# production these are set by the CRM processor; this fallback covers
# tests that construct LazyFrames directly and skip the CRM stage.
exposures = _ensure_guarantee_substitution_columns(exposures)
# Preserve pre-CRM risk weight for regulatory reporting (pre-CRM vs
# post-CRM views).
exposures = exposures.with_columns(
pl.col("risk_weight").alias("pre_crm_risk_weight"),
)
# Art. 114(4)/(7) domestic CGCB-guarantor currency check.
is_domestic_guarantor = _build_domestic_guarantor_expr(exposures.collect_schema().names())
# CRR/PS1/26 Art. 120(2) Table 4 short-term institution guarantor flag.
# The substituted exposure's original maturity (≤ 3 months / 0.25y)
# drives the short-term carve-out — same convention as the direct
# institution short-term branches in ``risk_weights.py`` (Art. 120(2),
# Art. 121(3)). ``original_maturity_years`` is derived earlier in
# ``apply_risk_weights`` from (maturity_date - value_date) when absent,
# so it is always populated here.
short_term_flag_col = "_inst_guarantor_short_term"
if "original_maturity_years" in exposures.collect_schema().names():
short_term_expr = pl.col("original_maturity_years").is_not_null() & (
pl.col("original_maturity_years") <= 0.25
)
else:
short_term_expr = pl.lit(False)
exposures = exposures.with_columns(
short_term_expr.fill_null(False).alias(short_term_flag_col),
)
# Look up guarantor's RW based on exposure class + CQS. The short-term
# flag is calculator scratch consumed only by this expression — drop it
# immediately so it never leaks into the branch/aggregator frames.
exposures = exposures.with_columns(
_build_guarantor_rw_expr(
is_domestic_guarantor,
resolved_pack.feature("sa_revised_risk_weight_tables"),
institution_short_term_flag_col=short_term_flag_col,
).alias("guarantor_rw"),
).drop(short_term_flag_col)
# The Art. 193(1) benefit test: no exposure with CRM may produce a HIGHER
# RWEA than the identical unprotected exposure, and the Art. 235(1) formula
# carries no ``min`` to stop it. DECLINING (rather than applying Art. 235
# then capping) and the STRICT ``<`` (equality is declined too) are both
# elections the text does not force — see this function's docstring, which is
# the single recorded basis for every consumer of this flag.
exposures = exposures.with_columns(
[
pl.when(
(pl.col("guaranteed_portion") > 0)
& (pl.col("guarantor_rw").is_not_null())
& (pl.col("guarantor_rw") < pl.col("pre_crm_risk_weight"))
)
.then(pl.lit(True))
.otherwise(pl.lit(False))
.alias("is_guarantee_beneficial"),
]
)
# Redistribute non-beneficial guarantee portions to beneficial guarantors.
# For multi-guarantor exposures, non-beneficial guarantors' EAD is reallocated
# to the most beneficial (lowest RW) guarantors using greedy fill.
from rwa_calc.engine.crm.guarantees import redistribute_non_beneficial
exposures = redistribute_non_beneficial(exposures)
# Calculate blended risk weight using substitution approach
# Only apply if guarantee is beneficial
# RWA = (unguaranteed_portion * borrower_rw + guaranteed_portion * guarantor_rw) / ead_final
exposures = exposures.with_columns(
[
# Blended risk weight when guarantee exists AND is beneficial
pl.when(
(pl.col("guaranteed_portion") > 0)
& (pl.col("guarantor_rw").is_not_null())
& (pl.col("is_guarantee_beneficial"))
)
.then(
# weighted average of borrower and guarantor risk weights
(
pl.col("unguaranteed_portion") * pl.col("pre_crm_risk_weight")
+ pl.col("guaranteed_portion") * pl.col("guarantor_rw")
)
/ pl.col("ead_final")
)
# No guarantee, no guarantor RW, or non-beneficial - use original risk weight
.otherwise(pl.col("pre_crm_risk_weight"))
.alias("risk_weight"),
]
)
# Track guarantee status for reporting
exposures = exposures.with_columns(
[
pl.when(pl.col("guaranteed_portion") <= 0)
.then(pl.lit("NO_GUARANTEE"))
.when(~pl.col("is_guarantee_beneficial"))
.then(pl.lit("GUARANTEE_NOT_APPLIED_NON_BENEFICIAL"))
.otherwise(pl.lit("SA_RW_SUBSTITUTION"))
.alias("guarantee_status"),
# Calculate RW benefit from guarantee (positive = RW reduced)
pl.when(pl.col("is_guarantee_beneficial"))
.then(pl.col("pre_crm_risk_weight") - pl.col("risk_weight"))
.otherwise(pl.lit(0.0))
.alias("guarantee_benefit_rw"),
]
)
return exposures
CRR Art. 194 — Principles governing the eligibility of credit risk mitigation techniques¶
get_crm_unified_bundle — src/rwa_calc/engine/crm/processor.py:568
@cites("CRR Art. 194")
def get_crm_unified_bundle(
self,
data: ClassifiedExposuresBundle,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> CRMAdjustedBundle:
"""
Apply CRM on the unified exposure frame (single-pass pipeline).
Runs the full CRM chain — look-through, provisions, CCF, collateral
(Comprehensive, plus FCSM precompute under a Simple Method election),
life insurance, guarantees — and returns the unified LazyFrame for
the calculators' approach split. Laziness is strictly intra-stage:
the two sanctioned checkpoints (``crm_post_ead``,
``crm_pre_guarantee_unified``) and the ``crm_exit`` stage edge keep
the plan shallow.
Args:
data: Classified exposures from classifier
config: Calculation configuration
pack: Resolved rulepack for the run's regime/date (Phase 5 — the
source of regulatory values, e.g. the Art. 222 FCSM floors).
Production passes the orchestrator's pack; direct callers may
omit it, in which case sub-steps resolve one from ``config``.
Returns:
CRMAdjustedBundle with all exposures in the unified frame
"""
errors: list[CalculationError] = []
# Step 0: PRA Art. 191A(2)(e)(i) two-layer protection look-through.
# Re-anchors collateral pledged against a guarantee onto the obligor
# exposure when the bank elects "funded_only" — and suppresses the
# guarantee row so RWSM substitution does not also apply. Runs first
# so the rewritten collateral / guarantee frames feed the rest of
# the CRM chain normally.
guarantees_lf, collateral_lf, look_through_errors = apply_funded_only_look_through(
data.guarantees, data.collateral
)
errors.extend(look_through_errors)
# Steps 1-3: provisions -> CCF -> init EAD -> crm_post_ead checkpoint
exposures = self._run_ead_pipeline(data, config, pack=pack)
# Generate synthetic collateral from netting (CRR Art. 195)
exposures, collateral = self._merge_netting_collateral(
exposures, collateral_lf, errors, config.reporting_date
)
# Step 3.6: CRR/PS1-26 Art. 194(4) own-issue / connected-issuer gate.
# Zero collateral whose issuer_counterparty_reference resolves to the
# obligor or a member of the obligor's group (materially correlated with
# obligor credit quality -> ineligible funded protection). Runs before the
# links split / haircut chain so the zeroed value cascades everywhere.
collateral = self._apply_own_issue_collateral_gate(
collateral, exposures, data.counterparty_lookup, errors
)
# Step 3.65: CRR/PS1-26 Art. 200(a)/232(2) — partition third-party deposits
# (cash held at another institution) out of the ordinary collateral frame
# so they feed NO cash-collateral value channel (SA E*, FIRB LGD*); they
# are re-introduced as an SA risk-weight substitution at the holder's
# institution RW (Step 4c). Own-bank deposits (null holder) are untouched.
collateral, third_party_deposits = split_third_party_deposits(collateral)
# Step 3.7: Split each finite collateral value across its linked
# beneficiaries (CRR Art. 230-231) when a collateral_links table is
# supplied. No-op otherwise; the single-beneficiary path is unchanged.
collateral, link_allocation = self._apply_collateral_links(
exposures, collateral, data, config, errors
)
# Step 3.8: Pre-compute FCSM columns if Simple Method is elected
# (Art. 222). Must run BEFORE the Comprehensive Method (which is
# still needed for IRB LGD adjustment).
use_simple_method = config.crm_collateral_method == CRMCollateralMethod.SIMPLE
if use_simple_method:
if has_required_columns(collateral, self.COLLATERAL_REQUIRED_COLUMNS):
exposures = compute_fcsm_columns(exposures, collateral, config, pack=pack)
else:
# Add default (zero) FCSM columns when no valid collateral
from rwa_calc.engine.crm.simple_method import _add_default_fcsm_columns
exposures = _add_default_fcsm_columns(exposures)
# Step 4: Apply collateral (if available and valid)
exposures, collateral_applied = self._apply_collateral_unified_step(
exposures, collateral, config, errors, pack=pack
)
# Step 4b: Under Simple Method, undo SA financial collateral EAD reduction.
# The Comprehensive pipeline reduced SA EAD by collateral_adjusted_value,
# but Art. 222 does not reduce EAD — it substitutes risk weights instead.
if use_simple_method:
exposures = undo_sa_ead_reduction(exposures)
# Pre-compute life insurance method columns (Art. 232) for SA RW mapping
exposures = self._apply_life_insurance_step(exposures, collateral, config, errors)
# Step 4c: Art. 200(a)/232(2) third-party-deposit SA RW substitution columns
# (holder institution RW on the covered part) + F-IRB deferral warning.
exposures = self._apply_third_party_deposit_step(
exposures, third_party_deposits, config, errors, pack=pack
)
# Step 4d: split the Art. 200(1) amounts both preceding steps produced
# between the Art. 232 substitution block (C 08.01/02 col 0060) and the
# A-IRB LGD Modelling block (cols 0171-0173). PS1/26 Annex II makes the
# two mutually exclusive per leg and the choice turns on the run-level
# election, which never reaches the COREP generator — so it is decided
# here, once, and emitted as exclusive-by-construction carriers (RD-8).
exposures = route_other_funded_protection(exposures, config, pack=pack)
# The second sanctioned INTRA-STAGE checkpoint (with crm_post_ead).
# Empirically irreducible on Polars 1.37: the guarantee module's
# 3-path concat (no-guarantee / single / multi-guarantor split)
# re-evaluates the full collateral plan per branch without it
# (~4x slowdown at 100K scale), and removing it alone SIGSEGVs on
# deep plans. Guarded by the plan-node ceiling tests
# (tests/integration/test_stage_edges.py); re-validate per Polars
# upgrade before attempting removal.
if (
has_required_columns(guarantees_lf, self.GUARANTEE_REQUIRED_COLUMNS)
and data.counterparty_lookup is not None
):
exposures = materialise_edge(exposures, config, "crm_pre_guarantee_unified")
exposures = self._apply_guarantees_step(
exposures, guarantees_lf, data, config, errors, pack=pack
)
else:
self._collect_guarantee_skip_errors(guarantees_lf, data, errors)
exposures = self._finalize_ead(exposures)
exposures = self._add_crm_audit(exposures)
# Stage-exit edge (producer-side): materialise before the audit
# projections below derive from `exposures`, so they read in-memory
# data instead of re-executing the guarantee plan.
exposures = materialise_edge(exposures, config, "crm_exit")
collateral_allocation = (
self._build_collateral_allocation(exposures) if collateral_applied else None
)
if collateral_allocation is not None:
sink_audit(collateral_allocation, config, "collateral_allocation")
# Surface the per-exposure CRM audit projection when the audit cache is
# opted in. Unified-path bundles normally leave crm_audit=None to avoid
# a redundant projection on hot runs; sinking only fires when the user
# has explicitly requested artifacts via config.audit_cache_dir.
if config.audit_cache_dir is not None:
sink_audit(self._build_crm_audit(exposures), config, "crm_audit")
# Producer seal (Phase 3): contract validated, intra-stage scratch
# stripped — pure plan ops over the eager-backed frame, after the
# audit projections above have read it. CCR runs carry the SA-CCR
# provenance columns through, so the contract is selected by the
# input frame's brand.
exit_edge = (
CRM_EXIT_CCR_EDGE
if sealed_edge_of(data.all_exposures) == "classifier_exit_ccr"
else CRM_EXIT_EDGE
)
exposures = seal(exposures, exit_edge)
return CRMAdjustedBundle(
exposures=exposures,
equity_exposures=data.equity_exposures,
ciu_holdings=data.ciu_holdings,
collateral_allocation=collateral_allocation,
collateral_link_allocation=link_allocation,
securitisation_audit=data.securitisation_audit,
crm_errors=errors,
)
_apply_own_issue_collateral_gate — src/rwa_calc/engine/crm/processor.py:837
@cites("CRR Art. 194")
def _apply_own_issue_collateral_gate(
self,
collateral: pl.LazyFrame | None,
exposures: pl.LazyFrame,
counterparty_lookup: CounterpartyLookup | None,
errors: list[CalculationError],
) -> pl.LazyFrame | None:
"""CRR/PS1-26 Art. 194(4): drop collateral issued by the obligor or its group.
Funded protection is ineligible where its value is materially positively
correlated with the obligor's credit quality — the canonical case (BCBS
CRE22) being a security issued by the obligor or a group member. Each
collateral row's ``issuer_counterparty_reference`` is resolved against the
obligor (the counterparty of the exposure it secures) and, via
``ultimate_parent_mappings``, the obligor's group. On a match the row is
removed before the haircut / allocation chain (so it yields no CRM benefit
by any path — filtering also side-steps the pledge-percentage re-resolution
that would revive a merely value-zeroed row) and one CRM015 warning raised.
Null ``issuer_counterparty_reference`` is PERMISSIVE — the gate never fires,
so existing data (which does not populate the field) is number-neutral.
"""
if collateral is None:
return None
coll_names = collateral.collect_schema().names()
exp_names = exposures.collect_schema().names()
if "issuer_counterparty_reference" not in coll_names or "beneficiary_reference" not in (
coll_names
):
return collateral
if "counterparty_reference" not in exp_names:
return collateral
# Resolve each collateral's obligor counterparty (any pledge level).
obligor_map = _build_beneficiary_obligor_map(exposures, exp_names)
collateral = collateral.join(
obligor_map, left_on="beneficiary_reference", right_on="_ben_key", how="left"
)
# Resolve obligor and issuer ultimate parents for the group limb.
if counterparty_lookup is not None:
up = counterparty_lookup.ultimate_parent_mappings.select(
pl.col("counterparty_reference"),
pl.col("ultimate_parent_reference"),
)
collateral = collateral.join(
up.rename(
{
"counterparty_reference": "_obligor_cp",
"ultimate_parent_reference": "_obligor_ult",
}
),
on="_obligor_cp",
how="left",
).join(
up.rename(
{
"counterparty_reference": "issuer_counterparty_reference",
"ultimate_parent_reference": "_issuer_ult",
}
),
on="issuer_counterparty_reference",
how="left",
)
else:
collateral = collateral.with_columns(
pl.lit(None).cast(pl.String).alias("_obligor_ult"),
pl.lit(None).cast(pl.String).alias("_issuer_ult"),
)
issuer = pl.col("issuer_counterparty_reference")
obligor = pl.col("_obligor_cp")
obligor_ult = pl.col("_obligor_ult")
issuer_ult = pl.col("_issuer_ult")
# fill_null(False): an unresolved obligor (null) must NOT drop the row.
is_own_issue = (
issuer.is_not_null()
& (
(issuer == obligor)
| (issuer == obligor_ult)
| (obligor == issuer_ult)
| (issuer_ult.is_not_null() & (issuer_ult == obligor_ult))
)
).fill_null(value=False)
_record_own_issue_collateral(collateral, is_own_issue, coll_names, errors)
return collateral.filter(~is_own_issue).drop(
"_obligor_cp", "_obligor_ult", "_issuer_ult", strict=False
)
CRR Art. 195 — On-balance sheet netting¶
generate_netting_collateral — src/rwa_calc/engine/crm/collateral.py:166
@cites("CRR Art. 195")
@cites("CRR Art. 219")
@cites("CRR Art. 223")
@cites("CRR Art. 238")
def generate_netting_collateral(
exposures: pl.LazyFrame,
errors: list[CalculationError] | None = None,
*,
reporting_date: date | None = None,
) -> pl.LazyFrame | None:
"""
Generate synthetic cash collateral from negative-drawn netting-eligible loans.
When a loan has a negative drawn amount (credit balance / deposit) and carries
a ``netting_agreement_reference`` (CRR Art. 195/219), the absolute value of
that negative balance can reduce other exposures covered by the SAME netting
agreement AND owed by the SAME counterparty — treated as synthetic cash
collateral.
CRR/PS1-26 Art. 195 (P1.238): on-balance-sheet netting is limited to "mutual
claims" / "reciprocal cash balances between the institution and the
counterparty" — a single counterparty. So a deposit from counterparty A may
net only loans owed by counterparty A under the same agreement; it may NOT
offset a loan to a different counterparty B, even where a group-level
agreement reference is shared. Pools are therefore keyed by
(netting_agreement_reference, counterparty_reference) — the agreement is the
legal set-off boundary, the counterparty the Art. 195 eligibility boundary.
A netting_agreement_reference that spans more than one counterparty raises a
CRM016 data-quality warning (the disallowed cross-counterparty offset is
otherwise invisible). Two exposures still do NOT net unless they share the
reference, regardless of facility hierarchy.
CRR Art. 219 limits on-balance-sheet netting to drawn loans and deposits
(cash-on-cash). Synthetic cash collateral is allocated pro-rata by the drawn
portion (`on_bs_for_ead`) to positive-drawn LOAN siblings carrying the same
reference — contingents and synthetic facility_undrawn rows are
off-balance-sheet and excluded from the beneficiary set. Netting pools also
keep currency (as (ref, currency, counterparty_reference)) so the haircut
pipeline can apply FX haircuts when the pool currency differs from the
sibling's currency.
Art. 219 treats the netted deposit as cash collateral, so the funded-
protection maturity-mismatch rules (Art. 237-239) apply exactly as for any
other funded protection (P1.241). The synthetic row therefore carries the
DEPOSIT's maturity — not the beneficiary loan's — as ``maturity_date``, the
deposit residual (t) as ``residual_maturity_years`` (when ``reporting_date``
is supplied), and the deposit ORIGINAL term as ``original_maturity_years``
(when ``value_date`` is available). The downstream ``apply_maturity_mismatch``
then, on a mismatch (t < T where T is the loan residual), zeroes the
protection when t < 0.25 (Art. 237(1)) OR the original term < 1y
(Art. 237(2)(a)), else applies (t-0.25)/(T-0.25) (Art. 238-239). Previously
the row carried the loan's maturity, a null residual (filled to 10y
downstream) and no original term, so no gate fired and a short deposit
netting a long loan was recognised in full.
The residual t uses the /365.25 day-count of the exposure-side T derivation in
``apply_maturity_mismatch`` (so equal deposit/loan maturities net in full with
NO phantom mismatch); the original term uses the /365 convention of the
engine's other original-maturity derivations (risk_weights.py / enrich.py).
Pooling convention (conservative): when several deposits of differing
maturities pool into one (ref, currency, counterparty) row, the pool carries
the EARLIEST (minimum) deposit maturity AND the minimum deposit original term.
The earliest-maturing deposit is when the pool's protection first begins to
lapse; representing the whole pool at that maturity maximises the mismatch
haircut (shortest t → smallest (t-0.25)/(T-0.25)), and the minimum original
term is the one most likely to trip the Art. 237(2)(a) <1y gate — both the
prudent single-value summary. A null deposit maturity (or no
``reporting_date``) leaves the residual null and is handled permissively
downstream — absent maturity data cannot establish a mismatch, the same
convention ordinary financial collateral without a supplied residual follows
(this is NOT an anti-conservative fill: the downstream 10y default is
unchanged, it is simply no longer fed a null when the data is present); a null
original term (no ``value_date``) likewise leaves the Art. 237(2)(a) gate
permissive.
Args:
exposures: Exposures with ead_for_crm, on_bs_for_ead, exposure_type set
errors: optional CRM error channel — receives Art. 195 CRM016 warnings
for netting agreements that span more than one counterparty.
reporting_date: run reporting date, used to derive the deposit residual
maturity (Art. 238) on the synthetic rows. When None (direct
unit-test callers), residual_maturity_years stays null and the
maturity mismatch is not applied — backward-compatible behaviour.
Returns:
LazyFrame of synthetic collateral rows, or None if no netting applies
"""
schema = exposures.collect_schema()
schema_names = set(schema.names())
if "netting_agreement_reference" not in schema_names:
return None
# value_date lets the pool derive each deposit's ORIGINAL maturity for the
# Art. 237(2)(a) gate. It is a core exposure column in production; injected as
# a typed null for direct unit-test callers that omit it (→ original maturity
# null → the gate stays permissive), via the schema-driven ensure_columns.
exposures = ensure_columns(exposures, {"value_date": ColumnSpec(pl.Date, required=False)})
# Graceful fallback for direct unit-test callers (production always
# supplies ead_for_crm via _initialize_ead, on_bs_for_ead via _compute_ead,
# and exposure_type via hierarchy).
if "ead_for_crm" not in schema_names:
exposures = exposures.with_columns(pl.col("ead_gross").alias("ead_for_crm"))
if "on_bs_for_ead" not in schema_names:
interest_expr = (
pl.col("interest").fill_null(0.0).clip(lower_bound=0.0)
if "interest" in schema_names
else pl.lit(0.0)
)
exposures = exposures.with_columns(
(pl.col("drawn_amount").clip(lower_bound=0.0) + interest_expr).alias("on_bs_for_ead")
)
if "exposure_type" not in schema_names:
exposures = exposures.with_columns(pl.lit("loan").alias("exposure_type"))
if "counterparty_reference" not in schema_names:
# Test-caller fallback only: production always supplies counterparty_reference
# (a core exposure column). Absent → treat every row as the same counterparty
# so the Art. 195 same-counterparty constraint is a no-op for legacy callers.
exposures = exposures.with_columns(pl.lit("_UNKNOWN_CP").alias("counterparty_reference"))
# Negative-drawn loans carrying a netting agreement reference provide the pool
negative_loans = exposures.filter(
pl.col("netting_agreement_reference").is_not_null() & (pl.col("drawn_amount") < 0)
)
# Art. 195 (P1.238): emit a CRM016 warning for any agreement that spans more
# than one counterparty (a deposit and a positive loan under the same
# reference but for different counterparties would previously have netted).
if errors is not None:
_record_cross_counterparty_netting(exposures, errors)
# Sum abs(drawn_amount) per (netting_agreement_reference, currency,
# counterparty_reference) → netting pool. Currency is kept so the synthetic
# collateral carries the source currency (FX haircut when currencies differ);
# counterparty_reference enforces the Art. 195 same-counterparty limit.
# Art. 219/238 (P1.241): the earliest (min) deposit maturity per pool is the
# conservative single-maturity summary — it drives the maturity-mismatch t.
# The minimum deposit ORIGINAL maturity is carried alongside for the
# Art. 237(2)(a) >=1y eligibility gate (min → shortest term is most likely to
# trip the <1y gate; derived from maturity_date - value_date, the same
# convention as engine/sa/risk_weights.py / hierarchy/enrich.py, /365). A null
# value_date yields a null original maturity (permissive — gate does not fire).
deposit_original_years = (
pl.col("maturity_date").cast(pl.Int32) - pl.col("value_date").cast(pl.Int32)
).cast(pl.Float64) / 365.0
netting_pool = (
negative_loans.group_by(
["netting_agreement_reference", "currency", "counterparty_reference"]
)
.agg(
pl.col("drawn_amount").abs().sum().alias("netting_pool"),
pl.col("maturity_date").min().alias("_pool_deposit_maturity_date"),
deposit_original_years.min().alias("_pool_deposit_orig_maturity"),
)
.rename({"currency": "_pool_currency"})
)
# CRR Art. 219: drawn-on-drawn cash netting. Synthetic cash collateral may
# only benefit the drawn portion of loan exposures — contingents and
# facility_undrawn synthetic rows are off-balance-sheet and ineligible. A
# sibling matches a pool iff it shares BOTH the netting_agreement_reference
# and the counterparty_reference (Art. 195 same-counterparty limit).
# The beneficiary loan's own maturity is NOT carried onto the synthetic row:
# it feeds the mismatch as the EXPOSURE side (T) via the exposure lookup join
# downstream, while the synthetic row carries the DEPOSIT maturity (t).
positive_siblings = exposures.filter(
(pl.col("exposure_type") == "loan")
& (pl.col("on_bs_for_ead") > 0)
& pl.col("netting_agreement_reference").is_not_null()
).select(
"exposure_reference",
"netting_agreement_reference",
"counterparty_reference",
"currency",
"on_bs_for_ead",
)
# Match siblings to pools by shared agreement reference AND counterparty.
matched = positive_siblings.join(
netting_pool,
on=["netting_agreement_reference", "counterparty_reference"],
how="inner",
)
# Total drawn EAD per pool for pro-rata allocation. CRR Art. 219 nets cash
# against drawn loans, so the pro-rata basis is the on-BS (drawn) portion,
# NOT ead_for_crm (which includes the off-BS nominal at CCF=100% per
# Art. 223(4) — that override is for collateral valuation, not for OBS
# netting allocation basis).
facility_totals = matched.group_by(
"netting_agreement_reference", "_pool_currency", "counterparty_reference"
).agg(
pl.col("on_bs_for_ead").sum().alias("_facility_total_drawn"),
)
# Join totals back for pro-rata
allocated = matched.join(
facility_totals,
on=["netting_agreement_reference", "_pool_currency", "counterparty_reference"],
how="left",
).filter(pl.col("_facility_total_drawn") > 0)
# Pro-rata market_value per sibling by drawn portion (Art. 219).
allocated = allocated.with_columns(
(pl.col("netting_pool") * pl.col("on_bs_for_ead") / pl.col("_facility_total_drawn")).alias(
"market_value"
),
)
# Deposit residual maturity (Art. 238 t). Derived from the pool's earliest
# deposit maturity when a reporting_date is available; null (permissive)
# otherwise. The /365.25 basis MATCHES the exposure-side T derivation in
# HaircutCalculator.apply_maturity_mismatch, so a deposit and loan sharing a
# maturity date net in full (t == T, no phantom mismatch). A null pool
# maturity date yields a null residual either way.
residual_expr = (
(
(pl.col("_pool_deposit_maturity_date").cast(pl.Date) - pl.lit(reporting_date))
.dt.total_days()
.cast(pl.Float64)
/ 365.25
)
if reporting_date is not None
else pl.lit(None, dtype=pl.Float64)
)
# Deposit original maturity (Art. 237(2)(a) t_orig): the pool's minimum
# deposit original term (null → permissive when value_date was absent). A
# deposit with original maturity < 1y and a mismatch is zeroed downstream.
original_expr = pl.col("_pool_deposit_orig_maturity")
# Build synthetic collateral rows — currency from the pool (source of funds).
# maturity_date / residual_maturity_years / original_maturity_years are the
# DEPOSIT's (Art. 219/237-238), not the beneficiary loan's.
synthetic = allocated.select(
(pl.lit("NETTING_") + pl.col("exposure_reference")).alias("collateral_reference"),
pl.lit("cash").alias("collateral_type"),
pl.col("_pool_currency").alias("currency"),
pl.col("_pool_deposit_maturity_date").alias("maturity_date"),
pl.col("market_value"),
pl.lit(None).cast(pl.Float64).alias("nominal_value"),
pl.lit(None).cast(pl.Float64).alias("pledge_percentage"),
pl.lit("loan").alias("beneficiary_type"),
pl.col("exposure_reference").alias("beneficiary_reference"),
pl.lit(None).cast(pl.Int8).alias("issuer_cqs"),
pl.lit(None).cast(pl.String).alias("issuer_type"),
residual_expr.alias("residual_maturity_years"),
original_expr.alias("original_maturity_years"),
pl.lit(True).alias("is_eligible_financial_collateral"),
pl.lit(True).alias("is_eligible_irb_collateral"),
pl.lit(None).cast(pl.Date).alias("valuation_date"),
pl.lit(None).cast(pl.String).alias("valuation_type"),
pl.lit(None).cast(pl.String).alias("property_type"),
pl.lit(None).cast(pl.Float64).alias("property_ltv"),
pl.lit(None).cast(pl.Boolean).alias("is_income_producing"),
pl.lit(None).cast(pl.Boolean).alias("is_adc"),
pl.lit(None).cast(pl.Boolean).alias("is_presold"),
)
return synthetic
CRR Art. 197 — Eligibility of collateral under all approaches and methods¶
non_main_index_equity_ineligible_expr — src/rwa_calc/engine/crm/haircuts.py:67
@cites("CRR Art. 197")
@cites("CRR Art. 198")
def non_main_index_equity_ineligible_expr(schema_names: Iterable[str]) -> pl.Expr:
"""Art. 197(1)(f)/198(1)(a): non-main-index, non-listed equity is ineligible.
Equities/convertible bonds are eligible financial collateral under all CRM
methods only when included in a MAIN index (CRR/PS1-26 Art. 197(1)(f)). A
non-main-index equity is eligible only where it is LISTED on a recognised
exchange (Art. 198(1)(a)), and then only under the comprehensive method this
calculator uses by default. An equity collateral row that is neither attested
main-index nor attested listed is therefore ineligible funded protection.
Null / absent ``is_main_index`` and ``is_listed`` resolve conservatively to
False (unknown membership / listing must not fabricate eligibility). When
neither signal column is present the expression is a no-op (``False``): that
is the legacy backward-compatibility path where ``is_eligible_financial_
collateral`` remains the eligibility proxy — production always carries both
columns via ``COLLATERAL_SCHEMA``.
Shared by the haircut-stage value gate (``HaircutCalculator``) and the CRM018
warning emission (``engine/crm/collateral.py``) so the predicate has a single
definition. It reads the raw ``collateral_type`` (equivalent to the normalised
``_lookup_type == "equity"``).
"""
names = set(schema_names)
if "is_main_index" not in names and "is_listed" not in names:
return pl.lit(False)
is_equity = pl.col("collateral_type").str.to_lowercase().is_in(EQUITY_COLLATERAL_TYPES)
is_main_index = (
pl.col("is_main_index").fill_null(False) if "is_main_index" in names else pl.lit(False)
)
is_listed = pl.col("is_listed").fill_null(False) if "is_listed" in names else pl.lit(False)
return is_equity & is_main_index.not_() & is_listed.not_()
CRR Art. 198 — Additional eligibility of collateral under the Financial Collateral Comprehensive Method¶
non_main_index_equity_ineligible_expr — src/rwa_calc/engine/crm/haircuts.py:68
@cites("CRR Art. 197")
@cites("CRR Art. 198")
def non_main_index_equity_ineligible_expr(schema_names: Iterable[str]) -> pl.Expr:
"""Art. 197(1)(f)/198(1)(a): non-main-index, non-listed equity is ineligible.
Equities/convertible bonds are eligible financial collateral under all CRM
methods only when included in a MAIN index (CRR/PS1-26 Art. 197(1)(f)). A
non-main-index equity is eligible only where it is LISTED on a recognised
exchange (Art. 198(1)(a)), and then only under the comprehensive method this
calculator uses by default. An equity collateral row that is neither attested
main-index nor attested listed is therefore ineligible funded protection.
Null / absent ``is_main_index`` and ``is_listed`` resolve conservatively to
False (unknown membership / listing must not fabricate eligibility). When
neither signal column is present the expression is a no-op (``False``): that
is the legacy backward-compatibility path where ``is_eligible_financial_
collateral`` remains the eligibility proxy — production always carries both
columns via ``COLLATERAL_SCHEMA``.
Shared by the haircut-stage value gate (``HaircutCalculator``) and the CRM018
warning emission (``engine/crm/collateral.py``) so the predicate has a single
definition. It reads the raw ``collateral_type`` (equivalent to the normalised
``_lookup_type == "equity"``).
"""
names = set(schema_names)
if "is_main_index" not in names and "is_listed" not in names:
return pl.lit(False)
is_equity = pl.col("collateral_type").str.to_lowercase().is_in(EQUITY_COLLATERAL_TYPES)
is_main_index = (
pl.col("is_main_index").fill_null(False) if "is_main_index" in names else pl.lit(False)
)
is_listed = pl.col("is_listed").fill_null(False) if "is_listed" in names else pl.lit(False)
return is_equity & is_main_index.not_() & is_listed.not_()
CRR Art. 199 — Additional eligibility for collateral under the IRB Approach¶
_apply_collateral_unified — src/rwa_calc/engine/crm/collateral.py:891
@cites("CRR Art. 199")
@cites("CRR Art. 211")
@cites("PS1/26 Art. 199")
@cites("PS1/26 Art. 211")
def _apply_collateral_unified(
exposures: pl.LazyFrame,
adjusted_collateral: pl.LazyFrame,
config: CalculationConfig,
cp_ead_totals: pl.LazyFrame,
*,
pack: ResolvedRulepack | None = None,
errors: list[CalculationError] | None = None,
) -> pl.LazyFrame:
"""
Unified EAD + LGD collateral allocation in a single pass.
Performs a single group_by over all collateral levels (direct, facility,
counterparty) and joins back to exposures for both SA EAD reduction and
F-IRB LGD calculation.
Art. 231 sequential fill: when multiple collateral types secure an
exposure, each type absorbs exposure starting from the lowest LGDS.
The institution receives the most favourable ordering (lowest LGDS first):
financial (0%) -> covered_bond (11.25%) -> receivables -> real_estate
-> other_physical. This replaces the former pro-rata allocation.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
# S9h: regime branches read honest cited Features off the resolved pack.
# firb_fse_senior_lgd_split → FSE 45/40 split; firb_overcollateralisation_divisor_
# applies → CRR Art. 230(2) subordinated secured-portion LGDS rows (B31 LGD* drops
# them); airb_lgd_collateral_method_applicable → B31 Art. 169A/169B AIRB method.
fse_senior_lgd_split = resolved_pack.feature("firb_fse_senior_lgd_split")
overcollateralisation_step_function = resolved_pack.feature(
"firb_overcollateralisation_divisor_applies"
)
airb_collateral_method_applies = resolved_pack.feature("airb_lgd_collateral_method_applicable")
lgd_values = supervisory_lgd_values(resolved_pack)
lgd_subordinated = subordinated_unsecured_lgd(resolved_pack)
lgd_unsecured = lgd_values["unsecured"]
# LGDS values per waterfall category (Art. 230/231)
lgds = {key: lgd_values[key] for _, key, _ in WATERFALL_ORDER}
# Under Basel 3.1, FSE senior unsecured LGDU = 45% (Art. 161(1)(a));
# non-FSE = 40% (Art. 161(1)(aa)). Under CRR, all = 45%.
exposure_schema = exposures.collect_schema()
_has_fse_col = (
fse_senior_lgd_split and "cp_is_financial_sector_entity" in exposure_schema.names()
)
if _has_fse_col:
lgd_unsecured_fse = lgd_values["unsecured_fse"]
# Defensive: fill in pool-aware columns when callers (typically unit tests)
# construct ead-total frames or exposures without them. Missing pool flag
# → all exposures treated as non-AIRB pool, which matches legacy behaviour
# (unflagged collateral pro-rates over the full population).
if "_is_airb_pool" not in exposure_schema.names():
exposures = exposures.with_columns(pl.lit(False).alias("_is_airb_pool"))
# CRR Art. 223(4) override: ead_for_crm is the CCF=100% basis. Production
# always supplies it via _initialize_ead; direct unit-test callers may
# not, in which case we fall back to ead_gross (correct for pure on-BS
# rows where the two are equal by construction).
if "ead_for_crm" not in exposure_schema.names():
exposures = exposures.with_columns(pl.col("ead_gross").alias("ead_for_crm"))
if "effective_ccf" not in exposure_schema.names():
exposures = exposures.with_columns(pl.lit(1.0).alias("effective_ccf"))
# Facility-ancestor closure for the multi-level facility collateral cascade.
# Production supplies ``ancestor_facilities`` (parent + all ancestors up to
# root, incl. self) from the HierarchyResolver. Direct unit-test callers and
# single-level inputs fall back to the 1-element [parent] list, which makes
# the cascade in ``_cascade_facility_collateral`` reduce exactly to the
# legacy single-level allocation.
if "ancestor_facilities" not in exposure_schema.names():
if "parent_facility_reference" in exposure_schema.names():
exposures = exposures.with_columns(
pl.concat_list("parent_facility_reference").alias("ancestor_facilities")
)
else:
exposures = exposures.with_columns(
pl.lit(None, dtype=pl.List(pl.String)).alias("ancestor_facilities")
)
cp_totals_schema = cp_ead_totals.collect_schema().names()
cp_total_fills: list[pl.Expr] = []
if "_cp_ead_total_non_airb" not in cp_totals_schema:
cp_total_fills.append(pl.col("_cp_ead_total").alias("_cp_ead_total_non_airb"))
if "_cp_ead_total_airb" not in cp_totals_schema:
cp_total_fills.append(pl.lit(0.0).alias("_cp_ead_total_airb"))
if cp_total_fills:
cp_ead_totals = cp_ead_totals.with_columns(cp_total_fills)
collateral_schema = adjusted_collateral.collect_schema()
# --- Determine eligible expression for EAD reduction ---
if "is_eligible_financial_collateral" in collateral_schema:
is_eligible = pl.col("is_eligible_financial_collateral")
else:
is_eligible = ~pl.col("collateral_type").str.to_lowercase().is_in(NON_ELIGIBLE_RE_TYPES)
# --- Annotate collateral with LGD categories (using shared expressions) ---
# Ensure the AIRB-model flag is present (default False) so the pool-aware
# aggregation below can rely on it. Backward-compatible with collateral
# frames built before the column existed.
if "is_airb_model_collateral" in collateral_schema.names():
airb_flag_expr = pl.col("is_airb_model_collateral").fill_null(False)
else:
airb_flag_expr = pl.lit(False)
annotated = adjusted_collateral.with_columns(
[
collateral_lgd_expr(resolved_pack).alias("collateral_lgd"),
overcollateralisation_ratio_expr(resolved_pack).alias("overcollateralisation_ratio"),
is_financial_collateral_type_expr().alias("is_financial_collateral_type"),
collateral_category_expr().alias("_coll_category"),
airb_flag_expr.alias("_is_airb_model_collateral"),
pl.coalesce(
pl.col("value_after_maturity_adj")
if "value_after_maturity_adj" in collateral_schema.names()
else pl.lit(None),
pl.col("value_after_haircut")
if "value_after_haircut" in collateral_schema.names()
else pl.lit(None),
pl.col("market_value"),
).alias("adjusted_value"),
]
)
annotated = annotated.with_columns(
(pl.col("adjusted_value") / pl.col("overcollateralisation_ratio")).alias(
"effectively_secured"
),
)
# CRR/PS1-26 Art. 199(2)/(5)/(6): FIRB Foundation Collateral Method non-
# financial collateral (real estate, receivables, other physical) is
# recognised on the LGD*-substitution path only where the institution ATTESTS
# eligibility via the pre-existing ``is_eligible_irb_collateral`` flag. Default
# False => ineligible — the flag IS the attestation, so the P1.10 new-field
# null-permissive precedent does NOT apply. Art. 199(5): a receivable whose
# ORIGINAL maturity is populated > 1 year is ineligible even if attested
# (explicit data contradicting the attestation wins conservatively); a NULL
# original maturity is PERMISSIVE (recorded deviation — the attestation covers
# the maturity condition, absence doesn't contradict it). Ineligible rows are
# zeroed on ``effectively_secured`` (the Art. 231 waterfall feed) with one
# CRM014 warning each. Scope: FIRB FCM non-financial only — financial
# collateral (Art. 197), SA EAD reduction, and exposure classification are
# untouched. Art. 199(7)/211 (P1.273): a leased asset attested via
# ``is_lease_collateral_attested`` is an alternative attestation route (OR-ed
# below), so a lessor row is recognised without the general IRB flag.
_non_financial = ~pl.col("is_financial_collateral_type")
_attested = (
pl.col("is_eligible_irb_collateral").fill_null(False)
if "is_eligible_irb_collateral" in collateral_schema.names()
else pl.lit(False)
)
# CRR Art. 199(7) with Art. 211 / PS1/26 Art. 199(7) with Art. 211 (P1.273):
# a leased asset supplied as a non-financial collateral row is recognised when
# the lessor attests the lease-specific Art. 211 conditions (b)/(c)/(d). This
# is an INDEPENDENT eligibility route — Art. 211(a) subsumes the Art. 208/210
# property-eligibility that is_eligible_irb_collateral otherwise attests — so it
# is OR-ed into the attestation. Art. 211 concerns leased PROPERTY, so the route
# is scoped to the real_estate / other_physical categories (Art. 208 immovable
# property / Art. 210 other physical): a lease attestation on a receivables row
# confers NO eligibility — it must still carry its own is_eligible_irb_collateral.
# Null -> False (conservative), leaving all existing non-lease collateral untouched.
if "is_lease_collateral_attested" in collateral_schema.names():
_lease_attested = pl.col("is_lease_collateral_attested").fill_null(False) & pl.col(
"_coll_category"
).is_in(["real_estate", "other_physical"])
_attested = _attested | _lease_attested
_not_attested = _non_financial & ~_attested
if "original_maturity_years" in collateral_schema.names():
# NULL original maturity is PERMISSIVE (recorded deviation — the
# attestation covers the maturity condition, absence doesn't contradict
# it), so fill the *Boolean* > 1y test to False rather than the float
# column to 0.0 (the latter would be an anti-conservative float fill).
_receivables_too_long = (pl.col("_coll_category") == "receivables") & (
pl.col("original_maturity_years") > 1.0
).fill_null(False)
else:
_receivables_too_long = pl.lit(False)
if errors is not None:
_record_ineligible_irb_collateral(annotated, _not_attested, _receivables_too_long, errors)
annotated = annotated.with_columns(
pl.when(_not_attested | _receivables_too_long)
.then(pl.lit(0.0))
.otherwise(pl.col("effectively_secured"))
.alias("effectively_secured")
)
# --- Single group_by: EAD + LGD aggregates in one pass, split by AIRB pool ---
# Each metric is split into a non-AIRB-pool variant (suffix ``_n``,
# collateral with is_airb_model_collateral=False) and an AIRB-pool variant
# (suffix ``_a``, collateral with is_airb_model_collateral=True). The two
# variants are pro-rata-allocated against disjoint exposure pools so that
# collateral incorporated in the AIRB internal LGD model never reaches
# non-AIRB exposures (CRR Art. 181 / Basel 3.1 Art. 169A).
val_expr = pl.coalesce(
pl.col("value_after_maturity_adj"),
pl.col("value_after_haircut"),
)
is_fin = pl.col("is_financial_collateral_type")
cat = pl.col("_coll_category")
is_flagged = pl.col("_is_airb_model_collateral")
is_unflagged = ~is_flagged
def _split_aggs(base_alias: str, value: pl.Expr, value_filter: pl.Expr) -> list[pl.Expr]:
return [
value.filter(value_filter & is_unflagged).sum().alias(f"{base_alias}_n"),
value.filter(value_filter & is_flagged).sum().alias(f"{base_alias}_a"),
]
# Build per-category effectively_secured aggregates for Art. 231 waterfall
waterfall_aggs: list[pl.Expr] = []
for cat_values, _lgds_key, suffix in WATERFALL_ORDER:
waterfall_aggs.extend(
_split_aggs(f"_e{suffix}", pl.col("effectively_secured"), cat.is_in(cat_values))
)
# Per-category MARKET-value aggregates, metric -> (category, carrier). These
# mirror the ``_adj_*`` set one-for-one through the same multi-level blend but
# sum the pre-haircut ``market_value``, and are pure reporting carriers —
# nothing in engine/ consumes them.
#
# PS1/26 Annex II col 0190 (likewise 0180/0200/0210): "Where exposures are
# subject to the Foundation Collateral Method … the adjusted value of
# collateral Ci … Where exposures are subject to the AIRB approach, the amount
# to be reported shall be the estimated market value." CRR Annex II cols
# 0150-0210 make the same split on whether own LGD estimates are used. The
# ``_adj_*`` twins serve the Foundation limb; these serve the AIRB limb, which
# the adjusted basis understates wherever a supervisory haircut applies (40%
# on real estate under Basel 3.1, 0% under CRR).
market_value_carriers = {
"_mv_fin": ("financial", "collateral_financial_market_value"),
"_mv_cash": ("cash", "collateral_cash_market_value"),
"_mv_re": ("real_estate", "collateral_re_market_value"),
"_mv_rec": ("receivables", "collateral_receivables_market_value"),
"_mv_oth": ("other_physical", "collateral_other_physical_market_value"),
"_mv_li": ("life_insurance", "collateral_life_insurance_market_value"),
}
market_value_aggs: list[pl.Expr] = []
for metric, (category, _) in market_value_carriers.items():
market_value_aggs.extend(_split_aggs(metric, pl.col("market_value"), cat == category))
all_coll = (
annotated.with_columns(
beneficiary_level_expr().alias("_level"),
)
.group_by(["_level", "beneficiary_reference"])
.agg(
_split_aggs("_cv", val_expr, is_eligible)
+ _split_aggs("_mv", pl.col("market_value"), is_eligible)
+ _split_aggs("_rn", pl.col("adjusted_value"), ~is_fin)
+ _split_aggs("_adj_fin", pl.col("adjusted_value"), cat == "financial")
+ _split_aggs("_adj_cash", pl.col("adjusted_value"), cat == "cash")
+ _split_aggs("_adj_re", pl.col("adjusted_value"), cat == "real_estate")
+ _split_aggs("_adj_rec", pl.col("adjusted_value"), cat == "receivables")
+ _split_aggs("_adj_oth", pl.col("adjusted_value"), cat == "other_physical")
+ market_value_aggs
+ waterfall_aggs
)
)
_wf_suffixes = [suffix for _, _, suffix in WATERFALL_ORDER]
# The market-value metrics allocate on a POOL-AGNOSTIC basis (see
# ``_pool_agnostic_metrics`` below); every other metric keeps the
# pool-gated allocation that drives LGD / EAD.
_pool_agnostic_metrics = list(market_value_carriers)
_metrics = (
[
"_cv",
"_mv",
"_rn",
"_adj_fin",
"_adj_cash",
"_adj_re",
"_adj_rec",
"_adj_oth",
]
+ _pool_agnostic_metrics
+ [f"_e{s}" for s in _wf_suffixes]
)
# Each metric has both _n (non-AIRB pool) and _a (AIRB pool) variants in the
# aggregated frame; the level suffix (_d/_f/_c) is appended on rename below.
_agg = [f"{m}_{p}" for m in _metrics for p in ("n", "a")]
# Split the small aggregated result for per-level joins
coll_direct = (
all_coll.filter(pl.col("_level") == "direct")
.drop("_level")
.rename({c: f"{c}_d" for c in _agg})
)
coll_facility = (
all_coll.filter(pl.col("_level") == "facility")
.drop("_level")
.rename({c: f"{c}_f" for c in _agg})
)
coll_counterparty = (
all_coll.filter(pl.col("_level") == "counterparty")
.drop("_level")
.rename({c: f"{c}_c" for c in _agg})
)
# --- Join direct + counterparty levels to exposures ---
exposures = exposures.join(
coll_direct,
left_on="exposure_reference",
right_on="beneficiary_reference",
how="left",
)
# Facility level: cascade collateral over each exposure's full ancestor set
# so a pledge at any ancestor facility (parent, grandparent, ... root) flows
# pro-rata to every descendant exposure (CRR Art. 230-231 pooling over the
# facility subtree). Produces pre-weighted, ancestor-summed ``{m}_{p}_f``
# columns that ``_sum6`` adds in directly (the pro-rata weight is already
# baked in, so no further ``_fw`` multiply is needed).
exposures = _cascade_facility_collateral(
exposures, coll_facility, _metrics, _pool_agnostic_metrics
)
exposures = exposures.join(
coll_counterparty,
left_on="counterparty_reference",
right_on="beneficiary_reference",
how="left",
).join(
cp_ead_totals,
on="counterparty_reference",
how="left",
)
# --- Fill nulls + counterparty pro-rata weights ---
# Facility ``{c}_f`` columns are already filled + pre-weighted by
# ``_cascade_facility_collateral``; only the direct (``_d``) and
# counterparty (``_c``) families plus the CP EAD totals need filling here.
fill_exprs = []
for sfx in ["d", "c"]:
for c in _agg:
fill_exprs.append(pl.col(f"{c}_{sfx}").fill_null(0.0))
fill_exprs.extend(
[
pl.col("_cp_ead_total").fill_null(0.0),
pl.col("_cp_ead_total_airb").fill_null(0.0),
pl.col("_cp_ead_total_non_airb").fill_null(0.0),
]
)
exposures = exposures.with_columns(fill_exprs)
# Pool-aware counterparty pro-rata weights. ``_is_airb_pool`` was tagged on
# exposures in ``apply_collateral`` via ``airb_lgd_preserved_expr``; weights
# bake in the pool-match gate so non-matching pools always contribute zero.
in_airb = pl.col("_is_airb_pool").fill_null(False)
in_non_airb = ~in_airb
# Pro-rata weights use ead_for_crm (CRR Art. 223(4) / PS1/26 Art. 223(4):
# off-BS items at CCF=100% for CRM allocation purposes), so the share
# an exposure receives of a CP collateral pool is proportional to its full
# pre-CCF basis rather than its post-CCF EAD.
# ``_cw_n_all`` is the pool-AGNOSTIC counterparty weight used by the
# market-value reporting carriers only: it drops the ``in_non_airb`` gate and
# shares over the whole counterparty population (``_cp_ead_total``) rather
# than the non-AIRB sub-pool. Dropping the gate while keeping the sub-pool
# denominator would allocate the pledge in full to BOTH pools; sharing on
# ``_cp_ead_total`` keeps it conserved. See ``_sum6_pool_agnostic``.
exposures = exposures.with_columns(
[
pl.when(in_non_airb & (pl.col("_cp_ead_total_non_airb") > 0))
.then(pl.col("ead_for_crm") / pl.col("_cp_ead_total_non_airb"))
.otherwise(pl.lit(0.0))
.alias("_cw_n"),
pl.when(pl.col("_cp_ead_total") > 0)
.then(pl.col("ead_for_crm") / pl.col("_cp_ead_total"))
.otherwise(pl.lit(0.0))
.alias("_cw_n_all"),
pl.when(in_airb & (pl.col("_cp_ead_total_airb") > 0))
.then(pl.col("ead_for_crm") / pl.col("_cp_ead_total_airb"))
.otherwise(pl.lit(0.0))
.alias("_cw_a"),
in_airb.cast(pl.Float64).alias("_airb_match"),
]
)
# --- Combine all levels for EAD + LGD ---
# Non-AIRB-flagged collateral (``_n`` family) flows to non-AIRB-pool
# exposures: facility via the ancestor cascade (``_n_f`` pre-weighted) and
# counterparty via the ``_cw_n`` weight (both gated to that pool); direct
# unflagged is unconditional (1:1, no pro-rata). AIRB-flagged collateral
# (``_a`` family) flows only to AIRB-pool exposures — facility via the
# cascade (``_a_f``), counterparty via ``_cw_a``, and direct gated by
# ``_airb_match``. Direct flagged collateral on a non-AIRB exposure is a
# data-quality issue surfaced as CRM006 by the validation pass.
def _sum6(metric: str) -> pl.Expr:
# Facility terms (``_f``) are already pro-rata-weighted and summed over
# the exposure's ancestor facilities by ``_cascade_facility_collateral``,
# so they enter the blend without a further weight multiply.
return (
pl.col(f"{metric}_n_d")
+ pl.col(f"{metric}_a_d") * pl.col("_airb_match")
+ pl.col(f"{metric}_n_f")
+ pl.col(f"{metric}_a_f")
+ pl.col(f"{metric}_n_c") * pl.col("_cw_n")
+ pl.col(f"{metric}_a_c") * pl.col("_cw_a")
)
# POOL-AGNOSTIC blend, for the market-value reporting carriers only. Same six
# terms and the same flagged (``_a``) weights — flagged collateral is in the
# firm's internal LGD model and still never reaches a non-AIRB exposure — but
# the two UNFLAGGED indirect terms share over the whole population instead of
# the non-AIRB sub-pool: ``_cw_n_all`` at counterparty level, and at facility
# level the ``{m}_n_f`` column, which ``_cascade_facility_collateral`` has
# already pre-weighted with the all-descendants subtree weight for exactly
# these metrics. Direct (``_n_d``) is unconditional on both blends.
# PS1/26 Art. 169A(1)-(2): recognition is an institution-level election, so an
# A-IRB row reports collateral pledged against it whether or not that pledge
# moved the modelled LGD. The Foundation election is applied below.
def _sum6_pool_agnostic(metric: str) -> pl.Expr:
return (
pl.col(f"{metric}_n_d")
+ pl.col(f"{metric}_a_d") * pl.col("_airb_match")
+ pl.col(f"{metric}_n_f")
+ pl.col(f"{metric}_a_f")
+ pl.col(f"{metric}_n_c") * pl.col("_cw_n_all")
+ pl.col(f"{metric}_a_c") * pl.col("_cw_a")
)
combine_exprs = [
_sum6("_cv").alias("collateral_adjusted_value"),
_sum6("_mv").alias("collateral_market_value"),
_sum6("_adj_fin").alias("collateral_financial_value"),
_sum6("_adj_cash").alias("collateral_cash_value"),
_sum6("_adj_re").alias("collateral_re_value"),
_sum6("_adj_rec").alias("collateral_receivables_value"),
_sum6("_adj_oth").alias("collateral_other_physical_value"),
_sum6("_rn").alias("_raw_nf_a"),
]
# RD-5 / PS1/26 Art. 169A(1)-(2): recognition of collateral in LGD estimates is
# an institution-level ELECTION, so the AIRB market-value limb of Annex II cols
# 0180-0210 (RD-1) is only open to an A-IRB row whose modelled LGD actually
# stands. ``_is_airb_pool`` IS ``airb_lgd_preserved_expr`` materialised on the
# frame, so reading it here keeps this gate and the pool definition on one
# expression: an A-IRB row loses the market-value limb exactly when that
# expression says its modelled LGD does not survive — the firm elected the
# Foundation Collateral Method, or Art. 169B insufficient-data drops the row
# back to the supervisory formula. Both cases report through the ``_adj_*``
# twins instead. Non-A-IRB rows (FIRB / SA / slotting) are unaffected.
_mv_limb_open = in_airb | (pl.col("approach") != ApproachType.AIRB.value)
for _mv_metric, (_, _mv_carrier) in market_value_carriers.items():
combine_exprs.append(
pl.when(_mv_limb_open)
.then(_sum6_pool_agnostic(_mv_metric))
.otherwise(pl.lit(0.0))
.alias(_mv_carrier)
)
# Per-category effectively_secured after multi-level combination
for suffix in _wf_suffixes:
combine_exprs.append(_sum6(f"_e{suffix}").alias(f"_eff_{suffix}_a"))
exposures = exposures.with_columns(combine_exprs)
# Per-type minimum collateralisation thresholds (CRR Art. 230)
# Art. 230 requires the threshold to apply per collateral type, not across
# the combined non-financial pool. Each type (real_estate, other_physical)
# must independently meet its 30% threshold to be eligible for LGDS
# reduction. Financial, covered_bond, and receivables have no threshold.
#
# PS1/26 Art. 230(1) replaces the CRR step-function with a continuous LGD*
# formula and removes the C* / C** thresholds entirely — under Basel 3.1
# any positive eligible non-financial collateral is recognised at LGDS.
if resolved_pack.feature("firb_min_collateralisation_threshold_applies"):
_min_thresholds = lookup_float_map(resolved_pack.lookup("min_collateralisation_thresholds"))
_type_threshold: dict[str, tuple[float, str]] = {
"re": (_min_thresholds["real_estate"], "collateral_re_value"),
"op": (
_min_thresholds["other_physical"],
"collateral_other_physical_value",
),
}
nf_threshold_exprs = []
for suffix in _wf_suffixes:
if suffix not in _type_threshold:
continue # No threshold for fin/cb/rec
threshold, raw_col = _type_threshold[suffix]
if threshold <= 0:
continue
col_name = f"_eff_{suffix}_a"
# Art. 230 minimum-collateralisation threshold uses E with CCF=100%
# per Art. 223(4) — the threshold is a fraction of the pre-CCF basis.
nf_threshold_exprs.append(
pl.when(pl.col(raw_col) >= threshold * pl.col("ead_for_crm"))
.then(pl.col(col_name))
.otherwise(pl.lit(0.0))
.alias(col_name)
)
if nf_threshold_exprs:
exposures = exposures.with_columns(nf_threshold_exprs)
# --- Art. 231 sequential fill (waterfall) ---
# Allocate from lowest LGDS to highest. Each category absorbs up to
# min(category_total, remaining_exposure). Uses the cumulative-cap
# trick: es_i = min(cum_through_i, EAD) - min(cum_through_i-1, EAD).
# EAD here is ead_for_crm (CCF=100% basis per Art. 223(4)) — the
# actual post-CCF EAD is recoupled later for SA via effective_ccf.
ead = pl.col("ead_for_crm")
cum = pl.lit(0.0)
es_exprs: list[pl.Expr] = []
for suffix in _wf_suffixes:
prev_cum = cum
cum = cum + pl.col(f"_eff_{suffix}_a")
es_i = pl.min_horizontal(cum, ead) - pl.min_horizontal(prev_cum, ead)
es_exprs.append(es_i.alias(f"_es_{suffix}"))
total_secured_expr = pl.min_horizontal(cum, ead)
# Blended lgd_secured = sum(lgds_i * es_i) / total_secured
# CRR Art. 230 Table 5: subordinated exposures use higher LGDS for the
# secured portion (receivables 65%, RE 65%, other physical 70%).
# Basel 3.1 Art. 230(2) removes the subordinated LGDS column entirely.
_has_seniority = "seniority" in exposure_schema.names()
_build_sub = overcollateralisation_step_function and _has_seniority
lgd_num = pl.lit(0.0)
lgd_num_sub = pl.lit(0.0) if _build_sub else None
for _, lgds_key, suffix in WATERFALL_ORDER:
es_col = pl.col(f"_es_{suffix}")
lgd_num = lgd_num + pl.lit(lgds[lgds_key]) * es_col
if _build_sub:
sub_lgds = lgd_values.get(f"{lgds_key}_subordinated", lgd_values[lgds_key])
lgd_num_sub = lgd_num_sub + pl.lit(sub_lgds) * es_col
if _build_sub:
is_sub = (
pl.col("seniority").fill_null("").str.to_lowercase().is_in(["subordinated", "junior"])
)
lgd_num_final = pl.when(is_sub).then(lgd_num_sub).otherwise(lgd_num)
else:
lgd_num_final = lgd_num
# Compute sequential allocations, then total + lgd_secured
exposures = exposures.with_columns(es_exprs)
exposures = exposures.with_columns(
[
total_secured_expr.alias("total_collateral_for_lgd"),
pl.when(total_secured_expr > 0)
.then(lgd_num_final / total_secured_expr)
.otherwise(pl.lit(lgd_unsecured))
.alias("lgd_secured"),
]
)
# --- Drop intermediate allocation columns ---
# Preserve _es_* columns (renamed to crm_alloc_*) for the A-IRB blended
# LGD floor (Art. 164(4)(c)). These encode the dollar amount of EAD
# absorbed by each collateral category in the Art. 231 waterfall.
drop_cols = (
[f"{c}_{sfx}" for sfx in ["d", "f", "c"] for c in _agg]
+ [
"_cp_ead_total",
"_cp_ead_total_airb",
"_cp_ead_total_non_airb",
"_cw_n",
"_cw_n_all",
"_cw_a",
"_airb_match",
"_is_airb_pool",
"_raw_nf_a",
]
+ [f"_eff_{s}_a" for s in _wf_suffixes]
)
exposures = exposures.drop(drop_cols)
exposures = exposures.rename({f"_es_{s}": CRM_ALLOC_COLUMNS[s] for s in _wf_suffixes})
# --- Apply EAD reduction + determine seniority-based LGDU ---
# Supervisory LGDU for unsecured portion: FSE-aware under Basel 3.1
# (Art. 161(1)(a) vs (aa))
if _has_fse_col:
supervisory_lgdu_expr = (
pl.when(pl.col("cp_is_financial_sector_entity").fill_null(False))
.then(pl.lit(lgd_unsecured_fse))
.otherwise(pl.lit(lgd_unsecured))
)
else:
supervisory_lgdu_expr = pl.lit(lgd_unsecured)
# --- Determine which AIRB exposures use the Foundation formula ---
# Art. 169A/169B (Basel 3.1 only): AIRB exposures may use the Foundation
# Collateral Method formula under two scenarios:
# (1) Foundation election: firm opts for FCM instead of LGD Modelling
# (2) Art. 169B fallback: insufficient data → FCM formula with own LGDU
# Under CRR, AIRB is free-form — own LGD always kept unchanged.
exposure_schema = exposures.collect_schema()
_has_lgd_unsecured_col = "lgd_unsecured" in exposure_schema.names()
schema_names = set(exposure_schema.names())
airb_method = config.airb_collateral_method
is_airb = pl.col("approach") == ApproachType.AIRB.value
# ``_airb_uses_formula`` is the negation of the LGD-preserved condition:
# AIRB rows that fall back to the supervisory formula under Foundation
# election or Art. 169B insufficient-data fallback.
_airb_uses_formula = is_airb & ~airb_lgd_preserved_expr(
config, schema_names, pack=resolved_pack
)
# Art. 169B(2)(c): use firm's own unsecured LGD when LGD-modelling falls back
_airb_own_lgdu = (
airb_collateral_method_applies and airb_method == AIRBCollateralMethod.LGD_MODELLING
)
# Combined condition: FIRB OR qualifying AIRB exposures use the formula
_uses_formula = (pl.col("approach") == ApproachType.FIRB.value) | _airb_uses_formula
# Build per-exposure LGDU expression
# For AIRB Art. 169B: LGDU = own lgd_unsecured (Art. 169B(2)(c))
# For FIRB and AIRB Foundation: LGDU = supervisory value
is_subordinated = pl.col("seniority").str.to_lowercase().is_in(["subordinated", "junior"])
if _airb_own_lgdu and _has_lgd_unsecured_col:
# Art. 169B: AIRB exposures with insufficient data use own lgd_unsecured,
# falling back to lgd_pre_crm if lgd_unsecured not provided.
own_lgdu = pl.coalesce(pl.col("lgd_unsecured"), pl.col("lgd_pre_crm"))
lgdu_expr = (
pl.when(is_subordinated)
.then(pl.lit(lgd_subordinated))
.when(_airb_uses_formula)
.then(own_lgdu)
.otherwise(supervisory_lgdu_expr)
)
else:
lgdu_expr = (
pl.when(is_subordinated).then(pl.lit(lgd_subordinated)).otherwise(supervisory_lgdu_expr)
)
# SA EAD reduction (CRR Art. 228(1) / PS1/26 Art. 228(1)) with the
# CRR Art. 223(5) FCCM exposure-side gross-up:
# E* = max(0, ead_for_crm × (1 + HE) − collateral_adjusted_value)
# EAD = E* × CCF_actual (i.e. × effective_ccf for blended rows)
# The CCF is applied to E*, not to the pre-collateral nominal — this is
# the regulatorily mandated ordering and reverses the previous
# implementation (which netted collateral against post-CCF ead_gross).
# FIRB / Slotting / AIRB keep ead_gross because under those approaches
# collateral modifies LGD (via lgd_post_crm), not EAD.
schema_for_he = exposures.collect_schema().names()
_has_he_col = "exposure_volatility_haircut" in schema_for_he
# E' = ead_for_crm × (1 + HE), shared with the A-IRB LGD input floor blend.
e_for_lgd_star = lgd_star_exposure_basis_expr(has_volatility_haircut=_has_he_col)
exposures = exposures.with_columns(
[
pl.when(pl.col("approach") == ApproachType.SA.value)
.then(
(e_for_lgd_star - pl.col("collateral_adjusted_value")).clip(lower_bound=0)
* pl.col("effective_ccf")
)
.otherwise(pl.col("ead_gross"))
.alias("ead_after_collateral"),
lgdu_expr.alias("lgd_unsecured"),
]
)
# --- Calculate LGD post-CRM + audit ---
# LGD* formula (Art. 230/231) applies to FIRB and qualifying AIRB exposures.
# Non-qualifying AIRB and SA keep lgd_pre_crm.
#
# CRR Art. 223(4) / PS1/26 Art. 223(4): the exposure value E used in the
# LGD* formula is the CCF=100% basis (ead_for_crm) for off-balance-sheet
# items, NOT the post-CCF EAD. For pure on-BS rows ead_for_crm == ead_gross.
#
# PS1/26 Art. 230(1) / CRR Art. 228(2) (P1.272): the exposure basis is
# grossed up by its own volatility haircut HE — E' = E(1 + HE) — so
# LGD* = (LGDS · min(C, E') + LGDU · max(0, E' - C)) / E'.
# HE (exposure_volatility_haircut, Art. 223(5)) is non-zero only for SFT rows
# lending out a debt security, so the HE factor == 1 for every other row and
# E' == E; the SFT-FCCM path is unaffected (it emits E* directly).
# ``e_for_lgd_star`` is built above from ``lgd_star_exposure_basis_expr``.
lgd_star_expr = (
(
pl.col("lgd_secured")
* pl.col("total_collateral_for_lgd").clip(upper_bound=e_for_lgd_star)
)
+ (
pl.col("lgd_unsecured")
* (e_for_lgd_star - pl.col("total_collateral_for_lgd")).clip(lower_bound=0)
)
) / e_for_lgd_star
exposures = exposures.with_columns(
[
pl.when(
_uses_formula
& (pl.col("ead_for_crm") > 0)
& (pl.col("total_collateral_for_lgd") > 0)
)
.then(lgd_star_expr)
.when(_uses_formula & (pl.col("ead_for_crm") > 0))
.then(pl.col("lgd_unsecured"))
.otherwise(pl.col("lgd_pre_crm"))
.alias("lgd_post_crm"),
# collateral_coverage_pct is the C/E ratio used for the Art. 230
# threshold tests, so it also uses ead_for_crm.
pl.when(pl.col("ead_for_crm") > 0)
.then(
pl.col("total_collateral_for_lgd").clip(upper_bound=pl.col("ead_for_crm"))
/ pl.col("ead_for_crm")
* 100
)
.otherwise(pl.lit(0.0))
.alias("collateral_coverage_pct"),
]
)
return exposures
CRR Art. 201 — Eligibility of protection providers under all approaches¶
_assign_guarantor_approach — src/rwa_calc/engine/crm/guarantees.py:417
@cites("CRR Art. 201")
@cites("PS1/26, paragraph 201")
def _assign_guarantor_approach(
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
errors: list[CalculationError] | None = None,
) -> pl.LazyFrame:
"""
Determine guarantor approach (IRB / SA) and rating provenance.
A guarantor is treated under IRB only if:
1. The beneficiary exposure is itself on FIRB/AIRB (CRR Art. 161 /
Basel 3.1 CRE22.70-85: parameter substitution applies only to IRB
beneficiaries; SA beneficiaries always substitute via guarantor's
SA risk weight regardless of the guarantor's internal rating —
SLOTTING beneficiaries are deliberately excluded, so the Art. 201(2)
internal-rating eligibility limb does not reach them either), AND
2. The firm has IRB permission for the guarantor's exposure class, AND
3. The guarantor has an internal rating (PD) — indicating the firm
actively rates this counterparty under its IRB model.
Counterparties with only external ratings (CQS) are treated under SA.
CRR/PS1-26 Art. 201(1)(g)/(2) eligibility gate: a CORPORATE guarantor is an
eligible protection provider only if it has an ECAI credit assessment
(``guarantor_cqs``) or — Art. 201(2), IRB-beneficiary-only — an internal
rating (``guarantor_internal_pd``) when the beneficiary is itself IRB. An
ineligible corporate guarantor is rejected: its ``guarantor_exposure_class``
is cleared so the SA guarantor-RW lookup returns null (non-beneficial), the
covered leg reverts to the borrower's own basis, and a CRM013 warning is
raised. Non-corporate classes are governed by other Art. 201 limbs and are
not gated here.
"""
# irb_permissions is derived non-None in CalculationConfig.__post_init__.
irb_exposure_class_values = {
ec.value
for ec, approaches in config.irb_permissions.permissions.items() # ty: ignore[unresolved-attribute]
if ApproachType.FIRB in approaches or ApproachType.AIRB in approaches
}
irb_beneficiary_approaches = [ApproachType.FIRB.value, ApproachType.AIRB.value]
schema_names = exposures.collect_schema().names()
beneficiary_is_irb = (
pl.col("approach").fill_null("").is_in(irb_beneficiary_approaches)
if "approach" in schema_names
else pl.lit(False)
)
is_domestic_cgcb_guarantor = _build_domestic_cgcb_flag(schema_names)
# Art. 201(1)(g)/(2) gate. All inputs are non-null booleans (is_not_null /
# == on the default-"" class), so no Kleene-null leaks into the gate. The
# class column can only ever say "corporate" (never "corporate_sme" — the
# entity->SA-class map has no such entity_type; SME-ness is derived later).
is_corporate_guarantor = pl.col("guarantor_exposure_class") == "corporate"
corporate_eligible = pl.col("guarantor_cqs").is_not_null() | (
beneficiary_is_irb & pl.col("guarantor_internal_pd").is_not_null()
)
guarantor_ineligible = (
is_corporate_guarantor & corporate_eligible.not_() & (pl.col("guaranteed_portion") > 0)
)
if errors is not None:
_record_ineligible_guarantors(exposures, guarantor_ineligible, errors)
return exposures.with_columns(
pl.when(is_domestic_cgcb_guarantor)
.then(pl.lit("sa"))
.when(
beneficiary_is_irb
& (pl.col("guarantor_exposure_class") != "")
& pl.col("guarantor_exposure_class").is_in(list(irb_exposure_class_values))
& pl.col("guarantor_internal_pd").is_not_null()
)
.then(pl.lit("irb"))
# SA fallback — gated: an ineligible corporate guarantor takes "" (the
# existing no-substitution value) rather than "sa".
.when((pl.col("guarantor_exposure_class") != "") & guarantor_ineligible.not_())
.then(pl.lit("sa"))
.otherwise(pl.lit(""))
.alias("guarantor_approach"),
# Audit: track whether guarantor approach was derived from internal or
# external rating (spec output field per CRR Art. 153(3) / Art. 233A).
pl.when(pl.col("guarantor_internal_pd").is_not_null())
.then(pl.lit("internal"))
.when(pl.col("guarantor_cqs").is_not_null())
.then(pl.lit("external"))
.otherwise(pl.lit(None).cast(pl.String))
.alias("guarantor_rating_type"),
# Explicit revert (Art. 201): clear the guarantor class for an ineligible
# corporate so ``build_guarantor_rw_expr`` returns null -> non-beneficial
# -> the covered leg reverts to the borrower's own basis. Mirrors the
# existing unmapped-guarantor (class "") no-substitution path.
pl.when(guarantor_ineligible)
.then(pl.lit(""))
.otherwise(pl.col("guarantor_exposure_class"))
.alias("guarantor_exposure_class"),
)
CRR Art. 211 — Requirements for treating lease exposures as collateralised¶
_apply_collateral_unified — src/rwa_calc/engine/crm/collateral.py:892
@cites("CRR Art. 199")
@cites("CRR Art. 211")
@cites("PS1/26 Art. 199")
@cites("PS1/26 Art. 211")
def _apply_collateral_unified(
exposures: pl.LazyFrame,
adjusted_collateral: pl.LazyFrame,
config: CalculationConfig,
cp_ead_totals: pl.LazyFrame,
*,
pack: ResolvedRulepack | None = None,
errors: list[CalculationError] | None = None,
) -> pl.LazyFrame:
"""
Unified EAD + LGD collateral allocation in a single pass.
Performs a single group_by over all collateral levels (direct, facility,
counterparty) and joins back to exposures for both SA EAD reduction and
F-IRB LGD calculation.
Art. 231 sequential fill: when multiple collateral types secure an
exposure, each type absorbs exposure starting from the lowest LGDS.
The institution receives the most favourable ordering (lowest LGDS first):
financial (0%) -> covered_bond (11.25%) -> receivables -> real_estate
-> other_physical. This replaces the former pro-rata allocation.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
# S9h: regime branches read honest cited Features off the resolved pack.
# firb_fse_senior_lgd_split → FSE 45/40 split; firb_overcollateralisation_divisor_
# applies → CRR Art. 230(2) subordinated secured-portion LGDS rows (B31 LGD* drops
# them); airb_lgd_collateral_method_applicable → B31 Art. 169A/169B AIRB method.
fse_senior_lgd_split = resolved_pack.feature("firb_fse_senior_lgd_split")
overcollateralisation_step_function = resolved_pack.feature(
"firb_overcollateralisation_divisor_applies"
)
airb_collateral_method_applies = resolved_pack.feature("airb_lgd_collateral_method_applicable")
lgd_values = supervisory_lgd_values(resolved_pack)
lgd_subordinated = subordinated_unsecured_lgd(resolved_pack)
lgd_unsecured = lgd_values["unsecured"]
# LGDS values per waterfall category (Art. 230/231)
lgds = {key: lgd_values[key] for _, key, _ in WATERFALL_ORDER}
# Under Basel 3.1, FSE senior unsecured LGDU = 45% (Art. 161(1)(a));
# non-FSE = 40% (Art. 161(1)(aa)). Under CRR, all = 45%.
exposure_schema = exposures.collect_schema()
_has_fse_col = (
fse_senior_lgd_split and "cp_is_financial_sector_entity" in exposure_schema.names()
)
if _has_fse_col:
lgd_unsecured_fse = lgd_values["unsecured_fse"]
# Defensive: fill in pool-aware columns when callers (typically unit tests)
# construct ead-total frames or exposures without them. Missing pool flag
# → all exposures treated as non-AIRB pool, which matches legacy behaviour
# (unflagged collateral pro-rates over the full population).
if "_is_airb_pool" not in exposure_schema.names():
exposures = exposures.with_columns(pl.lit(False).alias("_is_airb_pool"))
# CRR Art. 223(4) override: ead_for_crm is the CCF=100% basis. Production
# always supplies it via _initialize_ead; direct unit-test callers may
# not, in which case we fall back to ead_gross (correct for pure on-BS
# rows where the two are equal by construction).
if "ead_for_crm" not in exposure_schema.names():
exposures = exposures.with_columns(pl.col("ead_gross").alias("ead_for_crm"))
if "effective_ccf" not in exposure_schema.names():
exposures = exposures.with_columns(pl.lit(1.0).alias("effective_ccf"))
# Facility-ancestor closure for the multi-level facility collateral cascade.
# Production supplies ``ancestor_facilities`` (parent + all ancestors up to
# root, incl. self) from the HierarchyResolver. Direct unit-test callers and
# single-level inputs fall back to the 1-element [parent] list, which makes
# the cascade in ``_cascade_facility_collateral`` reduce exactly to the
# legacy single-level allocation.
if "ancestor_facilities" not in exposure_schema.names():
if "parent_facility_reference" in exposure_schema.names():
exposures = exposures.with_columns(
pl.concat_list("parent_facility_reference").alias("ancestor_facilities")
)
else:
exposures = exposures.with_columns(
pl.lit(None, dtype=pl.List(pl.String)).alias("ancestor_facilities")
)
cp_totals_schema = cp_ead_totals.collect_schema().names()
cp_total_fills: list[pl.Expr] = []
if "_cp_ead_total_non_airb" not in cp_totals_schema:
cp_total_fills.append(pl.col("_cp_ead_total").alias("_cp_ead_total_non_airb"))
if "_cp_ead_total_airb" not in cp_totals_schema:
cp_total_fills.append(pl.lit(0.0).alias("_cp_ead_total_airb"))
if cp_total_fills:
cp_ead_totals = cp_ead_totals.with_columns(cp_total_fills)
collateral_schema = adjusted_collateral.collect_schema()
# --- Determine eligible expression for EAD reduction ---
if "is_eligible_financial_collateral" in collateral_schema:
is_eligible = pl.col("is_eligible_financial_collateral")
else:
is_eligible = ~pl.col("collateral_type").str.to_lowercase().is_in(NON_ELIGIBLE_RE_TYPES)
# --- Annotate collateral with LGD categories (using shared expressions) ---
# Ensure the AIRB-model flag is present (default False) so the pool-aware
# aggregation below can rely on it. Backward-compatible with collateral
# frames built before the column existed.
if "is_airb_model_collateral" in collateral_schema.names():
airb_flag_expr = pl.col("is_airb_model_collateral").fill_null(False)
else:
airb_flag_expr = pl.lit(False)
annotated = adjusted_collateral.with_columns(
[
collateral_lgd_expr(resolved_pack).alias("collateral_lgd"),
overcollateralisation_ratio_expr(resolved_pack).alias("overcollateralisation_ratio"),
is_financial_collateral_type_expr().alias("is_financial_collateral_type"),
collateral_category_expr().alias("_coll_category"),
airb_flag_expr.alias("_is_airb_model_collateral"),
pl.coalesce(
pl.col("value_after_maturity_adj")
if "value_after_maturity_adj" in collateral_schema.names()
else pl.lit(None),
pl.col("value_after_haircut")
if "value_after_haircut" in collateral_schema.names()
else pl.lit(None),
pl.col("market_value"),
).alias("adjusted_value"),
]
)
annotated = annotated.with_columns(
(pl.col("adjusted_value") / pl.col("overcollateralisation_ratio")).alias(
"effectively_secured"
),
)
# CRR/PS1-26 Art. 199(2)/(5)/(6): FIRB Foundation Collateral Method non-
# financial collateral (real estate, receivables, other physical) is
# recognised on the LGD*-substitution path only where the institution ATTESTS
# eligibility via the pre-existing ``is_eligible_irb_collateral`` flag. Default
# False => ineligible — the flag IS the attestation, so the P1.10 new-field
# null-permissive precedent does NOT apply. Art. 199(5): a receivable whose
# ORIGINAL maturity is populated > 1 year is ineligible even if attested
# (explicit data contradicting the attestation wins conservatively); a NULL
# original maturity is PERMISSIVE (recorded deviation — the attestation covers
# the maturity condition, absence doesn't contradict it). Ineligible rows are
# zeroed on ``effectively_secured`` (the Art. 231 waterfall feed) with one
# CRM014 warning each. Scope: FIRB FCM non-financial only — financial
# collateral (Art. 197), SA EAD reduction, and exposure classification are
# untouched. Art. 199(7)/211 (P1.273): a leased asset attested via
# ``is_lease_collateral_attested`` is an alternative attestation route (OR-ed
# below), so a lessor row is recognised without the general IRB flag.
_non_financial = ~pl.col("is_financial_collateral_type")
_attested = (
pl.col("is_eligible_irb_collateral").fill_null(False)
if "is_eligible_irb_collateral" in collateral_schema.names()
else pl.lit(False)
)
# CRR Art. 199(7) with Art. 211 / PS1/26 Art. 199(7) with Art. 211 (P1.273):
# a leased asset supplied as a non-financial collateral row is recognised when
# the lessor attests the lease-specific Art. 211 conditions (b)/(c)/(d). This
# is an INDEPENDENT eligibility route — Art. 211(a) subsumes the Art. 208/210
# property-eligibility that is_eligible_irb_collateral otherwise attests — so it
# is OR-ed into the attestation. Art. 211 concerns leased PROPERTY, so the route
# is scoped to the real_estate / other_physical categories (Art. 208 immovable
# property / Art. 210 other physical): a lease attestation on a receivables row
# confers NO eligibility — it must still carry its own is_eligible_irb_collateral.
# Null -> False (conservative), leaving all existing non-lease collateral untouched.
if "is_lease_collateral_attested" in collateral_schema.names():
_lease_attested = pl.col("is_lease_collateral_attested").fill_null(False) & pl.col(
"_coll_category"
).is_in(["real_estate", "other_physical"])
_attested = _attested | _lease_attested
_not_attested = _non_financial & ~_attested
if "original_maturity_years" in collateral_schema.names():
# NULL original maturity is PERMISSIVE (recorded deviation — the
# attestation covers the maturity condition, absence doesn't contradict
# it), so fill the *Boolean* > 1y test to False rather than the float
# column to 0.0 (the latter would be an anti-conservative float fill).
_receivables_too_long = (pl.col("_coll_category") == "receivables") & (
pl.col("original_maturity_years") > 1.0
).fill_null(False)
else:
_receivables_too_long = pl.lit(False)
if errors is not None:
_record_ineligible_irb_collateral(annotated, _not_attested, _receivables_too_long, errors)
annotated = annotated.with_columns(
pl.when(_not_attested | _receivables_too_long)
.then(pl.lit(0.0))
.otherwise(pl.col("effectively_secured"))
.alias("effectively_secured")
)
# --- Single group_by: EAD + LGD aggregates in one pass, split by AIRB pool ---
# Each metric is split into a non-AIRB-pool variant (suffix ``_n``,
# collateral with is_airb_model_collateral=False) and an AIRB-pool variant
# (suffix ``_a``, collateral with is_airb_model_collateral=True). The two
# variants are pro-rata-allocated against disjoint exposure pools so that
# collateral incorporated in the AIRB internal LGD model never reaches
# non-AIRB exposures (CRR Art. 181 / Basel 3.1 Art. 169A).
val_expr = pl.coalesce(
pl.col("value_after_maturity_adj"),
pl.col("value_after_haircut"),
)
is_fin = pl.col("is_financial_collateral_type")
cat = pl.col("_coll_category")
is_flagged = pl.col("_is_airb_model_collateral")
is_unflagged = ~is_flagged
def _split_aggs(base_alias: str, value: pl.Expr, value_filter: pl.Expr) -> list[pl.Expr]:
return [
value.filter(value_filter & is_unflagged).sum().alias(f"{base_alias}_n"),
value.filter(value_filter & is_flagged).sum().alias(f"{base_alias}_a"),
]
# Build per-category effectively_secured aggregates for Art. 231 waterfall
waterfall_aggs: list[pl.Expr] = []
for cat_values, _lgds_key, suffix in WATERFALL_ORDER:
waterfall_aggs.extend(
_split_aggs(f"_e{suffix}", pl.col("effectively_secured"), cat.is_in(cat_values))
)
# Per-category MARKET-value aggregates, metric -> (category, carrier). These
# mirror the ``_adj_*`` set one-for-one through the same multi-level blend but
# sum the pre-haircut ``market_value``, and are pure reporting carriers —
# nothing in engine/ consumes them.
#
# PS1/26 Annex II col 0190 (likewise 0180/0200/0210): "Where exposures are
# subject to the Foundation Collateral Method … the adjusted value of
# collateral Ci … Where exposures are subject to the AIRB approach, the amount
# to be reported shall be the estimated market value." CRR Annex II cols
# 0150-0210 make the same split on whether own LGD estimates are used. The
# ``_adj_*`` twins serve the Foundation limb; these serve the AIRB limb, which
# the adjusted basis understates wherever a supervisory haircut applies (40%
# on real estate under Basel 3.1, 0% under CRR).
market_value_carriers = {
"_mv_fin": ("financial", "collateral_financial_market_value"),
"_mv_cash": ("cash", "collateral_cash_market_value"),
"_mv_re": ("real_estate", "collateral_re_market_value"),
"_mv_rec": ("receivables", "collateral_receivables_market_value"),
"_mv_oth": ("other_physical", "collateral_other_physical_market_value"),
"_mv_li": ("life_insurance", "collateral_life_insurance_market_value"),
}
market_value_aggs: list[pl.Expr] = []
for metric, (category, _) in market_value_carriers.items():
market_value_aggs.extend(_split_aggs(metric, pl.col("market_value"), cat == category))
all_coll = (
annotated.with_columns(
beneficiary_level_expr().alias("_level"),
)
.group_by(["_level", "beneficiary_reference"])
.agg(
_split_aggs("_cv", val_expr, is_eligible)
+ _split_aggs("_mv", pl.col("market_value"), is_eligible)
+ _split_aggs("_rn", pl.col("adjusted_value"), ~is_fin)
+ _split_aggs("_adj_fin", pl.col("adjusted_value"), cat == "financial")
+ _split_aggs("_adj_cash", pl.col("adjusted_value"), cat == "cash")
+ _split_aggs("_adj_re", pl.col("adjusted_value"), cat == "real_estate")
+ _split_aggs("_adj_rec", pl.col("adjusted_value"), cat == "receivables")
+ _split_aggs("_adj_oth", pl.col("adjusted_value"), cat == "other_physical")
+ market_value_aggs
+ waterfall_aggs
)
)
_wf_suffixes = [suffix for _, _, suffix in WATERFALL_ORDER]
# The market-value metrics allocate on a POOL-AGNOSTIC basis (see
# ``_pool_agnostic_metrics`` below); every other metric keeps the
# pool-gated allocation that drives LGD / EAD.
_pool_agnostic_metrics = list(market_value_carriers)
_metrics = (
[
"_cv",
"_mv",
"_rn",
"_adj_fin",
"_adj_cash",
"_adj_re",
"_adj_rec",
"_adj_oth",
]
+ _pool_agnostic_metrics
+ [f"_e{s}" for s in _wf_suffixes]
)
# Each metric has both _n (non-AIRB pool) and _a (AIRB pool) variants in the
# aggregated frame; the level suffix (_d/_f/_c) is appended on rename below.
_agg = [f"{m}_{p}" for m in _metrics for p in ("n", "a")]
# Split the small aggregated result for per-level joins
coll_direct = (
all_coll.filter(pl.col("_level") == "direct")
.drop("_level")
.rename({c: f"{c}_d" for c in _agg})
)
coll_facility = (
all_coll.filter(pl.col("_level") == "facility")
.drop("_level")
.rename({c: f"{c}_f" for c in _agg})
)
coll_counterparty = (
all_coll.filter(pl.col("_level") == "counterparty")
.drop("_level")
.rename({c: f"{c}_c" for c in _agg})
)
# --- Join direct + counterparty levels to exposures ---
exposures = exposures.join(
coll_direct,
left_on="exposure_reference",
right_on="beneficiary_reference",
how="left",
)
# Facility level: cascade collateral over each exposure's full ancestor set
# so a pledge at any ancestor facility (parent, grandparent, ... root) flows
# pro-rata to every descendant exposure (CRR Art. 230-231 pooling over the
# facility subtree). Produces pre-weighted, ancestor-summed ``{m}_{p}_f``
# columns that ``_sum6`` adds in directly (the pro-rata weight is already
# baked in, so no further ``_fw`` multiply is needed).
exposures = _cascade_facility_collateral(
exposures, coll_facility, _metrics, _pool_agnostic_metrics
)
exposures = exposures.join(
coll_counterparty,
left_on="counterparty_reference",
right_on="beneficiary_reference",
how="left",
).join(
cp_ead_totals,
on="counterparty_reference",
how="left",
)
# --- Fill nulls + counterparty pro-rata weights ---
# Facility ``{c}_f`` columns are already filled + pre-weighted by
# ``_cascade_facility_collateral``; only the direct (``_d``) and
# counterparty (``_c``) families plus the CP EAD totals need filling here.
fill_exprs = []
for sfx in ["d", "c"]:
for c in _agg:
fill_exprs.append(pl.col(f"{c}_{sfx}").fill_null(0.0))
fill_exprs.extend(
[
pl.col("_cp_ead_total").fill_null(0.0),
pl.col("_cp_ead_total_airb").fill_null(0.0),
pl.col("_cp_ead_total_non_airb").fill_null(0.0),
]
)
exposures = exposures.with_columns(fill_exprs)
# Pool-aware counterparty pro-rata weights. ``_is_airb_pool`` was tagged on
# exposures in ``apply_collateral`` via ``airb_lgd_preserved_expr``; weights
# bake in the pool-match gate so non-matching pools always contribute zero.
in_airb = pl.col("_is_airb_pool").fill_null(False)
in_non_airb = ~in_airb
# Pro-rata weights use ead_for_crm (CRR Art. 223(4) / PS1/26 Art. 223(4):
# off-BS items at CCF=100% for CRM allocation purposes), so the share
# an exposure receives of a CP collateral pool is proportional to its full
# pre-CCF basis rather than its post-CCF EAD.
# ``_cw_n_all`` is the pool-AGNOSTIC counterparty weight used by the
# market-value reporting carriers only: it drops the ``in_non_airb`` gate and
# shares over the whole counterparty population (``_cp_ead_total``) rather
# than the non-AIRB sub-pool. Dropping the gate while keeping the sub-pool
# denominator would allocate the pledge in full to BOTH pools; sharing on
# ``_cp_ead_total`` keeps it conserved. See ``_sum6_pool_agnostic``.
exposures = exposures.with_columns(
[
pl.when(in_non_airb & (pl.col("_cp_ead_total_non_airb") > 0))
.then(pl.col("ead_for_crm") / pl.col("_cp_ead_total_non_airb"))
.otherwise(pl.lit(0.0))
.alias("_cw_n"),
pl.when(pl.col("_cp_ead_total") > 0)
.then(pl.col("ead_for_crm") / pl.col("_cp_ead_total"))
.otherwise(pl.lit(0.0))
.alias("_cw_n_all"),
pl.when(in_airb & (pl.col("_cp_ead_total_airb") > 0))
.then(pl.col("ead_for_crm") / pl.col("_cp_ead_total_airb"))
.otherwise(pl.lit(0.0))
.alias("_cw_a"),
in_airb.cast(pl.Float64).alias("_airb_match"),
]
)
# --- Combine all levels for EAD + LGD ---
# Non-AIRB-flagged collateral (``_n`` family) flows to non-AIRB-pool
# exposures: facility via the ancestor cascade (``_n_f`` pre-weighted) and
# counterparty via the ``_cw_n`` weight (both gated to that pool); direct
# unflagged is unconditional (1:1, no pro-rata). AIRB-flagged collateral
# (``_a`` family) flows only to AIRB-pool exposures — facility via the
# cascade (``_a_f``), counterparty via ``_cw_a``, and direct gated by
# ``_airb_match``. Direct flagged collateral on a non-AIRB exposure is a
# data-quality issue surfaced as CRM006 by the validation pass.
def _sum6(metric: str) -> pl.Expr:
# Facility terms (``_f``) are already pro-rata-weighted and summed over
# the exposure's ancestor facilities by ``_cascade_facility_collateral``,
# so they enter the blend without a further weight multiply.
return (
pl.col(f"{metric}_n_d")
+ pl.col(f"{metric}_a_d") * pl.col("_airb_match")
+ pl.col(f"{metric}_n_f")
+ pl.col(f"{metric}_a_f")
+ pl.col(f"{metric}_n_c") * pl.col("_cw_n")
+ pl.col(f"{metric}_a_c") * pl.col("_cw_a")
)
# POOL-AGNOSTIC blend, for the market-value reporting carriers only. Same six
# terms and the same flagged (``_a``) weights — flagged collateral is in the
# firm's internal LGD model and still never reaches a non-AIRB exposure — but
# the two UNFLAGGED indirect terms share over the whole population instead of
# the non-AIRB sub-pool: ``_cw_n_all`` at counterparty level, and at facility
# level the ``{m}_n_f`` column, which ``_cascade_facility_collateral`` has
# already pre-weighted with the all-descendants subtree weight for exactly
# these metrics. Direct (``_n_d``) is unconditional on both blends.
# PS1/26 Art. 169A(1)-(2): recognition is an institution-level election, so an
# A-IRB row reports collateral pledged against it whether or not that pledge
# moved the modelled LGD. The Foundation election is applied below.
def _sum6_pool_agnostic(metric: str) -> pl.Expr:
return (
pl.col(f"{metric}_n_d")
+ pl.col(f"{metric}_a_d") * pl.col("_airb_match")
+ pl.col(f"{metric}_n_f")
+ pl.col(f"{metric}_a_f")
+ pl.col(f"{metric}_n_c") * pl.col("_cw_n_all")
+ pl.col(f"{metric}_a_c") * pl.col("_cw_a")
)
combine_exprs = [
_sum6("_cv").alias("collateral_adjusted_value"),
_sum6("_mv").alias("collateral_market_value"),
_sum6("_adj_fin").alias("collateral_financial_value"),
_sum6("_adj_cash").alias("collateral_cash_value"),
_sum6("_adj_re").alias("collateral_re_value"),
_sum6("_adj_rec").alias("collateral_receivables_value"),
_sum6("_adj_oth").alias("collateral_other_physical_value"),
_sum6("_rn").alias("_raw_nf_a"),
]
# RD-5 / PS1/26 Art. 169A(1)-(2): recognition of collateral in LGD estimates is
# an institution-level ELECTION, so the AIRB market-value limb of Annex II cols
# 0180-0210 (RD-1) is only open to an A-IRB row whose modelled LGD actually
# stands. ``_is_airb_pool`` IS ``airb_lgd_preserved_expr`` materialised on the
# frame, so reading it here keeps this gate and the pool definition on one
# expression: an A-IRB row loses the market-value limb exactly when that
# expression says its modelled LGD does not survive — the firm elected the
# Foundation Collateral Method, or Art. 169B insufficient-data drops the row
# back to the supervisory formula. Both cases report through the ``_adj_*``
# twins instead. Non-A-IRB rows (FIRB / SA / slotting) are unaffected.
_mv_limb_open = in_airb | (pl.col("approach") != ApproachType.AIRB.value)
for _mv_metric, (_, _mv_carrier) in market_value_carriers.items():
combine_exprs.append(
pl.when(_mv_limb_open)
.then(_sum6_pool_agnostic(_mv_metric))
.otherwise(pl.lit(0.0))
.alias(_mv_carrier)
)
# Per-category effectively_secured after multi-level combination
for suffix in _wf_suffixes:
combine_exprs.append(_sum6(f"_e{suffix}").alias(f"_eff_{suffix}_a"))
exposures = exposures.with_columns(combine_exprs)
# Per-type minimum collateralisation thresholds (CRR Art. 230)
# Art. 230 requires the threshold to apply per collateral type, not across
# the combined non-financial pool. Each type (real_estate, other_physical)
# must independently meet its 30% threshold to be eligible for LGDS
# reduction. Financial, covered_bond, and receivables have no threshold.
#
# PS1/26 Art. 230(1) replaces the CRR step-function with a continuous LGD*
# formula and removes the C* / C** thresholds entirely — under Basel 3.1
# any positive eligible non-financial collateral is recognised at LGDS.
if resolved_pack.feature("firb_min_collateralisation_threshold_applies"):
_min_thresholds = lookup_float_map(resolved_pack.lookup("min_collateralisation_thresholds"))
_type_threshold: dict[str, tuple[float, str]] = {
"re": (_min_thresholds["real_estate"], "collateral_re_value"),
"op": (
_min_thresholds["other_physical"],
"collateral_other_physical_value",
),
}
nf_threshold_exprs = []
for suffix in _wf_suffixes:
if suffix not in _type_threshold:
continue # No threshold for fin/cb/rec
threshold, raw_col = _type_threshold[suffix]
if threshold <= 0:
continue
col_name = f"_eff_{suffix}_a"
# Art. 230 minimum-collateralisation threshold uses E with CCF=100%
# per Art. 223(4) — the threshold is a fraction of the pre-CCF basis.
nf_threshold_exprs.append(
pl.when(pl.col(raw_col) >= threshold * pl.col("ead_for_crm"))
.then(pl.col(col_name))
.otherwise(pl.lit(0.0))
.alias(col_name)
)
if nf_threshold_exprs:
exposures = exposures.with_columns(nf_threshold_exprs)
# --- Art. 231 sequential fill (waterfall) ---
# Allocate from lowest LGDS to highest. Each category absorbs up to
# min(category_total, remaining_exposure). Uses the cumulative-cap
# trick: es_i = min(cum_through_i, EAD) - min(cum_through_i-1, EAD).
# EAD here is ead_for_crm (CCF=100% basis per Art. 223(4)) — the
# actual post-CCF EAD is recoupled later for SA via effective_ccf.
ead = pl.col("ead_for_crm")
cum = pl.lit(0.0)
es_exprs: list[pl.Expr] = []
for suffix in _wf_suffixes:
prev_cum = cum
cum = cum + pl.col(f"_eff_{suffix}_a")
es_i = pl.min_horizontal(cum, ead) - pl.min_horizontal(prev_cum, ead)
es_exprs.append(es_i.alias(f"_es_{suffix}"))
total_secured_expr = pl.min_horizontal(cum, ead)
# Blended lgd_secured = sum(lgds_i * es_i) / total_secured
# CRR Art. 230 Table 5: subordinated exposures use higher LGDS for the
# secured portion (receivables 65%, RE 65%, other physical 70%).
# Basel 3.1 Art. 230(2) removes the subordinated LGDS column entirely.
_has_seniority = "seniority" in exposure_schema.names()
_build_sub = overcollateralisation_step_function and _has_seniority
lgd_num = pl.lit(0.0)
lgd_num_sub = pl.lit(0.0) if _build_sub else None
for _, lgds_key, suffix in WATERFALL_ORDER:
es_col = pl.col(f"_es_{suffix}")
lgd_num = lgd_num + pl.lit(lgds[lgds_key]) * es_col
if _build_sub:
sub_lgds = lgd_values.get(f"{lgds_key}_subordinated", lgd_values[lgds_key])
lgd_num_sub = lgd_num_sub + pl.lit(sub_lgds) * es_col
if _build_sub:
is_sub = (
pl.col("seniority").fill_null("").str.to_lowercase().is_in(["subordinated", "junior"])
)
lgd_num_final = pl.when(is_sub).then(lgd_num_sub).otherwise(lgd_num)
else:
lgd_num_final = lgd_num
# Compute sequential allocations, then total + lgd_secured
exposures = exposures.with_columns(es_exprs)
exposures = exposures.with_columns(
[
total_secured_expr.alias("total_collateral_for_lgd"),
pl.when(total_secured_expr > 0)
.then(lgd_num_final / total_secured_expr)
.otherwise(pl.lit(lgd_unsecured))
.alias("lgd_secured"),
]
)
# --- Drop intermediate allocation columns ---
# Preserve _es_* columns (renamed to crm_alloc_*) for the A-IRB blended
# LGD floor (Art. 164(4)(c)). These encode the dollar amount of EAD
# absorbed by each collateral category in the Art. 231 waterfall.
drop_cols = (
[f"{c}_{sfx}" for sfx in ["d", "f", "c"] for c in _agg]
+ [
"_cp_ead_total",
"_cp_ead_total_airb",
"_cp_ead_total_non_airb",
"_cw_n",
"_cw_n_all",
"_cw_a",
"_airb_match",
"_is_airb_pool",
"_raw_nf_a",
]
+ [f"_eff_{s}_a" for s in _wf_suffixes]
)
exposures = exposures.drop(drop_cols)
exposures = exposures.rename({f"_es_{s}": CRM_ALLOC_COLUMNS[s] for s in _wf_suffixes})
# --- Apply EAD reduction + determine seniority-based LGDU ---
# Supervisory LGDU for unsecured portion: FSE-aware under Basel 3.1
# (Art. 161(1)(a) vs (aa))
if _has_fse_col:
supervisory_lgdu_expr = (
pl.when(pl.col("cp_is_financial_sector_entity").fill_null(False))
.then(pl.lit(lgd_unsecured_fse))
.otherwise(pl.lit(lgd_unsecured))
)
else:
supervisory_lgdu_expr = pl.lit(lgd_unsecured)
# --- Determine which AIRB exposures use the Foundation formula ---
# Art. 169A/169B (Basel 3.1 only): AIRB exposures may use the Foundation
# Collateral Method formula under two scenarios:
# (1) Foundation election: firm opts for FCM instead of LGD Modelling
# (2) Art. 169B fallback: insufficient data → FCM formula with own LGDU
# Under CRR, AIRB is free-form — own LGD always kept unchanged.
exposure_schema = exposures.collect_schema()
_has_lgd_unsecured_col = "lgd_unsecured" in exposure_schema.names()
schema_names = set(exposure_schema.names())
airb_method = config.airb_collateral_method
is_airb = pl.col("approach") == ApproachType.AIRB.value
# ``_airb_uses_formula`` is the negation of the LGD-preserved condition:
# AIRB rows that fall back to the supervisory formula under Foundation
# election or Art. 169B insufficient-data fallback.
_airb_uses_formula = is_airb & ~airb_lgd_preserved_expr(
config, schema_names, pack=resolved_pack
)
# Art. 169B(2)(c): use firm's own unsecured LGD when LGD-modelling falls back
_airb_own_lgdu = (
airb_collateral_method_applies and airb_method == AIRBCollateralMethod.LGD_MODELLING
)
# Combined condition: FIRB OR qualifying AIRB exposures use the formula
_uses_formula = (pl.col("approach") == ApproachType.FIRB.value) | _airb_uses_formula
# Build per-exposure LGDU expression
# For AIRB Art. 169B: LGDU = own lgd_unsecured (Art. 169B(2)(c))
# For FIRB and AIRB Foundation: LGDU = supervisory value
is_subordinated = pl.col("seniority").str.to_lowercase().is_in(["subordinated", "junior"])
if _airb_own_lgdu and _has_lgd_unsecured_col:
# Art. 169B: AIRB exposures with insufficient data use own lgd_unsecured,
# falling back to lgd_pre_crm if lgd_unsecured not provided.
own_lgdu = pl.coalesce(pl.col("lgd_unsecured"), pl.col("lgd_pre_crm"))
lgdu_expr = (
pl.when(is_subordinated)
.then(pl.lit(lgd_subordinated))
.when(_airb_uses_formula)
.then(own_lgdu)
.otherwise(supervisory_lgdu_expr)
)
else:
lgdu_expr = (
pl.when(is_subordinated).then(pl.lit(lgd_subordinated)).otherwise(supervisory_lgdu_expr)
)
# SA EAD reduction (CRR Art. 228(1) / PS1/26 Art. 228(1)) with the
# CRR Art. 223(5) FCCM exposure-side gross-up:
# E* = max(0, ead_for_crm × (1 + HE) − collateral_adjusted_value)
# EAD = E* × CCF_actual (i.e. × effective_ccf for blended rows)
# The CCF is applied to E*, not to the pre-collateral nominal — this is
# the regulatorily mandated ordering and reverses the previous
# implementation (which netted collateral against post-CCF ead_gross).
# FIRB / Slotting / AIRB keep ead_gross because under those approaches
# collateral modifies LGD (via lgd_post_crm), not EAD.
schema_for_he = exposures.collect_schema().names()
_has_he_col = "exposure_volatility_haircut" in schema_for_he
# E' = ead_for_crm × (1 + HE), shared with the A-IRB LGD input floor blend.
e_for_lgd_star = lgd_star_exposure_basis_expr(has_volatility_haircut=_has_he_col)
exposures = exposures.with_columns(
[
pl.when(pl.col("approach") == ApproachType.SA.value)
.then(
(e_for_lgd_star - pl.col("collateral_adjusted_value")).clip(lower_bound=0)
* pl.col("effective_ccf")
)
.otherwise(pl.col("ead_gross"))
.alias("ead_after_collateral"),
lgdu_expr.alias("lgd_unsecured"),
]
)
# --- Calculate LGD post-CRM + audit ---
# LGD* formula (Art. 230/231) applies to FIRB and qualifying AIRB exposures.
# Non-qualifying AIRB and SA keep lgd_pre_crm.
#
# CRR Art. 223(4) / PS1/26 Art. 223(4): the exposure value E used in the
# LGD* formula is the CCF=100% basis (ead_for_crm) for off-balance-sheet
# items, NOT the post-CCF EAD. For pure on-BS rows ead_for_crm == ead_gross.
#
# PS1/26 Art. 230(1) / CRR Art. 228(2) (P1.272): the exposure basis is
# grossed up by its own volatility haircut HE — E' = E(1 + HE) — so
# LGD* = (LGDS · min(C, E') + LGDU · max(0, E' - C)) / E'.
# HE (exposure_volatility_haircut, Art. 223(5)) is non-zero only for SFT rows
# lending out a debt security, so the HE factor == 1 for every other row and
# E' == E; the SFT-FCCM path is unaffected (it emits E* directly).
# ``e_for_lgd_star`` is built above from ``lgd_star_exposure_basis_expr``.
lgd_star_expr = (
(
pl.col("lgd_secured")
* pl.col("total_collateral_for_lgd").clip(upper_bound=e_for_lgd_star)
)
+ (
pl.col("lgd_unsecured")
* (e_for_lgd_star - pl.col("total_collateral_for_lgd")).clip(lower_bound=0)
)
) / e_for_lgd_star
exposures = exposures.with_columns(
[
pl.when(
_uses_formula
& (pl.col("ead_for_crm") > 0)
& (pl.col("total_collateral_for_lgd") > 0)
)
.then(lgd_star_expr)
.when(_uses_formula & (pl.col("ead_for_crm") > 0))
.then(pl.col("lgd_unsecured"))
.otherwise(pl.col("lgd_pre_crm"))
.alias("lgd_post_crm"),
# collateral_coverage_pct is the C/E ratio used for the Art. 230
# threshold tests, so it also uses ead_for_crm.
pl.when(pl.col("ead_for_crm") > 0)
.then(
pl.col("total_collateral_for_lgd").clip(upper_bound=pl.col("ead_for_crm"))
/ pl.col("ead_for_crm")
* 100
)
.otherwise(pl.lit(0.0))
.alias("collateral_coverage_pct"),
]
)
return exposures
CRR Art. 213 — Requirements common to guarantees and credit derivatives¶
apply_guarantees — src/rwa_calc/engine/crm/guarantees.py:111
@cites("CRR Art. 213")
@cites("CRR Art. 217")
def apply_guarantees(
exposures: pl.LazyFrame,
guarantees: pl.LazyFrame,
counterparty_lookup: pl.LazyFrame,
config: CalculationConfig,
rating_inheritance: pl.LazyFrame | None = None,
*,
pack: ResolvedRulepack | None = None,
errors: list[CalculationError] | None = None,
) -> pl.LazyFrame:
"""
Apply guarantee substitution.
For guaranteed portion, substitute borrower RW with guarantor RW.
Args:
exposures: Exposures with EAD
guarantees: Guarantee data
counterparty_lookup: For guarantor risk weights
config: Calculation configuration
rating_inheritance: For guarantor CQS lookup
errors: Optional CRM error channel. When provided, guarantees dropped by
the Art. 213(1)(c)(i) eligibility gate append a CRM012 warning each.
Returns:
Exposures with guarantee effects applied
"""
guarantees = _prepare_guarantees(guarantees, exposures, config, pack=pack, errors=errors)
exposures = exposures.with_columns(
pl.col("exposure_reference").alias("parent_exposure_reference"),
)
exposures = _apply_guarantee_splits(guarantees, exposures)
exposures = _join_guarantor_counterparty(exposures, counterparty_lookup)
exposures = _join_guarantor_ratings(exposures, rating_inheritance)
exposures = exposures.with_columns(
pl.col("guarantor_entity_type").fill_null("").alias("guarantor_entity_type"),
)
# Derive guarantor's exposure class from their entity type. Needed for
# post-CRM reporting where the guaranteed portion is reported under the
# guarantor's exposure class.
exposures = exposures.with_columns(
pl.col("guarantor_entity_type")
.replace_strict(ENTITY_TYPE_TO_SA_CLASS, default="")
.alias("guarantor_exposure_class"),
)
exposures = _assign_guarantor_approach(exposures, config, errors=errors)
# Cross-approach CCF substitution (CRR Art. 111 / COREP C07)
# When IRB exposure guaranteed by SA counterparty, use SA CCFs for guaranteed portion
exposures = _apply_cross_approach_ccf(exposures)
# Add post-CRM composite attributes for regulatory reporting. For the
# guaranteed portion, the post-CRM counterparty is the guarantor.
exposures = exposures.with_columns(
# Post-CRM counterparty for guaranteed portion (guarantor or original)
pl.when(pl.col("guaranteed_portion") > 0)
.then(pl.col("guarantor_reference"))
.otherwise(pl.col("counterparty_reference"))
.alias("post_crm_counterparty_guaranteed"),
# Post-CRM exposure class for guaranteed portion (guarantor's class or original)
pl.when((pl.col("guaranteed_portion") > 0) & (pl.col("guarantor_exposure_class") != ""))
.then(pl.col("guarantor_exposure_class"))
.otherwise(pl.col("exposure_class"))
.alias("post_crm_exposure_class_guaranteed"),
# Flag indicating whether exposure has an effective guarantee
(pl.col("guaranteed_portion").fill_null(0.0) > 0).alias("is_guaranteed"),
)
# Note: Transient columns (guarantor_entity_type, guarantor_cqs, etc.) are kept
# because downstream SA/IRB calculators need them for risk weight substitution.
# They can be dropped in the final output aggregation if needed.
return exposures
_gate_unilateral_protection — src/rwa_calc/engine/crm/guarantees.py:256
@cites("CRR Art. 213")
@cites("PS1/26, paragraph 213")
def _gate_unilateral_protection(
guarantees: pl.LazyFrame,
pack: ResolvedRulepack,
errors: list[CalculationError] | None,
) -> pl.LazyFrame:
"""
Drop guarantees ineligible under Art. 213(1)(c)(i) (unilateral cancel / change).
A guarantee the protection provider can unilaterally CANCEL is ineligible
under both regimes; one whose terms the provider can unilaterally CHANGE
(increasing the effective cost of protection) is additionally ineligible
under Basel 3.1 — the "or change" limb is new in PS1/26, gated by the
``ucp_unilateral_change_ineligible`` pack Feature. Dropped rows leave the
exposure un-guaranteed and each raises one CRM012 warning.
Both flags are null-permissive: a null means "no known defect => eligible",
mirroring the Art. 237(2)(a) original-maturity fallback in the caller.
References:
CRR Art. 213(1)(c)(i): unfunded credit protection eligibility.
PS1/26 Art. 213(1)(c)(i): adds the unilateral-change arm.
"""
guarantees = ensure_columns(
guarantees,
{
"is_unilaterally_cancellable": ColumnSpec(pl.Boolean, required=False),
"is_unilaterally_changeable": ColumnSpec(pl.Boolean, required=False),
},
)
change_gated = pack.feature("ucp_unilateral_change_ineligible")
ineligible = pl.col("is_unilaterally_cancellable")
if change_gated:
ineligible = ineligible | pl.col("is_unilaterally_changeable")
# Null is permissive (no known defect => eligible): coalesce the Kleene-OR
# result to False so a null flag never drops the guarantee.
ineligible = ineligible.fill_null(False)
if errors is not None:
_record_ucp_ineligibility(guarantees, ineligible, change_gated, errors)
return guarantees.filter(~ineligible)
CRR Art. 217 — Requirements to qualify for the treatment set out in Article 153(3)¶
apply_guarantees — src/rwa_calc/engine/crm/guarantees.py:112
@cites("CRR Art. 213")
@cites("CRR Art. 217")
def apply_guarantees(
exposures: pl.LazyFrame,
guarantees: pl.LazyFrame,
counterparty_lookup: pl.LazyFrame,
config: CalculationConfig,
rating_inheritance: pl.LazyFrame | None = None,
*,
pack: ResolvedRulepack | None = None,
errors: list[CalculationError] | None = None,
) -> pl.LazyFrame:
"""
Apply guarantee substitution.
For guaranteed portion, substitute borrower RW with guarantor RW.
Args:
exposures: Exposures with EAD
guarantees: Guarantee data
counterparty_lookup: For guarantor risk weights
config: Calculation configuration
rating_inheritance: For guarantor CQS lookup
errors: Optional CRM error channel. When provided, guarantees dropped by
the Art. 213(1)(c)(i) eligibility gate append a CRM012 warning each.
Returns:
Exposures with guarantee effects applied
"""
guarantees = _prepare_guarantees(guarantees, exposures, config, pack=pack, errors=errors)
exposures = exposures.with_columns(
pl.col("exposure_reference").alias("parent_exposure_reference"),
)
exposures = _apply_guarantee_splits(guarantees, exposures)
exposures = _join_guarantor_counterparty(exposures, counterparty_lookup)
exposures = _join_guarantor_ratings(exposures, rating_inheritance)
exposures = exposures.with_columns(
pl.col("guarantor_entity_type").fill_null("").alias("guarantor_entity_type"),
)
# Derive guarantor's exposure class from their entity type. Needed for
# post-CRM reporting where the guaranteed portion is reported under the
# guarantor's exposure class.
exposures = exposures.with_columns(
pl.col("guarantor_entity_type")
.replace_strict(ENTITY_TYPE_TO_SA_CLASS, default="")
.alias("guarantor_exposure_class"),
)
exposures = _assign_guarantor_approach(exposures, config, errors=errors)
# Cross-approach CCF substitution (CRR Art. 111 / COREP C07)
# When IRB exposure guaranteed by SA counterparty, use SA CCFs for guaranteed portion
exposures = _apply_cross_approach_ccf(exposures)
# Add post-CRM composite attributes for regulatory reporting. For the
# guaranteed portion, the post-CRM counterparty is the guarantor.
exposures = exposures.with_columns(
# Post-CRM counterparty for guaranteed portion (guarantor or original)
pl.when(pl.col("guaranteed_portion") > 0)
.then(pl.col("guarantor_reference"))
.otherwise(pl.col("counterparty_reference"))
.alias("post_crm_counterparty_guaranteed"),
# Post-CRM exposure class for guaranteed portion (guarantor's class or original)
pl.when((pl.col("guaranteed_portion") > 0) & (pl.col("guarantor_exposure_class") != ""))
.then(pl.col("guarantor_exposure_class"))
.otherwise(pl.col("exposure_class"))
.alias("post_crm_exposure_class_guaranteed"),
# Flag indicating whether exposure has an effective guarantee
(pl.col("guaranteed_portion").fill_null(0.0) > 0).alias("is_guaranteed"),
)
# Note: Transient columns (guarantor_entity_type, guarantor_cqs, etc.) are kept
# because downstream SA/IRB calculators need them for risk weight substitution.
# They can be dropped in the final output aggregation if needed.
return exposures
_apply_maturity_mismatch_to_guarantees — src/rwa_calc/engine/crm/guarantees.py:1509
@cites("CRR Art. 217")
@cites("CRR Art. 237")
@cites("PS1/26, paragraph 237")
def _apply_maturity_mismatch_to_guarantees(
guarantees: pl.LazyFrame,
exposures: pl.LazyFrame,
config: CalculationConfig,
) -> pl.LazyFrame:
"""
Apply CRR Art. 237/239(3) maturity mismatch treatment to guarantee amounts.
When the protection's residual maturity ``t`` is shorter than the
exposure's effective maturity ``T``, the covered amount ``G`` is scaled:
GA = G* × (t - 0.25) / (T - 0.25)
with ``T`` capped at 5.0 years and both ``t`` and ``T`` floored at 0.25.
Scaling is applied to ``amount_covered`` and ``percentage_covered`` before
the split, so the reduced nominal protection value propagates through
cap-at-EAD.
Three Art. 237 eligibility gates ZERO coverage (rather than merely scaling
it) before the 239(3) formula, mirroring the collateral sibling in
``engine/crm/haircuts.py``. All three bind ONLY WHERE a maturity mismatch
exists (Art. 237(2) chapeau) — matched / protection-outlives-exposure
guarantees stay recognised:
- Art. 237(1): credit protection whose RAW residual maturity is < 3 months
AND shorter than the exposure is not recognised. The test runs on the
pre-floor residuals so a short exposure — whose ``T`` also floors to 0.25
and would mask the mismatch under the scaling formula — no longer retains
full coverage.
- Art. 162(3)/237(2)(b): where the exposure is subject to the one-day IRB
maturity floor (daily-margined repos/SFTs), ANY maturity mismatch makes
the protection ineligible. The ``has_one_day_maturity_floor`` flag is
joined from the exposure; null/absent is PERMISSIVE (treated as no floor).
- Art. 237(2)(a): protection whose ORIGINAL maturity is < 1 year is ineligible
where a mismatch exists. Relocated here from an unconditional pre-filter
(P1.232) so matched short-dated (e.g. trade-finance) guarantees are no
longer discarded. Reads ``original_maturity_years`` (the original term, NOT
the residual ``t``); null is PERMISSIVE (>= 1y).
The protection residual maturity ``t`` is derived from the guarantee
row's ``maturity_date`` if present, otherwise from
``original_maturity_years``. The exposure residual ``T`` is derived
from the exposure's ``maturity_date``.
References:
CRR / PS1-26 Art. 237(1): <3-month-and-shorter protection ineligibility.
CRR / PS1-26 Art. 237(2)(a): <1y-original protection, mismatch-conditioned.
CRR / PS1-26 Art. 237(2)(b) with Art. 162(3): one-day-floor exposures
+ any mismatch => ineligible.
CRR Art. 238(1): maturity of credit protection — ``t`` is the RESIDUAL
maturity (time remaining to protection maturity), not the original
contract term; the residual from ``maturity_date`` therefore wins
and ``original_maturity_years`` is only a fallback.
CRR Art. 239(3): maturity mismatch adjustment formula.
"""
guar_schema = guarantees.collect_schema()
guar_cols = guar_schema.names()
exp_schema = exposures.collect_schema()
exp_cols = exp_schema.names()
# Need exposure maturity_date and at least one of guarantee maturity_date
# / original_maturity_years to compute t and T.
if "maturity_date" not in exp_cols:
return guarantees
has_guar_maturity_date = "maturity_date" in guar_cols
has_guar_original_maturity = "original_maturity_years" in guar_cols
if not (has_guar_maturity_date or has_guar_original_maturity):
return guarantees
# Bring exposure residual maturity (years) and the Art. 162(3) one-day
# maturity-floor flag onto each guarantee row.
exp_t_expr = exact_fractional_years_expr(config.reporting_date, "maturity_date").alias("_exp_T")
exp_select = [pl.col("exposure_reference"), exp_t_expr]
has_1d_floor_col = "has_one_day_maturity_floor" in exp_cols
if has_1d_floor_col:
exp_select.append(
pl.col("has_one_day_maturity_floor").fill_null(False).alias("_has_1d_floor")
)
exp_lookup = exposures.select(exp_select)
guarantees = guarantees.join(
exp_lookup,
left_on="beneficiary_reference",
right_on="exposure_reference",
how="left",
)
# Null-PERMISSIVE: an exposure with no flag, an absent column, or a
# join-miss beneficiary is treated as NOT subject to the one-day floor
# (mirrors the collateral sibling's default).
if has_1d_floor_col:
guarantees = guarantees.with_columns(pl.col("_has_1d_floor").fill_null(False))
else:
guarantees = guarantees.with_columns(pl.lit(False).alias("_has_1d_floor"))
# Compute t = RESIDUAL maturity (Art. 238(1)): the time REMAINING to
# protection maturity, derived from the guarantee ``maturity_date`` minus
# the reporting date. ``original_maturity_years`` is the ORIGINAL contract
# term and must NOT override the residual — otherwise a seasoned guarantee
# (long original term, short residual) is over-recognised. It is used for
# ``t`` only as a fallback when ``maturity_date`` is null. The separate
# Art. 237(2)(a) >=1y eligibility gate upstream still reads
# ``original_maturity_years`` (the original term). A null PROTECTION maturity
# t stays PERMISSIVE — no scaling, no gate, full coverage (t-side unknown =>
# no basis to reduce). This is asymmetric with the EXPOSURE maturity T, which
# a null defaults CONSERVATIVELY to 5y below (Art. 237 targets short
# protection on longer exposures, so an unknown exposure horizon must not
# defeat the gates).
if has_guar_maturity_date and has_guar_original_maturity:
t_from_date = exact_fractional_years_expr(config.reporting_date, "maturity_date")
t_raw = (
pl.when(pl.col("maturity_date").is_not_null())
.then(t_from_date)
.otherwise(pl.col("original_maturity_years"))
)
elif has_guar_maturity_date:
t_raw = exact_fractional_years_expr(config.reporting_date, "maturity_date")
else:
t_raw = pl.col("original_maturity_years")
# Art. 239(3) floors / caps: t and T floored at 0.25, T capped at 5.0.
# A null / join-miss exposure maturity defaults to a 5y exposure (the most
# conservative recognised maturity), aligning with the collateral twin
# (haircuts.py) so a guarantee on a null-maturity exposure is still subject
# to the mismatch gates and the 239(3) scaling rather than silently keeping
# full coverage.
floor = pl.lit(0.25)
cap = pl.lit(5.0)
exp_T = pl.col("_exp_T").fill_null(5.0) # raw exposure residual; null -> 5y
t_eff_safe = (
pl.when(t_raw.is_null())
.then(pl.lit(None, dtype=pl.Float64))
.otherwise(pl.max_horizontal(t_raw, floor))
)
# The 0.25 floor lives ONLY on the scaling denominator (its purpose); the
# eligibility gates below compare the RAW residuals.
exp_t_eff = pl.max_horizontal(pl.min_horizontal(exp_T, cap), floor)
# Mismatch (floored) drives the Art. 239(3) scaling.
is_mismatch = t_eff_safe.is_not_null() & (t_eff_safe < exp_t_eff)
scale = (t_eff_safe - floor) / (exp_t_eff - floor)
# Art. 237(1): a RAW protection residual < 3 months that is also shorter than
# the exposure is not recognised. Tested pre-floor (audit: "raw t < 0.25 AND
# t < raw T") so a short exposure — whose T also floors to 0.25 and would
# mask the mismatch under the scaling formula — no longer retains full
# coverage, while a protection that OUTLIVES a sub-3-month exposure (t >= T)
# stays recognised (Art. 238: no adjustment when protection >= exposure).
# (The collateral twin labels this sub-point 237(2)(a) and floors the
# exposure maturity at 0.25 for its mismatch test; per the audit we compare
# the raw T so the outlives case is not spuriously zeroed.)
raw_mismatch = t_raw.is_not_null() & (t_raw < exp_T)
short_protection = raw_mismatch & (t_raw < floor)
# Art. 162(3)/237(2)(b): a one-day-M-floor exposure (daily-margined repo/SFT)
# with ANY maturity mismatch makes the protection ineligible.
one_day_floor_gate = pl.col("_has_1d_floor") & raw_mismatch
# Art. 237(2)(a): unfunded protection whose ORIGINAL maturity is < 1 year is
# ineligible ONLY where a maturity mismatch exists (Art. 237(2) chapeau) — a
# matched or protection-outlives-exposure short-dated guarantee stays
# recognised. Relocated here (P1.232) from the former UNCONDITIONAL pre-filter
# in _prepare_guarantees, mirroring the collateral twin's conditioning
# (haircuts.py). Null original maturity is PERMISSIVE (treated as >= 1y => not
# ineligible), preserving the P1.10 policy. Reads the ORIGINAL term
# (original_maturity_years), NOT the residual t that feeds the scaling (P1.219).
orig_maturity = (
pl.col("original_maturity_years").fill_null(10.0)
if has_guar_original_maturity
else pl.lit(10.0)
)
short_original_gate = raw_mismatch & (orig_maturity < 1.0)
# Zero-gates take priority over the scaling; otherwise scale on mismatch,
# else full coverage. Mirrors the collateral sibling (engine/crm/haircuts.py).
scale_safe = (
pl.when(short_protection | one_day_floor_gate | short_original_gate)
.then(pl.lit(0.0))
.when(is_mismatch)
.then(scale)
.otherwise(pl.lit(1.0))
)
scale_exprs: list[pl.Expr] = []
if "amount_covered" in guar_cols:
scale_exprs.append((pl.col("amount_covered") * scale_safe).alias("amount_covered"))
if "percentage_covered" in guar_cols:
scale_exprs.append((pl.col("percentage_covered") * scale_safe).alias("percentage_covered"))
if scale_exprs:
guarantees = guarantees.with_columns(scale_exprs)
return _drop_columns_if_present(guarantees, ["_exp_T", "_has_1d_floor"])
CRR Art. 218 — Credit linked notes¶
credit_linked_note_ineligible_expr — src/rwa_calc/engine/crm/haircuts.py:102
@cites("CRR Art. 218")
def credit_linked_note_ineligible_expr(schema_names: Iterable[str]) -> pl.Expr:
"""Art. 218: a credit-linked note is cash collateral only if own-issued.
A credit-linked note earns cash-collateral treatment (0% haircut, full
EAD/LGD* offset) under CRR/PS1-26 Art. 218 only when it is ISSUED BY THE
LENDING INSTITUTION itself — the note's cash proceeds fund the protection. A
CLN issued by a THIRD PARTY is not within Art. 218: its value is materially
correlated with the reference entity (typically the obligor — Art. 194(4)
wrong-way risk), so it is ineligible funded protection.
A ``credit_linked_note`` collateral row that is not attested own-issued
(``is_own_issued_cln`` False or null) is therefore ineligible. Null / absent
resolves conservatively to False (absence of attestation must not fabricate
cash treatment). When the ``is_own_issued_cln`` column is not present the
expression is a no-op (``False``): the legacy backward-compatibility path
where every CLN retained cash treatment — production always carries the
column via ``COLLATERAL_SCHEMA``.
Shared by the haircut-stage value gate (``HaircutCalculator.apply_haircuts``)
and the CRM019 warning emission (``engine/crm/collateral.py``) so the
predicate has a single definition. It reads the raw ``collateral_type``.
"""
names = set(schema_names)
if "is_own_issued_cln" not in names:
return pl.lit(False)
is_cln = pl.col("collateral_type").str.to_lowercase().is_in(CREDIT_LINKED_NOTE_COLLATERAL_TYPES)
is_own_issued = pl.col("is_own_issued_cln").fill_null(False)
return is_cln & is_own_issued.not_()
CRR Art. 219 — On-balance sheet netting¶
generate_netting_collateral — src/rwa_calc/engine/crm/collateral.py:167
@cites("CRR Art. 195")
@cites("CRR Art. 219")
@cites("CRR Art. 223")
@cites("CRR Art. 238")
def generate_netting_collateral(
exposures: pl.LazyFrame,
errors: list[CalculationError] | None = None,
*,
reporting_date: date | None = None,
) -> pl.LazyFrame | None:
"""
Generate synthetic cash collateral from negative-drawn netting-eligible loans.
When a loan has a negative drawn amount (credit balance / deposit) and carries
a ``netting_agreement_reference`` (CRR Art. 195/219), the absolute value of
that negative balance can reduce other exposures covered by the SAME netting
agreement AND owed by the SAME counterparty — treated as synthetic cash
collateral.
CRR/PS1-26 Art. 195 (P1.238): on-balance-sheet netting is limited to "mutual
claims" / "reciprocal cash balances between the institution and the
counterparty" — a single counterparty. So a deposit from counterparty A may
net only loans owed by counterparty A under the same agreement; it may NOT
offset a loan to a different counterparty B, even where a group-level
agreement reference is shared. Pools are therefore keyed by
(netting_agreement_reference, counterparty_reference) — the agreement is the
legal set-off boundary, the counterparty the Art. 195 eligibility boundary.
A netting_agreement_reference that spans more than one counterparty raises a
CRM016 data-quality warning (the disallowed cross-counterparty offset is
otherwise invisible). Two exposures still do NOT net unless they share the
reference, regardless of facility hierarchy.
CRR Art. 219 limits on-balance-sheet netting to drawn loans and deposits
(cash-on-cash). Synthetic cash collateral is allocated pro-rata by the drawn
portion (`on_bs_for_ead`) to positive-drawn LOAN siblings carrying the same
reference — contingents and synthetic facility_undrawn rows are
off-balance-sheet and excluded from the beneficiary set. Netting pools also
keep currency (as (ref, currency, counterparty_reference)) so the haircut
pipeline can apply FX haircuts when the pool currency differs from the
sibling's currency.
Art. 219 treats the netted deposit as cash collateral, so the funded-
protection maturity-mismatch rules (Art. 237-239) apply exactly as for any
other funded protection (P1.241). The synthetic row therefore carries the
DEPOSIT's maturity — not the beneficiary loan's — as ``maturity_date``, the
deposit residual (t) as ``residual_maturity_years`` (when ``reporting_date``
is supplied), and the deposit ORIGINAL term as ``original_maturity_years``
(when ``value_date`` is available). The downstream ``apply_maturity_mismatch``
then, on a mismatch (t < T where T is the loan residual), zeroes the
protection when t < 0.25 (Art. 237(1)) OR the original term < 1y
(Art. 237(2)(a)), else applies (t-0.25)/(T-0.25) (Art. 238-239). Previously
the row carried the loan's maturity, a null residual (filled to 10y
downstream) and no original term, so no gate fired and a short deposit
netting a long loan was recognised in full.
The residual t uses the /365.25 day-count of the exposure-side T derivation in
``apply_maturity_mismatch`` (so equal deposit/loan maturities net in full with
NO phantom mismatch); the original term uses the /365 convention of the
engine's other original-maturity derivations (risk_weights.py / enrich.py).
Pooling convention (conservative): when several deposits of differing
maturities pool into one (ref, currency, counterparty) row, the pool carries
the EARLIEST (minimum) deposit maturity AND the minimum deposit original term.
The earliest-maturing deposit is when the pool's protection first begins to
lapse; representing the whole pool at that maturity maximises the mismatch
haircut (shortest t → smallest (t-0.25)/(T-0.25)), and the minimum original
term is the one most likely to trip the Art. 237(2)(a) <1y gate — both the
prudent single-value summary. A null deposit maturity (or no
``reporting_date``) leaves the residual null and is handled permissively
downstream — absent maturity data cannot establish a mismatch, the same
convention ordinary financial collateral without a supplied residual follows
(this is NOT an anti-conservative fill: the downstream 10y default is
unchanged, it is simply no longer fed a null when the data is present); a null
original term (no ``value_date``) likewise leaves the Art. 237(2)(a) gate
permissive.
Args:
exposures: Exposures with ead_for_crm, on_bs_for_ead, exposure_type set
errors: optional CRM error channel — receives Art. 195 CRM016 warnings
for netting agreements that span more than one counterparty.
reporting_date: run reporting date, used to derive the deposit residual
maturity (Art. 238) on the synthetic rows. When None (direct
unit-test callers), residual_maturity_years stays null and the
maturity mismatch is not applied — backward-compatible behaviour.
Returns:
LazyFrame of synthetic collateral rows, or None if no netting applies
"""
schema = exposures.collect_schema()
schema_names = set(schema.names())
if "netting_agreement_reference" not in schema_names:
return None
# value_date lets the pool derive each deposit's ORIGINAL maturity for the
# Art. 237(2)(a) gate. It is a core exposure column in production; injected as
# a typed null for direct unit-test callers that omit it (→ original maturity
# null → the gate stays permissive), via the schema-driven ensure_columns.
exposures = ensure_columns(exposures, {"value_date": ColumnSpec(pl.Date, required=False)})
# Graceful fallback for direct unit-test callers (production always
# supplies ead_for_crm via _initialize_ead, on_bs_for_ead via _compute_ead,
# and exposure_type via hierarchy).
if "ead_for_crm" not in schema_names:
exposures = exposures.with_columns(pl.col("ead_gross").alias("ead_for_crm"))
if "on_bs_for_ead" not in schema_names:
interest_expr = (
pl.col("interest").fill_null(0.0).clip(lower_bound=0.0)
if "interest" in schema_names
else pl.lit(0.0)
)
exposures = exposures.with_columns(
(pl.col("drawn_amount").clip(lower_bound=0.0) + interest_expr).alias("on_bs_for_ead")
)
if "exposure_type" not in schema_names:
exposures = exposures.with_columns(pl.lit("loan").alias("exposure_type"))
if "counterparty_reference" not in schema_names:
# Test-caller fallback only: production always supplies counterparty_reference
# (a core exposure column). Absent → treat every row as the same counterparty
# so the Art. 195 same-counterparty constraint is a no-op for legacy callers.
exposures = exposures.with_columns(pl.lit("_UNKNOWN_CP").alias("counterparty_reference"))
# Negative-drawn loans carrying a netting agreement reference provide the pool
negative_loans = exposures.filter(
pl.col("netting_agreement_reference").is_not_null() & (pl.col("drawn_amount") < 0)
)
# Art. 195 (P1.238): emit a CRM016 warning for any agreement that spans more
# than one counterparty (a deposit and a positive loan under the same
# reference but for different counterparties would previously have netted).
if errors is not None:
_record_cross_counterparty_netting(exposures, errors)
# Sum abs(drawn_amount) per (netting_agreement_reference, currency,
# counterparty_reference) → netting pool. Currency is kept so the synthetic
# collateral carries the source currency (FX haircut when currencies differ);
# counterparty_reference enforces the Art. 195 same-counterparty limit.
# Art. 219/238 (P1.241): the earliest (min) deposit maturity per pool is the
# conservative single-maturity summary — it drives the maturity-mismatch t.
# The minimum deposit ORIGINAL maturity is carried alongside for the
# Art. 237(2)(a) >=1y eligibility gate (min → shortest term is most likely to
# trip the <1y gate; derived from maturity_date - value_date, the same
# convention as engine/sa/risk_weights.py / hierarchy/enrich.py, /365). A null
# value_date yields a null original maturity (permissive — gate does not fire).
deposit_original_years = (
pl.col("maturity_date").cast(pl.Int32) - pl.col("value_date").cast(pl.Int32)
).cast(pl.Float64) / 365.0
netting_pool = (
negative_loans.group_by(
["netting_agreement_reference", "currency", "counterparty_reference"]
)
.agg(
pl.col("drawn_amount").abs().sum().alias("netting_pool"),
pl.col("maturity_date").min().alias("_pool_deposit_maturity_date"),
deposit_original_years.min().alias("_pool_deposit_orig_maturity"),
)
.rename({"currency": "_pool_currency"})
)
# CRR Art. 219: drawn-on-drawn cash netting. Synthetic cash collateral may
# only benefit the drawn portion of loan exposures — contingents and
# facility_undrawn synthetic rows are off-balance-sheet and ineligible. A
# sibling matches a pool iff it shares BOTH the netting_agreement_reference
# and the counterparty_reference (Art. 195 same-counterparty limit).
# The beneficiary loan's own maturity is NOT carried onto the synthetic row:
# it feeds the mismatch as the EXPOSURE side (T) via the exposure lookup join
# downstream, while the synthetic row carries the DEPOSIT maturity (t).
positive_siblings = exposures.filter(
(pl.col("exposure_type") == "loan")
& (pl.col("on_bs_for_ead") > 0)
& pl.col("netting_agreement_reference").is_not_null()
).select(
"exposure_reference",
"netting_agreement_reference",
"counterparty_reference",
"currency",
"on_bs_for_ead",
)
# Match siblings to pools by shared agreement reference AND counterparty.
matched = positive_siblings.join(
netting_pool,
on=["netting_agreement_reference", "counterparty_reference"],
how="inner",
)
# Total drawn EAD per pool for pro-rata allocation. CRR Art. 219 nets cash
# against drawn loans, so the pro-rata basis is the on-BS (drawn) portion,
# NOT ead_for_crm (which includes the off-BS nominal at CCF=100% per
# Art. 223(4) — that override is for collateral valuation, not for OBS
# netting allocation basis).
facility_totals = matched.group_by(
"netting_agreement_reference", "_pool_currency", "counterparty_reference"
).agg(
pl.col("on_bs_for_ead").sum().alias("_facility_total_drawn"),
)
# Join totals back for pro-rata
allocated = matched.join(
facility_totals,
on=["netting_agreement_reference", "_pool_currency", "counterparty_reference"],
how="left",
).filter(pl.col("_facility_total_drawn") > 0)
# Pro-rata market_value per sibling by drawn portion (Art. 219).
allocated = allocated.with_columns(
(pl.col("netting_pool") * pl.col("on_bs_for_ead") / pl.col("_facility_total_drawn")).alias(
"market_value"
),
)
# Deposit residual maturity (Art. 238 t). Derived from the pool's earliest
# deposit maturity when a reporting_date is available; null (permissive)
# otherwise. The /365.25 basis MATCHES the exposure-side T derivation in
# HaircutCalculator.apply_maturity_mismatch, so a deposit and loan sharing a
# maturity date net in full (t == T, no phantom mismatch). A null pool
# maturity date yields a null residual either way.
residual_expr = (
(
(pl.col("_pool_deposit_maturity_date").cast(pl.Date) - pl.lit(reporting_date))
.dt.total_days()
.cast(pl.Float64)
/ 365.25
)
if reporting_date is not None
else pl.lit(None, dtype=pl.Float64)
)
# Deposit original maturity (Art. 237(2)(a) t_orig): the pool's minimum
# deposit original term (null → permissive when value_date was absent). A
# deposit with original maturity < 1y and a mismatch is zeroed downstream.
original_expr = pl.col("_pool_deposit_orig_maturity")
# Build synthetic collateral rows — currency from the pool (source of funds).
# maturity_date / residual_maturity_years / original_maturity_years are the
# DEPOSIT's (Art. 219/237-238), not the beneficiary loan's.
synthetic = allocated.select(
(pl.lit("NETTING_") + pl.col("exposure_reference")).alias("collateral_reference"),
pl.lit("cash").alias("collateral_type"),
pl.col("_pool_currency").alias("currency"),
pl.col("_pool_deposit_maturity_date").alias("maturity_date"),
pl.col("market_value"),
pl.lit(None).cast(pl.Float64).alias("nominal_value"),
pl.lit(None).cast(pl.Float64).alias("pledge_percentage"),
pl.lit("loan").alias("beneficiary_type"),
pl.col("exposure_reference").alias("beneficiary_reference"),
pl.lit(None).cast(pl.Int8).alias("issuer_cqs"),
pl.lit(None).cast(pl.String).alias("issuer_type"),
residual_expr.alias("residual_maturity_years"),
original_expr.alias("original_maturity_years"),
pl.lit(True).alias("is_eligible_financial_collateral"),
pl.lit(True).alias("is_eligible_irb_collateral"),
pl.lit(None).cast(pl.Date).alias("valuation_date"),
pl.lit(None).cast(pl.String).alias("valuation_type"),
pl.lit(None).cast(pl.String).alias("property_type"),
pl.lit(None).cast(pl.Float64).alias("property_ltv"),
pl.lit(None).cast(pl.Boolean).alias("is_income_producing"),
pl.lit(None).cast(pl.Boolean).alias("is_adc"),
pl.lit(None).cast(pl.Boolean).alias("is_presold"),
)
return synthetic
CRR Art. 220 — Using the Supervisory Volatility Adjustments Approach or the Own Estimates Volatility Adjustments Approach for master netting agreements¶
sft_bundle_to_exposures — src/rwa_calc/engine/sft/fccm.py:109
@cites("CRR Art. 220")
@cites("CRR Art. 223")
@cites("CRR Art. 224")
@cites("CRR Art. 226")
@cites("CRR Art. 271")
@cites("CRR Art. 285")
def sft_bundle_to_exposures(
raw_sft: RawSFTBundle,
reporting_date: date,
rulepack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Shape FCCM SFT EADs into synthetic exposure rows from the lean SFT bundle.
The sole FCCM entry point (SFT/FCCM separation): consumes the dedicated
:class:`RawSFTBundle` (``RawDataBundle.sft``). The SFT/derivative
discrimination lives in the *input bundle* now, not in any in-engine
``transaction_type`` split:
- Every trade row is an SFT (no ``transaction_type`` filter): the whole
``raw_sft.trades`` frame is in scope.
- The netting-set ``counterparty_reference`` is denormalised onto the trade
row (FCCM scope is single-trade single-counterparty netting sets,
Art. 220(1)(a)), so the NS-grain counterparty frame is derived from the
trades themselves rather than a separate netting-set table.
- Collateral is OPTIONAL (``raw_sft.collateral is None`` for an
uncollateralised SFT, the common case): a missing collateral leaf yields a
zero collateral term (CVA·(1−HC−HFX) = 0), exactly as an empty
``ccr_collateral`` frame would.
Each emitted synthetic exposure row carries the FCCM provenance:
``exposure_reference = "ccr__<netting_set_id>"``, ``risk_type = "CCR_SFT"``,
``ccr_method = "fccm_sft"``, ``drawn_amount = E*``, ``ead_ccr = E*``.
Args:
raw_sft: The SFT (FCCM) input bundle — every trade row is an SFT with the
denormalised netting-set counterparty; collateral optional.
reporting_date: As-of date; written to ``value_date``.
rulepack: The resolved RUN rulepack supplying the Art. 162 effective-
maturity floors / regime gate for the ``ccr_effective_maturity``
carrier. ``None`` (the back-compat default used by direct unit /
acceptance calls) falls back to the module-level CRR ``_PACK``; the
stage adapter threads the run pack so production runs are regime-
correct.
Returns:
LazyFrame at netting-set grain. Empty (zero-row) frame when the trades
bundle is empty.
References:
CRR Art. 271(2); Art. 220(1)(a); Art. 223(5); Art. 224 Table 1;
Art. 224(2)(b); Art. 226; Art. 285(2)-(5).
"""
sft_trades_lf = raw_sft.trades.sft_trades
# Counterparty is denormalised onto the trade — collapse to NS grain. The
# ``first()`` aggregation is exact under the single-CP-per-NS scope
# (Art. 220(1)(a)); should a future netting set span counterparties the
# FCCM scope itself would need revisiting.
ns_counterparty_lf = sft_trades_lf.group_by("netting_set_id").agg(
pl.col("counterparty_reference").first()
)
ccr_collateral_lf = (
raw_sft.collateral.sft_collateral if raw_sft.collateral is not None else None
)
return _build_sft_exposure_rows(
sft_trades_lf=sft_trades_lf,
ns_counterparty_lf=ns_counterparty_lf,
ccr_collateral_lf=ccr_collateral_lf,
reporting_date=reporting_date,
pack=rulepack if rulepack is not None else _PACK,
)
CRR Art. 222 — Financial Collateral Simple Method¶
compute_fcsm_columns — src/rwa_calc/engine/crm/simple_method.py:271
@cites("CRR Art. 222")
def compute_fcsm_columns(
exposures: pl.LazyFrame,
collateral: pl.LazyFrame | None,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Compute FCSM columns on the exposure frame.
Aggregates eligible financial collateral per exposure and sets:
- fcsm_collateral_value: total raw market value of eligible financial collateral
allocated to this exposure (capped at EAD)
- fcsm_collateral_rw: weighted-average SA risk weight of the collateral
Does NOT modify any EAD columns. The SA calculator uses these columns
for risk weight substitution via _apply_fcsm_rw_substitution().
IRB exposures are unaffected — Simple Method is SA-only per Art. 222.
Args:
exposures: Exposure frame with ead_gross, exposure_reference, etc.
collateral: Collateral frame (may be None if no collateral).
config: Calculation configuration.
pack: Resolved rulepack supplying the Art. 222 floor scalars. Production
passes the run's pack; direct callers default to ``None``, which
resolves a pack from ``config`` (same regime/date).
Returns:
Exposure frame with fcsm_collateral_value and fcsm_collateral_rw columns.
"""
if collateral is None:
return _add_default_fcsm_columns(exposures)
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
floors = _FcsmFloors.from_pack(resolved_pack)
schema = exposures.collect_schema()
schema_names = schema.names()
ead_col = "ead_gross" if "ead_gross" in schema_names else "ead"
exp_ref_col = "exposure_reference" if "exposure_reference" in schema_names else "loan_reference"
facility_col = "parent_facility_reference"
cp_col = "counterparty_reference"
schema_flags = _SchemaFlags(
has_exp_maturity="residual_maturity_years" in schema_names,
sft_col=_resolve_sft_column(schema_names),
has_cmp_col="cp_is_core_market_participant" in schema_names,
has_currency="currency" in schema_names,
has_facility=facility_col in schema_names,
has_counterparty=cp_col in schema_names,
)
# 1. Filter to eligible financial collateral + ensure zero-haircut flag
# 2. Derive per-item RW. The institution (Art. 120 ECRA/SCRA) and corporate
# (Art. 122 Table 6) SA RW tables selected here are the SAME regime concept
# the SA calculator gates — reuse the cited `sa_revised_risk_weight_tables`
# Feature (S9b dedupe) rather than reading config.is_basel_3_1. The helper
# keeps its `is_b31` bool plumbing param (Option B).
eligible = _prepare_eligible_collateral(
collateral,
resolved_pack.feature("sa_revised_risk_weight_tables"),
floors.equity_collateral_rw,
)
# 3. Multi-level join (direct + facility + counterparty) to bring exposure
# currency, maturity, SFT and CMP flags onto each collateral row.
coll_with_exp = _join_exposure_levels(
eligible, exposures, exp_ref_col, facility_col, cp_col, ead_col, schema_flags
)
# 4. Resolve coalesced exposure-level columns and Art. 222(4) gating flags.
coll_with_exp = _resolve_exposure_levels(coll_with_exp)
# 5. Same-currency check + Art. 222(6)(b) sovereign-bond discount.
coll_with_exp = _apply_currency_and_sovereign_discount(coll_with_exp, floors)
# 6. Per-item secured-portion RW (Art. 222(3)/(4)/(6) decision tree).
coll_with_exp = coll_with_exp.with_columns(
_secured_floor_expr(floors).alias("_fcsm_effective_rw"),
)
# 6b. Art. 239(1) FCSM maturity-mismatch eligibility gate.
coll_with_exp = _apply_maturity_eligibility_gate(coll_with_exp)
# 7. Aggregate per beneficiary_reference.
agg = _aggregate_per_beneficiary(coll_with_exp)
# 8-9. Multi-level join back to exposures and combine with pro-rata shares.
result = _join_aggregates_back(
exposures, agg, exp_ref_col, facility_col, cp_col, ead_col, schema_flags
)
# 10. Cap collateral value at EAD; RW floor was applied per-item in step 6.
result = _finalise_fcsm_columns(result, ead_col)
# Drop temporary columns
temp_cols = [
c
for c in result.collect_schema().names()
if c.startswith("_fcsm_") or c in ("_fac_ead_total", "_cp_ead_total")
]
return result.drop(temp_cols)
apply_fcsm_rw_substitution — src/rwa_calc/engine/sa/rw_adjustments.py:73
@cites("CRR Art. 222")
def apply_fcsm_rw_substitution(lf: pl.LazyFrame, config: CalculationConfig) -> pl.LazyFrame:
"""Apply Art. 222 Financial Collateral Simple Method risk weight substitution.
When the Simple Method is elected, the secured portion of each SA exposure
gets the collateral's SA risk weight instead of the exposure's own risk
weight. The unsecured portion retains the original RW.
Blended RW = secured_pct × collateral_rw + unsecured_pct × exposure_rw
The 20% floor (Art. 222(1)/(3)) and same-currency 0% carve-outs (CRR
Art. 222(4) / PRA PS1/26 Art. 222(6)) are applied per item in
``compute_fcsm_columns``. Applying the floor again on the aggregate would
re-impose it on carve-out items — contrary to "except as specified in
paragraphs 4 to 6".
This function is a no-op when the Comprehensive Method is elected (default)
or when fcsm_collateral_value is zero/null — the crm_exit contract
injects both fcsm_* columns as typed nulls when the FCSM sub-step did
not run, and ``fill_null(0.0)`` makes an all-null column equivalent to
the historical absent-column early return.
"""
if config.crm_collateral_method != CRMCollateralMethod.SIMPLE:
return lf
ead = pl.col("ead_final").fill_null(0.0)
fcsm_value = pl.col("fcsm_collateral_value").fill_null(0.0)
fcsm_rw = pl.col("fcsm_collateral_rw").fill_null(0.0)
# Secured percentage (capped at 100%)
secured_pct = pl.when(ead > 0).then((fcsm_value / ead).clip(0.0, 1.0)).otherwise(0.0)
unsecured_pct = pl.lit(1.0) - secured_pct
# Blended risk weight; secured RW already reflects per-item floor + carve-outs.
blended_rw = secured_pct * fcsm_rw + unsecured_pct * pl.col("risk_weight")
# Only apply when there is actual collateral value
has_fcsm = fcsm_value > 0
return lf.with_columns(
# Save pre-FCSM risk weight for audit
pl.col("risk_weight").alias("pre_fcsm_risk_weight"),
# Apply blended RW
pl.when(has_fcsm).then(blended_rw).otherwise(pl.col("risk_weight")).alias("risk_weight"),
# Track method for audit/COREP
pl.when(has_fcsm)
.then(pl.lit("simple"))
.otherwise(pl.lit("comprehensive"))
.alias("ead_calculation_method"),
)
CRR Art. 223 — Financial Collateral Comprehensive Method¶
generate_netting_collateral — src/rwa_calc/engine/crm/collateral.py:168
@cites("CRR Art. 195")
@cites("CRR Art. 219")
@cites("CRR Art. 223")
@cites("CRR Art. 238")
def generate_netting_collateral(
exposures: pl.LazyFrame,
errors: list[CalculationError] | None = None,
*,
reporting_date: date | None = None,
) -> pl.LazyFrame | None:
"""
Generate synthetic cash collateral from negative-drawn netting-eligible loans.
When a loan has a negative drawn amount (credit balance / deposit) and carries
a ``netting_agreement_reference`` (CRR Art. 195/219), the absolute value of
that negative balance can reduce other exposures covered by the SAME netting
agreement AND owed by the SAME counterparty — treated as synthetic cash
collateral.
CRR/PS1-26 Art. 195 (P1.238): on-balance-sheet netting is limited to "mutual
claims" / "reciprocal cash balances between the institution and the
counterparty" — a single counterparty. So a deposit from counterparty A may
net only loans owed by counterparty A under the same agreement; it may NOT
offset a loan to a different counterparty B, even where a group-level
agreement reference is shared. Pools are therefore keyed by
(netting_agreement_reference, counterparty_reference) — the agreement is the
legal set-off boundary, the counterparty the Art. 195 eligibility boundary.
A netting_agreement_reference that spans more than one counterparty raises a
CRM016 data-quality warning (the disallowed cross-counterparty offset is
otherwise invisible). Two exposures still do NOT net unless they share the
reference, regardless of facility hierarchy.
CRR Art. 219 limits on-balance-sheet netting to drawn loans and deposits
(cash-on-cash). Synthetic cash collateral is allocated pro-rata by the drawn
portion (`on_bs_for_ead`) to positive-drawn LOAN siblings carrying the same
reference — contingents and synthetic facility_undrawn rows are
off-balance-sheet and excluded from the beneficiary set. Netting pools also
keep currency (as (ref, currency, counterparty_reference)) so the haircut
pipeline can apply FX haircuts when the pool currency differs from the
sibling's currency.
Art. 219 treats the netted deposit as cash collateral, so the funded-
protection maturity-mismatch rules (Art. 237-239) apply exactly as for any
other funded protection (P1.241). The synthetic row therefore carries the
DEPOSIT's maturity — not the beneficiary loan's — as ``maturity_date``, the
deposit residual (t) as ``residual_maturity_years`` (when ``reporting_date``
is supplied), and the deposit ORIGINAL term as ``original_maturity_years``
(when ``value_date`` is available). The downstream ``apply_maturity_mismatch``
then, on a mismatch (t < T where T is the loan residual), zeroes the
protection when t < 0.25 (Art. 237(1)) OR the original term < 1y
(Art. 237(2)(a)), else applies (t-0.25)/(T-0.25) (Art. 238-239). Previously
the row carried the loan's maturity, a null residual (filled to 10y
downstream) and no original term, so no gate fired and a short deposit
netting a long loan was recognised in full.
The residual t uses the /365.25 day-count of the exposure-side T derivation in
``apply_maturity_mismatch`` (so equal deposit/loan maturities net in full with
NO phantom mismatch); the original term uses the /365 convention of the
engine's other original-maturity derivations (risk_weights.py / enrich.py).
Pooling convention (conservative): when several deposits of differing
maturities pool into one (ref, currency, counterparty) row, the pool carries
the EARLIEST (minimum) deposit maturity AND the minimum deposit original term.
The earliest-maturing deposit is when the pool's protection first begins to
lapse; representing the whole pool at that maturity maximises the mismatch
haircut (shortest t → smallest (t-0.25)/(T-0.25)), and the minimum original
term is the one most likely to trip the Art. 237(2)(a) <1y gate — both the
prudent single-value summary. A null deposit maturity (or no
``reporting_date``) leaves the residual null and is handled permissively
downstream — absent maturity data cannot establish a mismatch, the same
convention ordinary financial collateral without a supplied residual follows
(this is NOT an anti-conservative fill: the downstream 10y default is
unchanged, it is simply no longer fed a null when the data is present); a null
original term (no ``value_date``) likewise leaves the Art. 237(2)(a) gate
permissive.
Args:
exposures: Exposures with ead_for_crm, on_bs_for_ead, exposure_type set
errors: optional CRM error channel — receives Art. 195 CRM016 warnings
for netting agreements that span more than one counterparty.
reporting_date: run reporting date, used to derive the deposit residual
maturity (Art. 238) on the synthetic rows. When None (direct
unit-test callers), residual_maturity_years stays null and the
maturity mismatch is not applied — backward-compatible behaviour.
Returns:
LazyFrame of synthetic collateral rows, or None if no netting applies
"""
schema = exposures.collect_schema()
schema_names = set(schema.names())
if "netting_agreement_reference" not in schema_names:
return None
# value_date lets the pool derive each deposit's ORIGINAL maturity for the
# Art. 237(2)(a) gate. It is a core exposure column in production; injected as
# a typed null for direct unit-test callers that omit it (→ original maturity
# null → the gate stays permissive), via the schema-driven ensure_columns.
exposures = ensure_columns(exposures, {"value_date": ColumnSpec(pl.Date, required=False)})
# Graceful fallback for direct unit-test callers (production always
# supplies ead_for_crm via _initialize_ead, on_bs_for_ead via _compute_ead,
# and exposure_type via hierarchy).
if "ead_for_crm" not in schema_names:
exposures = exposures.with_columns(pl.col("ead_gross").alias("ead_for_crm"))
if "on_bs_for_ead" not in schema_names:
interest_expr = (
pl.col("interest").fill_null(0.0).clip(lower_bound=0.0)
if "interest" in schema_names
else pl.lit(0.0)
)
exposures = exposures.with_columns(
(pl.col("drawn_amount").clip(lower_bound=0.0) + interest_expr).alias("on_bs_for_ead")
)
if "exposure_type" not in schema_names:
exposures = exposures.with_columns(pl.lit("loan").alias("exposure_type"))
if "counterparty_reference" not in schema_names:
# Test-caller fallback only: production always supplies counterparty_reference
# (a core exposure column). Absent → treat every row as the same counterparty
# so the Art. 195 same-counterparty constraint is a no-op for legacy callers.
exposures = exposures.with_columns(pl.lit("_UNKNOWN_CP").alias("counterparty_reference"))
# Negative-drawn loans carrying a netting agreement reference provide the pool
negative_loans = exposures.filter(
pl.col("netting_agreement_reference").is_not_null() & (pl.col("drawn_amount") < 0)
)
# Art. 195 (P1.238): emit a CRM016 warning for any agreement that spans more
# than one counterparty (a deposit and a positive loan under the same
# reference but for different counterparties would previously have netted).
if errors is not None:
_record_cross_counterparty_netting(exposures, errors)
# Sum abs(drawn_amount) per (netting_agreement_reference, currency,
# counterparty_reference) → netting pool. Currency is kept so the synthetic
# collateral carries the source currency (FX haircut when currencies differ);
# counterparty_reference enforces the Art. 195 same-counterparty limit.
# Art. 219/238 (P1.241): the earliest (min) deposit maturity per pool is the
# conservative single-maturity summary — it drives the maturity-mismatch t.
# The minimum deposit ORIGINAL maturity is carried alongside for the
# Art. 237(2)(a) >=1y eligibility gate (min → shortest term is most likely to
# trip the <1y gate; derived from maturity_date - value_date, the same
# convention as engine/sa/risk_weights.py / hierarchy/enrich.py, /365). A null
# value_date yields a null original maturity (permissive — gate does not fire).
deposit_original_years = (
pl.col("maturity_date").cast(pl.Int32) - pl.col("value_date").cast(pl.Int32)
).cast(pl.Float64) / 365.0
netting_pool = (
negative_loans.group_by(
["netting_agreement_reference", "currency", "counterparty_reference"]
)
.agg(
pl.col("drawn_amount").abs().sum().alias("netting_pool"),
pl.col("maturity_date").min().alias("_pool_deposit_maturity_date"),
deposit_original_years.min().alias("_pool_deposit_orig_maturity"),
)
.rename({"currency": "_pool_currency"})
)
# CRR Art. 219: drawn-on-drawn cash netting. Synthetic cash collateral may
# only benefit the drawn portion of loan exposures — contingents and
# facility_undrawn synthetic rows are off-balance-sheet and ineligible. A
# sibling matches a pool iff it shares BOTH the netting_agreement_reference
# and the counterparty_reference (Art. 195 same-counterparty limit).
# The beneficiary loan's own maturity is NOT carried onto the synthetic row:
# it feeds the mismatch as the EXPOSURE side (T) via the exposure lookup join
# downstream, while the synthetic row carries the DEPOSIT maturity (t).
positive_siblings = exposures.filter(
(pl.col("exposure_type") == "loan")
& (pl.col("on_bs_for_ead") > 0)
& pl.col("netting_agreement_reference").is_not_null()
).select(
"exposure_reference",
"netting_agreement_reference",
"counterparty_reference",
"currency",
"on_bs_for_ead",
)
# Match siblings to pools by shared agreement reference AND counterparty.
matched = positive_siblings.join(
netting_pool,
on=["netting_agreement_reference", "counterparty_reference"],
how="inner",
)
# Total drawn EAD per pool for pro-rata allocation. CRR Art. 219 nets cash
# against drawn loans, so the pro-rata basis is the on-BS (drawn) portion,
# NOT ead_for_crm (which includes the off-BS nominal at CCF=100% per
# Art. 223(4) — that override is for collateral valuation, not for OBS
# netting allocation basis).
facility_totals = matched.group_by(
"netting_agreement_reference", "_pool_currency", "counterparty_reference"
).agg(
pl.col("on_bs_for_ead").sum().alias("_facility_total_drawn"),
)
# Join totals back for pro-rata
allocated = matched.join(
facility_totals,
on=["netting_agreement_reference", "_pool_currency", "counterparty_reference"],
how="left",
).filter(pl.col("_facility_total_drawn") > 0)
# Pro-rata market_value per sibling by drawn portion (Art. 219).
allocated = allocated.with_columns(
(pl.col("netting_pool") * pl.col("on_bs_for_ead") / pl.col("_facility_total_drawn")).alias(
"market_value"
),
)
# Deposit residual maturity (Art. 238 t). Derived from the pool's earliest
# deposit maturity when a reporting_date is available; null (permissive)
# otherwise. The /365.25 basis MATCHES the exposure-side T derivation in
# HaircutCalculator.apply_maturity_mismatch, so a deposit and loan sharing a
# maturity date net in full (t == T, no phantom mismatch). A null pool
# maturity date yields a null residual either way.
residual_expr = (
(
(pl.col("_pool_deposit_maturity_date").cast(pl.Date) - pl.lit(reporting_date))
.dt.total_days()
.cast(pl.Float64)
/ 365.25
)
if reporting_date is not None
else pl.lit(None, dtype=pl.Float64)
)
# Deposit original maturity (Art. 237(2)(a) t_orig): the pool's minimum
# deposit original term (null → permissive when value_date was absent). A
# deposit with original maturity < 1y and a mismatch is zeroed downstream.
original_expr = pl.col("_pool_deposit_orig_maturity")
# Build synthetic collateral rows — currency from the pool (source of funds).
# maturity_date / residual_maturity_years / original_maturity_years are the
# DEPOSIT's (Art. 219/237-238), not the beneficiary loan's.
synthetic = allocated.select(
(pl.lit("NETTING_") + pl.col("exposure_reference")).alias("collateral_reference"),
pl.lit("cash").alias("collateral_type"),
pl.col("_pool_currency").alias("currency"),
pl.col("_pool_deposit_maturity_date").alias("maturity_date"),
pl.col("market_value"),
pl.lit(None).cast(pl.Float64).alias("nominal_value"),
pl.lit(None).cast(pl.Float64).alias("pledge_percentage"),
pl.lit("loan").alias("beneficiary_type"),
pl.col("exposure_reference").alias("beneficiary_reference"),
pl.lit(None).cast(pl.Int8).alias("issuer_cqs"),
pl.lit(None).cast(pl.String).alias("issuer_type"),
residual_expr.alias("residual_maturity_years"),
original_expr.alias("original_maturity_years"),
pl.lit(True).alias("is_eligible_financial_collateral"),
pl.lit(True).alias("is_eligible_irb_collateral"),
pl.lit(None).cast(pl.Date).alias("valuation_date"),
pl.lit(None).cast(pl.String).alias("valuation_type"),
pl.lit(None).cast(pl.String).alias("property_type"),
pl.lit(None).cast(pl.Float64).alias("property_ltv"),
pl.lit(None).cast(pl.Boolean).alias("is_income_producing"),
pl.lit(None).cast(pl.Boolean).alias("is_adc"),
pl.lit(None).cast(pl.Boolean).alias("is_presold"),
)
return synthetic
apply_collateral — src/rwa_calc/engine/crm/collateral.py:430
@cites("PS1/26 Art. 230(2)")
@cites("PS1/26 Art. 230(1)")
@cites("CRR Art. 223")
@cites("CRR Art. 230")
def apply_collateral(
exposures: pl.LazyFrame,
collateral: pl.LazyFrame,
config: CalculationConfig,
haircut_calculator: HaircutCalculator,
build_exposure_lookups_fn: Callable,
join_collateral_to_lookups_fn: Callable,
resolve_pledge_from_joined_fn: Callable,
*,
pack: ResolvedRulepack | None = None,
errors: list[CalculationError] | None = None,
) -> pl.LazyFrame:
"""
Apply collateral to reduce EAD (SA) or LGD (IRB).
Pre-computes shared exposure lookups once, then joins ALL lookup columns
(EAD, currency, maturity) in a single pass of 3 joins. Pledge resolution
and currency/maturity derivation operate on pre-joined columns — no
additional joins needed.
Args:
exposures: Exposures with ead_gross
collateral: Collateral data
config: Calculation configuration
haircut_calculator: HaircutCalculator instance
build_exposure_lookups_fn: Function to build exposure lookups
join_collateral_to_lookups_fn: Function to join collateral to lookups
resolve_pledge_from_joined_fn: Function to resolve pledge percentages
Returns:
Exposures with collateral effects applied
"""
# Tag each exposure with its AIRB-pool membership so downstream pro-rata
# bases can be split into AIRB and non-AIRB pools. CRR Art. 181 / Basel 3.1
# Art. 169A: AIRB own LGD already reflects collateral, so collateral
# incorporated in the model must not also be allocated to non-AIRB
# exposures of the same counterparty.
schema_names = set(exposures.collect_schema().names())
# Graceful fallback for direct unit-test callers that hand-build the
# exposures frame without going through _initialize_ead. In production
# both columns are always present. For pure on-BS rows the defaults
# produce identical behaviour to the explicit columns, so existing
# tests stay green without modification.
fallback_cols: list[pl.Expr] = []
if "ead_for_crm" not in schema_names:
fallback_cols.append(pl.col("ead_gross").alias("ead_for_crm"))
if "effective_ccf" not in schema_names:
fallback_cols.append(pl.lit(1.0).alias("effective_ccf"))
if fallback_cols:
exposures = exposures.with_columns(fallback_cols)
schema_names |= {expr.meta.output_name() for expr in fallback_cols}
# S9h: resolve the pack once; the collateral-LGD regime branches downstream
# (haircut maturity bands, AIRB pool membership, FSE split, Art. 230(2) sub-rows)
# read honest cited Features off it instead of a single config.is_basel_3_1 bool.
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
# CRR Art. 223(5) FCCM exposure volatility haircut (HE). Computed once on
# the exposure frame so the SA branch in ``_apply_collateral_unified`` can
# gross E by (1 + HE). Non-SFT / cash / standard-loan rows yield HE = 0.
exposures = haircut_calculator.apply_exposure_haircut(
exposures,
resolved_pack.feature("collateral_haircut_maturity_bands_revised"),
pack=resolved_pack,
)
exposures = exposures.with_columns(
airb_lgd_preserved_expr(config, schema_names, pack=resolved_pack).alias("_is_airb_pool")
)
# Pre-compute shared exposure lookups once
direct_lookup, facility_lookup, cp_lookup = build_exposure_lookups_fn(exposures)
# Materialise the small lookup frames in parallel to prevent plan-tree
# duplication. Each lookup is referenced in multiple downstream joins;
# without this, Polars re-evaluates the group_by/select at each reference.
# collect_all runs all 3 concurrently and enables CSE on shared upstream.
direct_df, facility_df, cp_df = pl.collect_all([direct_lookup, facility_lookup, cp_lookup])
direct_lookup = direct_df.lazy()
facility_lookup = facility_df.lazy()
cp_lookup = cp_df.lazy()
# Derive pool-aware counterparty EAD totals from the lookups. Unflagged
# collateral pro-rates over the non-AIRB pool only; flagged collateral
# (is_airb_model_collateral=True) pro-rates over the AIRB pool only.
# Facility-level subtree totals are derived per-ancestor inside
# ``_apply_collateral_unified`` (``_cascade_facility_collateral``) so that
# collateral pledged at any ancestor facility cascades over its whole
# descendant subtree for nested facility hierarchies.
cp_ead_totals = cp_lookup.select(
pl.col("_ben_ref_cp").alias("counterparty_reference"),
pl.col("_ead_cp").alias("_cp_ead_total"),
pl.col("_ead_cp_airb").alias("_cp_ead_total_airb"),
pl.col("_ead_cp_non_airb").alias("_cp_ead_total_non_airb"),
)
# Single pass: join all lookup columns (EAD, currency, maturity)
collateral = join_collateral_to_lookups_fn(
collateral, direct_lookup, facility_lookup, cp_lookup
)
# Resolve pledge_percentage → market_value (uses pre-joined _beneficiary_ead)
collateral = resolve_pledge_from_joined_fn(collateral)
# Apply haircuts to collateral (no longer needs exposures)
adjusted_collateral = haircut_calculator.apply_haircuts(collateral, config, pack=pack)
# CRR/PS1-26 Art. 197(1)(f)/198(1)(a) (P1.271): apply_haircuts has already
# zeroed non-main-index / non-listed equity collateral and cleared its
# eligibility flag; record one CRM018 warning per gated row.
if errors is not None:
_record_non_main_index_equity_ineligible(adjusted_collateral, errors)
# CRR/PS1-26 Art. 218 (P1.274): apply_haircuts has already zeroed a
# credit-linked note that is not attested own-issued; record one CRM019
# warning per gated row.
_record_credit_linked_note_not_own_issued(adjusted_collateral, errors)
# Apply maturity mismatch using actual exposure maturity (Art. 238)
adjusted_collateral = haircut_calculator.apply_maturity_mismatch(adjusted_collateral, config)
# Opt-in audit cache: persist the per-collateral haircut frame for inspection.
# No-op unless config.audit_cache_dir is set. Surfaces fx_haircut /
# collateral_haircut / value_after_haircut / value_after_maturity_adj — the
# diagnostic columns users need to confirm whether H_fx is firing on a row.
sink_audit(adjusted_collateral, config, "collateral_haircuts")
return _apply_collateral_unified(
exposures,
adjusted_collateral,
config,
cp_ead_totals,
pack=resolved_pack,
errors=errors,
)
lgd_star_exposure_basis_expr — src/rwa_calc/engine/crm/expressions.py:106
@cites("CRR Art. 223(4)")
@cites("PS1/26 Art. 230(1)")
def lgd_star_exposure_basis_expr(*, has_volatility_haircut: bool = True) -> pl.Expr:
"""The Art. 230(1) exposure basis E' = E x (1 + HE) that LGD* divides by.
``E`` is ``ead_for_crm``, the CCF=100% exposure value (CRR Art. 223(4) /
PS1/26 Art. 223(4)) — NOT the post-CCF ``ead_gross``: an off-balance-sheet
item enters credit risk mitigation at 100% of nominal, so the collateral
shares that weight the LGD* blend are shares of the pre-CCF basis. ``HE``
is the exposure's own volatility haircut (Art. 223(5)), non-zero only where
the row lends out a debt security, so E' == E on every other row.
The single home for this quantity: the F-IRB / A-IRB LGD* formula and the
Art. 161(5)(b) / 164(4)(c) A-IRB LGD *input floor* blend must divide by the
same basis (``engine/crm/collateral.py``, ``engine/irb/formulas.py``).
Args:
has_volatility_haircut: False where the caller's frame predates the
``exposure_volatility_haircut`` column (pre-seal CRM inputs built
by direct unit-test callers), which is equivalent to HE = 0.
"""
if not has_volatility_haircut:
return pl.col("ead_for_crm")
he_factor = pl.lit(1.0) + pl.col("exposure_volatility_haircut").fill_null(0.0)
return pl.col("ead_for_crm") * he_factor
sft_bundle_to_exposures — src/rwa_calc/engine/sft/fccm.py:110
@cites("CRR Art. 220")
@cites("CRR Art. 223")
@cites("CRR Art. 224")
@cites("CRR Art. 226")
@cites("CRR Art. 271")
@cites("CRR Art. 285")
def sft_bundle_to_exposures(
raw_sft: RawSFTBundle,
reporting_date: date,
rulepack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Shape FCCM SFT EADs into synthetic exposure rows from the lean SFT bundle.
The sole FCCM entry point (SFT/FCCM separation): consumes the dedicated
:class:`RawSFTBundle` (``RawDataBundle.sft``). The SFT/derivative
discrimination lives in the *input bundle* now, not in any in-engine
``transaction_type`` split:
- Every trade row is an SFT (no ``transaction_type`` filter): the whole
``raw_sft.trades`` frame is in scope.
- The netting-set ``counterparty_reference`` is denormalised onto the trade
row (FCCM scope is single-trade single-counterparty netting sets,
Art. 220(1)(a)), so the NS-grain counterparty frame is derived from the
trades themselves rather than a separate netting-set table.
- Collateral is OPTIONAL (``raw_sft.collateral is None`` for an
uncollateralised SFT, the common case): a missing collateral leaf yields a
zero collateral term (CVA·(1−HC−HFX) = 0), exactly as an empty
``ccr_collateral`` frame would.
Each emitted synthetic exposure row carries the FCCM provenance:
``exposure_reference = "ccr__<netting_set_id>"``, ``risk_type = "CCR_SFT"``,
``ccr_method = "fccm_sft"``, ``drawn_amount = E*``, ``ead_ccr = E*``.
Args:
raw_sft: The SFT (FCCM) input bundle — every trade row is an SFT with the
denormalised netting-set counterparty; collateral optional.
reporting_date: As-of date; written to ``value_date``.
rulepack: The resolved RUN rulepack supplying the Art. 162 effective-
maturity floors / regime gate for the ``ccr_effective_maturity``
carrier. ``None`` (the back-compat default used by direct unit /
acceptance calls) falls back to the module-level CRR ``_PACK``; the
stage adapter threads the run pack so production runs are regime-
correct.
Returns:
LazyFrame at netting-set grain. Empty (zero-row) frame when the trades
bundle is empty.
References:
CRR Art. 271(2); Art. 220(1)(a); Art. 223(5); Art. 224 Table 1;
Art. 224(2)(b); Art. 226; Art. 285(2)-(5).
"""
sft_trades_lf = raw_sft.trades.sft_trades
# Counterparty is denormalised onto the trade — collapse to NS grain. The
# ``first()`` aggregation is exact under the single-CP-per-NS scope
# (Art. 220(1)(a)); should a future netting set span counterparties the
# FCCM scope itself would need revisiting.
ns_counterparty_lf = sft_trades_lf.group_by("netting_set_id").agg(
pl.col("counterparty_reference").first()
)
ccr_collateral_lf = (
raw_sft.collateral.sft_collateral if raw_sft.collateral is not None else None
)
return _build_sft_exposure_rows(
sft_trades_lf=sft_trades_lf,
ns_counterparty_lf=ns_counterparty_lf,
ccr_collateral_lf=ccr_collateral_lf,
reporting_date=reporting_date,
pack=rulepack if rulepack is not None else _PACK,
)
CRR Art. 224 — Supervisory volatility adjustment under the Financial Collateral Comprehensive Method¶
get_haircut_table — src/rwa_calc/engine/crm/haircut_tables.py:339
@cites("CRR Art. 224")
def get_haircut_table(is_basel_3_1: bool = False) -> pl.DataFrame:
"""
Get collateral haircut lookup table for the given framework.
Args:
is_basel_3_1: True for Basel 3.1 haircuts (CRE22.52-53), False for CRR (Art. 224)
Returns:
DataFrame with columns: collateral_type, cqs, maturity_band, haircut, is_main_index
"""
return _create_haircut_df(is_basel_3_1=is_basel_3_1)
get_maturity_band — src/rwa_calc/engine/crm/haircut_tables.py:353
@cites("CRR Art. 224")
def get_maturity_band(residual_maturity_years: float, is_basel_3_1: bool = False) -> str:
"""
Determine maturity band from residual maturity.
CRR uses 3 bands: 0-1y, 1-5y, 5y+
Basel 3.1 uses 5 bands: 0-1y, 1-3y, 3-5y, 5-10y, 10y+
Args:
residual_maturity_years: Residual maturity in years
is_basel_3_1: True for Basel 3.1 maturity bands
Returns:
Maturity band string
"""
if is_basel_3_1:
if residual_maturity_years <= 1.0:
return "0_1y"
elif residual_maturity_years <= 3.0:
return "1_3y"
elif residual_maturity_years <= 5.0:
return "3_5y"
elif residual_maturity_years <= 10.0:
return "5_10y"
else:
return "10y_plus"
else:
if residual_maturity_years <= 1.0:
return "0_1y"
elif residual_maturity_years <= 5.0:
return "1_5y"
else:
return "5y_plus"
apply_haircuts — src/rwa_calc/engine/crm/haircuts.py:190
@cites("CRR Art. 224")
def apply_haircuts(
self,
collateral: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""
Apply haircuts to collateral.
Expects exposure_currency and exposure_maturity columns to already be
present on collateral (joined via _join_collateral_to_lookups before calling).
Args:
collateral: Collateral data with market values, exposure_currency, exposure_maturity
config: Calculation configuration
Returns:
LazyFrame with haircut-adjusted collateral values
"""
# Bootstrap: _resolve_pack_for_haircut needs a regime hint only for its
# no-config fallback; in apply_haircuts config is always present, so the
# resolved pack's regime matches config. The maturity-band GATE then reads
# the cited Feature (S9d) — _maturity_band_expression keeps its bool param
# (Option B). The haircut VALUES already come from the pack DecisionTable.
resolved_pack = _resolve_pack_for_haircut(pack, config, config.is_basel_3_1)
is_b31 = resolved_pack.feature("collateral_haircut_maturity_bands_revised")
haircut_table = decision_table_df(
resolved_pack.decision("collateral_haircuts"),
value_name="haircut",
key_dtypes={"cqs": pl.Int8},
)
# Add maturity band for bond haircut lookup
collateral = collateral.with_columns(
[self._maturity_band_expression(is_b31).alias("maturity_band")]
)
# Calculate collateral-specific haircut based on type. The framework is
# selected by the per-call ``haircut_table`` derived from config above.
collateral = self._apply_collateral_haircuts(collateral, haircut_table)
# Scale collateral haircut and FX haircut by liquidation period (Art. 226(2))
# H_m = H_10 × sqrt(T_m / 10)
# P1.186: derive default liquidation period from exposure_is_sft when no
# explicit liquidation_period_days is supplied. Non-SFT secured lending
# defaults to 20 days (Art. 224(2)(a)), SFT/repo to 5 days (Art. 224(2)(c)).
schema = collateral.collect_schema()
has_liq_period = "liquidation_period_days" in schema.names()
has_sft_col = "exposure_is_sft" in schema.names()
if has_sft_col:
sft_default = (
pl.when(pl.col("exposure_is_sft").fill_null(False))
.then(pl.lit(_LIQUIDATION_PERIOD_REPO))
.otherwise(pl.lit(_LIQUIDATION_PERIOD_SECURED_LENDING))
)
else:
sft_default = pl.lit(_LIQUIDATION_PERIOD_SECURED_LENDING)
if has_liq_period:
liq = pl.col("liquidation_period_days").fill_null(sft_default).cast(pl.Float64)
else:
liq = sft_default.cast(pl.Float64)
scaling_factor = (liq / 10.0).sqrt()
# Art. 226(1): non-daily mark-to-market / non-daily-remargining adjustment.
# When revaluation_frequency_days (N_R) > 1, scale the haircut upward by
# sqrt((N_R + T_m - 1) / T_m). Null or N_R <= 1 leaves the multiplier at 1.0.
# PS1/26 carries Art. 226(1) forward unchanged so the same gate applies under
# Basel 3.1 — selection is on collateral input, not framework.
has_reval_freq = "revaluation_frequency_days" in schema.names()
if has_reval_freq:
n_r = pl.col("revaluation_frequency_days").fill_null(1).cast(pl.Float64)
reval_factor = (
pl.when(n_r > 1.0).then(((n_r + liq - 1.0) / liq).sqrt()).otherwise(pl.lit(1.0))
)
else:
reval_factor = pl.lit(1.0)
# Scale collateral haircut by liquidation period, then apply the Art. 226(1)
# non-daily-revaluation multiplier (order matters per the spec composition
# H = H_n × sqrt(T_m/10) × sqrt((N_R + T_m - 1)/T_m)).
# Non-financial collateral (real_estate, receivables, other_physical) uses
# Art. 230 / PS1/26 Art. 230(2) HC values which are NOT subject to Art. 226
# liquidation-period scaling — the Art. 230 HC is a credit-quality multiplier
# tied to the FCM LGD* formula, not a volatility adjustment. Only the
# Art. 224 financial-collateral haircuts (cash/gold/bonds/equity) scale.
is_non_financial_hc = (
pl.col("collateral_type").str.to_lowercase().is_in(NON_FINANCIAL_COLLATERAL_TYPES)
)
scaled_haircut = pl.col("collateral_haircut") * scaling_factor * reval_factor
collateral = collateral.with_columns(
pl.when(is_non_financial_hc)
.then(pl.col("collateral_haircut"))
.otherwise(scaled_haircut)
.alias("collateral_haircut")
)
# Apply FX haircut (Art. 224 Table 4, scaled per Art. 226).
# Compare pre-FX-conversion currencies: after `FXConverter.convert_*` has
# rebased values to the reporting currency, the `currency` column is the
# reporting currency on both sides and a raw comparison would always be
# false (P1.135). `original_currency` on collateral and `exposure_currency`
# (sourced from the exposure's `original_currency` in the processor) both
# carry the true pre-conversion currency pair.
#
# Scope: H_fx is the comprehensive-method volatility adjustment for
# *financial* collateral (Art. 224 Table 4). Funded non-financial
# collateral (real_estate, receivables, other_physical) is recognised
# under Art. 230 (Foundation Collateral Method), whose LGD* formula uses
# the raw collateral value C against C* / C** thresholds with no FX
# adjustment. Art. 233 H_fx is unfunded-protection only (guarantees /
# CDS — see engine/crm/guarantees.py). FX risk on Art. 230 collateral
# is captured upstream by the spot-rate FXConverter rebasing.
#
# Art. 227: zero-haircut repos waive ALL volatility adjustments including H_fx.
fx_base = scalar_value(resolved_pack.scalar_param("fx_haircut"))
schema_names = collateral.collect_schema().names()
has_zero_flag = "_is_zero_haircut" in schema_names
coll_ccy_col = "original_currency" if "original_currency" in schema_names else "currency"
# Art. 226(1) symmetry: FX haircut is also subject to the non-daily-
# revaluation scaling — apply ``reval_factor`` after the Art. 226(2)
# liquidation-period factor, mirroring the collateral haircut path.
is_financial = ~pl.col("collateral_type").is_in(NON_FINANCIAL_COLLATERAL_TYPES)
fx_expr = (
pl.when((pl.col(coll_ccy_col) != pl.col("exposure_currency")) & is_financial)
.then(pl.lit(fx_base) * scaling_factor * reval_factor)
.otherwise(pl.lit(0.0))
)
if has_zero_flag:
fx_expr = pl.when(pl.col("_is_zero_haircut")).then(pl.lit(0.0)).otherwise(fx_expr)
collateral = collateral.with_columns([fx_expr.alias("fx_haircut")])
# Calculate adjusted value after haircuts
collateral = collateral.with_columns(
[
(
pl.col("market_value")
* (1.0 - pl.col("collateral_haircut") - pl.col("fx_haircut"))
)
.clip(lower_bound=0.0)
.alias("value_after_haircut"),
]
)
# Zero out value for ineligible collateral. Three independent gates share the
# same value/eligibility treatment:
# _bond_ineligible — Art. 197 CQS gate (govt CQS 5-6, corp CQS 4-6,
# securitisation / non-SFT covered bonds); the
# row already carries a 100% collateral_haircut.
# _equity_listing_ineligible — Art. 197(1)(f)/198(1)(a) non-main-index /
# non-listed equity (P1.271); the 25%/30%
# supervisory haircut is deliberately left
# intact (valuation ≠ eligibility).
# _cln_ineligible — Art. 218 credit-linked note not attested
# own-issued (P1.274); the 0% cash haircut is
# left intact (valuation ≠ eligibility).
names = collateral.collect_schema().names()
ineligible_flags = [
f
for f in ("_bond_ineligible", "_equity_listing_ineligible", "_cln_ineligible")
if f in names
]
if ineligible_flags:
is_ineligible = pl.any_horizontal([pl.col(f) for f in ineligible_flags])
collateral = collateral.with_columns(
pl.when(is_ineligible)
.then(pl.lit(0.0))
.otherwise(pl.col("value_after_haircut"))
.alias("value_after_haircut")
)
# Also enforce is_eligible_financial_collateral = False for ineligible rows
if "is_eligible_financial_collateral" in names:
collateral = collateral.with_columns(
pl.when(is_ineligible)
.then(pl.lit(False))
.otherwise(pl.col("is_eligible_financial_collateral"))
.alias("is_eligible_financial_collateral")
)
collateral = collateral.drop(ineligible_flags)
# Clean up Art. 227 temp column
if "_is_zero_haircut" in collateral.collect_schema().names():
collateral = collateral.drop("_is_zero_haircut")
# Add haircut audit trail
collateral = collateral.with_columns(
[
pl.concat_str(
[
pl.lit("MV="),
pl.col("market_value").round(0).cast(pl.String),
pl.lit("; Hc="),
(pl.col("collateral_haircut") * 100).round(1).cast(pl.String),
pl.lit("%; Hfx="),
(pl.col("fx_haircut") * 100).round(1).cast(pl.String),
pl.lit("%; Adj="),
pl.col("value_after_haircut").round(0).cast(pl.String),
]
).alias("haircut_calculation"),
]
)
return collateral
sft_bundle_to_exposures — src/rwa_calc/engine/sft/fccm.py:111
@cites("CRR Art. 220")
@cites("CRR Art. 223")
@cites("CRR Art. 224")
@cites("CRR Art. 226")
@cites("CRR Art. 271")
@cites("CRR Art. 285")
def sft_bundle_to_exposures(
raw_sft: RawSFTBundle,
reporting_date: date,
rulepack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Shape FCCM SFT EADs into synthetic exposure rows from the lean SFT bundle.
The sole FCCM entry point (SFT/FCCM separation): consumes the dedicated
:class:`RawSFTBundle` (``RawDataBundle.sft``). The SFT/derivative
discrimination lives in the *input bundle* now, not in any in-engine
``transaction_type`` split:
- Every trade row is an SFT (no ``transaction_type`` filter): the whole
``raw_sft.trades`` frame is in scope.
- The netting-set ``counterparty_reference`` is denormalised onto the trade
row (FCCM scope is single-trade single-counterparty netting sets,
Art. 220(1)(a)), so the NS-grain counterparty frame is derived from the
trades themselves rather than a separate netting-set table.
- Collateral is OPTIONAL (``raw_sft.collateral is None`` for an
uncollateralised SFT, the common case): a missing collateral leaf yields a
zero collateral term (CVA·(1−HC−HFX) = 0), exactly as an empty
``ccr_collateral`` frame would.
Each emitted synthetic exposure row carries the FCCM provenance:
``exposure_reference = "ccr__<netting_set_id>"``, ``risk_type = "CCR_SFT"``,
``ccr_method = "fccm_sft"``, ``drawn_amount = E*``, ``ead_ccr = E*``.
Args:
raw_sft: The SFT (FCCM) input bundle — every trade row is an SFT with the
denormalised netting-set counterparty; collateral optional.
reporting_date: As-of date; written to ``value_date``.
rulepack: The resolved RUN rulepack supplying the Art. 162 effective-
maturity floors / regime gate for the ``ccr_effective_maturity``
carrier. ``None`` (the back-compat default used by direct unit /
acceptance calls) falls back to the module-level CRR ``_PACK``; the
stage adapter threads the run pack so production runs are regime-
correct.
Returns:
LazyFrame at netting-set grain. Empty (zero-row) frame when the trades
bundle is empty.
References:
CRR Art. 271(2); Art. 220(1)(a); Art. 223(5); Art. 224 Table 1;
Art. 224(2)(b); Art. 226; Art. 285(2)-(5).
"""
sft_trades_lf = raw_sft.trades.sft_trades
# Counterparty is denormalised onto the trade — collapse to NS grain. The
# ``first()`` aggregation is exact under the single-CP-per-NS scope
# (Art. 220(1)(a)); should a future netting set span counterparties the
# FCCM scope itself would need revisiting.
ns_counterparty_lf = sft_trades_lf.group_by("netting_set_id").agg(
pl.col("counterparty_reference").first()
)
ccr_collateral_lf = (
raw_sft.collateral.sft_collateral if raw_sft.collateral is not None else None
)
return _build_sft_exposure_rows(
sft_trades_lf=sft_trades_lf,
ns_counterparty_lf=ns_counterparty_lf,
ccr_collateral_lf=ccr_collateral_lf,
reporting_date=reporting_date,
pack=rulepack if rulepack is not None else _PACK,
)
CRR Art. 226 — Scaling up of volatility adjustment under the Financial Collateral Comprehensive Method¶
scale_haircut_for_non_daily_revaluation — src/rwa_calc/engine/crm/haircut_tables.py:123
@cites("CRR Art. 226")
def scale_haircut_for_non_daily_revaluation(
daily_haircut: float,
revaluation_freq_days: int,
holding_period_days: int,
) -> float:
"""Scale a daily-revaluation haircut for non-daily revaluation (Art. 226).
H = H_daily × sqrt((N_R + T_M − 1) / T_M)
where N_R = ``revaluation_freq_days`` (actual business days between
revaluations) and T_M = ``holding_period_days`` (the holding / liquidation
period in business days). Collapses to the identity when N_R = 1 (daily) —
the regression anchor for the unmargined-daily SFT path. Art. 226 has no
numbered paragraphs (do not write "226(2)").
Args:
daily_haircut: Haircut already scaled to the holding period at daily
revaluation (i.e. ``H_10 × sqrt(T_M / 10)``).
revaluation_freq_days: Actual business days between revaluations (N_R).
holding_period_days: Holding / liquidation period in business days (T_M).
Returns:
The non-daily-scaled haircut; ``daily_haircut`` unchanged when daily,
when the haircut is zero (cash / ineligible), or for a non-positive
holding period (defensive div-guard).
"""
if (
revaluation_freq_days == 1
or holding_period_days <= 0
or math.isclose(daily_haircut, 0.0, abs_tol=1e-10)
):
return daily_haircut
return daily_haircut * math.sqrt(
(revaluation_freq_days + holding_period_days - 1) / holding_period_days
)
sft_bundle_to_exposures — src/rwa_calc/engine/sft/fccm.py:112
@cites("CRR Art. 220")
@cites("CRR Art. 223")
@cites("CRR Art. 224")
@cites("CRR Art. 226")
@cites("CRR Art. 271")
@cites("CRR Art. 285")
def sft_bundle_to_exposures(
raw_sft: RawSFTBundle,
reporting_date: date,
rulepack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Shape FCCM SFT EADs into synthetic exposure rows from the lean SFT bundle.
The sole FCCM entry point (SFT/FCCM separation): consumes the dedicated
:class:`RawSFTBundle` (``RawDataBundle.sft``). The SFT/derivative
discrimination lives in the *input bundle* now, not in any in-engine
``transaction_type`` split:
- Every trade row is an SFT (no ``transaction_type`` filter): the whole
``raw_sft.trades`` frame is in scope.
- The netting-set ``counterparty_reference`` is denormalised onto the trade
row (FCCM scope is single-trade single-counterparty netting sets,
Art. 220(1)(a)), so the NS-grain counterparty frame is derived from the
trades themselves rather than a separate netting-set table.
- Collateral is OPTIONAL (``raw_sft.collateral is None`` for an
uncollateralised SFT, the common case): a missing collateral leaf yields a
zero collateral term (CVA·(1−HC−HFX) = 0), exactly as an empty
``ccr_collateral`` frame would.
Each emitted synthetic exposure row carries the FCCM provenance:
``exposure_reference = "ccr__<netting_set_id>"``, ``risk_type = "CCR_SFT"``,
``ccr_method = "fccm_sft"``, ``drawn_amount = E*``, ``ead_ccr = E*``.
Args:
raw_sft: The SFT (FCCM) input bundle — every trade row is an SFT with the
denormalised netting-set counterparty; collateral optional.
reporting_date: As-of date; written to ``value_date``.
rulepack: The resolved RUN rulepack supplying the Art. 162 effective-
maturity floors / regime gate for the ``ccr_effective_maturity``
carrier. ``None`` (the back-compat default used by direct unit /
acceptance calls) falls back to the module-level CRR ``_PACK``; the
stage adapter threads the run pack so production runs are regime-
correct.
Returns:
LazyFrame at netting-set grain. Empty (zero-row) frame when the trades
bundle is empty.
References:
CRR Art. 271(2); Art. 220(1)(a); Art. 223(5); Art. 224 Table 1;
Art. 224(2)(b); Art. 226; Art. 285(2)-(5).
"""
sft_trades_lf = raw_sft.trades.sft_trades
# Counterparty is denormalised onto the trade — collapse to NS grain. The
# ``first()`` aggregation is exact under the single-CP-per-NS scope
# (Art. 220(1)(a)); should a future netting set span counterparties the
# FCCM scope itself would need revisiting.
ns_counterparty_lf = sft_trades_lf.group_by("netting_set_id").agg(
pl.col("counterparty_reference").first()
)
ccr_collateral_lf = (
raw_sft.collateral.sft_collateral if raw_sft.collateral is not None else None
)
return _build_sft_exposure_rows(
sft_trades_lf=sft_trades_lf,
ns_counterparty_lf=ns_counterparty_lf,
ccr_collateral_lf=ccr_collateral_lf,
reporting_date=reporting_date,
pack=rulepack if rulepack is not None else _PACK,
)
CRR Art. 230 — Calculating risk-weighted exposure amounts and expected loss amounts for other eligible collateral under the IRB Approach¶
apply_collateral — src/rwa_calc/engine/crm/collateral.py:431
@cites("PS1/26 Art. 230(2)")
@cites("PS1/26 Art. 230(1)")
@cites("CRR Art. 223")
@cites("CRR Art. 230")
def apply_collateral(
exposures: pl.LazyFrame,
collateral: pl.LazyFrame,
config: CalculationConfig,
haircut_calculator: HaircutCalculator,
build_exposure_lookups_fn: Callable,
join_collateral_to_lookups_fn: Callable,
resolve_pledge_from_joined_fn: Callable,
*,
pack: ResolvedRulepack | None = None,
errors: list[CalculationError] | None = None,
) -> pl.LazyFrame:
"""
Apply collateral to reduce EAD (SA) or LGD (IRB).
Pre-computes shared exposure lookups once, then joins ALL lookup columns
(EAD, currency, maturity) in a single pass of 3 joins. Pledge resolution
and currency/maturity derivation operate on pre-joined columns — no
additional joins needed.
Args:
exposures: Exposures with ead_gross
collateral: Collateral data
config: Calculation configuration
haircut_calculator: HaircutCalculator instance
build_exposure_lookups_fn: Function to build exposure lookups
join_collateral_to_lookups_fn: Function to join collateral to lookups
resolve_pledge_from_joined_fn: Function to resolve pledge percentages
Returns:
Exposures with collateral effects applied
"""
# Tag each exposure with its AIRB-pool membership so downstream pro-rata
# bases can be split into AIRB and non-AIRB pools. CRR Art. 181 / Basel 3.1
# Art. 169A: AIRB own LGD already reflects collateral, so collateral
# incorporated in the model must not also be allocated to non-AIRB
# exposures of the same counterparty.
schema_names = set(exposures.collect_schema().names())
# Graceful fallback for direct unit-test callers that hand-build the
# exposures frame without going through _initialize_ead. In production
# both columns are always present. For pure on-BS rows the defaults
# produce identical behaviour to the explicit columns, so existing
# tests stay green without modification.
fallback_cols: list[pl.Expr] = []
if "ead_for_crm" not in schema_names:
fallback_cols.append(pl.col("ead_gross").alias("ead_for_crm"))
if "effective_ccf" not in schema_names:
fallback_cols.append(pl.lit(1.0).alias("effective_ccf"))
if fallback_cols:
exposures = exposures.with_columns(fallback_cols)
schema_names |= {expr.meta.output_name() for expr in fallback_cols}
# S9h: resolve the pack once; the collateral-LGD regime branches downstream
# (haircut maturity bands, AIRB pool membership, FSE split, Art. 230(2) sub-rows)
# read honest cited Features off it instead of a single config.is_basel_3_1 bool.
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
# CRR Art. 223(5) FCCM exposure volatility haircut (HE). Computed once on
# the exposure frame so the SA branch in ``_apply_collateral_unified`` can
# gross E by (1 + HE). Non-SFT / cash / standard-loan rows yield HE = 0.
exposures = haircut_calculator.apply_exposure_haircut(
exposures,
resolved_pack.feature("collateral_haircut_maturity_bands_revised"),
pack=resolved_pack,
)
exposures = exposures.with_columns(
airb_lgd_preserved_expr(config, schema_names, pack=resolved_pack).alias("_is_airb_pool")
)
# Pre-compute shared exposure lookups once
direct_lookup, facility_lookup, cp_lookup = build_exposure_lookups_fn(exposures)
# Materialise the small lookup frames in parallel to prevent plan-tree
# duplication. Each lookup is referenced in multiple downstream joins;
# without this, Polars re-evaluates the group_by/select at each reference.
# collect_all runs all 3 concurrently and enables CSE on shared upstream.
direct_df, facility_df, cp_df = pl.collect_all([direct_lookup, facility_lookup, cp_lookup])
direct_lookup = direct_df.lazy()
facility_lookup = facility_df.lazy()
cp_lookup = cp_df.lazy()
# Derive pool-aware counterparty EAD totals from the lookups. Unflagged
# collateral pro-rates over the non-AIRB pool only; flagged collateral
# (is_airb_model_collateral=True) pro-rates over the AIRB pool only.
# Facility-level subtree totals are derived per-ancestor inside
# ``_apply_collateral_unified`` (``_cascade_facility_collateral``) so that
# collateral pledged at any ancestor facility cascades over its whole
# descendant subtree for nested facility hierarchies.
cp_ead_totals = cp_lookup.select(
pl.col("_ben_ref_cp").alias("counterparty_reference"),
pl.col("_ead_cp").alias("_cp_ead_total"),
pl.col("_ead_cp_airb").alias("_cp_ead_total_airb"),
pl.col("_ead_cp_non_airb").alias("_cp_ead_total_non_airb"),
)
# Single pass: join all lookup columns (EAD, currency, maturity)
collateral = join_collateral_to_lookups_fn(
collateral, direct_lookup, facility_lookup, cp_lookup
)
# Resolve pledge_percentage → market_value (uses pre-joined _beneficiary_ead)
collateral = resolve_pledge_from_joined_fn(collateral)
# Apply haircuts to collateral (no longer needs exposures)
adjusted_collateral = haircut_calculator.apply_haircuts(collateral, config, pack=pack)
# CRR/PS1-26 Art. 197(1)(f)/198(1)(a) (P1.271): apply_haircuts has already
# zeroed non-main-index / non-listed equity collateral and cleared its
# eligibility flag; record one CRM018 warning per gated row.
if errors is not None:
_record_non_main_index_equity_ineligible(adjusted_collateral, errors)
# CRR/PS1-26 Art. 218 (P1.274): apply_haircuts has already zeroed a
# credit-linked note that is not attested own-issued; record one CRM019
# warning per gated row.
_record_credit_linked_note_not_own_issued(adjusted_collateral, errors)
# Apply maturity mismatch using actual exposure maturity (Art. 238)
adjusted_collateral = haircut_calculator.apply_maturity_mismatch(adjusted_collateral, config)
# Opt-in audit cache: persist the per-collateral haircut frame for inspection.
# No-op unless config.audit_cache_dir is set. Surfaces fx_haircut /
# collateral_haircut / value_after_haircut / value_after_maturity_adj — the
# diagnostic columns users need to confirm whether H_fx is firing on a row.
sink_audit(adjusted_collateral, config, "collateral_haircuts")
return _apply_collateral_unified(
exposures,
adjusted_collateral,
config,
cp_ead_totals,
pack=resolved_pack,
errors=errors,
)
allocate_links — src/rwa_calc/engine/crm/link_allocation.py:89
@cites("CRR Art. 230")
@cites("CRR Art. 231")
def allocate_links(
self,
exposures: pl.LazyFrame,
collateral: pl.LazyFrame | None,
collateral_links: pl.LazyFrame | None,
config: CalculationConfig,
) -> CollateralLinkAllocation:
"""Expand ``collateral_links`` into per-beneficiary collateral slices.
Returns the original collateral unchanged when no usable links table is
supplied (the single-beneficiary path). Never raises.
"""
if collateral is None or collateral_links is None:
# Absent collateral stays None — never an empty-frame sentinel.
return CollateralLinkAllocation(collateral=collateral, audit=None)
coll_cols = collateral.collect_schema().names()
link_cols = set(collateral_links.collect_schema().names())
required = {"collateral_reference", "beneficiary_type", "beneficiary_reference"}
if "collateral_reference" not in coll_cols or not required.issubset(link_cols):
return CollateralLinkAllocation(collateral=collateral, audit=None)
demand_metric = self._beneficiary_demand_metric(exposures)
links = self._resolve_links(collateral_links, collateral, demand_metric, link_cols)
links = self._allocate_slices(links)
expanded = self._build_expanded_collateral(collateral, links, coll_cols)
passthrough = collateral.join(
collateral_links.select(pl.col("collateral_reference").cast(pl.String)).unique(),
on="collateral_reference",
how="anti",
)
merged = pl.concat([passthrough, expanded], how="vertical_relaxed")
audit = links.select(
pl.col("collateral_reference"),
pl.col("beneficiary_type"),
pl.col("beneficiary_reference"),
pl.col("_demand").alias("beneficiary_demand"),
pl.col("_metric").alias("rank_metric"),
pl.col("_value").alias("collateral_value"),
pl.col("_slice").alias("allocated_value"),
)
return CollateralLinkAllocation(collateral=merged, audit=audit)
CRR Art. 231 — Calculating risk-weighted exposure amounts and expected loss amounts in the case of mixed pools of collateral¶
allocate_links — src/rwa_calc/engine/crm/link_allocation.py:90
@cites("CRR Art. 230")
@cites("CRR Art. 231")
def allocate_links(
self,
exposures: pl.LazyFrame,
collateral: pl.LazyFrame | None,
collateral_links: pl.LazyFrame | None,
config: CalculationConfig,
) -> CollateralLinkAllocation:
"""Expand ``collateral_links`` into per-beneficiary collateral slices.
Returns the original collateral unchanged when no usable links table is
supplied (the single-beneficiary path). Never raises.
"""
if collateral is None or collateral_links is None:
# Absent collateral stays None — never an empty-frame sentinel.
return CollateralLinkAllocation(collateral=collateral, audit=None)
coll_cols = collateral.collect_schema().names()
link_cols = set(collateral_links.collect_schema().names())
required = {"collateral_reference", "beneficiary_type", "beneficiary_reference"}
if "collateral_reference" not in coll_cols or not required.issubset(link_cols):
return CollateralLinkAllocation(collateral=collateral, audit=None)
demand_metric = self._beneficiary_demand_metric(exposures)
links = self._resolve_links(collateral_links, collateral, demand_metric, link_cols)
links = self._allocate_slices(links)
expanded = self._build_expanded_collateral(collateral, links, coll_cols)
passthrough = collateral.join(
collateral_links.select(pl.col("collateral_reference").cast(pl.String)).unique(),
on="collateral_reference",
how="anti",
)
merged = pl.concat([passthrough, expanded], how="vertical_relaxed")
audit = links.select(
pl.col("collateral_reference"),
pl.col("beneficiary_type"),
pl.col("beneficiary_reference"),
pl.col("_demand").alias("beneficiary_demand"),
pl.col("_metric").alias("rank_metric"),
pl.col("_value").alias("collateral_value"),
pl.col("_slice").alias("allocated_value"),
)
return CollateralLinkAllocation(collateral=merged, audit=audit)
CRR Art. 232 — Other funded credit protection¶
compute_life_insurance_columns — src/rwa_calc/engine/crm/life_insurance.py:92
@cites("CRR Art. 232(3)")
@cites("CRR Art. 233(3)")
def compute_life_insurance_columns(
exposures: pl.LazyFrame,
collateral: pl.LazyFrame | None,
config: CalculationConfig,
*,
errors: list[CalculationError] | None = None,
) -> pl.LazyFrame:
"""Compute life insurance CRM columns on the exposure frame.
Aggregates eligible life insurance collateral per exposure and sets:
- life_ins_collateral_value: surrender value allocated to this exposure, after
the Art. 233(3) 8% FX reduction on a currency mismatch (capped at EAD)
- life_ins_secured_rw: value-weighted mapped risk weight per Art. 232(3)
A pledge is resolved at whichever level its ``beneficiary_reference`` names —
exposure, facility (``parent_facility_reference``) or counterparty
(``counterparty_reference``) — and a facility/counterparty pledge is shared
pro-rata by EAD across the covered exposures (Art. 230-231 pooling). Reference
namespaces are disjoint, so a key resolves at exactly one level; a direct
exposure pledge therefore benefits only that exposure.
Art. 233(3) FX reduction: the 8% cut is applied PER POLICY (cut-then-sum), each
policy's own denomination (``original_currency`` pre-FX, else ``currency``)
compared against the covered exposure's denomination — never on a summed pool via
a single representative currency (that under-cuts a mixed-currency pool and is
plan-order-dependent). A present-but-null policy currency cannot prove a match, so
it takes the reduction conservatively and raises CRM020; when the collateral
carries no currency column at all the FX dimension is absent and no reduction
applies.
Does NOT modify EAD columns. The SA calculator uses these columns
for risk weight blending via _apply_life_insurance_rw_mapping().
Args:
exposures: Exposure frame with ead_gross, exposure_reference, etc.
collateral: Collateral frame (may be None if no collateral).
config: Calculation configuration.
errors: Optional error accumulator for CRM020 unknown-currency warnings.
Returns:
Exposure frame with life_ins_collateral_value and life_ins_secured_rw columns.
"""
if collateral is None:
return _add_default_life_ins_columns(exposures)
# Filter to life insurance collateral only
coll_schema = collateral.collect_schema()
ctype_col = "collateral_type"
if ctype_col not in coll_schema.names():
return _add_default_life_ins_columns(exposures)
li_coll = collateral.filter(
pl.col(ctype_col).str.to_lowercase().is_in(LIFE_INSURANCE_COLLATERAL_TYPES)
)
# Check if insurer_risk_weight column exists
has_insurer_rw = "insurer_risk_weight" in coll_schema.names()
if not has_insurer_rw:
li_coll = li_coll.with_columns(pl.lit(1.00).alias("insurer_risk_weight"))
# Use market_value as the surrender value (documented convention)
# Apply Art. 232(3) mapped RW per item
li_coll = li_coll.with_columns(_map_insurer_rw_to_secured_rw_expr().alias("_li_item_rw"))
# The policy's own denomination for the Art. 233(3) FX test: original_currency
# (pre-FX-conversion) if present, else currency; None when neither exists.
coll_names = coll_schema.names()
coll_ccy_col = (
"original_currency"
if "original_currency" in coll_names
else "currency"
if "currency" in coll_names
else None
)
if coll_ccy_col is not None and errors is not None:
_record_unknown_currency_warnings(li_coll, coll_ccy_col, coll_names, errors)
# Aggregate the (small) life-insurance collateral per beneficiary key — NO
# exposures reference here, so the deep exposures plan stays single-referenced.
# ``.sum()`` ignores nulls, so no fill_null is needed on the value channels.
# ``li_total`` = value + value-weighted-RW per beneficiary; ``li_matched`` splits
# the same channels by policy currency so the Art. 233(3) cut can be applied
# PER POLICY (cut-then-sum) against each covered exposure's own denomination —
# never on a summed pool via a representative currency (that would be
# anti-conservative AND plan-order-nondeterministic on a mixed-currency pool).
li_total = li_coll.group_by("beneficiary_reference").agg(
pl.col("market_value").sum().alias("_li_v"),
(pl.col("market_value") * pl.col("_li_item_rw")).sum().alias("_li_vrw"),
)
li_matched = None
if coll_ccy_col is not None:
li_matched = li_coll.group_by(["beneficiary_reference", coll_ccy_col]).agg(
pl.col("market_value").sum().alias("_li_mv"),
(pl.col("market_value") * pl.col("_li_item_rw")).sum().alias("_li_mvrw"),
)
exp_schema = exposures.collect_schema()
exp_names = exp_schema.names()
exp_ref_col = "exposure_reference" if "exposure_reference" in exp_names else "loan_reference"
ead_col = "ead_gross" if "ead_gross" in exp_names else "ead"
ead = pl.col(ead_col).fill_null(0.0)
# Materialise the exposure denomination once as a join key for the matched-
# currency lookup (the Art. 233(3) test compares policy vs exposure currency).
exposures = exposures.with_columns(denomination_currency_expr(exp_names).alias("_exp_ccy"))
# Match each pledge to its covered exposures via chained left-joins onto the ONE
# exposures base (Art. 230-231 pooling): the direct level keys on the exposure
# reference (weight 1.0); a facility / counterparty pledge is shared pro-rata by
# EAD across that key's exposures. Reference namespaces are disjoint, so a key
# fires at exactly one level (a direct pledge benefits only its own exposure).
levels: list[tuple[str, pl.Expr, str]] = [(exp_ref_col, pl.lit(1.0), "d")]
if "parent_facility_reference" in exp_names:
levels.append(
("parent_facility_reference", _pro_rata_weight(ead, "parent_facility_reference"), "f")
)
if "counterparty_reference" in exp_names:
levels.append(
("counterparty_reference", _pro_rata_weight(ead, "counterparty_reference"), "c")
)
value_terms: list[pl.Expr] = []
vrw_terms: list[pl.Expr] = []
scratch: list[str] = ["_exp_ccy"]
for key_col, weight, suffix in levels:
exposures, v, w, cols = _join_pledge_level(
exposures, li_total, li_matched, key_col, weight, coll_ccy_col, suffix
)
value_terms.append(v)
vrw_terms.append(w)
scratch.extend(cols)
# Total allocated value + value-weighted mapped RW (nulls skipped), capped at EAD.
total_value = pl.sum_horizontal(value_terms)
total_vrw = pl.sum_horizontal(vrw_terms)
capped_value = pl.min_horizontal(total_value, ead)
avg_rw = pl.when(total_value > 0).then(total_vrw / total_value).otherwise(pl.lit(0.0))
return exposures.with_columns(
capped_value.alias("life_ins_collateral_value"),
avg_rw.alias("life_ins_secured_rw"),
).drop(scratch)
route_other_funded_protection — src/rwa_calc/engine/crm/ofcp_routing.py:76
@cites("CRR Art. 232")
@cites("PS1/26, paragraph 169A")
def route_other_funded_protection(
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Split the Art. 200(1) amounts between the substitution and LGD blocks.
Reads the two amounts the earlier CRM sub-steps already produced —
``third_party_deposit_value`` (Art. 200(1)(a)) and
``life_ins_collateral_value`` (Art. 200(1)(b)) — and emits:
============================== ==== ==================================
carrier cell content
============================== ==== ==================================
``ofcp_lgd_cash_deposit`` 0171 deposit, on the LGD-Modelling route
``ofcp_lgd_life_insurance`` 0172 policy, on the LGD-Modelling route
``ofcp_substitution_amount`` 0060 both, on the Art. 232 route
============================== ==== ==================================
One boolean selects all three branches, so for any leg a positive
``ofcp_lgd_*`` implies a zero ``ofcp_substitution_amount`` and vice versa —
the exclusivity is structural, not a convention a consumer must uphold.
Col 0173 (Art. 200(1)(c), instruments repurchased on request) has no engine
carrier and stays 0.0 downstream.
The two LGD carriers are capped per exposure: PS1/26 p.107 repeats "The
value of collateral reported shall be limited to the value of the exposure
at the level of an individual exposure" for each of 0171/0172/0173.
``ofcp_substitution_amount`` is NOT capped here — the whole substitution
block (cols 0040+0050+0060) is capped jointly at the leg's gross exposure
downstream by ``reporting/corep/crm_substitution.py::irb_block_cap_scale``,
which sheds the over-run proportionally across the block; capping a single
limb first would double-count the shed.
Both source columns are producer-sealed non-null — each sub-step emits
either a computed value or an explicit ``0.0`` default — so no null fill is
needed or performed here.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
# The route exists only where the firm both CAN and DID elect LGD Modelling.
# ``airb_lgd_collateral_method_applicable`` is a Basel-3.1-only Feature, so
# under CRR this is False and every amount stays on the Art. 232 route —
# today's behaviour, unchanged. Read as a Feature, never a regime bool.
lgd_modelling_elected = (
bool(resolved_pack.feature("airb_lgd_collateral_method_applicable"))
and config.airb_collateral_method == AIRBCollateralMethod.LGD_MODELLING
)
deposit = pl.col("third_party_deposit_value")
life_insurance = pl.col("life_ins_collateral_value")
substitution_total = pl.sum_horizontal(deposit, life_insurance)
if not lgd_modelling_elected:
logger.debug("Art. 200(1) protection routed wholly to the Art. 232 substitution block")
return exposures.with_columns(
pl.lit(0.0).alias("ofcp_lgd_cash_deposit"),
pl.lit(0.0).alias("ofcp_lgd_life_insurance"),
substitution_total.alias("ofcp_substitution_amount"),
)
# Per-row limb test. ``airb_lgd_preserved_expr`` is the SAME expression that
# defines the A-IRB collateral pool, so the routing and the pool can never
# drift: it is True only for an A-IRB row whose modelled LGD actually stands,
# which excludes an Art. 169B insufficient-data row that has fallen back to
# the supervisory formula and therefore reports on the substitution limb.
#
# It inspects ``schema_names`` for exactly one column, so seal that column
# onto the frame rather than probing the schema here: ``ensure_columns``
# injects a typed NULL when absent, which the callee's ``.fill_null(True)``
# resolves to the same value as its column-absent branch returns. The two
# paths are therefore behaviourally identical, and the set below is exact.
exposures = ensure_columns(
exposures,
{"has_sufficient_collateral_data": ColumnSpec(pl.Boolean, required=False)},
)
on_lgd_route = airb_lgd_preserved_expr(
config, {"has_sufficient_collateral_data"}, pack=resolved_pack
)
exposure_cap = pl.col("ead_gross")
logger.debug("Art. 200(1) protection routed per-leg (LGD Modelling Collateral Method elected)")
return exposures.with_columns(
pl.when(on_lgd_route)
.then(pl.min_horizontal(deposit, exposure_cap))
.otherwise(pl.lit(0.0))
.alias("ofcp_lgd_cash_deposit"),
pl.when(on_lgd_route)
.then(pl.min_horizontal(life_insurance, exposure_cap))
.otherwise(pl.lit(0.0))
.alias("ofcp_lgd_life_insurance"),
pl.when(on_lgd_route)
.then(pl.lit(0.0))
.otherwise(substitution_total)
.alias("ofcp_substitution_amount"),
)
compute_third_party_deposit_columns — src/rwa_calc/engine/crm/third_party_deposit.py:82
@cites("CRR Art. 232")
def compute_third_party_deposit_columns(
exposures: pl.LazyFrame,
third_party_deposits: pl.LazyFrame | None,
*,
is_basel_3_1: bool,
errors: list[CalculationError] | None = None,
) -> pl.LazyFrame:
"""Set SA third-party-deposit CRM columns on the exposure frame.
Aggregates the third-party deposits per beneficiary exposure and sets:
- third_party_deposit_value: total INSTITUTION-held deposit value (capped at EAD)
- third_party_deposit_secured_rw: value-weighted holder institution RW
Only deposits held by an INSTITUTION (Art. 232(2)) drive the substitution; the
holder RW is looked up via the shared ``build_institution_guarantor_rw_expr``
(CRR Art. 120 / PS1/26 Art. 120A ECRA, with the CRE20.21 SCRA Grade-C 150%
fallback for an unrated Basel 3.1 holder — the deposit carries no SCRA grade,
so the conservative fallback binds). A populated NON-institution holder is out
of scope: no benefit (it is already excluded from the 0% cash path) + CRM017.
Under F-IRB the substitution is deferred: no benefit + CRM017.
"""
if third_party_deposits is None:
return _add_default_columns(exposures)
is_inst = pl.col("issuer_type").str.to_lowercase().is_in(INSTITUTION_DEPOSIT_HOLDER_TYPES)
tpd = third_party_deposits.with_columns(
is_inst.alias("_tpd_is_inst"),
# A null SCRA-grade column so build_institution_guarantor_rw_expr routes an
# unrated B31 holder to the CRE20.21 Grade-C conservative fallback.
pl.lit(None).cast(pl.String).alias("_tpd_scra_grade"),
).with_columns(
build_institution_guarantor_rw_expr(
"issuer_cqs", is_basel_3_1, scra_grade_col="_tpd_scra_grade"
).alias("_tpd_item_rw"),
)
val = pl.col("market_value").fill_null(0.0)
inst_val = val.filter(pl.col("_tpd_is_inst"))
agg = tpd.group_by("beneficiary_reference").agg(
inst_val.sum().alias("_tpd_inst_value"),
(inst_val * pl.col("_tpd_item_rw").filter(pl.col("_tpd_is_inst")))
.sum()
.alias("_tpd_weighted_rw"),
(~pl.col("_tpd_is_inst")).any().alias("_tpd_has_non_inst"),
)
exp_names = exposures.collect_schema().names()
exp_ref = "exposure_reference" if "exposure_reference" in exp_names else "loan_reference"
ead_col = "ead_gross" if "ead_gross" in exp_names else "ead"
exposures = exposures.join(agg, left_on=exp_ref, right_on="beneficiary_reference", how="left")
ead = pl.col(ead_col).fill_null(0.0)
inst_value = pl.col("_tpd_inst_value").fill_null(0.0)
wrw = pl.col("_tpd_weighted_rw").fill_null(0.0)
has_non_inst = pl.col("_tpd_has_non_inst").fill_null(value=False)
avg_rw = pl.when(inst_value > 0).then(wrw / inst_value).otherwise(pl.lit(0.0))
is_firb = pl.col("approach").is_in([ApproachType.FIRB.value, ApproachType.AIRB.value])
if errors is not None:
_record_third_party_deposit_warnings(
exposures, inst_value, has_non_inst, is_firb, exp_ref, errors
)
exposures = exposures.with_columns(
pl.when(is_firb)
.then(pl.lit(0.0))
.otherwise(pl.min_horizontal(inst_value, ead))
.alias("third_party_deposit_value"),
avg_rw.alias("third_party_deposit_secured_rw"),
).drop(["_tpd_inst_value", "_tpd_weighted_rw", "_tpd_has_non_inst"])
return exposures
apply_life_insurance_rw_mapping — src/rwa_calc/engine/sa/rw_adjustments.py:125
@cites("CRR Art. 232")
def apply_life_insurance_rw_mapping(lf: pl.LazyFrame) -> pl.LazyFrame:
"""Apply Art. 232 life insurance risk weight mapping for SA exposures.
When life insurance collateral secures an exposure, the secured portion
receives a mapped risk weight (not direct substitution):
Insurer RW 20% -> 20%
Insurer RW 30% or 50% -> 35%
Insurer RW 65%-135% -> 70%
Insurer RW 150% -> 150%
Blended RW = secured_pct x mapped_rw + unsecured_pct x exposure_rw
This function is a no-op when no life insurance collateral is present.
"""
ead = pl.col("ead_final").fill_null(0.0)
li_value = pl.col("life_ins_collateral_value").fill_null(0.0)
li_rw = pl.col("life_ins_secured_rw").fill_null(0.0)
# Secured percentage (capped at 100%)
secured_pct = pl.when(ead > 0).then((li_value / ead).clip(0.0, 1.0)).otherwise(0.0)
unsecured_pct = pl.lit(1.0) - secured_pct
# Blended risk weight: no floor — Art. 232 has no 20% floor like FCSM
blended_rw = secured_pct * li_rw + unsecured_pct * pl.col("risk_weight")
# Only apply when there is actual life insurance collateral
has_li = li_value > 0
return lf.with_columns(
pl.when(has_li).then(blended_rw).otherwise(pl.col("risk_weight")).alias("risk_weight"),
)
apply_third_party_deposit_rw_mapping — src/rwa_calc/engine/sa/rw_adjustments.py:159
@cites("CRR Art. 232")
def apply_third_party_deposit_rw_mapping(lf: pl.LazyFrame) -> pl.LazyFrame:
"""Apply Art. 232(2) third-party-deposit risk-weight substitution for SA.
A cash deposit held at a third-party institution is other funded credit
protection treated as a guarantee by that holder institution: the covered
part of the exposure takes the holder's own SA risk weight (P1.239/P1.240).
blended = secured_pct x holder_rw + unsecured_pct x exposure_rw
Benefit-only cap (Art. 232 protection can never increase RWA): the blended
weight is applied only where it is at or below the exposure's own weight, so
a holder RW >= obligor RW leaves the exposure unchanged. No-op when no
third-party deposit is present (columns absent or value 0).
"""
cols = lf.collect_schema().names()
if "third_party_deposit_value" not in cols or "third_party_deposit_secured_rw" not in cols:
return lf
ead = pl.col("ead_final").fill_null(0.0)
value = pl.col("third_party_deposit_value").fill_null(0.0)
holder_rw = pl.col("third_party_deposit_secured_rw").fill_null(0.0)
secured_pct = pl.when(ead > 0).then((value / ead).clip(0.0, 1.0)).otherwise(0.0)
unsecured_pct = pl.lit(1.0) - secured_pct
blended_rw = secured_pct * holder_rw + unsecured_pct * pl.col("risk_weight")
# Substitution only helps: cap at the exposure's own risk weight.
beneficial_rw = pl.min_horizontal(blended_rw, pl.col("risk_weight"))
has_tpd = value > 0
return lf.with_columns(
pl.when(has_tpd).then(beneficial_rw).otherwise(pl.col("risk_weight")).alias("risk_weight"),
)
CRR Art. 233 — Valuation¶
compute_life_insurance_columns — src/rwa_calc/engine/crm/life_insurance.py:93
@cites("CRR Art. 232(3)")
@cites("CRR Art. 233(3)")
def compute_life_insurance_columns(
exposures: pl.LazyFrame,
collateral: pl.LazyFrame | None,
config: CalculationConfig,
*,
errors: list[CalculationError] | None = None,
) -> pl.LazyFrame:
"""Compute life insurance CRM columns on the exposure frame.
Aggregates eligible life insurance collateral per exposure and sets:
- life_ins_collateral_value: surrender value allocated to this exposure, after
the Art. 233(3) 8% FX reduction on a currency mismatch (capped at EAD)
- life_ins_secured_rw: value-weighted mapped risk weight per Art. 232(3)
A pledge is resolved at whichever level its ``beneficiary_reference`` names —
exposure, facility (``parent_facility_reference``) or counterparty
(``counterparty_reference``) — and a facility/counterparty pledge is shared
pro-rata by EAD across the covered exposures (Art. 230-231 pooling). Reference
namespaces are disjoint, so a key resolves at exactly one level; a direct
exposure pledge therefore benefits only that exposure.
Art. 233(3) FX reduction: the 8% cut is applied PER POLICY (cut-then-sum), each
policy's own denomination (``original_currency`` pre-FX, else ``currency``)
compared against the covered exposure's denomination — never on a summed pool via
a single representative currency (that under-cuts a mixed-currency pool and is
plan-order-dependent). A present-but-null policy currency cannot prove a match, so
it takes the reduction conservatively and raises CRM020; when the collateral
carries no currency column at all the FX dimension is absent and no reduction
applies.
Does NOT modify EAD columns. The SA calculator uses these columns
for risk weight blending via _apply_life_insurance_rw_mapping().
Args:
exposures: Exposure frame with ead_gross, exposure_reference, etc.
collateral: Collateral frame (may be None if no collateral).
config: Calculation configuration.
errors: Optional error accumulator for CRM020 unknown-currency warnings.
Returns:
Exposure frame with life_ins_collateral_value and life_ins_secured_rw columns.
"""
if collateral is None:
return _add_default_life_ins_columns(exposures)
# Filter to life insurance collateral only
coll_schema = collateral.collect_schema()
ctype_col = "collateral_type"
if ctype_col not in coll_schema.names():
return _add_default_life_ins_columns(exposures)
li_coll = collateral.filter(
pl.col(ctype_col).str.to_lowercase().is_in(LIFE_INSURANCE_COLLATERAL_TYPES)
)
# Check if insurer_risk_weight column exists
has_insurer_rw = "insurer_risk_weight" in coll_schema.names()
if not has_insurer_rw:
li_coll = li_coll.with_columns(pl.lit(1.00).alias("insurer_risk_weight"))
# Use market_value as the surrender value (documented convention)
# Apply Art. 232(3) mapped RW per item
li_coll = li_coll.with_columns(_map_insurer_rw_to_secured_rw_expr().alias("_li_item_rw"))
# The policy's own denomination for the Art. 233(3) FX test: original_currency
# (pre-FX-conversion) if present, else currency; None when neither exists.
coll_names = coll_schema.names()
coll_ccy_col = (
"original_currency"
if "original_currency" in coll_names
else "currency"
if "currency" in coll_names
else None
)
if coll_ccy_col is not None and errors is not None:
_record_unknown_currency_warnings(li_coll, coll_ccy_col, coll_names, errors)
# Aggregate the (small) life-insurance collateral per beneficiary key — NO
# exposures reference here, so the deep exposures plan stays single-referenced.
# ``.sum()`` ignores nulls, so no fill_null is needed on the value channels.
# ``li_total`` = value + value-weighted-RW per beneficiary; ``li_matched`` splits
# the same channels by policy currency so the Art. 233(3) cut can be applied
# PER POLICY (cut-then-sum) against each covered exposure's own denomination —
# never on a summed pool via a representative currency (that would be
# anti-conservative AND plan-order-nondeterministic on a mixed-currency pool).
li_total = li_coll.group_by("beneficiary_reference").agg(
pl.col("market_value").sum().alias("_li_v"),
(pl.col("market_value") * pl.col("_li_item_rw")).sum().alias("_li_vrw"),
)
li_matched = None
if coll_ccy_col is not None:
li_matched = li_coll.group_by(["beneficiary_reference", coll_ccy_col]).agg(
pl.col("market_value").sum().alias("_li_mv"),
(pl.col("market_value") * pl.col("_li_item_rw")).sum().alias("_li_mvrw"),
)
exp_schema = exposures.collect_schema()
exp_names = exp_schema.names()
exp_ref_col = "exposure_reference" if "exposure_reference" in exp_names else "loan_reference"
ead_col = "ead_gross" if "ead_gross" in exp_names else "ead"
ead = pl.col(ead_col).fill_null(0.0)
# Materialise the exposure denomination once as a join key for the matched-
# currency lookup (the Art. 233(3) test compares policy vs exposure currency).
exposures = exposures.with_columns(denomination_currency_expr(exp_names).alias("_exp_ccy"))
# Match each pledge to its covered exposures via chained left-joins onto the ONE
# exposures base (Art. 230-231 pooling): the direct level keys on the exposure
# reference (weight 1.0); a facility / counterparty pledge is shared pro-rata by
# EAD across that key's exposures. Reference namespaces are disjoint, so a key
# fires at exactly one level (a direct pledge benefits only its own exposure).
levels: list[tuple[str, pl.Expr, str]] = [(exp_ref_col, pl.lit(1.0), "d")]
if "parent_facility_reference" in exp_names:
levels.append(
("parent_facility_reference", _pro_rata_weight(ead, "parent_facility_reference"), "f")
)
if "counterparty_reference" in exp_names:
levels.append(
("counterparty_reference", _pro_rata_weight(ead, "counterparty_reference"), "c")
)
value_terms: list[pl.Expr] = []
vrw_terms: list[pl.Expr] = []
scratch: list[str] = ["_exp_ccy"]
for key_col, weight, suffix in levels:
exposures, v, w, cols = _join_pledge_level(
exposures, li_total, li_matched, key_col, weight, coll_ccy_col, suffix
)
value_terms.append(v)
vrw_terms.append(w)
scratch.extend(cols)
# Total allocated value + value-weighted mapped RW (nulls skipped), capped at EAD.
total_value = pl.sum_horizontal(value_terms)
total_vrw = pl.sum_horizontal(vrw_terms)
capped_value = pl.min_horizontal(total_value, ead)
avg_rw = pl.when(total_value > 0).then(total_vrw / total_value).otherwise(pl.lit(0.0))
return exposures.with_columns(
capped_value.alias("life_ins_collateral_value"),
avg_rw.alias("life_ins_secured_rw"),
).drop(scratch)
CRR Art. 234 — Calculating risk-weighted exposure amounts and expected loss amounts in the event of partial protection and tranching¶
_build_remainder_sub_rows — src/rwa_calc/engine/crm/guarantees.py:929
@cites("CRR Art. 234")
def _build_remainder_sub_rows(multi_joined: pl.LazyFrame) -> pl.LazyFrame:
"""
Build the borrower-retained remainder sub-rows (uncovered portion).
Default (first-loss attach, CRR Art. 235): the protection covers loss band
``[0, G*)`` and the borrower retains a single senior remainder ``[G*, EAD]``
emitted as one ``__REM`` row.
CRR Art. 234 (tranched coverage): when the guarantee carries an
``attachment_amount`` (a) and ``detachment_amount`` (d), the protection
attaches to the mezzanine band ``[a, d)`` instead of first loss. The
borrower then retains TWO tranches at its own obligor risk weight:
a first-loss tranche ``[0, a)`` (``__REM_FL``) and a senior tranche
``[d, EAD]`` (``__REM_SEN``). Both retained tranches carry a null
``guarantor_reference`` so downstream SA/IRB risk-weight the obligor.
Tranche widths compose AFTER the existing FX / restructuring / maturity
mismatch haircuts have reduced ``amount_covered`` to G* (the protected
width on the guarantor sub-row); ``_total_effective`` is that post-haircut
capped coverage. When ``attachment_amount`` is null behaviour is unchanged.
References:
CRR Art. 234: tranching of credit protection (attachment/detachment).
CRR Art. 235: SA risk-weight substitution on the protected tranche.
"""
remainder = (
multi_joined.sort("parent_exposure_reference", "guarantor")
.group_by("parent_exposure_reference", maintain_order=True)
.first()
)
schema_names = remainder.collect_schema().names()
has_tranching = "attachment_amount" in schema_names
# Total borrower-retained EAD (uncovered portion across all guarantors).
retained_total = pl.col("ead_after_collateral") - pl.col("_total_effective")
if not has_tranching:
return _retained_tranche_rows(remainder, schema_names, retained_total, "__REM")
# CRR Art. 234: attachment a (null/0 => first-loss). Detachment d defaults to
# a + protected width so a null detachment collapses to the legacy split.
attach = pl.col("attachment_amount").fill_null(0.0)
detach = pl.col("detachment_amount").fill_null(attach + pl.col("_total_effective"))
# First-loss tranche [0, a) and senior tranche [d, EAD]. Clip widths to the
# exposure EAD to guard against attachment/detachment overshoot.
first_loss_width = attach.clip(lower_bound=0.0, upper_bound=pl.col("ead_after_collateral"))
senior_width = (pl.col("ead_after_collateral") - detach).clip(lower_bound=0.0)
is_tranched = pl.col("attachment_amount").is_not_null() & (pl.col("attachment_amount") > 0.0)
legacy_rows = _retained_tranche_rows(
remainder.filter(~is_tranched), schema_names, retained_total, "__REM"
)
first_loss_rows = _retained_tranche_rows(
remainder.filter(is_tranched), schema_names, first_loss_width, "__REM_FL"
)
senior_rows = _retained_tranche_rows(
remainder.filter(is_tranched), schema_names, senior_width, "__REM_SEN"
)
return pl.concat([legacy_rows, first_loss_rows, senior_rows], how="diagonal_relaxed")
CRR Art. 235 — Calculating risk-weighted exposure amounts under the Standardised Approach¶
_add_post_crm_reporting_class — src/rwa_calc/engine/aggregator/aggregator.py:685
@cites("CRR Art. 235")
def _add_post_crm_reporting_class(lf: pl.LazyFrame) -> pl.LazyFrame:
"""Add ``exposure_class_post_crm`` — the post-guarantee (post-substitution) class.
The reconciliation ties out on a post-guarantee basis, so the guaranteed slice
must be reported under the GUARANTOR's class (CRR Art. 235 substitution) while
everything else keeps its obligor applied class. A guaranteed exposure is
physically split into a ``__G_`` guaranteed leg and a ``__REM`` retained leg
(``engine/crm/guarantees.py``); only the guaranteed leg carries
``is_guaranteed=True`` and the guarantor's class in
``post_crm_exposure_class_guaranteed``, so:
- guaranteed leg -> ``post_crm_exposure_class_guaranteed`` (guarantor class)
- retained leg / unguaranteed exposure -> ``exposure_class_applied``
``exposure_class_applied`` stays the PRE-substitution class that COREP C 07.00
keys its sheet + substitution flows on; this is its post-substitution twin,
consumed by the reconciliation's by-class allocation so our totals per class
tie to a post-guarantee legacy extract. A guaranteed leg whose guarantor class
is unresolved (null / empty) falls back to the applied class.
THE GATE IS ``is_guaranteed AND BENEFICIAL``, not ``is_guaranteed`` alone.
``is_guaranteed`` means protection EXISTS (``guaranteed_portion > 0``); what
MOVES an exposure into the guarantor's class is Art. 235 risk-weight
substitution actually being applied. Where the calculators decline it
(Art. 193(1) bars a guarantee from RAISING the RWEA, and Art. 193(3) makes
amending the calculation an election rather than a duty) there is no covered
part to move, so the leg keeps the obligor's applied class. See
:func:`_beneficial_gate` for the reproduction, the null/absence convention
and the decline-vs-apply-and-cap election this gate depends on.
"""
guarantor_class = pl.col("post_crm_exposure_class_guaranteed")
return lf.with_columns(
pl.when(
(pl.col("is_guaranteed") == True) # noqa: E712
& _beneficial_gate()
& guarantor_class.is_not_null()
& (guarantor_class != "")
)
.then(guarantor_class)
.otherwise(pl.col("exposure_class_applied"))
.alias("exposure_class_post_crm")
)
_add_post_crm_reporting_approach — src/rwa_calc/engine/aggregator/aggregator.py:730
@cites("CRR Art. 235")
def _add_post_crm_reporting_approach(lf: pl.LazyFrame) -> pl.LazyFrame:
"""Add ``approach_post_crm`` — the post-guarantee (post-substitution) approach.
The approach twin of :func:`_add_post_crm_reporting_class`. Where the guaranteed
slice is reported under the GUARANTOR's class, it must also be reported under the
approach the guarantor exposure is treated with, so the class and the approach
partition the same post-guarantee money consistently:
- guaranteed leg, SA guarantor -> ``standardised`` (Art. 235 risk-weight
substitution treats the protected portion as a direct SA exposure to the
guarantor)
- guaranteed leg, IRB guarantor -> the obligor's ``approach_applied`` (Art. 161 /
CRE22.70-85 parameter substitution keeps the exposure under IRB)
- retained leg / unguaranteed exposure -> ``approach_applied``
``approach_applied`` stays the approach the row's RWA was computed under (the
branch it ran through); this is its post-substitution twin, consumed by the
reconciliation's post-guarantee by-class x method allocation and by the post-CRM
detailed reporting view.
References:
CRR Art. 235: SA risk-weight substitution on the protected portion.
CRR Art. 161 / CRE22.70-85: IRB parameter substitution.
"""
return lf.with_columns(_post_crm_approach_expr().alias("approach_post_crm"))
_add_reporting_projection — src/rwa_calc/engine/aggregator/aggregator.py:793
@cites("CRR Art. 235")
@cites("CRR Art. 112")
def _add_reporting_projection(lf: pl.LazyFrame) -> pl.LazyFrame:
"""Add the canonical per-leg reporting projection (Phase 7 S2).
The results frame IS the two-leg substitution ledger — CRM physically splits
each guaranteed exposure into ``__G_<guarantor>`` guaranteed legs and
``__REM`` / ``__REM_FL`` / ``__REM_SEN`` retained legs
(``engine/crm/guarantees.py``). This projection names that ledger once, on
the sealed exit, so no downstream consumer re-derives class/approach/method
or sniffs reference suffixes (COREP, Pillar 3, reconciliation, and the UI
all read these columns instead of re-picking among the raw twins):
- ``reporting_class`` — post-substitution class the RWA is bucketed under
(Art. 235: guarantor class on guaranteed legs) = ``exposure_class_post_crm``.
- ``reporting_class_origin`` — obligor applied class, uniform across a
guaranteed exposure's legs (Art. 112/123) = ``exposure_class_applied``.
- ``reporting_approach`` / ``reporting_approach_origin`` — the post- and
pre-substitution approach twins (``approach_post_crm`` / ``approach_applied``).
- ``reporting_country`` / ``reporting_country_origin`` — the post- and
pre-substitution COUNTRY twins, the geographical mirror of the class pair.
PS1/26 Annex II §3.4 ¶86 says outright that "CRM techniques with
substitution effects can change the allocation of an exposure to a
country", and ¶87 splits the geographical breakdown by column: "original
exposure pre-conversion factors" reports at the country of residence of
the IMMEDIATE obligor, "exposure value" and "risk-weighted exposure
amounts" at the country of residence of the ULTIMATE obligor. So the
origin twin is the obligor's own ``cp_country_code`` on every leg, and the
post twin is the guarantor's country on a BENEFICIALLY guaranteed leg —
gated identically to ``reporting_class`` (see
:func:`_add_post_crm_reporting_class`), because a guarantee the engine
DECLINES moves neither the class nor the country. Degrades to the
obligor's country wherever the guarantor's is unknown, so a run with no
CRM guarantee sub-step reports one country on both twins.
- ``reporting_method`` — the STD/FIRB/AIRB/SLOTTING/EQUITY methodology label
of the post-substitution approach (``method_label_expr`` materialised).
- ``reporting_leg_role`` — ``guaranteed`` (the ``__G_`` leg,
``is_guaranteed=True``), ``retained`` (the ``__REM*`` remainder /
Art. 234 tranche legs), or ``whole``. COREP C 07.00 substitution
outflow/inflow reconstruct as two sums over the ``guaranteed`` legs
grouped by origin vs post-substitution class.
- ``reporting_on_balance_sheet`` — declared at source from
``exposure_type`` (loan -> on; facility/contingent -> off; anything else
null = excluded from both on- and off-BS template cells). Mirrors the
production rule in ``reporting/kernel/filters.py`` (``bs_type`` never
reaches the aggregator, so the exposure-type rule IS today's behaviour).
- ``reporting_subclass`` / ``reporting_ead`` / ``reporting_rw`` — aliases of
``exposure_subclass`` / ``ead_final`` / ``risk_weight``.
- ``reporting_gross_drawn`` / ``_interest`` / ``_nominal`` / ``_undrawn`` —
the raw gross carriers (``drawn_amount`` / ``interest`` /
``nominal_amount`` / ``undrawn_amount``) clipped at 0. A negative
drawn/interest is the on-balance netting convention (a deposit under a
``netting_agreement_reference``); the raw carriers seal negative, so
gross-exposure template cells sum these floored twins instead (CRR
Art. 111 SA / Art. 166 IRB). Nulls stay null. Computed after the CRM
guarantee split so the floored amounts are leg-consistent.
- ``reporting_gross_on_bs`` / ``reporting_gross_off_bs`` — the per-side
floored gross carriers a template's on/off-balance-sheet gross cells sum
DIRECTLY, independent of ``reporting_on_balance_sheet`` (which stays a
strict loan/facility/contingent ladder — the unified pipeline emits
``facility_undrawn`` for undrawn commitment headroom, a value that ladder
leaves null, silently dropping the leg from both gross sides while its EAD
stays in the EAD/RWEA cells). The side rule keys on ``exposure_type``:
an on-balance credit type (loan/contingent/facility_undrawn) with an
unknown drawn AND interest stays null (unknown stays unknown), else its
on-side is the floored drawn + interest (a null component counts as 0);
the off-side is a contingent's floored nominal, a facility_undrawn's
floored undrawn (counted exactly ONCE — the two carriers alias the same
headroom), a loan's true 0.0, else null. The legacy ``"facility"`` alias
(never emitted by the pipeline but recognised by the on/off-BS
discriminators and R11-era fixtures) joins the on-side credit types and
takes the aliased-pair ``max_horizontal(nominal, undrawn)`` off-side, so a
type the discriminators put on a side always has that side's carrier
populated. CCR / settlement legs are outside the on/off-BS credit-risk
gross scope, so both sides are null there (their EAD/RWEA still report).
CRR Art. 111 SA / Art. 166 IRB.
- ``reporting_crm_lgd_financial`` / ``_real_estate`` / ``_other_physical`` /
``_receivables`` (W5) — the four "CRM techniques taken into account in LGD
estimates" amounts (COREP C 08.01/02 cols 0180/0190/0200/0210), with the
METHOD-DEPENDENT basis resolved here, once, so no template re-derives it:
an AIRB leg reports the ESTIMATED MARKET VALUE, every other leg the
ADJUSTED value C_i (PS1/26 Annex II p.108 "where exposures are subject to
the Foundation Collateral Method … the adjusted value of collateral Ci …
where exposures are subject to the AIRB approach … the estimated market
value"; CRR Annex II p.101 keys the same split on "where own estimates of
LGD are (not) used" — CRR Art. 181(1)(e)-(f)). The discriminator is
``approach_applied``, NOT the post-substitution twin, because the C 08
sheets themselves key on ``reporting_approach_origin``: resolving the
basis on the post twin would report a market-value figure on a sheet
selected by the origin approach. The financial carrier folds cash on
deposit in (defect D4): ``collateral_category_expr`` routes cash/deposit
to its own category ahead of financial, but Art. 197(1)(a) makes it
eligible financial collateral and col 0180 is where it belongs — the
Art. 231 waterfall already groups the two together. The fold mirrors
``reporting_gross_on_bs``'s two-component convention: a null component
counts as 0, but BOTH null stays null.
- ``reporting_ofcp_lgd_cash_deposit`` / ``_life_insurance`` /
``reporting_ofcp_substitution`` (RD-8) — plain aliases of the three
Art. 200(1) "other funded credit protection" amounts the CRM stage has
ALREADY routed. Whether a leg's protection reports as an Art. 232
guarantee (col 0060) or under the AIRB LGD Modelling Collateral Method
(cols 0171/0172) turns on the run-level ``AIRBCollateralMethod``
election, which never reaches a template — so ``engine/crm/``, holding
the config and the pack, decides once and the projection adds no logic
here. The three are mutually exclusive by construction, which is what
makes the ``{c0170} = {c0171}+{c0172}+{c0173}`` identity and the
0060/0171-0172 exclusivity structural rather than conventional.
- ``guarantee_rwa_benefit`` (Phase 7 decision F8, recorded) — the additive
per-leg Art. 235/236 substitution relief:
``ead_final x guarantee_benefit_rw`` = leg EAD x (borrower-basis RW -
substituted RW). PRE-supporting-factor and PRE-floor by definition (the
branch snapshots the delta before Art. 501/501a and the portfolio
floor), isolating the substitution effect; the applied delta already
folds the double-default override (Art. 153(3)) and the Art. 160(4)
no-better-than-direct floor, so the benefit ties exactly to the relief
the engine granted. 0.0 on retained/whole/non-beneficial legs; NULL
where the substitution machinery never ran (unguaranteed runs, where
the branch delta column is absent). Slotting legs substitute via
RWSM (Art. 235(1), fixed 2026-07-12) and carry real benefits on the
slotting borrower basis.
Called after the residual multiplier and the output floor so the aliases
mirror the sealed final values. Per-row post-floor RWA is deliberately NOT
projected here — the floor is a portfolio-level max and its per-row
allocation is a recorded-decision slice of its own (Phase 7 plan S5).
"""
is_retained_leg = pl.col("exposure_reference").str.contains(r"__REM(?:_FL|_SEN)?$")
leg_role = (
pl.when(pl.col("is_guaranteed") == True) # noqa: E712
.then(pl.lit("guaranteed"))
.when(is_retained_leg)
.then(pl.lit("retained"))
.otherwise(pl.lit("whole"))
)
on_balance_sheet = (
pl.when(pl.col("exposure_type") == "loan")
.then(pl.lit(True))
.when(pl.col("exposure_type").is_in(["facility", "contingent"]))
.then(pl.lit(False))
.otherwise(pl.lit(None, dtype=pl.Boolean))
)
if "guarantee_benefit_rw" in lf.collect_schema().names():
# Every branch (SA/IRB/slotting) produces the delta on guaranteed
# runs; non-beneficial legs are clamped to 0.0 at the branch.
rwa_benefit = pl.col("ead_final") * pl.col("guarantee_benefit_rw")
else:
rwa_benefit = pl.lit(None, dtype=pl.Float64)
# The ¶87 ULTIMATE-obligor country. Read through the same absence-tolerant
# selector ``_beneficial_gate`` uses rather than a schema branch: an
# unguaranteed run never joined a guarantor counterparty, so
# ``guarantor_country_code`` is simply not there and the coalesce yields the
# typed null the gate then routes to ``otherwise``. An empty string is
# treated as unknown for the same reason the class twin does it — a joined
# counterparty row with a blank country is not a country.
guarantor_country = _optional_country(_GUARANTOR_COUNTRY_COL)
# The obligor's own country is read through the same selector, not as a bare
# column: it is a required aggregator-exit column, but the projection is also
# exercised directly on minimal frames, and a hard read would make the
# function's input contract wider than the two twins actually need.
obligor_country = _optional_country("cp_country_code")
country_post = (
pl.when(
(pl.col("is_guaranteed") == True) # noqa: E712
& _beneficial_gate()
& guarantor_country.is_not_null()
& (guarantor_country != "")
)
.then(guarantor_country)
.otherwise(obligor_country)
)
# Per-side floored gross carriers (CRR Art. 111 SA / Art. 166 IRB). See the
# docstring: on-side = floored drawn + interest for the on-balance credit
# types (unknown drawn AND interest -> null); off-side = a contingent's
# nominal, a facility_undrawn's undrawn (once), a loan's true 0.0. CCR /
# settlement legs fall outside the credit-risk gross scope -> null both
# sides. sum_horizontal treats a null component as 0 (never fill_null in
# engine/), and the is_null guard keeps a wholly-unknown on-side null.
# "facility" is a LEGACY OFF-BS ALIAS (Wave 3 amendment): the production
# pipeline never emits it, but reporting_on_balance_sheet / filter_off_bs /
# the c07_bs+c08_bs ladders all put it off-BS, and R11-era unit fixtures use
# it (off-BS gross in undrawn_amount). A type the discriminators put on a
# side MUST have that side's carrier populated, so "facility" joins the
# credit-type list on-side and takes the aliased-pair off-side rule below.
on_bs_carrier = (
pl.when(
pl.col("exposure_type").is_in(["loan", "contingent", "facility_undrawn", "facility"])
)
.then(
pl.when(pl.col("drawn_amount").is_null() & pl.col("interest").is_null())
.then(pl.lit(None, dtype=pl.Float64))
.otherwise(
pl.sum_horizontal(
pl.col("drawn_amount").clip(lower_bound=0.0),
pl.col("interest").clip(lower_bound=0.0),
)
)
)
.otherwise(pl.lit(None, dtype=pl.Float64))
)
# The method-dependent CRM-in-LGD basis (COREP C 08.01/02 cols 0180-0210).
# See the docstring: AIRB legs report the estimated market value, every
# other leg the adjusted value C_i, keyed on the ORIGIN approach because
# that is the approach the C 08 sheets themselves are selected by.
crm_lgd_financial, crm_lgd_re, crm_lgd_other_physical, crm_lgd_receivables = _crm_lgd_carriers()
off_bs_carrier = (
pl.when(pl.col("exposure_type") == "contingent")
.then(pl.col("nominal_amount").clip(lower_bound=0.0))
.when(pl.col("exposure_type") == "facility_undrawn")
.then(pl.col("undrawn_amount").clip(lower_bound=0.0))
.when(pl.col("exposure_type") == "loan")
.then(pl.lit(0.0))
# Legacy "facility" alias: its off-BS carrier home is ambiguous
# (nominal or undrawn), which pipeline facility_undrawn rows alias, so
# max_horizontal counts the pair exactly once. All-null -> null.
.when(pl.col("exposure_type") == "facility")
.then(
pl.max_horizontal(
pl.col("nominal_amount").clip(lower_bound=0.0),
pl.col("undrawn_amount").clip(lower_bound=0.0),
)
)
.otherwise(pl.lit(None, dtype=pl.Float64))
)
return lf.with_columns(
pl.col("exposure_class_post_crm").alias("reporting_class"),
pl.col("exposure_class_applied").alias("reporting_class_origin"),
pl.col("approach_post_crm").alias("reporting_approach"),
pl.col("approach_applied").alias("reporting_approach_origin"),
country_post.alias("reporting_country"),
obligor_country.alias("reporting_country_origin"),
method_label_expr("approach_post_crm").alias("reporting_method"),
leg_role.alias("reporting_leg_role"),
on_balance_sheet.alias("reporting_on_balance_sheet"),
pl.col("exposure_subclass").alias("reporting_subclass"),
pl.col("ead_final").alias("reporting_ead"),
pl.col("risk_weight").alias("reporting_rw"),
rwa_benefit.alias("guarantee_rwa_benefit"),
# Floored gross-exposure carriers (CRR Art. 111 SA / Art. 166 IRB).
# A negative drawn/interest is the on-balance netting convention (a
# deposit under a netting_agreement_reference); the EAD path already
# floors it, but the RAW carriers seal negative and would make a
# gross-exposure template cell (COREP C 07/C 08, Pillar 3 CR4/5/6/10)
# report a negative figure. Clip at 0 so gross cells never go negative;
# nulls stay null (never fill Float nulls to 0.0 — anti-conservative).
# Computed here, after the CRM guarantee split, so they are leg-consistent.
pl.col("drawn_amount").clip(lower_bound=0.0).alias("reporting_gross_drawn"),
pl.col("interest").clip(lower_bound=0.0).alias("reporting_gross_interest"),
pl.col("nominal_amount").clip(lower_bound=0.0).alias("reporting_gross_nominal"),
pl.col("undrawn_amount").clip(lower_bound=0.0).alias("reporting_gross_undrawn"),
on_bs_carrier.alias("reporting_gross_on_bs"),
off_bs_carrier.alias("reporting_gross_off_bs"),
# CRM techniques taken into account in LGD estimates, on the
# method-resolved basis (COREP C 08.01/02 cols 0180/0190/0200/0210).
crm_lgd_financial.alias("reporting_crm_lgd_financial"),
crm_lgd_re.alias("reporting_crm_lgd_real_estate"),
crm_lgd_other_physical.alias("reporting_crm_lgd_other_physical"),
crm_lgd_receivables.alias("reporting_crm_lgd_receivables"),
# RD-8: plain aliases of the three already-routed Art. 200(1) amounts.
_optional_amount("ofcp_lgd_cash_deposit").alias("reporting_ofcp_lgd_cash_deposit"),
_optional_amount("ofcp_lgd_life_insurance").alias("reporting_ofcp_lgd_life_insurance"),
_optional_amount("ofcp_substitution_amount").alias("reporting_ofcp_substitution"),
)
_build_guarantor_sub_rows — src/rwa_calc/engine/crm/guarantees.py:900
@cites("CRR Art. 235")
def _build_guarantor_sub_rows(multi_joined: pl.LazyFrame, schema_names: list[str]) -> pl.LazyFrame:
"""Build the per-guarantor sub-rows for guaranteed exposures.
``schema_names`` is the post-join column set from ``_join_multi_guarantees``
(reused here so the joined schema is only materialised once).
"""
guar_stock_splits: list[pl.Expr] = [
# Covered EAD (post-CCF) = coverage fraction x ead_after_collateral. The
# nominal credit-protection amount (G*) stays on guarantee_amount /
# original_guarantee_amount. CRR Art. 235(1) / 236(3).
pl.col("_effective_ead").alias("guaranteed_portion"),
pl.lit(0.0).alias("unguaranteed_portion"),
pl.col("_effective_ead").alias("ead_after_collateral"),
pl.col("_effective_amount").alias("guarantee_amount"),
pl.col("_guar_amount").alias("original_guarantee_amount"),
pl.col("guarantor").alias("guarantor_reference"),
pl.concat_str(
[pl.col("parent_exposure_reference"), pl.lit("__G_"), pl.col("guarantor")],
).alias("exposure_reference"),
]
guar_stock_splits.extend(
(pl.col(c) * pl.col("_guar_ratio")).alias(c)
for c in _stock_split_cols()
if c in schema_names
)
return multi_joined.with_columns(guar_stock_splits)
build_domestic_cgcb_guarantor_expr — src/rwa_calc/engine/eu_sovereign.py:83
@cites("CRR Art. 114")
@cites("CRR Art. 235")
@cites("PS1/26, paragraph 235")
def build_domestic_cgcb_guarantor_expr(
country_col: str,
currency_col: str | pl.Expr,
funding_currency_col: str | pl.Expr | None = None,
) -> pl.Expr:
"""
Build a Polars expression that identifies a domestic-currency CGCB guarantor
under CRR Art. 114(4) and Art. 114(7) (Basel 3.1 preservation).
Combines the UK (GB/GBP) and EU (member state / member-state-domestic-currency)
branches into a single boolean expression.
Callers pass the guarantor's country code column and the currency column to
test against. For guarantee substitution (Art. 215-217) the currency column
should be the **guarantee** currency — the Art. 233(3) 8% FX haircut handles
any mismatch between the guarantee and the underlying exposure separately.
Art. 235(3) funding limb: the Art. 114(4)/(7) 0% extension to a centrally-
guaranteed exposure requires the exposure to be BOTH denominated in the
guarantor's domestic currency (the ``currency_col`` limb) AND *funded* in
that same currency. When ``funding_currency_col`` is supplied, the limb
``funding == currency`` is ANDed in — because ``currency`` has already passed
the domestic-currency test, equality with it is equivalent to "funded in the
domestic currency", and holds uniformly across the UK/GBP and EU branches.
When it is None (the frame carries no funding source) the funding limb is
omitted, preserving the pure-denomination behaviour. Callers should pass a
null-PERMISSIVE funding expression (see :func:`funding_currency_expr`) so an
unreported funding currency reuses the denomination and keeps the exposure's
existing 0% treatment.
Args:
country_col: Column name containing the guarantor's ISO country code.
currency_col: Column name (str) or Polars expression for the currency
to test against the guarantor's domestic currency.
funding_currency_col: Column name (str) or Polars expression for the
exposure's funding currency. When None, the Art. 235(3) funding limb
is not applied.
Returns:
Boolean Polars expression: True when the guarantor is UK CGCB in GBP or
an EU-member CGCB in that member state's domestic currency, and — when a
funding currency is supplied — the exposure is funded in that currency.
"""
currency_expr = pl.col(currency_col) if isinstance(currency_col, str) else currency_col
is_uk_domestic = (pl.col(country_col).fill_null("") == "GB") & (currency_expr == "GBP")
is_eu_domestic = build_eu_domestic_currency_expr(country_col, currency_expr)
denominated_domestic = is_uk_domestic | is_eu_domestic
if funding_currency_col is None:
return denominated_domestic
funding_expr = (
pl.col(funding_currency_col)
if isinstance(funding_currency_col, str)
else funding_currency_col
)
return denominated_domestic & funding_expr.eq(currency_expr)
funding_currency_expr — src/rwa_calc/engine/eu_sovereign.py:170
@cites("CRR Art. 114")
@cites("CRR Art. 235")
def funding_currency_expr(schema_names: list[str] | set[str]) -> pl.Expr | None:
"""
Return the exposure's funding-currency expression for the Art. 235(3) limb.
The Art. 114(4)/(7) 0% risk weight — and its Art. 235(3) extension to
centrally-guaranteed exposures — requires the exposure to be BOTH
denominated AND *funded* in the relevant domestic currency. This helper
yields the "funded in" currency: an explicit ``funding_currency`` column when
present, otherwise the exposure's denomination currency as the proxy the
audit endorses.
Null-PERMISSIVE: a null ``funding_currency`` falls back to the denomination
(``denomination_currency_expr``), so a dataset that does not report a
separate funding currency keeps the treatment it had before this limb existed
(mirrors the Art. 237(2)(a) original-maturity null fallback). Returns None
when the frame carries no currency column at all, signalling the caller to
omit the funding limb entirely.
Args:
schema_names: Column names from ``lf.collect_schema().names()``.
Returns:
Polars expression yielding the funding currency per row, or None when no
currency source is available on the frame.
"""
names = set(schema_names)
has_denomination = "original_currency" in names or "currency" in names
if "funding_currency" in names:
if has_denomination:
return pl.col("funding_currency").fill_null(denomination_currency_expr(names))
return pl.col("funding_currency")
if has_denomination:
return denomination_currency_expr(names)
return None
_compute_guarantor_rw_sa — src/rwa_calc/engine/irb/guarantee.py:256
@cites("CRR Art. 122")
@cites("CRR Art. 235")
def _compute_guarantor_rw_sa(
lf: pl.LazyFrame,
cols: list[str],
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Compute the guarantor's SA risk weight via the shared builder.
Compiles ``build_guarantor_rw_expr`` (data/tables/guarantor_rw.py) with
the IRB chain's column names — the same branch chain and order as the
SA-side twin (engine/sa/namespace.py::_build_guarantor_rw_expr). This
closes the IRB-guarantor PSE / RGLA substitution gap (the recorded
Phase 4 fix) plus the IO 0%, named-MDB 0% and MDB Table 2B closures.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
# Ensure guarantor_exposure_class is available (set by CRM processor;
# fallback for unit tests that construct LazyFrames directly)
if "guarantor_exposure_class" not in cols:
from rwa_calc.engine.entity_class_maps import ENTITY_TYPE_TO_SA_CLASS
lf = lf.with_columns(
pl.col("guarantor_entity_type")
.fill_null("")
.replace_strict(ENTITY_TYPE_TO_SA_CLASS, default="")
.alias("guarantor_exposure_class"),
)
if "guarantor_is_ccp_client_cleared" not in cols:
lf = lf.with_columns(
pl.lit(None).cast(pl.Boolean).alias("guarantor_is_ccp_client_cleared"),
)
# B31 SCRA dispatch fallback: ensure ``guarantor_scra_grade`` is referenceable
# by ``build_institution_guarantor_rw_expr``. The CRM processor populates this
# column from counterparties.scra_grade (engine/crm/guarantees.py); fall back
# to null for unit tests that construct LazyFrames directly without going
# through the CRM join.
if "guarantor_scra_grade" not in cols:
lf = lf.with_columns(
pl.lit(None).cast(pl.String).alias("guarantor_scra_grade"),
)
_gec = pl.col("guarantor_exposure_class").fill_null("")
# Art. 114(4)/(7): Domestic CGCB guarantors -> 0% RW regardless of CQS.
# Evaluate the domestic-currency (denomination) test against the guarantee
# currency (the currency of the substituted exposure to the sovereign); the
# Art. 233(3) 8% FX haircut separately handles any mismatch between the
# guarantee and the underlying exposure. Fall back to the exposure's pre-FX
# denomination when `guarantee_currency` is missing (legacy / no-guarantee
# rows). Art. 235(3): the 0% extension additionally requires the exposure to
# be *funded* in the domestic currency, so the funding limb (null-PERMISSIVE
# fallback to the denomination — see `funding_currency_expr`) is ANDed in.
_irb_schema_names = lf.collect_schema().names()
_has_country = "guarantor_country_code" in _irb_schema_names
_has_exposure_ccy_irb = (
"original_currency" in _irb_schema_names or "currency" in _irb_schema_names
)
_has_guarantee_ccy_irb = "guarantee_currency" in _irb_schema_names
if _has_guarantee_ccy_irb and _has_exposure_ccy_irb:
_ccy_expr_irb = pl.col("guarantee_currency").fill_null(
denomination_currency_expr(_irb_schema_names)
)
elif _has_guarantee_ccy_irb:
_ccy_expr_irb = pl.col("guarantee_currency")
elif _has_exposure_ccy_irb:
_ccy_expr_irb = denomination_currency_expr(_irb_schema_names)
else:
_ccy_expr_irb = None
_is_domestic_guarantor = (
build_domestic_cgcb_guarantor_expr(
"guarantor_country_code", _ccy_expr_irb, funding_currency_expr(_irb_schema_names)
)
if _has_country and _ccy_expr_irb is not None
else pl.lit(False)
)
# The shared expression's unrated PSE/RGLA fallback reads the guarantor
# country column; ensure it is referenceable for direct (non-pipeline)
# invocation, mirroring the ccp / scra fallbacks above. The pipeline
# always carries it (joined by engine/crm/guarantees.py).
if not _has_country:
lf = lf.with_columns(
pl.lit(None).cast(pl.String).alias("guarantor_country_code"),
)
return lf.with_columns(
build_guarantor_rw_expr(
exposure_class_col="guarantor_exposure_class",
entity_type_col="guarantor_entity_type",
cqs_col="guarantor_cqs",
country_code_col="guarantor_country_code",
ccp_client_cleared_col="guarantor_is_ccp_client_cleared",
scra_grade_col="guarantor_scra_grade",
is_basel_3_1=resolved_pack.feature("sa_revised_risk_weight_tables"),
domestic_cgcb_expr=_is_domestic_guarantor,
# No borrower-maturity short-term flag is threaded on the IRB
# path today (the SA twin derives one from its own stage
# scratch); long-term Table 3 applies throughout.
short_term_flag_col=None,
no_guarantee_expr=pl.col("guaranteed_portion").fill_null(0) <= 0,
).alias("guarantor_rw_sa"),
)
build_guarantor_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:135
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("PS1/26, paragraph 117")
@cites("CRR Art. 118")
@cites("CRR Art. 235")
def build_guarantor_rw_expr(
*,
exposure_class_col: str,
entity_type_col: str,
cqs_col: str,
country_code_col: str,
ccp_client_cleared_col: str,
scra_grade_col: str,
is_basel_3_1: bool,
domestic_cgcb_expr: pl.Expr | None = None,
short_term_flag_col: str | None = None,
no_guarantee_expr: pl.Expr | None = None,
) -> pl.Expr:
"""Build the full when/then chain that maps a guarantor to its SA RW.
Dispatches on the guarantor's SA exposure class (derived from
``ENTITY_TYPE_TO_SA_CLASS`` by the caller — e.g. the CRM processor)
rather than regex on entity_type, ensuring all valid entity types are
covered. Reproduces the SA-side reference chain
(``engine/sa/rw_adjustments.py::_build_guarantor_rw_expr``) branch-for-branch.
Branch order (first match wins):
no guarantee -> null (only when ``no_guarantee_expr`` is supplied)
domestic CGCB sovereign (Art. 114(4)/(7)) -> 0%
CGCB CQS table (Art. 114 Table 1)
CCP (CRR Art. 306, CRE54.14-15)
International Organisation (Art. 118) -> 0%
Named MDB (Art. 117(2)) -> 0%
Non-named MDB (Art. 117(1)) — PS1/26 Table 2B under Basel 3.1;
institution treatment (Art. 120 Table 3 / Art. 121 unrated,
no short-term preferential) under CRR
Institution (ECRA / SCRA via build_institution_guarantor_rw_expr —
short-term Art. 120(2) Table 4 when ``short_term_flag_col``
evaluates True, otherwise long-term Table 3)
PSE (Art. 116(2) Table 2A, sovereign-derived for unrated)
RGLA (Art. 115(1)(b) Table 1B, sovereign-derived for unrated)
Corporate (Art. 122 corporate CQS table)
else -> null (no substitution)
The unrated PSE / RGLA fallback is the documented SA-side approximation
(no guarantor sovereign-CQS join exists in the CRM column production):
a GB guarantor receives the 20% RGLA / PSE domestic-currency treatment,
any other country the conservative 100% unrated default — NOT the full
Art. 116(1) Table 2 / Art. 115(1)(a) Table 1A sovereign-derived lookup.
Args:
exposure_class_col: Name of the guarantor SA exposure-class column
(e.g. ``guarantor_exposure_class``).
entity_type_col: Name of the guarantor entity-type column — used for
the CCP override and the named-MDB (``mdb_named``) carve-out.
cqs_col: Name of the integer guarantor CQS column.
country_code_col: Name of the guarantor country-code column — drives
the unrated PSE / RGLA GB-vs-other approximation.
ccp_client_cleared_col: Name of the Boolean client-cleared flag
column (null -> proprietary 2%).
scra_grade_col: Name of the guarantor SCRA-grade column threaded
into ``build_institution_guarantor_rw_expr`` for the B31 unrated
institution dispatch.
is_basel_3_1: Select PS1/26 tables (institution ECRA / corporate
Table 6 / MDB Table 2B) when True, CRR tables when False — for
MDBs that means the Art. 117(1) institution treatment, since CRR
has no MDB table. PSE / RGLA / IO / CCP values are
framework-identical. Threaded by both live call sites from the
cited ``sa_revised_risk_weight_tables`` pack Feature.
domestic_cgcb_expr: Caller-supplied Art. 114(4)/(7) domestic-currency
test (SA and IRB derive domesticity differently). ``None``
disables the domestic 0% branch (treated as never-domestic).
short_term_flag_col: Optional Boolean column routing institution
guarantors to the Art. 120(2) Table 4 short-term dicts. The IRB
chain passes ``None`` today.
no_guarantee_expr: Caller-owned leading guard — rows where it
evaluates True yield null (no substitution priced). ``None``
omits the guard (the chain prices every row).
Returns:
Float64 Polars expression evaluating to the guarantor's SA RW, or
null where no substitution treatment exists.
"""
gec = pl.col(exposure_class_col).fill_null("")
# PSE/RGLA Art. 116(2)/115(1)(b) unrated fallback: domestic-GB guarantors
# get the RGLA/PSE 20% domestic-currency treatment; otherwise the
# conservative 100% PSE/RGLA unrated default applies.
sovereign_derived_unrated = _pse_rgla_unrated_fallback_expr(country_code_col)
cgcb_unrated = float(_CGCB_RW[CQS.UNRATED])
is_domestic_guarantor = domestic_cgcb_expr if domestic_cgcb_expr is not None else pl.lit(False)
skip_substitution = no_guarantee_expr if no_guarantee_expr is not None else pl.lit(False)
return (
pl.when(skip_substitution)
.then(pl.lit(None).cast(pl.Float64))
# Art. 114(4)/(7): Domestic sovereign -> 0% regardless of CQS.
.when((gec == "central_govt_central_bank") & is_domestic_guarantor)
.then(pl.lit(0.0))
# CGCB guarantors via CQS (Table 1 — sovereign weights).
.when(gec == "central_govt_central_bank")
.then(
_cqs_table_lookup_expr(
cqs_col,
_CGCB_RW,
cgcb_unrated,
)
)
# CCP guarantors: 2% proprietary / 4% client-cleared
# (CRR Art. 306, CRE54.14-15) — overrides institution CQS weights.
.when(pl.col(entity_type_col) == "ccp")
.then(
pl.when(pl.col(ccp_client_cleared_col).fill_null(False))
.then(pl.lit(_QCCP_CLIENT_CLEARED_RW))
.otherwise(pl.lit(_QCCP_PROPRIETARY_RW))
)
# International Organisation (Art. 118): 0% unconditional.
.when(gec == "international_organisation")
.then(pl.lit(float(_IO_ZERO_RW)))
# Named MDB (Art. 117(2)): 0% unconditional.
.when((gec == "mdb") & (pl.col(entity_type_col).fill_null("") == "mdb_named"))
.then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
# Rated / unrated non-named MDB (Art. 117(1)) — framework-divergent:
# PS1/26 Art. 117(1)(a)/(b): the dedicated Basel 3.1 MDB Table 2B
# (CQS2 30%, unrated 50%).
# CRR Art. 117(1): non-named MDBs "shall be treated in the same manner
# as exposures to institutions" — Art. 120 Table 3 when rated, the
# Art. 121 unrated institution fallback (100%) otherwise. There is no
# MDB table in CRR. The Art. 119(2)/120(2)/121(3) short-term
# preferential "shall not be applied", so ``short_term_flag_col`` is
# deliberately NOT threaded into this branch (unlike the institution
# branch below). Mirrors the direct, non-guarantor CRR MDB path in
# ``sa/risk_weights.py::_apply_crr_risk_weight_overrides`` (P1.253).
.when(gec == "mdb")
.then(
_cqs_table_lookup_expr(
cqs_col,
_MDB_RW,
float(_MDB_UNRATED_RW),
)
if is_basel_3_1
else build_institution_guarantor_rw_expr(cqs_col, is_basel_3_1=False)
)
# Institution guarantors — RW driven from institution_rw_crr /
# institution_rw_b31_ecra so the pack remains the single source of
# truth. When the short-term flag evaluates True (CRR/PS1/26
# Art. 120(2)), the short-term Table 4 dicts apply instead.
.when(gec == "institution")
.then(
build_institution_guarantor_rw_expr(
cqs_col,
is_basel_3_1,
short_term_flag_col=short_term_flag_col,
scra_grade_col=scra_grade_col,
)
)
# PSE guarantors — Art. 116(2) Table 2A for rated, sovereign-derived for unrated.
.when(gec == "pse")
.then(
_cqs_table_lookup_expr(
cqs_col,
_PSE_OWN_RW,
sovereign_derived_unrated,
)
)
# RGLA guarantors — Art. 115(1)(b) Table 1B for rated, sovereign-derived for unrated.
.when(gec == "rgla")
.then(
_cqs_table_lookup_expr(
cqs_col,
_RGLA_OWN_RW,
sovereign_derived_unrated,
)
)
# Corporate guarantors — Art. 122 corporate CQS table.
# Basel 3.1 (PRA PS1/26 Art. 122(2) Table 6): CQS3 = 75% (CRR: 100%);
# PRA retains CQS5 = 150%. Gated on framework so CRR runs are
# unchanged.
.when(gec.is_in(["corporate", "corporate_sme"]))
.then(build_corporate_guarantor_rw_expr(cqs_col, is_basel_3_1))
.otherwise(pl.lit(None).cast(pl.Float64))
)
apply_guarantee_substitution — src/rwa_calc/engine/sa/rw_adjustments.py:196
@cites("CRR Art. 193")
@cites("CRR Art. 235")
def apply_guarantee_substitution(
lf: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Apply guarantee substitution for unfunded credit protection.
For guaranteed portions the risk weight is substituted with the guarantor's
risk weight, and the row's RWA is the Art. 235(1) blend of the two:
``max(0, E - GA) x r + GA x g``.
THE DECLINE AND ITS AUTHORITY. This is the canonical account for the whole
``is_guarantee_beneficial`` machinery — its IRB twin
(``engine/irb/guarantee.py``) and the three reporting consumers that gate on
it (``engine/aggregator/aggregator.py::_beneficial_gate``,
``reporting/corep/crm_substitution.py::_decline_gate``,
``reporting/corep/c07.py::_protection_exprs``) all point here rather than
restating it.
THE BASIS IS ART. 193, NOT ART. 213. Art. 213 ("Requirements common to
guarantees and credit derivatives") is an ELIGIBILITY gate on the protection
CONTRACT — the protection is direct, its extent clearly defined and
incontrovertible, no clause permitting unilateral cancellation / cost
escalation on credit deterioration / obstruction of timely payout / reduction
of protection maturity, legally effective and enforceable — plus
concentration-risk systems (213(2)) and contractual/statutory fulfilment
(213(3)). It says nothing about whether a recognised guarantee must be
APPLIED, yet this code recorded "recognition is permissive (Art. 213)" in six
places. The two provisions that actually carry the decline both sit in
Art. 193, "Principles for recognising the effect of credit risk mitigation
techniques":
- Art. 193(1), MANDATORY, about the OUTCOME: "No exposure in respect of which
an institution obtains credit risk mitigation shall produce a higher
risk-weighted exposure amount or expected loss amount than an otherwise
identical exposure in respect of which an institution has no credit risk
mitigation." The unit is THE EXPOSURE, not the portfolio, and it binds the
EXPECTED LOSS amount as well as the RWEA — which is what makes it the right
authority for the IRB leg too.
- Art. 193(3), PERMISSIVE, about the MECHANISM: "Where the provisions in
Sections 2 and 3 are met, institutions MAY amend the calculation of
risk-weighted exposure amounts under the Standardised Approach and the
calculation of risk-weighted exposure amounts and expected loss amounts
under the IRB Approach in accordance with the provisions of Sections 4, 5
and 6." Art. 213 sits IN Section 2 — it is one of the preconditions 193(3)
makes the election conditional on, which is exactly why it kept being
mistaken for the election itself.
Art. 113(3) ("Where an exposure is subject to credit protection the risk
weight applicable to that item may be amended in accordance with Chapter 4")
says the same thing per ITEM, and is the hook Art. 235(1) opens with — "For
the purposes of Article 113(3) institutions shall calculate ..." — so that
``shall`` governs HOW you compute if you amend, not WHETHER you amend. But
113(3) lives in Chapter 2 and speaks only to the SA risk weight; it does not
reach the IRB path. Art. 193(3) covers both, so it is the citation of record.
THE ARGUMENT THAT NEEDS NO CITATION. The Art. 235(1) formula carries no
``min`` against ``E x r``. Applied mechanically with ``g > r`` it returns a
HIGHER RWEA than the identical unprotected exposure — a credit risk
mitigation chapter that penalises mitigation. That reading cannot be right,
which is precisely why Art. 193(1) exists.
TWO ELECTIONS THE TEXT DOES NOT FORCE, recorded because nothing else records
them:
(1) DECLINE vs APPLY-AND-CAP — AND ITS CAPITAL-NEUTRALITY IS CRR-CONDITIONAL,
WHICH IS THE WHOLE POINT. Art. 193(1) mandates the OUTCOME, not the
mechanism, so under CRR there are exactly TWO compliant implementations:
decline the guarantee, or apply Art. 235(1) and then cap the RWEA at the
unmitigated amount. They give IDENTICAL CAPITAL — but only BECAUSE the
Art. 193(1) cap is MANDATORY. That is what collapses the two onto one
number and leaves the choice affecting DISCLOSURE alone: under
apply-and-cap Art. 235 HAS fired, the covered part HAS been assigned to
the guarantor's class, and the C 07.00 / C 08.01 outflow and inflow would
both be reported. We decline; that is an implementation election, not a
reading of the text. Remove the mandatory cap and a THIRD option appears
— apply Art. 235(1) UNCAPPED, returning a HIGHER RWEA — at which point
the election stops being capital-neutral and our decline stops being the
only permissible behaviour. Whether that coincidence survives into
PS1/26 is UNVERIFIED (below); if it does not, this election acquires a
capital consequence and needs a recorded policy basis rather than a code
comment. Note the scope: this is the ENGINE gate, which moves RWA. The
reporting gates that consume its flag
(``engine/aggregator/aggregator.py::_beneficial_gate``,
``reporting/corep/crm_substitution.py::_decline_gate``) move no RWA at
all, so THEIR capital-neutrality is unconditional and nothing here
qualifies it.
(2) THE STRICT ``<``. The benefit test below is
``guarantor_rw < pre_crm_risk_weight`` (the IRB twin is identical against
``risk_weight_irb_original``). At EQUALITY Art. 193(1) is SILENT — equal
is not "higher" — so an equally-weighted guarantor is declined BY CHOICE:
zero capital effect, and a real effect on which sheet the exposure
appears on.
A PS1/26 GAP, STATED RATHER THAN GLOSSED. PS1/26 Art. 113(3) is word for word
the CRR text with "the Credit Risk Mitigation (CRR) Part" substituted for
"Chapter 4", so the permissive hook carries into 2027. A PS1/26 equivalent of
Art. 193(1) has NOT been verified: the PRA renumbers (in ``ps126app1.pdf``
"Article 193" is the CRR Art. 161 IRB-parameter-substitution rule — read the
``[Note: This rule corresponds to Article NNN of CRR ...]`` line, never the
number), and the PRA Credit Risk Mitigation (CRR) Part is not in
``docs/assets/`` at all. The consequence matters: under CRR the decline is
COMPELLED, so doing neither it nor apply-and-cap is a breach; if the PRA CRM
Part turns out to lack the equivalent, from 1 Jan 2027 this becomes a pure
ELECTION — and an election needs a recorded policy basis in a way that
compliance with a mandatory cap does not. No PS1/26 citation is asserted
here, because none has been read.
References:
CRR Art. 193(1), (3): CRM recognition principles — the no-worse outcome
cap, and the election to amend the calculation at all.
CRR Art. 113(3): the SA per-item permissive hook Art. 235(1) hangs off.
CRR Art. 235: SA risk-weight substitution formula.
CRR Art. 213-217: eligibility of the protection itself, gated upstream in
``engine/crm/guarantees.py``.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
exposures = lf
cols = exposures.collect_schema().names()
# Run-level sentinel gate: guarantor_entity_type is the one crm_exit
# column still CONDITIONAL (inject=False) — present iff the CRM
# guarantee sub-step ran. Keying on it keeps this machinery (and its
# derived audit columns: pre_crm_risk_weight, guarantor_rw,
# is_guarantee_beneficial, guarantee_status, guarantee_benefit_rw)
# off unguaranteed runs; see contracts/edges.py. The
# guaranteed_portion check covers direct (non-pipeline) invocation.
if "guaranteed_portion" not in cols or "guarantor_entity_type" not in cols:
return exposures
# Ensure defensive column fallbacks (guarantor_exposure_class,
# guarantor_country_code, guarantor_is_ccp_client_cleared). In
# production these are set by the CRM processor; this fallback covers
# tests that construct LazyFrames directly and skip the CRM stage.
exposures = _ensure_guarantee_substitution_columns(exposures)
# Preserve pre-CRM risk weight for regulatory reporting (pre-CRM vs
# post-CRM views).
exposures = exposures.with_columns(
pl.col("risk_weight").alias("pre_crm_risk_weight"),
)
# Art. 114(4)/(7) domestic CGCB-guarantor currency check.
is_domestic_guarantor = _build_domestic_guarantor_expr(exposures.collect_schema().names())
# CRR/PS1/26 Art. 120(2) Table 4 short-term institution guarantor flag.
# The substituted exposure's original maturity (≤ 3 months / 0.25y)
# drives the short-term carve-out — same convention as the direct
# institution short-term branches in ``risk_weights.py`` (Art. 120(2),
# Art. 121(3)). ``original_maturity_years`` is derived earlier in
# ``apply_risk_weights`` from (maturity_date - value_date) when absent,
# so it is always populated here.
short_term_flag_col = "_inst_guarantor_short_term"
if "original_maturity_years" in exposures.collect_schema().names():
short_term_expr = pl.col("original_maturity_years").is_not_null() & (
pl.col("original_maturity_years") <= 0.25
)
else:
short_term_expr = pl.lit(False)
exposures = exposures.with_columns(
short_term_expr.fill_null(False).alias(short_term_flag_col),
)
# Look up guarantor's RW based on exposure class + CQS. The short-term
# flag is calculator scratch consumed only by this expression — drop it
# immediately so it never leaks into the branch/aggregator frames.
exposures = exposures.with_columns(
_build_guarantor_rw_expr(
is_domestic_guarantor,
resolved_pack.feature("sa_revised_risk_weight_tables"),
institution_short_term_flag_col=short_term_flag_col,
).alias("guarantor_rw"),
).drop(short_term_flag_col)
# The Art. 193(1) benefit test: no exposure with CRM may produce a HIGHER
# RWEA than the identical unprotected exposure, and the Art. 235(1) formula
# carries no ``min`` to stop it. DECLINING (rather than applying Art. 235
# then capping) and the STRICT ``<`` (equality is declined too) are both
# elections the text does not force — see this function's docstring, which is
# the single recorded basis for every consumer of this flag.
exposures = exposures.with_columns(
[
pl.when(
(pl.col("guaranteed_portion") > 0)
& (pl.col("guarantor_rw").is_not_null())
& (pl.col("guarantor_rw") < pl.col("pre_crm_risk_weight"))
)
.then(pl.lit(True))
.otherwise(pl.lit(False))
.alias("is_guarantee_beneficial"),
]
)
# Redistribute non-beneficial guarantee portions to beneficial guarantors.
# For multi-guarantor exposures, non-beneficial guarantors' EAD is reallocated
# to the most beneficial (lowest RW) guarantors using greedy fill.
from rwa_calc.engine.crm.guarantees import redistribute_non_beneficial
exposures = redistribute_non_beneficial(exposures)
# Calculate blended risk weight using substitution approach
# Only apply if guarantee is beneficial
# RWA = (unguaranteed_portion * borrower_rw + guaranteed_portion * guarantor_rw) / ead_final
exposures = exposures.with_columns(
[
# Blended risk weight when guarantee exists AND is beneficial
pl.when(
(pl.col("guaranteed_portion") > 0)
& (pl.col("guarantor_rw").is_not_null())
& (pl.col("is_guarantee_beneficial"))
)
.then(
# weighted average of borrower and guarantor risk weights
(
pl.col("unguaranteed_portion") * pl.col("pre_crm_risk_weight")
+ pl.col("guaranteed_portion") * pl.col("guarantor_rw")
)
/ pl.col("ead_final")
)
# No guarantee, no guarantor RW, or non-beneficial - use original risk weight
.otherwise(pl.col("pre_crm_risk_weight"))
.alias("risk_weight"),
]
)
# Track guarantee status for reporting
exposures = exposures.with_columns(
[
pl.when(pl.col("guaranteed_portion") <= 0)
.then(pl.lit("NO_GUARANTEE"))
.when(~pl.col("is_guarantee_beneficial"))
.then(pl.lit("GUARANTEE_NOT_APPLIED_NON_BENEFICIAL"))
.otherwise(pl.lit("SA_RW_SUBSTITUTION"))
.alias("guarantee_status"),
# Calculate RW benefit from guarantee (positive = RW reduced)
pl.when(pl.col("is_guarantee_beneficial"))
.then(pl.col("pre_crm_risk_weight") - pl.col("risk_weight"))
.otherwise(pl.lit(0.0))
.alias("guarantee_benefit_rw"),
]
)
return exposures
apply_guarantee_substitution — src/rwa_calc/engine/slotting/transforms.py:195
@cites("CRR Art. 235")
@cites("PS1/26, paragraph 235")
def apply_guarantee_substitution(
lf: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Risk-Weight Substitution Method on slotting legs (Art. 235(1)).
The CRM stage already splits guaranteed slotting exposures into
physical ``__G_``/``__REM`` legs and assigns ``guarantor_approach="sa"``
(slotting has no PD, so parameter substitution never applies); this
step is the previously-MISSING consumer: the covered leg takes the
guarantor's SA risk weight when beneficial, via the SAME shared
substitution step the SA branch runs (identical beneficial gate,
multi-guarantor redistribution and audit columns — the F8
``guarantee_benefit_rw`` snapshot lands here, on the SLOTTING borrower
basis, before supporting factors and the portfolio floor).
Gated by the cited pack Feature ``slotting_guarantee_substitution``
(recorded decision 2026-07-12: enabled under BOTH regimes — PS1/26
mandates RWSM; the CRR-side basis is recorded as unsettled on the
Feature's citation). Runs only when the CRM guarantee sub-step ran
(the SA step's ``guarantor_entity_type`` sentinel gate).
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
if not resolved_pack.feature("slotting_guarantee_substitution"):
return lf
return sa_apply_guarantee_substitution(lf, config, pack=resolved_pack)
CRR Art. 237 — Maturity mismatch¶
_apply_maturity_mismatch_to_guarantees — src/rwa_calc/engine/crm/guarantees.py:1510
@cites("CRR Art. 217")
@cites("CRR Art. 237")
@cites("PS1/26, paragraph 237")
def _apply_maturity_mismatch_to_guarantees(
guarantees: pl.LazyFrame,
exposures: pl.LazyFrame,
config: CalculationConfig,
) -> pl.LazyFrame:
"""
Apply CRR Art. 237/239(3) maturity mismatch treatment to guarantee amounts.
When the protection's residual maturity ``t`` is shorter than the
exposure's effective maturity ``T``, the covered amount ``G`` is scaled:
GA = G* × (t - 0.25) / (T - 0.25)
with ``T`` capped at 5.0 years and both ``t`` and ``T`` floored at 0.25.
Scaling is applied to ``amount_covered`` and ``percentage_covered`` before
the split, so the reduced nominal protection value propagates through
cap-at-EAD.
Three Art. 237 eligibility gates ZERO coverage (rather than merely scaling
it) before the 239(3) formula, mirroring the collateral sibling in
``engine/crm/haircuts.py``. All three bind ONLY WHERE a maturity mismatch
exists (Art. 237(2) chapeau) — matched / protection-outlives-exposure
guarantees stay recognised:
- Art. 237(1): credit protection whose RAW residual maturity is < 3 months
AND shorter than the exposure is not recognised. The test runs on the
pre-floor residuals so a short exposure — whose ``T`` also floors to 0.25
and would mask the mismatch under the scaling formula — no longer retains
full coverage.
- Art. 162(3)/237(2)(b): where the exposure is subject to the one-day IRB
maturity floor (daily-margined repos/SFTs), ANY maturity mismatch makes
the protection ineligible. The ``has_one_day_maturity_floor`` flag is
joined from the exposure; null/absent is PERMISSIVE (treated as no floor).
- Art. 237(2)(a): protection whose ORIGINAL maturity is < 1 year is ineligible
where a mismatch exists. Relocated here from an unconditional pre-filter
(P1.232) so matched short-dated (e.g. trade-finance) guarantees are no
longer discarded. Reads ``original_maturity_years`` (the original term, NOT
the residual ``t``); null is PERMISSIVE (>= 1y).
The protection residual maturity ``t`` is derived from the guarantee
row's ``maturity_date`` if present, otherwise from
``original_maturity_years``. The exposure residual ``T`` is derived
from the exposure's ``maturity_date``.
References:
CRR / PS1-26 Art. 237(1): <3-month-and-shorter protection ineligibility.
CRR / PS1-26 Art. 237(2)(a): <1y-original protection, mismatch-conditioned.
CRR / PS1-26 Art. 237(2)(b) with Art. 162(3): one-day-floor exposures
+ any mismatch => ineligible.
CRR Art. 238(1): maturity of credit protection — ``t`` is the RESIDUAL
maturity (time remaining to protection maturity), not the original
contract term; the residual from ``maturity_date`` therefore wins
and ``original_maturity_years`` is only a fallback.
CRR Art. 239(3): maturity mismatch adjustment formula.
"""
guar_schema = guarantees.collect_schema()
guar_cols = guar_schema.names()
exp_schema = exposures.collect_schema()
exp_cols = exp_schema.names()
# Need exposure maturity_date and at least one of guarantee maturity_date
# / original_maturity_years to compute t and T.
if "maturity_date" not in exp_cols:
return guarantees
has_guar_maturity_date = "maturity_date" in guar_cols
has_guar_original_maturity = "original_maturity_years" in guar_cols
if not (has_guar_maturity_date or has_guar_original_maturity):
return guarantees
# Bring exposure residual maturity (years) and the Art. 162(3) one-day
# maturity-floor flag onto each guarantee row.
exp_t_expr = exact_fractional_years_expr(config.reporting_date, "maturity_date").alias("_exp_T")
exp_select = [pl.col("exposure_reference"), exp_t_expr]
has_1d_floor_col = "has_one_day_maturity_floor" in exp_cols
if has_1d_floor_col:
exp_select.append(
pl.col("has_one_day_maturity_floor").fill_null(False).alias("_has_1d_floor")
)
exp_lookup = exposures.select(exp_select)
guarantees = guarantees.join(
exp_lookup,
left_on="beneficiary_reference",
right_on="exposure_reference",
how="left",
)
# Null-PERMISSIVE: an exposure with no flag, an absent column, or a
# join-miss beneficiary is treated as NOT subject to the one-day floor
# (mirrors the collateral sibling's default).
if has_1d_floor_col:
guarantees = guarantees.with_columns(pl.col("_has_1d_floor").fill_null(False))
else:
guarantees = guarantees.with_columns(pl.lit(False).alias("_has_1d_floor"))
# Compute t = RESIDUAL maturity (Art. 238(1)): the time REMAINING to
# protection maturity, derived from the guarantee ``maturity_date`` minus
# the reporting date. ``original_maturity_years`` is the ORIGINAL contract
# term and must NOT override the residual — otherwise a seasoned guarantee
# (long original term, short residual) is over-recognised. It is used for
# ``t`` only as a fallback when ``maturity_date`` is null. The separate
# Art. 237(2)(a) >=1y eligibility gate upstream still reads
# ``original_maturity_years`` (the original term). A null PROTECTION maturity
# t stays PERMISSIVE — no scaling, no gate, full coverage (t-side unknown =>
# no basis to reduce). This is asymmetric with the EXPOSURE maturity T, which
# a null defaults CONSERVATIVELY to 5y below (Art. 237 targets short
# protection on longer exposures, so an unknown exposure horizon must not
# defeat the gates).
if has_guar_maturity_date and has_guar_original_maturity:
t_from_date = exact_fractional_years_expr(config.reporting_date, "maturity_date")
t_raw = (
pl.when(pl.col("maturity_date").is_not_null())
.then(t_from_date)
.otherwise(pl.col("original_maturity_years"))
)
elif has_guar_maturity_date:
t_raw = exact_fractional_years_expr(config.reporting_date, "maturity_date")
else:
t_raw = pl.col("original_maturity_years")
# Art. 239(3) floors / caps: t and T floored at 0.25, T capped at 5.0.
# A null / join-miss exposure maturity defaults to a 5y exposure (the most
# conservative recognised maturity), aligning with the collateral twin
# (haircuts.py) so a guarantee on a null-maturity exposure is still subject
# to the mismatch gates and the 239(3) scaling rather than silently keeping
# full coverage.
floor = pl.lit(0.25)
cap = pl.lit(5.0)
exp_T = pl.col("_exp_T").fill_null(5.0) # raw exposure residual; null -> 5y
t_eff_safe = (
pl.when(t_raw.is_null())
.then(pl.lit(None, dtype=pl.Float64))
.otherwise(pl.max_horizontal(t_raw, floor))
)
# The 0.25 floor lives ONLY on the scaling denominator (its purpose); the
# eligibility gates below compare the RAW residuals.
exp_t_eff = pl.max_horizontal(pl.min_horizontal(exp_T, cap), floor)
# Mismatch (floored) drives the Art. 239(3) scaling.
is_mismatch = t_eff_safe.is_not_null() & (t_eff_safe < exp_t_eff)
scale = (t_eff_safe - floor) / (exp_t_eff - floor)
# Art. 237(1): a RAW protection residual < 3 months that is also shorter than
# the exposure is not recognised. Tested pre-floor (audit: "raw t < 0.25 AND
# t < raw T") so a short exposure — whose T also floors to 0.25 and would
# mask the mismatch under the scaling formula — no longer retains full
# coverage, while a protection that OUTLIVES a sub-3-month exposure (t >= T)
# stays recognised (Art. 238: no adjustment when protection >= exposure).
# (The collateral twin labels this sub-point 237(2)(a) and floors the
# exposure maturity at 0.25 for its mismatch test; per the audit we compare
# the raw T so the outlives case is not spuriously zeroed.)
raw_mismatch = t_raw.is_not_null() & (t_raw < exp_T)
short_protection = raw_mismatch & (t_raw < floor)
# Art. 162(3)/237(2)(b): a one-day-M-floor exposure (daily-margined repo/SFT)
# with ANY maturity mismatch makes the protection ineligible.
one_day_floor_gate = pl.col("_has_1d_floor") & raw_mismatch
# Art. 237(2)(a): unfunded protection whose ORIGINAL maturity is < 1 year is
# ineligible ONLY where a maturity mismatch exists (Art. 237(2) chapeau) — a
# matched or protection-outlives-exposure short-dated guarantee stays
# recognised. Relocated here (P1.232) from the former UNCONDITIONAL pre-filter
# in _prepare_guarantees, mirroring the collateral twin's conditioning
# (haircuts.py). Null original maturity is PERMISSIVE (treated as >= 1y => not
# ineligible), preserving the P1.10 policy. Reads the ORIGINAL term
# (original_maturity_years), NOT the residual t that feeds the scaling (P1.219).
orig_maturity = (
pl.col("original_maturity_years").fill_null(10.0)
if has_guar_original_maturity
else pl.lit(10.0)
)
short_original_gate = raw_mismatch & (orig_maturity < 1.0)
# Zero-gates take priority over the scaling; otherwise scale on mismatch,
# else full coverage. Mirrors the collateral sibling (engine/crm/haircuts.py).
scale_safe = (
pl.when(short_protection | one_day_floor_gate | short_original_gate)
.then(pl.lit(0.0))
.when(is_mismatch)
.then(scale)
.otherwise(pl.lit(1.0))
)
scale_exprs: list[pl.Expr] = []
if "amount_covered" in guar_cols:
scale_exprs.append((pl.col("amount_covered") * scale_safe).alias("amount_covered"))
if "percentage_covered" in guar_cols:
scale_exprs.append((pl.col("percentage_covered") * scale_safe).alias("percentage_covered"))
if scale_exprs:
guarantees = guarantees.with_columns(scale_exprs)
return _drop_columns_if_present(guarantees, ["_exp_T", "_has_1d_floor"])
apply_maturity_mismatch — src/rwa_calc/engine/crm/haircuts.py:814
@cites("CRR Art. 237")
@cites("CRR Art. 238")
def apply_maturity_mismatch(
self,
collateral: pl.LazyFrame,
config: CalculationConfig,
) -> pl.LazyFrame:
"""
Apply maturity mismatch adjustment per CRR Art. 237-238.
Art. 237(2) ineligibility conditions (protection zeroed when mismatch exists):
- (a) Residual maturity < 3 months (existing check)
- (b) Original maturity of protection < 1 year
- Art. 162(3) exposures with 1-day IRB maturity floor: ANY mismatch makes
protection ineligible (repos/SFTs with daily margining)
Formula (Art. 238): CVAM = CVA × (t - 0.25) / (T - 0.25)
where t = collateral residual maturity, T = min(exposure residual maturity, 5).
Args:
collateral: Collateral with value_after_haircut, residual_maturity_years,
and exposure_maturity (Date) columns. Optionally:
original_maturity_years (Float64) and
exposure_has_one_day_maturity_floor (Boolean).
config: Calculation configuration (provides reporting_date)
Returns:
LazyFrame with maturity-adjusted collateral values
"""
reporting_date = config.reporting_date
coll_schema = collateral.collect_schema()
# Derive exposure maturity in years from the Date column, capped at 5y, floored at 0.25y
exposure_maturity_years_expr = (
(
(pl.col("exposure_maturity").cast(pl.Date) - pl.lit(reporting_date))
.dt.total_days()
.cast(pl.Float64)
/ 365.25
)
.clip(lower_bound=0.25, upper_bound=5.0)
.fill_null(5.0)
)
prep_cols = [
pl.col("residual_maturity_years").fill_null(10.0).alias("coll_maturity"),
exposure_maturity_years_expr.alias("_exposure_maturity_years"),
]
# Art. 237(2): original maturity of protection — null defaults to >= 1yr (permissive)
if "original_maturity_years" in coll_schema.names():
prep_cols.append(
pl.col("original_maturity_years").fill_null(10.0).alias("_orig_maturity")
)
else:
prep_cols.append(pl.lit(10.0).alias("_orig_maturity"))
# Art. 162(3): 1-day maturity floor flag — null/absent defaults to False (permissive)
if "exposure_has_one_day_maturity_floor" in coll_schema.names():
prep_cols.append(
pl.col("exposure_has_one_day_maturity_floor")
.fill_null(False)
.alias("_has_1d_floor")
)
else:
prep_cols.append(pl.lit(False).alias("_has_1d_floor"))
collateral = collateral.with_columns(prep_cols)
# Determine whether a maturity mismatch exists (collateral < exposure)
has_mismatch = pl.col("coll_maturity") < pl.col("_exposure_maturity_years")
# Calculate maturity mismatch adjustment per Art. 237-238
collateral = collateral.with_columns(
[
# No adjustment when collateral maturity >= exposure maturity
pl.when(~has_mismatch)
.then(pl.lit(1.0))
# Art. 237(2)(a): No protection when collateral maturity < 3 months
.when(pl.col("coll_maturity") < 0.25)
.then(pl.lit(0.0))
# Art. 237(2): Original maturity of protection < 1 year → ineligible
.when(pl.col("_orig_maturity") < 1.0)
.then(pl.lit(0.0))
# Art. 162(3)/237(2): 1-day M floor exposure → any mismatch makes
# protection ineligible (repos/SFTs with daily margining)
.when(pl.col("_has_1d_floor"))
.then(pl.lit(0.0))
# CVAM = (t - 0.25) / (T - 0.25) where T = exposure maturity capped at 5y
.otherwise(
(pl.col("coll_maturity") - 0.25) / (pl.col("_exposure_maturity_years") - 0.25)
)
.alias("maturity_adjustment_factor"),
]
)
# Apply maturity adjustment
collateral = collateral.with_columns(
[
(pl.col("value_after_haircut") * pl.col("maturity_adjustment_factor")).alias(
"value_after_maturity_adj"
),
]
)
return collateral.drop(["_exposure_maturity_years", "_orig_maturity", "_has_1d_floor"])
CRR Art. 238 — Maturity of credit protection¶
generate_netting_collateral — src/rwa_calc/engine/crm/collateral.py:169
@cites("CRR Art. 195")
@cites("CRR Art. 219")
@cites("CRR Art. 223")
@cites("CRR Art. 238")
def generate_netting_collateral(
exposures: pl.LazyFrame,
errors: list[CalculationError] | None = None,
*,
reporting_date: date | None = None,
) -> pl.LazyFrame | None:
"""
Generate synthetic cash collateral from negative-drawn netting-eligible loans.
When a loan has a negative drawn amount (credit balance / deposit) and carries
a ``netting_agreement_reference`` (CRR Art. 195/219), the absolute value of
that negative balance can reduce other exposures covered by the SAME netting
agreement AND owed by the SAME counterparty — treated as synthetic cash
collateral.
CRR/PS1-26 Art. 195 (P1.238): on-balance-sheet netting is limited to "mutual
claims" / "reciprocal cash balances between the institution and the
counterparty" — a single counterparty. So a deposit from counterparty A may
net only loans owed by counterparty A under the same agreement; it may NOT
offset a loan to a different counterparty B, even where a group-level
agreement reference is shared. Pools are therefore keyed by
(netting_agreement_reference, counterparty_reference) — the agreement is the
legal set-off boundary, the counterparty the Art. 195 eligibility boundary.
A netting_agreement_reference that spans more than one counterparty raises a
CRM016 data-quality warning (the disallowed cross-counterparty offset is
otherwise invisible). Two exposures still do NOT net unless they share the
reference, regardless of facility hierarchy.
CRR Art. 219 limits on-balance-sheet netting to drawn loans and deposits
(cash-on-cash). Synthetic cash collateral is allocated pro-rata by the drawn
portion (`on_bs_for_ead`) to positive-drawn LOAN siblings carrying the same
reference — contingents and synthetic facility_undrawn rows are
off-balance-sheet and excluded from the beneficiary set. Netting pools also
keep currency (as (ref, currency, counterparty_reference)) so the haircut
pipeline can apply FX haircuts when the pool currency differs from the
sibling's currency.
Art. 219 treats the netted deposit as cash collateral, so the funded-
protection maturity-mismatch rules (Art. 237-239) apply exactly as for any
other funded protection (P1.241). The synthetic row therefore carries the
DEPOSIT's maturity — not the beneficiary loan's — as ``maturity_date``, the
deposit residual (t) as ``residual_maturity_years`` (when ``reporting_date``
is supplied), and the deposit ORIGINAL term as ``original_maturity_years``
(when ``value_date`` is available). The downstream ``apply_maturity_mismatch``
then, on a mismatch (t < T where T is the loan residual), zeroes the
protection when t < 0.25 (Art. 237(1)) OR the original term < 1y
(Art. 237(2)(a)), else applies (t-0.25)/(T-0.25) (Art. 238-239). Previously
the row carried the loan's maturity, a null residual (filled to 10y
downstream) and no original term, so no gate fired and a short deposit
netting a long loan was recognised in full.
The residual t uses the /365.25 day-count of the exposure-side T derivation in
``apply_maturity_mismatch`` (so equal deposit/loan maturities net in full with
NO phantom mismatch); the original term uses the /365 convention of the
engine's other original-maturity derivations (risk_weights.py / enrich.py).
Pooling convention (conservative): when several deposits of differing
maturities pool into one (ref, currency, counterparty) row, the pool carries
the EARLIEST (minimum) deposit maturity AND the minimum deposit original term.
The earliest-maturing deposit is when the pool's protection first begins to
lapse; representing the whole pool at that maturity maximises the mismatch
haircut (shortest t → smallest (t-0.25)/(T-0.25)), and the minimum original
term is the one most likely to trip the Art. 237(2)(a) <1y gate — both the
prudent single-value summary. A null deposit maturity (or no
``reporting_date``) leaves the residual null and is handled permissively
downstream — absent maturity data cannot establish a mismatch, the same
convention ordinary financial collateral without a supplied residual follows
(this is NOT an anti-conservative fill: the downstream 10y default is
unchanged, it is simply no longer fed a null when the data is present); a null
original term (no ``value_date``) likewise leaves the Art. 237(2)(a) gate
permissive.
Args:
exposures: Exposures with ead_for_crm, on_bs_for_ead, exposure_type set
errors: optional CRM error channel — receives Art. 195 CRM016 warnings
for netting agreements that span more than one counterparty.
reporting_date: run reporting date, used to derive the deposit residual
maturity (Art. 238) on the synthetic rows. When None (direct
unit-test callers), residual_maturity_years stays null and the
maturity mismatch is not applied — backward-compatible behaviour.
Returns:
LazyFrame of synthetic collateral rows, or None if no netting applies
"""
schema = exposures.collect_schema()
schema_names = set(schema.names())
if "netting_agreement_reference" not in schema_names:
return None
# value_date lets the pool derive each deposit's ORIGINAL maturity for the
# Art. 237(2)(a) gate. It is a core exposure column in production; injected as
# a typed null for direct unit-test callers that omit it (→ original maturity
# null → the gate stays permissive), via the schema-driven ensure_columns.
exposures = ensure_columns(exposures, {"value_date": ColumnSpec(pl.Date, required=False)})
# Graceful fallback for direct unit-test callers (production always
# supplies ead_for_crm via _initialize_ead, on_bs_for_ead via _compute_ead,
# and exposure_type via hierarchy).
if "ead_for_crm" not in schema_names:
exposures = exposures.with_columns(pl.col("ead_gross").alias("ead_for_crm"))
if "on_bs_for_ead" not in schema_names:
interest_expr = (
pl.col("interest").fill_null(0.0).clip(lower_bound=0.0)
if "interest" in schema_names
else pl.lit(0.0)
)
exposures = exposures.with_columns(
(pl.col("drawn_amount").clip(lower_bound=0.0) + interest_expr).alias("on_bs_for_ead")
)
if "exposure_type" not in schema_names:
exposures = exposures.with_columns(pl.lit("loan").alias("exposure_type"))
if "counterparty_reference" not in schema_names:
# Test-caller fallback only: production always supplies counterparty_reference
# (a core exposure column). Absent → treat every row as the same counterparty
# so the Art. 195 same-counterparty constraint is a no-op for legacy callers.
exposures = exposures.with_columns(pl.lit("_UNKNOWN_CP").alias("counterparty_reference"))
# Negative-drawn loans carrying a netting agreement reference provide the pool
negative_loans = exposures.filter(
pl.col("netting_agreement_reference").is_not_null() & (pl.col("drawn_amount") < 0)
)
# Art. 195 (P1.238): emit a CRM016 warning for any agreement that spans more
# than one counterparty (a deposit and a positive loan under the same
# reference but for different counterparties would previously have netted).
if errors is not None:
_record_cross_counterparty_netting(exposures, errors)
# Sum abs(drawn_amount) per (netting_agreement_reference, currency,
# counterparty_reference) → netting pool. Currency is kept so the synthetic
# collateral carries the source currency (FX haircut when currencies differ);
# counterparty_reference enforces the Art. 195 same-counterparty limit.
# Art. 219/238 (P1.241): the earliest (min) deposit maturity per pool is the
# conservative single-maturity summary — it drives the maturity-mismatch t.
# The minimum deposit ORIGINAL maturity is carried alongside for the
# Art. 237(2)(a) >=1y eligibility gate (min → shortest term is most likely to
# trip the <1y gate; derived from maturity_date - value_date, the same
# convention as engine/sa/risk_weights.py / hierarchy/enrich.py, /365). A null
# value_date yields a null original maturity (permissive — gate does not fire).
deposit_original_years = (
pl.col("maturity_date").cast(pl.Int32) - pl.col("value_date").cast(pl.Int32)
).cast(pl.Float64) / 365.0
netting_pool = (
negative_loans.group_by(
["netting_agreement_reference", "currency", "counterparty_reference"]
)
.agg(
pl.col("drawn_amount").abs().sum().alias("netting_pool"),
pl.col("maturity_date").min().alias("_pool_deposit_maturity_date"),
deposit_original_years.min().alias("_pool_deposit_orig_maturity"),
)
.rename({"currency": "_pool_currency"})
)
# CRR Art. 219: drawn-on-drawn cash netting. Synthetic cash collateral may
# only benefit the drawn portion of loan exposures — contingents and
# facility_undrawn synthetic rows are off-balance-sheet and ineligible. A
# sibling matches a pool iff it shares BOTH the netting_agreement_reference
# and the counterparty_reference (Art. 195 same-counterparty limit).
# The beneficiary loan's own maturity is NOT carried onto the synthetic row:
# it feeds the mismatch as the EXPOSURE side (T) via the exposure lookup join
# downstream, while the synthetic row carries the DEPOSIT maturity (t).
positive_siblings = exposures.filter(
(pl.col("exposure_type") == "loan")
& (pl.col("on_bs_for_ead") > 0)
& pl.col("netting_agreement_reference").is_not_null()
).select(
"exposure_reference",
"netting_agreement_reference",
"counterparty_reference",
"currency",
"on_bs_for_ead",
)
# Match siblings to pools by shared agreement reference AND counterparty.
matched = positive_siblings.join(
netting_pool,
on=["netting_agreement_reference", "counterparty_reference"],
how="inner",
)
# Total drawn EAD per pool for pro-rata allocation. CRR Art. 219 nets cash
# against drawn loans, so the pro-rata basis is the on-BS (drawn) portion,
# NOT ead_for_crm (which includes the off-BS nominal at CCF=100% per
# Art. 223(4) — that override is for collateral valuation, not for OBS
# netting allocation basis).
facility_totals = matched.group_by(
"netting_agreement_reference", "_pool_currency", "counterparty_reference"
).agg(
pl.col("on_bs_for_ead").sum().alias("_facility_total_drawn"),
)
# Join totals back for pro-rata
allocated = matched.join(
facility_totals,
on=["netting_agreement_reference", "_pool_currency", "counterparty_reference"],
how="left",
).filter(pl.col("_facility_total_drawn") > 0)
# Pro-rata market_value per sibling by drawn portion (Art. 219).
allocated = allocated.with_columns(
(pl.col("netting_pool") * pl.col("on_bs_for_ead") / pl.col("_facility_total_drawn")).alias(
"market_value"
),
)
# Deposit residual maturity (Art. 238 t). Derived from the pool's earliest
# deposit maturity when a reporting_date is available; null (permissive)
# otherwise. The /365.25 basis MATCHES the exposure-side T derivation in
# HaircutCalculator.apply_maturity_mismatch, so a deposit and loan sharing a
# maturity date net in full (t == T, no phantom mismatch). A null pool
# maturity date yields a null residual either way.
residual_expr = (
(
(pl.col("_pool_deposit_maturity_date").cast(pl.Date) - pl.lit(reporting_date))
.dt.total_days()
.cast(pl.Float64)
/ 365.25
)
if reporting_date is not None
else pl.lit(None, dtype=pl.Float64)
)
# Deposit original maturity (Art. 237(2)(a) t_orig): the pool's minimum
# deposit original term (null → permissive when value_date was absent). A
# deposit with original maturity < 1y and a mismatch is zeroed downstream.
original_expr = pl.col("_pool_deposit_orig_maturity")
# Build synthetic collateral rows — currency from the pool (source of funds).
# maturity_date / residual_maturity_years / original_maturity_years are the
# DEPOSIT's (Art. 219/237-238), not the beneficiary loan's.
synthetic = allocated.select(
(pl.lit("NETTING_") + pl.col("exposure_reference")).alias("collateral_reference"),
pl.lit("cash").alias("collateral_type"),
pl.col("_pool_currency").alias("currency"),
pl.col("_pool_deposit_maturity_date").alias("maturity_date"),
pl.col("market_value"),
pl.lit(None).cast(pl.Float64).alias("nominal_value"),
pl.lit(None).cast(pl.Float64).alias("pledge_percentage"),
pl.lit("loan").alias("beneficiary_type"),
pl.col("exposure_reference").alias("beneficiary_reference"),
pl.lit(None).cast(pl.Int8).alias("issuer_cqs"),
pl.lit(None).cast(pl.String).alias("issuer_type"),
residual_expr.alias("residual_maturity_years"),
original_expr.alias("original_maturity_years"),
pl.lit(True).alias("is_eligible_financial_collateral"),
pl.lit(True).alias("is_eligible_irb_collateral"),
pl.lit(None).cast(pl.Date).alias("valuation_date"),
pl.lit(None).cast(pl.String).alias("valuation_type"),
pl.lit(None).cast(pl.String).alias("property_type"),
pl.lit(None).cast(pl.Float64).alias("property_ltv"),
pl.lit(None).cast(pl.Boolean).alias("is_income_producing"),
pl.lit(None).cast(pl.Boolean).alias("is_adc"),
pl.lit(None).cast(pl.Boolean).alias("is_presold"),
)
return synthetic
apply_maturity_mismatch — src/rwa_calc/engine/crm/haircuts.py:815
@cites("CRR Art. 237")
@cites("CRR Art. 238")
def apply_maturity_mismatch(
self,
collateral: pl.LazyFrame,
config: CalculationConfig,
) -> pl.LazyFrame:
"""
Apply maturity mismatch adjustment per CRR Art. 237-238.
Art. 237(2) ineligibility conditions (protection zeroed when mismatch exists):
- (a) Residual maturity < 3 months (existing check)
- (b) Original maturity of protection < 1 year
- Art. 162(3) exposures with 1-day IRB maturity floor: ANY mismatch makes
protection ineligible (repos/SFTs with daily margining)
Formula (Art. 238): CVAM = CVA × (t - 0.25) / (T - 0.25)
where t = collateral residual maturity, T = min(exposure residual maturity, 5).
Args:
collateral: Collateral with value_after_haircut, residual_maturity_years,
and exposure_maturity (Date) columns. Optionally:
original_maturity_years (Float64) and
exposure_has_one_day_maturity_floor (Boolean).
config: Calculation configuration (provides reporting_date)
Returns:
LazyFrame with maturity-adjusted collateral values
"""
reporting_date = config.reporting_date
coll_schema = collateral.collect_schema()
# Derive exposure maturity in years from the Date column, capped at 5y, floored at 0.25y
exposure_maturity_years_expr = (
(
(pl.col("exposure_maturity").cast(pl.Date) - pl.lit(reporting_date))
.dt.total_days()
.cast(pl.Float64)
/ 365.25
)
.clip(lower_bound=0.25, upper_bound=5.0)
.fill_null(5.0)
)
prep_cols = [
pl.col("residual_maturity_years").fill_null(10.0).alias("coll_maturity"),
exposure_maturity_years_expr.alias("_exposure_maturity_years"),
]
# Art. 237(2): original maturity of protection — null defaults to >= 1yr (permissive)
if "original_maturity_years" in coll_schema.names():
prep_cols.append(
pl.col("original_maturity_years").fill_null(10.0).alias("_orig_maturity")
)
else:
prep_cols.append(pl.lit(10.0).alias("_orig_maturity"))
# Art. 162(3): 1-day maturity floor flag — null/absent defaults to False (permissive)
if "exposure_has_one_day_maturity_floor" in coll_schema.names():
prep_cols.append(
pl.col("exposure_has_one_day_maturity_floor")
.fill_null(False)
.alias("_has_1d_floor")
)
else:
prep_cols.append(pl.lit(False).alias("_has_1d_floor"))
collateral = collateral.with_columns(prep_cols)
# Determine whether a maturity mismatch exists (collateral < exposure)
has_mismatch = pl.col("coll_maturity") < pl.col("_exposure_maturity_years")
# Calculate maturity mismatch adjustment per Art. 237-238
collateral = collateral.with_columns(
[
# No adjustment when collateral maturity >= exposure maturity
pl.when(~has_mismatch)
.then(pl.lit(1.0))
# Art. 237(2)(a): No protection when collateral maturity < 3 months
.when(pl.col("coll_maturity") < 0.25)
.then(pl.lit(0.0))
# Art. 237(2): Original maturity of protection < 1 year → ineligible
.when(pl.col("_orig_maturity") < 1.0)
.then(pl.lit(0.0))
# Art. 162(3)/237(2): 1-day M floor exposure → any mismatch makes
# protection ineligible (repos/SFTs with daily margining)
.when(pl.col("_has_1d_floor"))
.then(pl.lit(0.0))
# CVAM = (t - 0.25) / (T - 0.25) where T = exposure maturity capped at 5y
.otherwise(
(pl.col("coll_maturity") - 0.25) / (pl.col("_exposure_maturity_years") - 0.25)
)
.alias("maturity_adjustment_factor"),
]
)
# Apply maturity adjustment
collateral = collateral.with_columns(
[
(pl.col("value_after_haircut") * pl.col("maturity_adjustment_factor")).alias(
"value_after_maturity_adj"
),
]
)
return collateral.drop(["_exposure_maturity_years", "_orig_maturity", "_has_1d_floor"])
CRR Art. 271 — Determination of the exposure value¶
sft_bundle_to_exposures — src/rwa_calc/engine/sft/fccm.py:113
@cites("CRR Art. 220")
@cites("CRR Art. 223")
@cites("CRR Art. 224")
@cites("CRR Art. 226")
@cites("CRR Art. 271")
@cites("CRR Art. 285")
def sft_bundle_to_exposures(
raw_sft: RawSFTBundle,
reporting_date: date,
rulepack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Shape FCCM SFT EADs into synthetic exposure rows from the lean SFT bundle.
The sole FCCM entry point (SFT/FCCM separation): consumes the dedicated
:class:`RawSFTBundle` (``RawDataBundle.sft``). The SFT/derivative
discrimination lives in the *input bundle* now, not in any in-engine
``transaction_type`` split:
- Every trade row is an SFT (no ``transaction_type`` filter): the whole
``raw_sft.trades`` frame is in scope.
- The netting-set ``counterparty_reference`` is denormalised onto the trade
row (FCCM scope is single-trade single-counterparty netting sets,
Art. 220(1)(a)), so the NS-grain counterparty frame is derived from the
trades themselves rather than a separate netting-set table.
- Collateral is OPTIONAL (``raw_sft.collateral is None`` for an
uncollateralised SFT, the common case): a missing collateral leaf yields a
zero collateral term (CVA·(1−HC−HFX) = 0), exactly as an empty
``ccr_collateral`` frame would.
Each emitted synthetic exposure row carries the FCCM provenance:
``exposure_reference = "ccr__<netting_set_id>"``, ``risk_type = "CCR_SFT"``,
``ccr_method = "fccm_sft"``, ``drawn_amount = E*``, ``ead_ccr = E*``.
Args:
raw_sft: The SFT (FCCM) input bundle — every trade row is an SFT with the
denormalised netting-set counterparty; collateral optional.
reporting_date: As-of date; written to ``value_date``.
rulepack: The resolved RUN rulepack supplying the Art. 162 effective-
maturity floors / regime gate for the ``ccr_effective_maturity``
carrier. ``None`` (the back-compat default used by direct unit /
acceptance calls) falls back to the module-level CRR ``_PACK``; the
stage adapter threads the run pack so production runs are regime-
correct.
Returns:
LazyFrame at netting-set grain. Empty (zero-row) frame when the trades
bundle is empty.
References:
CRR Art. 271(2); Art. 220(1)(a); Art. 223(5); Art. 224 Table 1;
Art. 224(2)(b); Art. 226; Art. 285(2)-(5).
"""
sft_trades_lf = raw_sft.trades.sft_trades
# Counterparty is denormalised onto the trade — collapse to NS grain. The
# ``first()`` aggregation is exact under the single-CP-per-NS scope
# (Art. 220(1)(a)); should a future netting set span counterparties the
# FCCM scope itself would need revisiting.
ns_counterparty_lf = sft_trades_lf.group_by("netting_set_id").agg(
pl.col("counterparty_reference").first()
)
ccr_collateral_lf = (
raw_sft.collateral.sft_collateral if raw_sft.collateral is not None else None
)
return _build_sft_exposure_rows(
sft_trades_lf=sft_trades_lf,
ns_counterparty_lf=ns_counterparty_lf,
ccr_collateral_lf=ccr_collateral_lf,
reporting_date=reporting_date,
pack=rulepack if rulepack is not None else _PACK,
)
CRR Art. 277 — Transactions with a linear risk profile¶
assign_ir_maturity_bucket — src/rwa_calc/engine/ccr/hedging_sets.py:56
@cites("CRR Art. 277")
def assign_ir_maturity_bucket(trades: pl.LazyFrame) -> pl.LazyFrame:
"""Assign an IR maturity bucket per CRR Art. 277(2).
For ``asset_class == "interest_rate"`` rows, derive ``maturity_bucket``
from ``years_to_maturity``:
LT_1Y : M < 1
1Y_5Y : 1 <= M <= 5
GT_5Y : M > 5
Non-IR rows receive a null bucket (extended in subsequent batches).
Args:
trades: LazyFrame with ``asset_class`` and ``years_to_maturity``
columns.
Returns:
The input LazyFrame with a new ``maturity_bucket: Utf8`` column.
References:
CRR Art. 277(2)(a)-(c); BCBS CRE52.32.
"""
is_ir = pl.col("asset_class") == "interest_rate"
m = pl.col("years_to_maturity")
bucket = (
pl.when(is_ir & (m < 1.0))
.then(pl.lit("LT_1Y"))
.when(is_ir & (m <= 5.0))
.then(pl.lit("1Y_5Y"))
.when(is_ir & (m > 5.0))
.then(pl.lit("GT_5Y"))
.otherwise(pl.lit(None, dtype=pl.Utf8))
.alias("maturity_bucket")
)
return trades.with_columns(bucket)
assign_hedging_set — src/rwa_calc/engine/ccr/hedging_sets.py:96
@cites("CRR Art. 277")
def assign_hedging_set(trades: pl.LazyFrame) -> pl.LazyFrame:
"""Assign a composite ``hedging_set_id`` per CRR Art. 277(1).
Pipeline-position note: ``years_to_maturity`` must already be on the
input frame (the upstream maturity-factor stage adds it).
The hedging-set identifier composes the asset-class short code, the
netting-set id, the trade currency and the maturity bucket as
``"<asset_short>-<netting_set_id>-<currency>-<maturity_bucket>"`` —
e.g. ``"IR-NS-IR-01-GBP-GT_5Y"``. Non-IR rows receive a null
``hedging_set_id`` until the corresponding asset-class batch lands.
Args:
trades: LazyFrame with ``asset_class``, ``netting_set_id``,
``currency``, ``years_to_maturity`` columns.
Returns:
The input LazyFrame with new ``maturity_bucket: Utf8`` and
``hedging_set_id: Utf8`` columns.
References:
CRR Art. 277(1)-(2); BCBS CRE52.30-32.
"""
trades = assign_ir_maturity_bucket(trades)
# Defensive: upstream stages that pre-date the credit/equity/commodity
# branches may pass frames without ``commodity_type``. The column is
# required only by the commodity branch and may be safely treated as
# all-null when absent — Polars evaluates the dispatch ladder eagerly
# at plan-resolve time, so the column must exist on the schema.
schema_names = trades.collect_schema().names()
if "commodity_type" not in schema_names:
trades = trades.with_columns(pl.lit(None, dtype=pl.Utf8).alias("commodity_type"))
asset_short = pl.col("asset_class").replace_strict(
ASSET_CLASS_SHORT_CODE, default=None, return_dtype=pl.Utf8
)
# Order-independent currency pair for FX hedging-set keying (Art. 277(3)(a)).
# ``min/max`` of the two ISO-4217 strings collapses EUR/USD and USD/EUR
# into a single hedging set per netting set.
fx_pair = pl.concat_str(
[
pl.min_horizontal(pl.col("currency"), pl.col("currency_leg2")),
pl.lit("/"),
pl.max_horizontal(pl.col("currency"), pl.col("currency_leg2")),
]
)
ir_hs = pl.concat_str(
[
asset_short,
pl.col("netting_set_id"),
pl.col("currency"),
pl.col("maturity_bucket"),
],
separator="-",
)
fx_hs = pl.concat_str(
[pl.lit("FX"), pl.col("netting_set_id"), fx_pair],
separator="-",
)
credit_hs = pl.concat_str(
[pl.lit("CR"), pl.col("netting_set_id")],
separator="-",
)
equity_hs = pl.concat_str(
[pl.lit("EQ"), pl.col("netting_set_id")],
separator="-",
)
commodity_hs = pl.concat_str(
[pl.lit("CO"), pl.col("netting_set_id"), pl.col("commodity_type")],
separator="-",
)
hedging_set_id = (
pl.when(pl.col("asset_class") == "interest_rate")
.then(pl.when(pl.col("maturity_bucket").is_not_null()).then(ir_hs).otherwise(None))
.when(pl.col("asset_class") == "fx")
.then(fx_hs)
.when(pl.col("asset_class") == "credit")
.then(credit_hs)
.when(pl.col("asset_class") == "equity")
.then(equity_hs)
.when(pl.col("asset_class") == "commodity")
.then(pl.when(pl.col("commodity_type").is_not_null()).then(commodity_hs).otherwise(None))
.otherwise(pl.lit(None, dtype=pl.Utf8))
.alias("hedging_set_id")
)
return trades.with_columns(hedging_set_id)
compute_addon_per_asset_class — src/rwa_calc/engine/ccr/pfe.py:147
@cites("CRR Art. 277")
def compute_addon_per_asset_class(trades: pl.LazyFrame) -> pl.LazyFrame:
"""Per-asset-class SA-CCR add-on aggregated from per-trade effective notionals.
Dispatches to asset-class-specific helpers and unions the results onto a
keys frame that anchors every ``(netting_set_id, asset_class)`` combination
present in the input. Asset classes without an implementation (credit /
equity / commodity) keep their ``asset_class_addon`` as null — that's the
contract callers downstream depend on.
Implemented asset classes:
- ``interest_rate``: three IR maturity buckets aggregated per Art. 277a(1)(a)
via :func:`_compute_addon_ir`.
- ``fx``: per-currency-pair hedging sets summed with no cross-set
correlation per BCBS CRE52.55 via :func:`_compute_addon_fx`.
- ``credit``: per-entity effective notionals aggregated inside a single
credit hedging set via the supervisory-correlation formula per
Art. 277a + Art. 280a via :func:`_compute_addon_credit`.
- ``equity``: single hedging set per NS with SN/IDX sub-class aggregation
per Art. 277a + Art. 280b via :func:`_compute_addon_equity`.
- ``commodity``: five commodity buckets (ELECTRICITY / OIL_GAS / METALS /
AGRICULTURAL / OTHER) with within-bucket correlation ρ=0.40
(Art. 280c) and no cross-bucket correlation (CRE52.69) via
:func:`_compute_addon_commodity`.
Args:
trades: LazyFrame at trade grain with at minimum ``netting_set_id``,
``asset_class``, ``hedging_set_id``, ``maturity_bucket``,
``supervisory_delta``, ``adjusted_notional`` and ``maturity_factor``
columns.
Returns:
LazyFrame with one row per (``netting_set_id``, ``asset_class``)
and columns ``netting_set_id``, ``asset_class``,
``asset_class_addon: Float64``.
References:
CRR Art. 277a(1)(a) (IR); CRR Art. 277(3)(a) + BCBS CRE52.55 (FX);
CRR Art. 277(2)(c) + Art. 277a + Art. 280a (credit);
CRR Art. 277(2)(d) + Art. 280b (equity);
CRR Art. 277(3)(b) + Art. 280c + BCBS CRE52.67-69 (commodity);
CRR Art. 280 Table 1/2 (SF_IR=0.5%, SF_FX=4%, SF_EQ_SN=32%, SF_EQ_IDX=20%,
SF_CR by quality/index, SF_CM per bucket).
"""
keys = trades.select(["netting_set_id", "asset_class"]).unique()
ir_addon = _compute_addon_ir(trades).rename({"asset_class_addon": "_ir_addon"})
fx_addon = _compute_addon_fx(trades).rename({"asset_class_addon": "_fx_addon"})
credit_addon = _compute_addon_credit(trades).rename({"asset_class_addon": "_credit_addon"})
eq_addon = _compute_addon_equity(trades).rename({"asset_class_addon": "_eq_addon"})
co_addon = _compute_addon_commodity(trades).rename({"asset_class_addon": "_co_addon"})
return (
keys.join(ir_addon, on=["netting_set_id", "asset_class"], how="left")
.join(fx_addon, on=["netting_set_id", "asset_class"], how="left")
.join(credit_addon, on=["netting_set_id", "asset_class"], how="left")
.join(eq_addon, on=["netting_set_id", "asset_class"], how="left")
.join(co_addon, on=["netting_set_id", "asset_class"], how="left")
.with_columns(
pl.coalesce(
pl.col("_ir_addon"),
pl.col("_fx_addon"),
pl.col("_credit_addon"),
pl.col("_eq_addon"),
pl.col("_co_addon"),
).alias("asset_class_addon")
)
.select(["netting_set_id", "asset_class", "asset_class_addon"])
)
CRR Art. 278 — Transactions with a non-linear risk profile¶
compute_pfe — src/rwa_calc/engine/ccr/pfe.py:63
@cites("CRR Art. 278")
def compute_pfe(
netting_sets: pl.LazyFrame,
config: CCRConfig | None = None,
) -> pl.LazyFrame:
"""SA-CCR PFE multiplier and aggregate PFE per CRR Art. 278(3).
Implements the netting-set-grain PFE composition layer:
multiplier = min(1, F + (1 − F) × exp((V − C) / (2 × (1 − F) × AddOn_agg)))
pfe_addon = multiplier × AddOn_aggregate (Art. 278(1))
rc_unmarg = max(V_net − C_net, 0) (Art. 275(1))
ead_ccr = α × (rc_unmarg + pfe_addon) (Art. 274(2))
where ``F = 0.05`` (``PFE_MULTIPLIER_FLOOR_F``) and the ``2`` in the
denominator is ``PFE_AGGREGATE_DENOM_COEFF``. The ``min(1, ...)`` cap
binds whenever ``V − C ≥ 0`` (over-collateralised / in-the-money).
Args:
netting_sets: LazyFrame at netting-set grain with at minimum
``v_net: Float64``, ``c_net: Float64`` and
``addon_aggregate: Float64`` columns.
config: Optional CCRConfig; when provided ``config.alpha`` overrides
the default α=1.4 (CRR Art. 274(2)).
Returns:
Input LazyFrame with four new columns:
- ``pfe_multiplier: Float64`` — Art. 278(3) multiplier.
- ``pfe_addon: Float64`` — Art. 278(1) PFE.
- ``rc_unmargined: Float64`` — Art. 275(1) replacement cost.
- ``ead_ccr: Float64`` — Art. 274(2) EAD at α = 1.4.
Per-row α (CRR Art. 274(2) second sub-paragraph): when the caller supplies
a per-netting-set ``alpha_applied`` column (the SA-CCR adapter sets it to
1.0 for non-financial / pension-scheme counterparties and 1.4 otherwise),
the EAD step honours it per row. When the column is absent the scalar
``config.alpha`` / 1.4 is used for every row — this keeps the default path
backward-compatible with callers that do not supply ``alpha_applied``.
References:
CRR Art. 274(2); CRR Art. 275(1); CRR Art. 278(1)-(3);
BCBS CRE52.20-23.
"""
alpha_value = float(config.alpha) if config is not None else 1.4
# CRR Art. 274(2) second sub-paragraph: prefer the per-row carve-out scalar
# (``alpha_applied``) when the caller has joined it onto the frame; fall back
# to the scalar α for backward compatibility.
has_alpha_col = "alpha_applied" in netting_sets.collect_schema().names()
alpha_expr = pl.col("alpha_applied") if has_alpha_col else pl.lit(alpha_value)
floor_f = _PFE_MULTIPLIER_FLOOR_F
denom_coeff = _PFE_AGGREGATE_DENOM_COEFF
one_minus_f = 1.0 - floor_f
v_minus_c = pl.col("v_net") - pl.col("c_net")
denom = denom_coeff * one_minus_f * pl.col("addon_aggregate")
uncapped = floor_f + one_minus_f * (v_minus_c / denom).exp()
# Delegate the unmargined RC derivation to the canonical
# ``compute_rc_unmargined`` (Art. 275(1)) so the ``rc_unmargined`` column is
# guaranteed present before the EAD coalesce reads it.
netting_sets = compute_rc_unmargined(netting_sets)
# EAD consumes a unified replacement cost. When the caller has already
# supplied an ``rc`` column (e.g. the SA-CCR adapter coalescing margined
# RC per Art. 275(2) over unmargined RC per Art. 275(1)) the EAD step
# honours it; otherwise it falls back to the unmargined RC computed here.
has_unified_rc = "rc" in netting_sets.collect_schema().names()
rc_for_ead = pl.col("rc") if has_unified_rc else pl.col("rc_unmargined")
return (
netting_sets.with_columns(
pl.min_horizontal(pl.lit(1.0), uncapped).alias("pfe_multiplier"),
)
.with_columns(
(pl.col("pfe_multiplier") * pl.col("addon_aggregate")).alias("pfe_addon"),
)
.with_columns((alpha_expr * (rc_for_ead + pl.col("pfe_addon"))).alias("ead_ccr"))
)
CRR Art. 279 — Treatment of collateral¶
compute_adjusted_notional_ir — src/rwa_calc/engine/ccr/adjusted_notional.py:56
@cites("CRR Art. 279")
def compute_adjusted_notional_ir(
trades: pl.LazyFrame,
reporting_date: date,
) -> pl.LazyFrame:
"""SA-CCR adjusted notional for interest-rate trades per CRR Art. 279b(1)(a).
For ``asset_class == "interest_rate"``:
d = notional * SD(S, E)
SD(S, E) = (exp(-0.05*S) - exp(-0.05*E)) / 0.05
where ``S`` is the years-to-start floored at 10 business days
(10/250 = 0.04y) and ``E`` is the years-to-maturity. FX / credit / equity
/ commodity branches return null (deferred to subsequent batches).
Args:
trades: LazyFrame at trade grain with columns ``asset_class``,
``notional``, ``start_date``, ``maturity_date``.
reporting_date: As-of date for the calculation; used to compute the
year fractions ``S`` (start) and ``E`` (maturity).
Returns:
The input LazyFrame with a new ``adjusted_notional: Float64`` column;
null for non-IR rows.
References:
- CRR Art. 279b(1)(a)
- BCBS CRE52.40 (footnote: 250-business-day year for the start floor)
"""
rate = _SUPERVISORY_DURATION_RATE
s_floor = _START_FLOOR_YEARS
# Calendar-day -> year fraction. 365.25 is the standard SA-CCR convention
# for year fractions; the 250-business-day year applies only to the
# 10-BD start-date floor, which is pre-computed into ``s_floor`` above.
years_to_start = (pl.col("start_date") - pl.lit(reporting_date)).dt.total_days() / 365.25
years_to_maturity = (pl.col("maturity_date") - pl.lit(reporting_date)).dt.total_days() / 365.25
# S floored at 10 BD = 10/250 = 0.04y per Art. 279b(1)(a).
s_floored = pl.max_horizontal(years_to_start, pl.lit(s_floor))
# SD(S, E) = (exp(-rate*S) - exp(-rate*E)) / rate
sd = ((-rate * s_floored).exp() - (-rate * years_to_maturity).exp()) / rate
d = pl.col("notional") * sd
return trades.with_columns(
pl.when(pl.col("asset_class") == "interest_rate")
.then(d)
.otherwise(pl.lit(None, dtype=pl.Float64))
.alias("adjusted_notional")
)
compute_adjusted_notional_fx — src/rwa_calc/engine/ccr/adjusted_notional.py:110
@cites("CRR Art. 279")
def compute_adjusted_notional_fx(
trades: pl.LazyFrame,
base_currency: str,
fx_rates: pl.LazyFrame,
) -> pl.LazyFrame:
"""SA-CCR adjusted notional for FX trades per CRR Art. 279b(1)(b).
For ``asset_class == "fx"``:
- If at least one leg is in the reporting (base) currency
(Art. 279b(1)(b)(i)): adjusted_notional = the *other* leg's notional
converted to the base currency at spot.
- If both legs are in non-base currencies (Art. 279b(1)(b)(ii)):
adjusted_notional = max(|notional_leg1|, |notional_leg2|) after each
leg is converted to the base currency at spot.
Direction lives on ``is_long`` / ``delta``; the adjusted-notional value
itself is taken in absolute terms per the regulatory comparison rule.
FX rates are sourced from ``FX_RATES_SCHEMA`` rows where
``currency_to == base_currency``; an identity row
``{currency_from: base_currency, rate: 1.0}`` is added so a leg already
in the base currency converts trivially. Rows where a required rate is
missing produce a null ``adjusted_notional`` — the orchestrator is
responsible for surfacing the CCR data-quality error.
Args:
trades: LazyFrame at trade grain with columns ``asset_class``,
``notional``, ``currency``, ``notional_leg2``, ``currency_leg2``.
base_currency: ISO-4217 reporting currency (e.g. ``"GBP"``) — typically
``CalculationConfig.base_currency``.
fx_rates: LazyFrame conforming to ``FX_RATES_SCHEMA`` with columns
``currency_from``, ``currency_to``, ``rate``.
Returns:
The input LazyFrame with a new ``adjusted_notional: Float64`` column
populated for ``asset_class == "fx"`` rows only; null elsewhere.
References:
- CRR Art. 279b(1)(b)(i): one-leg-is-base case.
- CRR Art. 279b(1)(b)(ii): both-legs-foreign max-of-converted case.
"""
# Build the leg-currency -> base-currency lookup with an identity row so
# legs already in the base currency convert at 1.0.
fx_to_base = fx_rates.filter(pl.col("currency_to") == pl.lit(base_currency)).select(
pl.col("currency_from"),
pl.col("rate").alias("rate_to_base"),
)
identity = pl.LazyFrame(
{"currency_from": [base_currency], "rate_to_base": [1.0]},
schema={"currency_from": pl.String, "rate_to_base": pl.Float64},
)
rate_lookup = pl.concat([fx_to_base, identity], how="vertical_relaxed")
# Join twice — once for each leg currency. Use left-joins so missing rates
# propagate as nulls (the orchestrator emits the CCR error downstream).
enriched = trades.join(
rate_lookup.rename({"rate_to_base": "_rate_leg1"}),
left_on="currency",
right_on="currency_from",
how="left",
).join(
rate_lookup.rename({"rate_to_base": "_rate_leg2"}),
left_on="currency_leg2",
right_on="currency_from",
how="left",
)
# Converted absolute notionals per leg.
abs_leg1 = pl.col("notional").abs() * pl.col("_rate_leg1")
abs_leg2 = pl.col("notional_leg2").abs() * pl.col("_rate_leg2")
one_leg_is_base = (pl.col("currency") == pl.lit(base_currency)) | (
pl.col("currency_leg2") == pl.lit(base_currency)
)
# Art. 279b(1)(b)(i): when one leg is the base currency, take the *other*
# leg converted (which equals its absolute notional × spot). When leg1 is
# the base, take abs_leg2; when leg2 is the base, take abs_leg1.
one_leg_value = (
pl.when(pl.col("currency") == pl.lit(base_currency)).then(abs_leg2).otherwise(abs_leg1)
)
# Art. 279b(1)(b)(ii): both legs foreign — take max of converted notionals.
both_foreign_value = pl.max_horizontal(abs_leg1, abs_leg2)
fx_adjusted = pl.when(one_leg_is_base).then(one_leg_value).otherwise(both_foreign_value)
# Gate on asset_class == "fx"; preserve any existing adjusted_notional from
# the IR branch via coalesce — callers may have run the IR branch first.
out = enriched.with_columns(
pl.when(pl.col("asset_class") == "fx")
.then(fx_adjusted)
.otherwise(pl.lit(None, dtype=pl.Float64))
.alias("_fx_adjusted_notional")
)
# If the input already has an adjusted_notional column (e.g. from the IR
# branch), preserve non-null values and overlay FX where applicable.
if "adjusted_notional" in trades.collect_schema().names():
out = out.with_columns(
pl.coalesce(pl.col("adjusted_notional"), pl.col("_fx_adjusted_notional")).alias(
"adjusted_notional"
)
)
else:
out = out.rename({"_fx_adjusted_notional": "adjusted_notional"})
return out.drop("_rate_leg1", "_rate_leg2", strict=False).drop(
"_fx_adjusted_notional", strict=False
)
compute_adjusted_notional_credit — src/rwa_calc/engine/ccr/adjusted_notional.py:224
@cites("CRR Art. 279")
def compute_adjusted_notional_credit(
trades: pl.LazyFrame,
reporting_date: date,
) -> pl.LazyFrame:
"""SA-CCR adjusted notional for credit derivatives per CRR Art. 279b(1)(a).
For ``asset_class == "credit"``:
d = notional * SD(S, E)
SD(S, E) = (exp(-0.05*S) - exp(-0.05*E)) / 0.05
where ``S`` is the years-to-start floored at 10 business days
(10/250 = 0.04y) and ``E`` is the years-to-maturity. The supervisory-
duration kernel is shared with the interest-rate asset class — Art. 279b(1)(a)
covers both. Coalesce-safe with the IR / FX branches when run in sequence:
the credit branch only overlays rows where ``asset_class == "credit"``.
Args:
trades: LazyFrame at trade grain with columns ``asset_class``,
``notional``, ``start_date``, ``maturity_date``.
reporting_date: As-of date for the calculation; used to compute the
year fractions ``S`` (start) and ``E`` (maturity).
Returns:
The input LazyFrame with a new (or coalesced) ``adjusted_notional: Float64``
column. Non-credit rows preserve any existing value from a prior branch
(IR / FX) or remain null.
References:
- CRR Art. 279b(1)(a)
- BCBS CRE52.41-43 (supervisory duration shared with IR)
"""
rate = _SUPERVISORY_DURATION_RATE
s_floor = _START_FLOOR_YEARS
# Calendar-day -> year fraction. 365.25 is the standard SA-CCR convention
# for year fractions; the 250-business-day year applies only to the
# 10-BD start-date floor, which is pre-computed into ``s_floor`` above.
years_to_start = (pl.col("start_date") - pl.lit(reporting_date)).dt.total_days() / 365.25
years_to_maturity = (pl.col("maturity_date") - pl.lit(reporting_date)).dt.total_days() / 365.25
# S floored at 10 BD = 10/250 = 0.04y per Art. 279b(1)(a).
s_floored = pl.max_horizontal(years_to_start, pl.lit(s_floor))
# SD(S, E) = (exp(-rate*S) - exp(-rate*E)) / rate
sd = ((-rate * s_floored).exp() - (-rate * years_to_maturity).exp()) / rate
d = pl.col("notional") * sd
credit_adjusted = (
pl.when(pl.col("asset_class") == "credit").then(d).otherwise(pl.lit(None, dtype=pl.Float64))
)
# Preserve any existing adjusted_notional column from upstream IR / FX
# branches via coalesce; otherwise emit a fresh column.
if "adjusted_notional" in trades.collect_schema().names():
return trades.with_columns(
pl.coalesce(pl.col("adjusted_notional"), credit_adjusted).alias("adjusted_notional")
)
return trades.with_columns(credit_adjusted.alias("adjusted_notional"))
compute_adjusted_notional_equity — src/rwa_calc/engine/ccr/adjusted_notional.py:286
@cites("CRR Art. 279")
def compute_adjusted_notional_equity(trades: pl.LazyFrame) -> pl.LazyFrame:
"""SA-CCR adjusted notional for equity trades per CRR Art. 279b(1)(c).
For ``asset_class == "equity"``:
d = abs(market_price * number_of_units)
Direction lives on ``is_long`` / ``supervisory_delta``; the adjusted-notional
value itself is taken in absolute terms per the regulatory rule. Null
``market_price`` or null ``number_of_units`` propagate as null
``adjusted_notional`` — the orchestrator surfaces the CCR data-quality
error at the pipeline-adapter boundary.
When the input frame already carries an ``adjusted_notional`` column from a
prior IR / FX branch, this function coalesces — non-null upstream values
are preserved and the equity result only overlays where the upstream value
is null (equity rows).
Args:
trades: LazyFrame at trade grain with columns ``asset_class``,
``market_price`` and ``number_of_units``.
Returns:
The input LazyFrame with an ``adjusted_notional: Float64`` column
populated for ``asset_class == "equity"`` rows; existing non-null
values from upstream branches are preserved.
References:
- CRR Art. 279b(1)(c): equity adjusted notional d = market_price × units.
"""
equity_adjusted = (pl.col("market_price") * pl.col("number_of_units")).abs()
out = trades.with_columns(
pl.when(pl.col("asset_class") == "equity")
.then(equity_adjusted)
.otherwise(pl.lit(None, dtype=pl.Float64))
.alias("_eq_adjusted_notional")
)
if "adjusted_notional" in trades.collect_schema().names():
out = out.with_columns(
pl.coalesce(pl.col("adjusted_notional"), pl.col("_eq_adjusted_notional")).alias(
"adjusted_notional"
)
)
else:
out = out.rename({"_eq_adjusted_notional": "adjusted_notional"})
return out.drop("_eq_adjusted_notional", strict=False)
compute_adjusted_notional_commodity — src/rwa_calc/engine/ccr/adjusted_notional.py:338
@cites("CRR Art. 279")
def compute_adjusted_notional_commodity(trades: pl.LazyFrame) -> pl.LazyFrame:
"""SA-CCR adjusted notional for commodity trades per CRR Art. 279b(1)(c).
For ``asset_class == "commodity"``:
d = market_price × number_of_units
The product is in the trade currency; no FX conversion is required because
``market_price`` is already denominated in the same currency as the trade.
Direction lives on ``is_long`` / ``delta`` — the adjusted-notional value
itself is the unsigned product per Art. 279b(1)(c).
Coalesce-safe overlay: when the input already carries an
``adjusted_notional`` column from a prior IR / FX / credit / equity branch,
non-null values on non-commodity rows are preserved.
Args:
trades: LazyFrame at trade grain with columns ``asset_class``,
``market_price`` and ``number_of_units``.
Returns:
The input LazyFrame with a (possibly overlaid) ``adjusted_notional:
Float64`` column populated for ``asset_class == "commodity"`` rows
only; null on non-commodity rows when no prior branch populated them.
References:
- CRR Art. 279b(1)(c)
- BCBS CRE52.46-48
"""
co_adjusted = pl.col("market_price") * pl.col("number_of_units")
out = trades.with_columns(
pl.when(pl.col("asset_class") == "commodity")
.then(co_adjusted)
.otherwise(pl.lit(None, dtype=pl.Float64))
.alias("_co_adjusted_notional")
)
if "adjusted_notional" in trades.collect_schema().names():
out = out.with_columns(
pl.coalesce(pl.col("adjusted_notional"), pl.col("_co_adjusted_notional")).alias(
"adjusted_notional"
)
)
else:
out = out.rename({"_co_adjusted_notional": "adjusted_notional"})
return out.drop("_co_adjusted_notional", strict=False)
compute_maturity_factor_unmargined — src/rwa_calc/engine/ccr/maturity_factor.py:65
@cites("CRR Art. 279")
def compute_maturity_factor_unmargined(trades: pl.LazyFrame) -> pl.LazyFrame:
"""Maturity factor for unmargined transactions per CRR Art. 279c(1).
``MF = sqrt(min(M, 1y) / 1y)`` measured on the 250-business-day-year basis,
with the residual maturity floored at 10 business days:
MF = sqrt(min(max(BD, 10), 250) / 250)
where ``BD`` is the residual maturity in *business days* from the reporting
date to the trade's maturity date (``business_days_to_maturity``, built by
the SA-CCR adapter via ``pl.business_day_count``). CRR Art. 279c expresses
both the unmargined and margined maturity factors against the same "1 year"
denominator; because the margined branch's MPOR is a business-day count
divided by 250, "1 year" = 250 business days throughout — so the unmargined
residual maturity is measured in business days too. A trade with ≥ 250 BD
(≈ 1 calendar year) to maturity collapses to MF = 1.0; the 10-BD floor
(BCBS CRE52.47-52.48 fn.13) means a trade with < 10 BD to maturity never
drops below ``sqrt(10/250) = 0.20``.
The 10-BD floor here is on the residual maturity ``M`` and is distinct from
(a) the Art. 279b 10-BD floor on the *start date* ``S`` in the supervisory
duration (``engine/ccr/adjusted_notional.py``) and (b) the Art. 285 margined
MPOR floors — same numeric value, different provisions on different quantities.
Note: the IR maturity-bucket thresholds (Art. 277(2): 1y / 5y) are a
separate, calendar-based partition handled by ``assign_ir_maturity_bucket``
and are NOT affected by this business-day measure.
Args:
trades: LazyFrame containing a ``business_days_to_maturity`` column
(integer) — residual maturity in business days from the reporting
date to the trade's maturity date.
Returns:
The input LazyFrame with a new ``maturity_factor: Float64`` column.
References:
CRR Art. 279c(1); BCBS CRE52.47-52.48 (+ fn.13 10-BD M floor), 52.50-52.
"""
bd_per_year = _SA_CCR_BUSINESS_DAYS_PER_YEAR
floor_days = _MF_UNMARGINED_FLOOR_DAYS
return trades.with_columns(
(
pl.min_horizontal(
pl.max_horizontal(
pl.col("business_days_to_maturity"),
pl.lit(floor_days),
),
pl.lit(bd_per_year),
).cast(pl.Float64)
/ float(bd_per_year)
)
.sqrt()
.alias("maturity_factor")
)
compute_maturity_factor_margined — src/rwa_calc/engine/ccr/maturity_factor.py:126
@cites("CRR Art. 279")
def compute_maturity_factor_margined(trades: pl.LazyFrame) -> pl.LazyFrame:
"""Maturity factor for margined transactions per CRR Art. 279c(2).
``MF = (3/2) * sqrt(MPOR_eff / 250)``
``MPOR_eff`` is derived per the CRR Art. 285 cascade:
1. Base MPOR (Art. 285(2)): 10 BD (OTC derivative netting set,
Art. 285(2)(b)). The Art. 285(2)(a) 5-BD SFT/repo/margin-lending base is
NOT modelled here: this function is derivatives-only since the SFT/FCCM
separation (SFTs are priced by the FCCM ``sft_fccm`` stage from
``RawDataBundle.sft`` and never enter the SA-CCR chain), so every netting
set reaching this function is an OTC derivative netting set.
2. Upgrade to 20 BD (Art. 285(3)) when either:
- ``number_of_trades > 5000`` (Art. 285(3)(a)), or
- ``has_illiquid_collateral_or_hard_to_replace_otc`` is True
(Art. 285(3)(b))
3. Dispute doubling (Art. 285(4)): if ``dispute_count_qtr > 2``, double
the resulting MPOR base.
4. Remargining-frequency adjustment (Art. 285(5)):
``MPOR_eff = base + remargining_frequency_days - 1``.
5. Input-MPOR floor: ``MPOR_eff = max(MPOR_eff, mpor_days_input)``.
Args:
trades: LazyFrame with one row per trade carrying the Art. 285 cascade
inputs as columns:
- ``netting_set_id`` — group key
- ``number_of_trades`` — count of trades in the NS
- ``has_illiquid`` — bool flag (aliased from the netting-set
column ``has_illiquid_collateral_or_hard_to_replace_otc`` at
the join site)
- ``dispute_count_qtr`` — disputes in the prior quarter
- ``remargining_frequency_days`` — CSA remargining frequency
- ``mpor_days_input`` — firm-supplied MPOR floor (BD)
Returns:
The input LazyFrame with two new Float64 columns:
- ``maturity_factor_margined`` — the gated margined MF (null on
unmargined rows so the pipeline-adapter coalesce can fall back to
the unmargined MF without clobbering it).
- ``maturity_factor`` — an alias of ``maturity_factor_margined``
retained for the P8.14 unit tests, which feed an all-margined
denormalised frame and read the bare column.
References:
CRR Art. 279c(2); CRR Art. 285(2)-(5); BCBS CRE52.51-52.
"""
# Step 1 — base MPOR per Art. 285(2)(b): 10 BD for the OTC derivative
# netting set. The Art. 285(2)(a) 5-BD SFT/repo base is not modelled here:
# this function is derivatives-only since the SFT/FCCM separation, so every
# netting set reaching it is an OTC derivative netting set (SFTs are priced
# by the FCCM ``sft_fccm`` stage and never enter the SA-CCR chain).
base_post_step1 = pl.lit(_MF_FLOOR_DAYS_OTC)
# Step 2 — upgrade to 20 BD when the netting set is large
# (Art. 285(3)(a)) or contains illiquid collateral / hard-to-replace
# OTC trades (Art. 285(3)(b)).
is_large_or_illiquid = pl.col("number_of_trades") > pl.lit(_MF_LARGE_NETTING_SET_TRADE_COUNT)
is_large_or_illiquid = is_large_or_illiquid | pl.col("has_illiquid")
base_post_step2 = (
pl.when(is_large_or_illiquid)
.then(pl.lit(_MF_FLOOR_DAYS_LARGE_OR_ILLIQUID))
.otherwise(base_post_step1)
)
# Step 3 — dispute doubling per Art. 285(4): when dispute_count_qtr
# exceeds the regulatory threshold (more than two), the MPOR base
# is doubled.
base_post_step3 = (
pl.when(pl.col("dispute_count_qtr") > pl.lit(_MF_DISPUTE_THRESHOLD))
.then(base_post_step2 * pl.lit(_MF_DISPUTE_MULTIPLIER))
.otherwise(base_post_step2)
)
# Step 4 — remargining frequency adjustment per Art. 285(5):
# MPOR_eff = base + remargining_frequency_days − 1.
mpor_eff_pre_floor = base_post_step3 + pl.col("remargining_frequency_days") - pl.lit(1)
# Step 5 — input-MPOR floor: MPOR_eff = max(MPOR_eff, mpor_days_input).
# Null-safety: a null ``mpor_days_input`` would null the whole MF through
# ``max_horizontal``; fall back to the Art. 285(2)(b) 10-BD OTC floor so a
# missing firm-supplied MPOR never silently drops the margined MF to null.
mpor_eff = pl.max_horizontal(
mpor_eff_pre_floor, pl.col("mpor_days_input").fill_null(_MF_FLOOR_DAYS_OTC)
)
# MF = 1.5 * sqrt(MPOR_eff / 250) per Art. 279c(2).
maturity_factor = (
pl.lit(_MF_MARGINED_SCALAR)
* (mpor_eff.cast(pl.Float64) / pl.lit(float(_SA_CCR_BUSINESS_DAYS_PER_YEAR))).sqrt()
).cast(pl.Float64)
# Gate on ``is_margined`` (mirrors ``compute_rc_margined``): emit the MF only
# for margined rows; unmargined rows get null so the pipeline-adapter
# coalesce falls back to ``maturity_factor_unmargined``. A null/absent
# ``is_margined`` flows to the ``.otherwise`` (null) branch exactly like an
# explicit False — the conservative NETTING_SET_SCHEMA default — so no
# ``fill_null`` is needed on the gate. ``maturity_factor`` is written as an
# alias of the gated margined column for the P8.14 unit tests (all-margined
# frame).
maturity_factor_margined = (
pl.when(pl.col("is_margined"))
.then(maturity_factor)
.otherwise(pl.lit(None, dtype=pl.Float64))
)
return trades.with_columns(
maturity_factor_margined.alias("maturity_factor_margined"),
maturity_factor_margined.alias("maturity_factor"),
)
CRR Art. 279a — Supervisory delta¶
compute_supervisory_delta_linear — src/rwa_calc/engine/ccr/supervisory_delta.py:64
@cites("CRR Art. 279a")
def compute_supervisory_delta_linear(trades: pl.LazyFrame) -> pl.LazyFrame:
"""Supervisory delta for non-option directional trades per CRR Art. 279a(1).
delta = +1 for long positions in the primary risk driver
delta = -1 for short positions in the primary risk driver
The European-option Black-Scholes Phi(d1) branch (rows where
``option_strike`` is not null) and the CDO-tranche formula are handled by
:func:`compute_supervisory_delta_option` and
:func:`compute_supervisory_delta_cdo_tranche` respectively.
Args:
trades: LazyFrame containing an ``is_long`` Boolean column.
Returns:
The input LazyFrame with a new ``supervisory_delta: Float64`` column.
References:
CRR Art. 279a(1); BCBS CRE52.41-43.
"""
return trades.with_columns(
pl.when(pl.col("is_long")).then(1.0).otherwise(-1.0).alias("supervisory_delta")
)
compute_supervisory_delta_option — src/rwa_calc/engine/ccr/supervisory_delta.py:90
@cites("CRR Art. 279a")
def compute_supervisory_delta_option(trades: pl.LazyFrame) -> pl.LazyFrame:
"""Supervisory delta for European options per CRR Art. 279a(2).
For rows that carry ``option_strike`` AND ``option_underlying_price``,
apply the Black-Scholes Phi(d1) formula:
d1 = (ln(P/K) + 0.5 * sigma^2 * T) / (sigma * sqrt(T))
long call: delta = +Phi(d1)
short call: delta = -Phi(d1)
long put: delta = -Phi(-d1)
short put: delta = +Phi(-d1)
where:
P = ``option_underlying_price``
K = ``option_strike``
T = (maturity_date - start_date).days / 365 (calendar-day basis)
sigma = supervisory option volatility from
``SA_CCR_OPTION_VOLATILITY_*`` keyed off ``asset_class``.
Rows where ``option_strike`` is null fall back to the linear +/- 1 delta
per Art. 279a(1), preserving the behaviour of
:func:`compute_supervisory_delta_linear`.
Args:
trades: LazyFrame with ``is_long``, ``asset_class``, ``option_type``,
``option_strike``, ``option_underlying_price``, ``start_date``,
and ``maturity_date`` columns.
Returns:
The input LazyFrame with a new ``supervisory_delta: Float64`` column.
References:
CRR Art. 279a(2); BCBS CRE52.42; BCBS CRE52.47 (supervisory volatility).
"""
# Asset-class -> sigma lookup as a small in-memory frame for join.
sigma_lookup = pl.LazyFrame(
{
"asset_class": list(_OPTION_VOLATILITY_BY_ASSET_CLASS.keys()),
"_option_sigma": list(_OPTION_VOLATILITY_BY_ASSET_CLASS.values()),
},
schema={"asset_class": pl.Utf8, "_option_sigma": pl.Float64},
)
is_option = (
pl.col("option_strike").is_not_null() & pl.col("option_underlying_price").is_not_null()
)
# T = calendar days / 365 between start_date and maturity_date. The fixture
# encodes T via maturity = start + round(T_nominal * 365) so this recovers
# T_nominal exactly when reporting_date == start_date.
t_years = (pl.col("maturity_date") - pl.col("start_date")).dt.total_days().cast(
pl.Float64
) / 365.0
sigma = pl.col("_option_sigma")
p = pl.col("option_underlying_price")
k = pl.col("option_strike")
d1 = ((p / k).log() + 0.5 * sigma * sigma * t_years) / (sigma * t_years.sqrt())
phi_d1 = normal_cdf(d1)
phi_neg_d1 = normal_cdf(-d1)
is_call = pl.col("option_type") == "call"
is_long = pl.col("is_long")
# Sign rule per CRR Art. 279a(2):
# long call -> +Phi(d1)
# short call -> -Phi(d1)
# long put -> -Phi(-d1)
# short put -> +Phi(-d1)
option_delta = (
pl.when(is_call & is_long)
.then(phi_d1)
.when(is_call & ~is_long)
.then(-phi_d1)
.when(~is_call & is_long)
.then(-phi_neg_d1)
.otherwise(phi_neg_d1)
)
linear_delta = pl.when(is_long).then(1.0).otherwise(-1.0)
return (
trades.join(sigma_lookup, on="asset_class", how="left")
.with_columns(
pl.when(is_option)
.then(option_delta)
.otherwise(linear_delta)
.cast(pl.Float64)
.alias("supervisory_delta")
)
.drop("_option_sigma")
)
compute_supervisory_delta_cdo_tranche — src/rwa_calc/engine/ccr/supervisory_delta.py:188
@cites("CRR Art. 279a")
def compute_supervisory_delta_cdo_tranche(trades: pl.LazyFrame) -> pl.LazyFrame:
"""Supervisory delta for CDO tranches per CRR Art. 279a(3).
For rows that carry ``cdo_attachment`` AND ``cdo_detachment``, apply the
closed-form:
|delta| = 15 / ((1 + 14 * A) * (1 + 14 * D))
with sign +1 for long tranches and -1 for short tranches.
Rows where ``cdo_attachment`` is null fall back to the linear +/- 1 delta
per Art. 279a(1).
Args:
trades: LazyFrame with ``is_long``, ``cdo_attachment``, and
``cdo_detachment`` columns.
Returns:
The input LazyFrame with a new ``supervisory_delta: Float64`` column.
References:
CRR Art. 279a(3); BCBS CRE52.43.
"""
is_cdo = pl.col("cdo_attachment").is_not_null() & pl.col("cdo_detachment").is_not_null()
a = pl.col("cdo_attachment")
d = pl.col("cdo_detachment")
numerator = _CDO_TRANCHE_NUMERATOR
coefficient = _CDO_TRANCHE_COEFFICIENT
magnitude = numerator / ((1.0 + coefficient * a) * (1.0 + coefficient * d))
cdo_delta = pl.when(pl.col("is_long")).then(magnitude).otherwise(-magnitude)
linear_delta = pl.when(pl.col("is_long")).then(1.0).otherwise(-1.0)
return trades.with_columns(
pl.when(is_cdo)
.then(cdo_delta)
.otherwise(linear_delta)
.cast(pl.Float64)
.alias("supervisory_delta")
)
CRR Art. 285 — Exposure value for netting sets subject to a margin agreement¶
sft_bundle_to_exposures — src/rwa_calc/engine/sft/fccm.py:114
@cites("CRR Art. 220")
@cites("CRR Art. 223")
@cites("CRR Art. 224")
@cites("CRR Art. 226")
@cites("CRR Art. 271")
@cites("CRR Art. 285")
def sft_bundle_to_exposures(
raw_sft: RawSFTBundle,
reporting_date: date,
rulepack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Shape FCCM SFT EADs into synthetic exposure rows from the lean SFT bundle.
The sole FCCM entry point (SFT/FCCM separation): consumes the dedicated
:class:`RawSFTBundle` (``RawDataBundle.sft``). The SFT/derivative
discrimination lives in the *input bundle* now, not in any in-engine
``transaction_type`` split:
- Every trade row is an SFT (no ``transaction_type`` filter): the whole
``raw_sft.trades`` frame is in scope.
- The netting-set ``counterparty_reference`` is denormalised onto the trade
row (FCCM scope is single-trade single-counterparty netting sets,
Art. 220(1)(a)), so the NS-grain counterparty frame is derived from the
trades themselves rather than a separate netting-set table.
- Collateral is OPTIONAL (``raw_sft.collateral is None`` for an
uncollateralised SFT, the common case): a missing collateral leaf yields a
zero collateral term (CVA·(1−HC−HFX) = 0), exactly as an empty
``ccr_collateral`` frame would.
Each emitted synthetic exposure row carries the FCCM provenance:
``exposure_reference = "ccr__<netting_set_id>"``, ``risk_type = "CCR_SFT"``,
``ccr_method = "fccm_sft"``, ``drawn_amount = E*``, ``ead_ccr = E*``.
Args:
raw_sft: The SFT (FCCM) input bundle — every trade row is an SFT with the
denormalised netting-set counterparty; collateral optional.
reporting_date: As-of date; written to ``value_date``.
rulepack: The resolved RUN rulepack supplying the Art. 162 effective-
maturity floors / regime gate for the ``ccr_effective_maturity``
carrier. ``None`` (the back-compat default used by direct unit /
acceptance calls) falls back to the module-level CRR ``_PACK``; the
stage adapter threads the run pack so production runs are regime-
correct.
Returns:
LazyFrame at netting-set grain. Empty (zero-row) frame when the trades
bundle is empty.
References:
CRR Art. 271(2); Art. 220(1)(a); Art. 223(5); Art. 224 Table 1;
Art. 224(2)(b); Art. 226; Art. 285(2)-(5).
"""
sft_trades_lf = raw_sft.trades.sft_trades
# Counterparty is denormalised onto the trade — collapse to NS grain. The
# ``first()`` aggregation is exact under the single-CP-per-NS scope
# (Art. 220(1)(a)); should a future netting set span counterparties the
# FCCM scope itself would need revisiting.
ns_counterparty_lf = sft_trades_lf.group_by("netting_set_id").agg(
pl.col("counterparty_reference").first()
)
ccr_collateral_lf = (
raw_sft.collateral.sft_collateral if raw_sft.collateral is not None else None
)
return _build_sft_exposure_rows(
sft_trades_lf=sft_trades_lf,
ns_counterparty_lf=ns_counterparty_lf,
ccr_collateral_lf=ccr_collateral_lf,
reporting_date=reporting_date,
pack=rulepack if rulepack is not None else _PACK,
)
_derive_margining_terms — src/rwa_calc/engine/sft/fccm.py:186
@cites("CRR Art. 285")
def _derive_margining_terms(
is_margined: bool | None,
remargining_frequency_days: int | None,
mpor_floor_category: str | None,
has_margin_dispute_doubling: bool | None,
mpor_days_override: int | None,
) -> tuple[int, int]:
"""Return ``(T_M, non_daily_N_R)`` for one SFT netting set.
Selects the applied-haircut holding period T_M and whether the Art. 226
non-daily revaluation factor √((N_R+T_M−1)/T_M) applies:
- Branch (a) unmargined: ``(5-BD repo period, real N_R)`` → the Art. 226
factor applies (driven by ``remargining_frequency_days``; collapses to
1.0 at daily revaluation). T_M per Art. 224(2)(b).
- Branch (b) margined: ``(MPOR, 1)`` → the Art. 226 factor is suppressed
(N_R=1), because the MPOR already encodes the remargin period N.
MPOR = ``mpor_days_override`` when supplied, else F·mult + N − 1 where F is
the Art. 285(2)-(3) floor by category and mult = the Art. 285(4) doubling
multiplier (2) when a margin dispute applies.
All F values and the dispute multiplier are read from cited pack scalars at
module load — no regulatory numerics here. Single-trade-per-NS scope is
assumed (Art. 220(1)(a)); the caller derives terms per trade.
Args:
is_margined: True selects the margined branch (b); False/None → (a).
remargining_frequency_days: N (branch a: N_R; branch b: N). Default 1.
mpor_floor_category: F selector ('repo_only'/'other'/'illiquid_or_large').
has_margin_dispute_doubling: True doubles F (Art. 285(4)).
mpor_days_override: Explicit MPOR (business days); supersedes derivation.
Returns:
``(T_M, non_daily_N_R)`` — both ints in business days / count.
"""
n = int(remargining_frequency_days) if remargining_frequency_days is not None else 1
if not is_margined:
return (_LIQUIDATION_PERIOD_REPO, n)
if mpor_days_override is not None:
return (int(mpor_days_override), 1)
floor = _MPOR_FLOOR_BY_CATEGORY[mpor_floor_category or "repo_only"]
mult = _MPOR_DISPUTE_MULT if has_margin_dispute_doubling else 1
return (floor * mult + n - 1, 1)
CRR Art. 291 — Wrong-Way Risk¶
apply_wwr_gate — src/rwa_calc/engine/ccr/wwr.py:93
@cites("CRR Art. 291")
def apply_wwr_gate(raw_ccr: RawCCRBundle) -> RawCCRBundle:
"""Partition netting sets to isolate specific-WWR trades; tag general WWR.
Implements CRR Art. 291(4)-(5):
- **Specific WWR** (Art. 291(1)(b) / 291(5)(a)/(c)): every trade with
``is_specific_wwr=True`` is broken out into its own single-trade
synthetic netting set whose id is
``<original_ns_id>__wwr__<trade_id>``. The synthetic NS inherits all
attributes from the original and additionally carries
``wwr_lgd_override = 1.0`` so downstream IRB consumption applies
LGD = 100% (Art. 291(5)(c)). A residual NS keyed by the original
``netting_set_id`` retains the non-WWR trades with
``wwr_lgd_override = null``.
- **General WWR** (Art. 291(1)(a) / 291(6)): netting sets with
``has_general_wwr_flag=True`` are not partitioned but emit a
diagnostic CCR011 WARNING.
Pipeline position:
apply_legal_enforceability_gate -> apply_wwr_gate -> CCR calculators
Args:
raw_ccr: Aggregate CCR input bundle.
Returns:
A new ``RawCCRBundle`` (frozen dataclass) with:
- ``trades`` remapped: each specific-WWR trade carries its new
synthetic ``netting_set_id``.
- ``netting_sets`` partitioned: each affected original NS is
replaced by (1) a residual row (non-WWR trades, override null)
plus (2) one synthetic row per WWR trade (override = 1.0).
- ``errors`` extended with one CCR010 WARNING per original NS
containing >=1 WWR trade, plus one CCR011 WARNING per NS with
``has_general_wwr_flag=True``.
Netting sets with no WWR trades and ``has_general_wwr_flag=False``
pass through unchanged.
References:
CRR Art. 291(1)(a)/(1)(b)/(4)/(5)(a)/(5)(c)/(6).
"""
# Backfill the schema-declared WWR columns when the loader/fixture has
# not yet populated them. ``ensure_columns`` is a no-op when the columns
# are already present.
netting_sets_lf = ensure_columns(raw_ccr.netting_sets.netting_sets, _WWR_NS_DEFAULTS)
trades_lf = ensure_columns(raw_ccr.trades.trades, _WWR_TRADE_DEFAULTS)
# Materialise the small NS and trade frames to drive partition logic.
# Netting-set and trade frames are at firm scale (hundreds to low
# thousands of rows), so collecting is acceptable — mirrors the
# apply_legal_enforceability_gate precedent.
netting_sets_df = netting_sets_lf.collect()
trades_df = trades_lf.collect()
new_errors: list[CalculationError] = list(raw_ccr.errors)
# --- General WWR (Art. 291(1)(a), 291(6)): diagnostic only --------------
general_wwr_mask = netting_sets_df["has_general_wwr_flag"].fill_null(False)
general_wwr_rows = netting_sets_df.filter(general_wwr_mask)
for ns_row in general_wwr_rows.iter_rows(named=True):
new_errors.append(
CalculationError(
code=CCR_WWR_GENERAL_ERROR_CODE,
message=(
f"Netting set {ns_row['netting_set_id']} carries "
"has_general_wwr_flag=True per Art. 291(1)(a); "
"general WWR identified for downstream review."
),
severity=ErrorSeverity.WARNING,
category=ErrorCategory.CCR_WWR_GENERAL,
counterparty_reference=ns_row.get("counterparty_reference"),
regulatory_reference=CCR_WWR_GENERAL_REG_REF,
field_name="has_general_wwr_flag",
expected_value="False (no general WWR correlation)",
actual_value="True",
)
)
# --- Specific WWR (Art. 291(1)(b), 291(5)(a)/(c)): break-out -----------
wwr_trade_mask = trades_df["is_specific_wwr"].fill_null(False)
if not wwr_trade_mask.any():
logger.info("wwr gate: no specific-WWR trades flagged; no break-out applied")
return dataclasses.replace(raw_ccr, errors=new_errors)
wwr_trades_df = trades_df.filter(wwr_trade_mask)
affected_ns_ids = wwr_trades_df["netting_set_id"].unique().to_list()
# Rewrite the trades frame: each WWR trade gets a synthetic NS id.
new_trades_lf = trades_lf.with_columns(
pl.when(pl.col("is_specific_wwr").fill_null(False))
.then(
pl.concat_str(
[pl.col("netting_set_id"), pl.lit(_WWR_NS_ID_SEPARATOR), pl.col("trade_id")]
)
)
.otherwise(pl.col("netting_set_id"))
.alias("netting_set_id")
)
# Build the partitioned netting-set frame. Both halves already carry the
# ``wwr_lgd_override`` column thanks to the ``ensure_columns`` call above.
affected_ns_df = netting_sets_df.filter(
netting_sets_df["netting_set_id"].is_in(affected_ns_ids)
)
unaffected_ns_df = netting_sets_df.filter(
~netting_sets_df["netting_set_id"].is_in(affected_ns_ids)
)
# Residual rows: same NS attributes, override null. Synthetic rows: same
# attributes plus override = 1.0 and the synthetic id.
residual_rows_df = affected_ns_df.with_columns(
pl.lit(None, dtype=pl.Float64).alias("wwr_lgd_override")
)
synthetic_rows_df = (
wwr_trades_df.select(["trade_id", "netting_set_id"])
.join(affected_ns_df, on="netting_set_id", how="left")
.with_columns(
pl.concat_str(
[pl.col("netting_set_id"), pl.lit(_WWR_NS_ID_SEPARATOR), pl.col("trade_id")]
).alias("netting_set_id"),
pl.lit(_WWR_SPECIFIC_LGD_OVERRIDE).alias("wwr_lgd_override"),
)
.drop("trade_id")
.select(residual_rows_df.columns)
)
new_netting_sets_df = pl.concat(
[unaffected_ns_df, residual_rows_df, synthetic_rows_df],
how="vertical_relaxed",
)
# Emit one CCR010 WARNING per affected original netting set.
for ns_row in affected_ns_df.iter_rows(named=True):
ns_id = ns_row["netting_set_id"]
new_errors.append(
CalculationError(
code=CCR_WWR_SPECIFIC_ERROR_CODE,
message=(
f"Netting set {ns_id} contains >=1 trade with "
"is_specific_wwr=True per Art. 291(1)(b); each WWR trade "
"broken out into its own synthetic netting set with "
"LGD = 100% per Art. 291(5)(c)."
),
severity=ErrorSeverity.WARNING,
category=ErrorCategory.CCR_WWR_SPECIFIC,
counterparty_reference=ns_row.get("counterparty_reference"),
regulatory_reference=CCR_WWR_SPECIFIC_REG_REF,
field_name="is_specific_wwr",
expected_value="False (no Art. 291(1)(b) legal connection)",
actual_value="True",
)
)
logger.info(
"wwr gate broke out %d trade(s) across %d netting set(s) into synthetic single-trade NSes",
wwr_trades_df.height,
len(affected_ns_ids),
)
return dataclasses.replace(
raw_ccr,
trades=TradeBundle(trades=new_trades_lf, errors=list(raw_ccr.trades.errors)),
netting_sets=NettingSetBundle(
netting_sets=new_netting_sets_df.lazy(),
errors=list(raw_ccr.netting_sets.errors),
),
errors=new_errors,
)
CRR Art. 306 — Own funds requirements for trade exposures¶
apply_ccp_risk_weight — src/rwa_calc/engine/ccr/ccp.py:51
@cites("CRR Art. 306")
def apply_ccp_risk_weight(
exposures: pl.LazyFrame,
counterparties: pl.LazyFrame,
trades: pl.LazyFrame,
) -> pl.LazyFrame:
"""Annotate ``risk_weight`` for QCCP trade exposures per CRR Art. 306(1).
The function joins the QCCP flag from ``counterparties`` and the
client-cleared flag from ``trades`` onto ``exposures`` and writes a
new ``risk_weight`` column with the regulatory trade-exposure weight:
is_qccp=True, is_client_cleared=False -> 0.02 (Art. 306(1)(a))
is_qccp=True, is_client_cleared=True -> 0.04 (Art. 306(1)(c))
is_qccp=False -> NULL (pass-through to SA)
The non-QCCP NULL pass-through is intentional: the 20% SA-institution
weight for CQS-1 is applied by the downstream classifier (P8.30),
not here. Signalling pass-through via NULL keeps the routing layer
able to detect which rows have already had a regulatory weight set.
Load-bearing invariant: ``ead_ccr`` is never mutated by this function.
EAD is produced upstream by SA-CCR (Art. 274) and must be identical
across all three CCR-B1 variants (proprietary, client-cleared,
non-QCCP).
Args:
exposures: LazyFrame carrying ``ead_ccr``. Other columns pass
through unchanged.
counterparties: LazyFrame carrying the ``is_qccp`` Boolean flag
(CRR Art. 272 Def (88)).
trades: LazyFrame carrying the ``is_client_cleared`` Boolean
flag (CRR Art. 306(1)(c) client-cleared trade relationship).
Returns:
LazyFrame with the input ``exposures`` columns plus a new
``risk_weight: Float64`` column. ``ead_ccr`` is unchanged.
References:
- CRR Art. 306(1)(a), 306(1)(c), 306(4); CRR Art. 107(2)(a).
- BCBS CRE54.14 (2% proprietary), CRE54.15 (4% client-cleared).
"""
# Reduce counterparties / trades to the single flag column each carries
# for the QCCP branching decision. We broadcast via cross-join because
# the test-level ``exposures`` frame is keyless (a single ``ead_ccr``
# column) and the fixture is single-row per side; in production code
# the caller would key the joins on counterparty/trade identifiers.
cp_flag = counterparties.select(pl.col("is_qccp").fill_null(False).alias("is_qccp"))
trade_flag = trades.select(
pl.col("is_client_cleared").fill_null(False).alias("is_client_cleared")
)
joined = exposures.join(cp_flag, how="cross").join(trade_flag, how="cross")
proprietary_rw = _QCCP_PROPRIETARY_RW
client_cleared_rw = _QCCP_CLIENT_CLEARED_RW
return joined.with_columns(
pl.when(pl.col("is_qccp") & pl.col("is_client_cleared"))
.then(pl.lit(client_cleared_rw))
.when(pl.col("is_qccp") & ~pl.col("is_client_cleared"))
.then(pl.lit(proprietary_rw))
.otherwise(pl.lit(None, dtype=pl.Float64))
.alias("risk_weight")
)
CRR Art. 501 — Adjustment of risk-weighted non-defaulted SME exposures¶
apply_supporting_factors — src/rwa_calc/engine/sa/factors_output.py:64
@cites("CRR Art. 501")
def apply_supporting_factors(
lf: pl.LazyFrame,
config: CalculationConfig,
*,
errors: list[CalculationError] | None = None,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Apply SME / infrastructure supporting factors (CRR Art. 501 / 501a).
Under Basel 3.1 the supporting-factor calculator returns a factor of
1.0 for every row, preserving RWA unchanged.
Args:
lf: SA exposures frame with ``rwa_pre_factor`` computed.
config: Calculation configuration (selects framework).
errors: Optional accumulator for data-quality warnings.
"""
lf = ensure_columns(lf, _SUPPORTING_FACTOR_COLUMNS)
return SupportingFactorCalculator().apply_factors(lf, config, errors=errors, pack=pack)
calculate_sme_factor — src/rwa_calc/engine/supporting_factors.py:90
@cites("CRR Art. 501")
def calculate_sme_factor(
self,
total_exposure: Decimal,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> Decimal:
"""
Calculate SME supporting factor based on total drawn exposure.
Args:
total_exposure: Total drawn (on-balance-sheet) amount to the SME
config: Calculation configuration
Returns:
Effective supporting factor (0.7619 to 0.85)
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
if not resolved_pack.feature("supporting_factors"):
return Decimal("1.0")
if total_exposure <= 0:
return Decimal("1.0")
# FX-derived SME exposure threshold stays config (RegulatoryThresholds → S11c);
# the factor multipliers are pack-sourced (Decimal, exact).
threshold_gbp = regulatory_threshold(
resolved_pack, "sme_exposure_threshold", config.eur_gbp_rate
)
sf_values = resolved_pack.formula("supporting_factors_values").params
factor_tier1 = sf_values["sme_factor_under_threshold"]
factor_tier2 = sf_values["sme_factor_above_threshold"]
# Use GBP threshold for GBP currency (default)
threshold = threshold_gbp
# Calculate tiered factor
tier1_amount = min(total_exposure, threshold)
tier2_amount = max(total_exposure - threshold, Decimal("0"))
weighted_factor = tier1_amount * factor_tier1 + tier2_amount * factor_tier2
return weighted_factor / total_exposure
apply_factors — src/rwa_calc/engine/supporting_factors.py:197
@cites("CRR Art. 501")
def apply_factors(
self,
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
errors: list[CalculationError] | None = None,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""
Apply supporting factors to exposures LazyFrame.
The SME supporting factor threshold (EUR 2.5m) is applied to E*,
which CRR Art. 501 defines as the total drawn amount owed by the SME's
group of connected clients, excluding claims secured on residential
property collateral. Aggregation runs on the unified frame **before**
the pipeline's SA / IRB / slotting branch split via
``compute_e_star_group_drawn``; this method reads the pre-computed
``e_star_group_drawn`` column when present (production path) and
falls back to a local windowed sum over ``lending_group_reference``
(with fallback to ``counterparty_reference``) when the column is
absent (test harnesses that bypass the pipeline). The residential
carve-out is applied per row by subtracting
``residential_collateral_value`` (capped at drawn) from each row's
contribution to E*, mirroring the retail-threshold logic in
``engine/stages/hierarchy/`` (Art. 123(c)). BTL rows receive factor=1.0
via a separate eligibility gate. The resulting blended factor is
applied to each SME row's full RWA.
The tier calculation uses drawn_amount + interest ("amount owed"),
NOT ead_final which includes CCF-adjusted undrawn commitments.
Expects columns:
- is_sme: bool
- is_infrastructure: bool
- drawn_amount: float (on-balance-sheet drawn amount)
- interest: float (accrued interest)
- ead_final: float (fallback if drawn_amount not available)
- rwa_pre_factor: float (RWA before supporting factor)
- counterparty_reference: str (optional, for fallback aggregation)
- lending_group_reference: str (optional, primary aggregation key)
- residential_collateral_value: float (optional, netted from E* per
Art. 501 residential carve-out)
- is_buy_to_let: bool (optional, factor=1.0 eligibility gate)
- e_star_group_drawn: float (optional, pre-computed unified-frame E*
from ``compute_e_star_group_drawn`` — when present, the per-branch
windowed sum is bypassed and this column is used directly so the
tier threshold honours cross-approach siblings)
Adds columns:
- supporting_factor: float
- rwa_post_factor: float (RWA after supporting factor)
- supporting_factor_applied: bool
- total_cp_drawn: float (E* — drawn aggregated across the SME's group of
connected clients, net of residential collateral per Art. 501)
Args:
exposures: Exposures with RWA calculated
config: Calculation configuration
errors: Optional error accumulator for data quality warnings
Returns:
Exposures with supporting factors applied
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
if not resolved_pack.feature("supporting_factors"):
# Basel 3.1: No supporting factors
return exposures.with_columns(
[
pl.lit(1.0).alias("supporting_factor"),
pl.col("rwa_pre_factor").alias("rwa_post_factor"),
pl.lit(False).alias("supporting_factor_applied"),
]
)
# FX-derived threshold stays config (RegulatoryThresholds → S11c); the factor
# multipliers are pack-sourced (float boundary via formula_float_map).
threshold_gbp = float(
regulatory_threshold(resolved_pack, "sme_exposure_threshold", config.eur_gbp_rate)
)
sf_values = formula_float_map(resolved_pack.formula("supporting_factors_values"))
factor_tier1 = sf_values["sme_factor_under_threshold"]
factor_tier2 = sf_values["sme_factor_above_threshold"]
infra_factor = sf_values["infrastructure_factor"]
# Check for optional columns (is_sme / is_infrastructure /
# lending_group_reference are crm_exit contract columns and read
# directly).
schema = exposures.collect_schema()
has_counterparty = "counterparty_reference" in schema.names()
has_btl = "is_buy_to_let" in schema.names()
has_defaulted = "is_defaulted" in schema.names()
has_drawn = "drawn_amount" in schema.names()
has_res_coll = "residential_collateral_value" in schema.names()
# Build the drawn (on-balance-sheet) expression for tier calculation.
# Use drawn_amount + interest when available; fall back to ead_final.
# fill_nan before clip/sum — a single NaN in the group would otherwise
# poison the windowed sum and zero out the supporting factor.
if has_drawn:
drawn_expr = pl.col("drawn_amount").fill_nan(0.0).fill_null(0.0).clip(
lower_bound=0.0
) + pl.col("interest").fill_nan(0.0).fill_null(0.0)
else:
drawn_expr = pl.col("ead_final").fill_nan(0.0).fill_null(0.0)
# Build SME factor expression with group-of-connected-clients aggregation.
# CRR Art. 501 defines E* as the total amount owed across the SME's group
# of connected clients, excluding claims secured on residential property.
# The unified-frame helper ``compute_e_star_group_drawn`` (called by the
# pipeline orchestrator before the approach split) populates
# ``e_star_group_drawn`` across SA / IRB / slotting rows so the tier
# calculation honours the full cross-approach group. When that column is
# absent (test harnesses that bypass the pipeline) we fall back to the
# legacy per-branch windowed sum.
has_e_star_pre_computed = "e_star_group_drawn" in schema.names()
if has_e_star_pre_computed:
# Pre-computed unified-frame E* (CRR Art. 501 cross-approach).
# Mirror to ``total_cp_drawn`` so downstream consumers and the
# output schema stay stable.
exposures = exposures.with_columns(pl.col("e_star_group_drawn").alias("total_cp_drawn"))
ead_for_tier = pl.col("total_cp_drawn")
elif has_counterparty:
group_key_expr = (
pl.when(pl.col("lending_group_reference").is_not_null())
.then(pl.col("lending_group_reference"))
.otherwise(pl.col("counterparty_reference"))
).alias("_sme_group_key")
exposures = exposures.with_columns([group_key_expr])
# Art. 501 carve-out: "excluding claims or contingent claims
# secured on residential property collateral". Implemented as
# per-row netting of residential_collateral_value (capped at
# drawn so the contribution never goes negative), mirroring
# the retail-threshold logic in engine/stages/hierarchy/
# (Art. 123(c)). Defaulted exposures stay in E* (Art. 501
# explicitly includes "any exposure in default").
if has_res_coll:
res_coll_expr = (
pl.col("residential_collateral_value")
.fill_nan(0.0)
.fill_null(0.0)
.clip(lower_bound=0.0)
)
drawn_in_e_star = drawn_expr - pl.min_horizontal(res_coll_expr, drawn_expr)
else:
drawn_in_e_star = drawn_expr
total_cp_drawn_expr = (
pl.when(pl.col("is_sme") & pl.col("_sme_group_key").is_not_null())
.then(drawn_in_e_star.sum().over("_sme_group_key"))
.otherwise(drawn_in_e_star)
)
exposures = exposures.with_columns([total_cp_drawn_expr.alias("total_cp_drawn")])
ead_for_tier = pl.col("total_cp_drawn")
else:
# counterparty_reference is not present — per-exposure fallback.
# This can misclassify the tier when multiple exposures to the
# same group individually fall below the EUR 2.5m threshold but
# aggregate above it (Art. 501 requires aggregation across the
# SME's group of connected clients).
if errors is not None:
errors.append(
CalculationError(
code=ERROR_SME_MISSING_COUNTERPARTY_REF,
message=(
"SME supporting factor: neither counterparty_reference "
"nor lending_group_reference is available. Tier threshold "
"(EUR 2.5m) evaluated per-exposure instead of across the "
"SME's group of connected clients as required by CRR "
"Art. 501. This may produce an incorrectly low supporting "
"factor when multiple exposures to the same group "
"individually fall below the threshold but aggregate above it."
),
severity=ErrorSeverity.WARNING,
category=ErrorCategory.DATA_QUALITY,
regulatory_reference="CRR Art. 501",
field_name="counterparty_reference",
)
)
ead_for_tier = drawn_expr
# Calculate tiered factor based on aggregated drawn exposure
tier1_expr = (
pl.when(ead_for_tier <= threshold_gbp)
.then(ead_for_tier)
.otherwise(pl.lit(threshold_gbp))
)
tier2_expr = (
pl.when(ead_for_tier > threshold_gbp)
.then(ead_for_tier - threshold_gbp)
.otherwise(pl.lit(0.0))
)
# BTL exposures are excluded from the SME factor itself (the
# eligibility gate is separate from the E* netting). For E* the
# residential carve-out is applied via residential_collateral_value
# netting on drawn_in_e_star above; a typical BTL row's RRE
# collateral covers its drawn balance so its E* contribution is 0.
is_btl = pl.col("is_buy_to_let") if has_btl else pl.lit(False)
# Defaulted exposures are excluded from SME factor (CRR Art. 501)
is_defaulted = pl.col("is_defaulted") if has_defaulted else pl.lit(False)
# Art. 501(2)(c): the SME supporting factor is keyed on annual
# turnover only — the Commission Rec 2003/361/EC total-assets
# fallback (used by other SME-classification gates and by the
# IRB Art. 153(4) correlation adjustment) does NOT apply here.
# Counterparties identified as SME via assets receive the
# CORPORATE_SME class and IRB correlation benefit but
# supporting_factor=1.0. The check is conditional on the column
# being present so test harnesses that build minimal LazyFrames
# without cp_annual_revenue still hit the legacy is_sme-only
# predicate; production pipelines always project this column via
# the classifier so the gate fires there.
has_revenue = "cp_annual_revenue" in schema.names()
turnover_eligible = (
(pl.col("cp_annual_revenue").is_not_null() & (pl.col("cp_annual_revenue") > 0))
if has_revenue
else pl.lit(True)
)
sme_eligible = pl.col("is_sme") & turnover_eligible & ~is_btl & ~is_defaulted
sme_factor_expr = (
pl.when(sme_eligible & (ead_for_tier > 0))
.then((tier1_expr * factor_tier1 + tier2_expr * factor_tier2) / ead_for_tier)
.when(sme_eligible & (ead_for_tier <= 0))
.then(
# Zero drawn = all within tier 1 → pure 0.7619
pl.lit(factor_tier1)
)
.otherwise(pl.lit(1.0))
)
# Build infrastructure factor expression inline
infra_factor_expr = (
pl.when(pl.col("is_infrastructure")).then(pl.lit(infra_factor)).otherwise(pl.lit(1.0))
)
# Compute minimum (most beneficial) factor
min_factor_expr = pl.min_horizontal(sme_factor_expr, infra_factor_expr)
# Single with_columns call for maximum performance
return exposures.with_columns(
[
min_factor_expr.alias("supporting_factor"),
(pl.col("rwa_pre_factor") * min_factor_expr).alias("rwa_post_factor"),
(min_factor_expr < 1.0).alias("supporting_factor_applied"),
]
)
compute_e_star_group_drawn — src/rwa_calc/engine/supporting_factors.py:455
@cites("CRR Art. 501")
def compute_e_star_group_drawn(
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
errors: list[CalculationError] | None = None,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""
Compute Art. 501 E* across the unified frame before the approach split.
The SME supporting factor's EUR 2.5m / GBP 2.2m tier threshold is defined
by CRR Art. 501 against the total amount owed by the SME's *group of
connected clients*, regardless of which regulatory approach (SA, IRB,
slotting) each member is treated under. Running the windowed sum inside
each branch after the pipeline splits by approach (the historical
behaviour) under-counts E* whenever a lending group spans multiple
approaches.
This helper runs once on the unified frame, before the split in
``engine/pipeline.py``, so SA / IRB / slotting siblings all contribute.
The resulting ``e_star_group_drawn`` column is then read by
``apply_factors`` in each branch.
Population rules (mirroring the existing ``apply_factors`` logic):
- per-row contribution = ``drawn_amount + interest`` (clipped at zero),
minus ``min(residential_collateral_value, contribution)``
(Art. 501 residential carve-out)
- aggregation key = ``lending_group_reference`` if not null, else
``counterparty_reference`` (mirrors the connected-clients pattern)
- written to every row (SME and non-SME) in a partition so all three
branch calculators can read it
No-ops:
- if supporting factors are disabled (Basel 3.1), returns the frame
unchanged — column is not added
- if ``counterparty_reference`` is not present (missing group key),
emits the existing ``SF001`` warning and returns unchanged
Args:
exposures: Unified-frame LazyFrame post-CRM, pre-branch-split
config: Calculation configuration
errors: Optional error accumulator for data-quality warnings
Returns:
LazyFrame with ``e_star_group_drawn`` column added
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
if not resolved_pack.feature("supporting_factors"):
return exposures
schema = exposures.collect_schema()
names = schema.names()
if "counterparty_reference" not in names:
if errors is not None:
errors.append(
CalculationError(
code=ERROR_SME_MISSING_COUNTERPARTY_REF,
message=(
"SME supporting factor: neither counterparty_reference "
"nor lending_group_reference is available on the unified "
"frame. Cross-approach E* (CRR Art. 501) cannot be "
"computed; the tier threshold will fall back to per-branch "
"aggregation and may under-count exposures."
),
severity=ErrorSeverity.WARNING,
category=ErrorCategory.DATA_QUALITY,
regulatory_reference="CRR Art. 501",
field_name="counterparty_reference",
)
)
return exposures
has_drawn = "drawn_amount" in names
has_interest = "interest" in names
has_res_coll = "residential_collateral_value" in names
drawn_principal = (
pl.col("drawn_amount").fill_nan(0.0).fill_null(0.0).clip(lower_bound=0.0)
if has_drawn
else pl.lit(0.0)
)
interest_expr = pl.col("interest").fill_nan(0.0).fill_null(0.0) if has_interest else pl.lit(0.0)
drawn_expr = drawn_principal + interest_expr
if has_res_coll:
res_coll_expr = (
pl.col("residential_collateral_value")
.fill_nan(0.0)
.fill_null(0.0)
.clip(lower_bound=0.0)
)
drawn_in_e_star = drawn_expr - pl.min_horizontal(res_coll_expr, drawn_expr)
else:
drawn_in_e_star = drawn_expr
group_key_expr = (
pl.when(pl.col("lending_group_reference").is_not_null())
.then(pl.col("lending_group_reference"))
.otherwise(pl.col("counterparty_reference"))
)
exposures = exposures.with_columns(group_key_expr.alias("_sme_group_key"))
exposures = exposures.with_columns(
drawn_in_e_star.sum().over("_sme_group_key").alias("e_star_group_drawn")
)
return exposures.drop("_sme_group_key")
CRR Art. 501a — Adjustment to own funds requirements for credit risk for exposures to entities that operate or finance physical structures or facilities, systems and networks that provide or support essential public services¶
calculate_infrastructure_factor — src/rwa_calc/engine/supporting_factors.py:136
@cites("CRR Art. 501a")
def calculate_infrastructure_factor(
self,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> Decimal:
"""
Get infrastructure supporting factor.
Args:
config: Calculation configuration
Returns:
Infrastructure factor (0.75 for CRR, 1.0 for Basel 3.1)
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
if not resolved_pack.feature("supporting_factors"):
return Decimal("1.0")
return resolved_pack.formula("supporting_factors_values").params["infrastructure_factor"]
PS1/26 (PRA Policy Statement)¶
PS1/26, paragraph 4.8 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_equity_holding_higher_of_rw — src/rwa_calc/engine/equity/calculator.py:548
@cites("CRR Art. 155(2)")
@cites("PS1/26, paragraph 4.8")
@cites("PS1/26, paragraph 4.9")
def _equity_holding_higher_of_rw(
self, config: CalculationConfig, *, pack: ResolvedRulepack | None = None
) -> float | None:
"""Rules 4.7-4.8 higher-of RW for EQUITY-class CIU look-through holdings.
Returns ``max(legacy Art. 155(2) "other equity" simple RW, Rule 4.2/4.3
transitional SA RW)`` when the Basel 3.1 equity transitional regime is
active for the reporting date, else ``None`` (no override — holdings keep
the _DEFAULT_HOLDING_RW fallback).
The transitional regime only applies to firms that held IRB equity
permission, so ``equity_transitional.enabled`` (plus a transitional RW
existing for the reporting date) is the gate.
Per Rule 4.9-4.10, a firm that has irrevocably opted out of the
transitional regime (``equity_transitional.opt_out``) suppresses the
higher-of: ``None`` is returned so the holding falls back to the
``_DEFAULT_HOLDING_RW`` standard treatment. The opt-out applies jointly
with the direct-equity transitional floor (Rule 4.9).
References:
- CRR Art. 155(2): IRB simple method equity RW ("other" = 370%).
- PRA PS1/26 Rule 4.8: higher-of(Art. 155(2) simple, Rule 4.2/4.3 band).
- PRA PS1/26 Rule 4.9-4.10: irrevocable joint opt-out suppresses higher-of.
"""
if config.equity_transitional.opt_out:
return None
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
transitional_rw = _equity_transitional_rw(
resolved_pack, config.reporting_date, is_higher_risk=False
)
if transitional_rw is None:
return None
legacy_simple_rw = _IRB_RW[EquityType.OTHER]
return max(legacy_simple_rw, float(transitional_rw))
PS1/26, paragraph 4.9 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_equity_holding_higher_of_rw — src/rwa_calc/engine/equity/calculator.py:549
@cites("CRR Art. 155(2)")
@cites("PS1/26, paragraph 4.8")
@cites("PS1/26, paragraph 4.9")
def _equity_holding_higher_of_rw(
self, config: CalculationConfig, *, pack: ResolvedRulepack | None = None
) -> float | None:
"""Rules 4.7-4.8 higher-of RW for EQUITY-class CIU look-through holdings.
Returns ``max(legacy Art. 155(2) "other equity" simple RW, Rule 4.2/4.3
transitional SA RW)`` when the Basel 3.1 equity transitional regime is
active for the reporting date, else ``None`` (no override — holdings keep
the _DEFAULT_HOLDING_RW fallback).
The transitional regime only applies to firms that held IRB equity
permission, so ``equity_transitional.enabled`` (plus a transitional RW
existing for the reporting date) is the gate.
Per Rule 4.9-4.10, a firm that has irrevocably opted out of the
transitional regime (``equity_transitional.opt_out``) suppresses the
higher-of: ``None`` is returned so the holding falls back to the
``_DEFAULT_HOLDING_RW`` standard treatment. The opt-out applies jointly
with the direct-equity transitional floor (Rule 4.9).
References:
- CRR Art. 155(2): IRB simple method equity RW ("other" = 370%).
- PRA PS1/26 Rule 4.8: higher-of(Art. 155(2) simple, Rule 4.2/4.3 band).
- PRA PS1/26 Rule 4.9-4.10: irrevocable joint opt-out suppresses higher-of.
"""
if config.equity_transitional.opt_out:
return None
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
transitional_rw = _equity_transitional_rw(
resolved_pack, config.reporting_date, is_higher_risk=False
)
if transitional_rw is None:
return None
legacy_simple_rw = _IRB_RW[EquityType.OTHER]
return max(legacy_simple_rw, float(transitional_rw))
PS1/26, paragraph 92 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
apply_floor_with_impact — src/rwa_calc/engine/aggregator/_floor.py:108
@cites("PS1/26, paragraph 92")
def apply_floor_with_impact(
combined: pl.LazyFrame,
sa_results: pl.LazyFrame,
floor_pct: float,
of_adj: float = 0.0,
irb_t2_credit: float = 0.0,
irb_cet1_deduction: float = 0.0,
gcra_amount: float = 0.0,
sa_t2_credit: float = 0.0,
) -> tuple[pl.LazyFrame, pl.LazyFrame, OutputFloorSummary]:
"""
Apply portfolio-level output floor and generate impact analysis.
The floor is applied at portfolio level per PRA PS1/26 Art. 92 para 2A:
``TREA = max(U-TREA, x * S-TREA + OF-ADJ)``. When the floor binds, the
shortfall (``x * S-TREA + OF-ADJ - U-TREA``) is distributed pro-rata
across floor-eligible exposures (IRB + slotting) proportional to each
exposure's ``sa_rwa``.
Args:
combined: Combined results with ``rwa_final`` column.
sa_results: SA results to derive floor RWA from.
floor_pct: Floor percentage (e.g. 0.725 for 72.5%).
of_adj: Pre-computed OF-ADJ amount (default 0.0 for backward compat).
irb_t2_credit: Art. 62(d) IRB T2 credit (for summary reporting).
irb_cet1_deduction: Art. 36(1)(d) + Art. 40 CET1 deductions (for summary).
gcra_amount: GCRA after cap (for summary reporting).
sa_t2_credit: Art. 62(c) SA T2 credit (for summary reporting).
Returns:
Tuple of (floored results, floor impact analysis, portfolio summary).
"""
# Ensure combined has rwa_final column
combined_cols = set(combined.collect_schema().names())
if "rwa_final" not in combined_cols:
rwa_col = resolve_rwa_col(combined_cols)
if rwa_col:
combined = combined.with_columns(pl.col(rwa_col).alias("rwa_final"))
else:
combined = combined.with_columns(pl.lit(0.0).alias("rwa_final"))
# Store pre-floor RWA for impact calculation
combined = combined.with_columns(pl.col("rwa_final").alias("rwa_pre_floor"))
# Get SA RWA for each exposure. If calculate_unified already stored
# sa_rwa inline (single-pass path), use it directly. Otherwise join
# from the separate SA results frame (aggregate_with_audit path).
combined_cols = set(combined.collect_schema().names())
if "sa_rwa" in combined_cols:
result = combined
else:
sa_cols = set(sa_results.collect_schema().names())
sa_rwa_col = resolve_rwa_col(sa_cols)
if not sa_rwa_col:
sa_rwa_total, equity_rwa_total = _portfolio_sa_equity_totals(combined)
summary = OutputFloorSummary(
u_trea=0.0,
s_trea=0.0,
floor_pct=floor_pct,
floor_threshold=0.0,
shortfall=0.0,
portfolio_floor_binding=False,
floored_modelled_rwa=0.0,
of_adj=of_adj,
irb_t2_credit=irb_t2_credit,
irb_cet1_deduction=irb_cet1_deduction,
gcra_amount=gcra_amount,
sa_t2_credit=sa_t2_credit,
sa_rwa_total=sa_rwa_total,
equity_rwa_total=equity_rwa_total,
total_rwa_post_floor=sa_rwa_total + equity_rwa_total,
)
return combined, empty_frame(FLOOR_IMPACT_SCHEMA), summary
sa_rwa = sa_results.select(
pl.col("exposure_reference"),
pl.col(sa_rwa_col).alias("sa_rwa"),
)
result = combined.join(sa_rwa, on="exposure_reference", how="left", suffix="_sa")
# --- Portfolio-level output floor (Art. 92 para 2A) ---
#
# 1. Compute portfolio totals: U-TREA and S-TREA for floor-eligible
# exposures (IRB + slotting). SA exposures cancel out (same RWA
# in both U-TREA and S-TREA) so we only need the modelled subset.
#
# 2. Floor threshold = x * S-TREA + OF-ADJ. OF-ADJ reconciles the
# different provision treatments (IRB EL vs SA general CRA).
#
# 3. If floor binds (threshold > U-TREA), distribute the shortfall
# pro-rata by each exposure's sa_rwa share.
#
# 4. Per-exposure columns: floor_rwa, floor_impact_rwa, is_floor_binding,
# rwa_final (post-floor), output_floor_pct for COREP reporting.
floor_eligible_approaches = list(FLOOR_ELIGIBLE_APPROACHES)
is_eligible = pl.col("approach_applied").is_in(floor_eligible_approaches)
# Non-finite guard: Polars float ``.sum()`` propagates NaN, so one poisoned
# row would turn U-TREA/S-TREA — and through the pro-rata shortfall every
# eligible row's post-floor rwa_final — into NaN. A non-finite ``sa_rwa``
# follows the existing null convention (counts as 0 in S-TREA); a row whose
# ``rwa_pre_floor`` is non-finite is excluded from the floor computation
# entirely — no share, no total contribution — and keeps its own value,
# which the aggregator's AGG001 scan reports per-row.
sa_rwa_filled = pl.when(pl.col("sa_rwa").is_finite()).then(pl.col("sa_rwa")).otherwise(0.0)
pre_floor_finite = pl.col("rwa_pre_floor").is_finite().fill_null(value=False)
is_eligible_finite = is_eligible & pre_floor_finite
result = (
result
# Step 1: Portfolio-level totals (broadcast as scalar to every row)
.with_columns(
pl.when(is_eligible_finite)
.then(pl.col("rwa_pre_floor"))
.otherwise(0.0)
.sum()
.alias("_u_trea"),
pl.when(is_eligible_finite).then(sa_rwa_filled).otherwise(0.0).sum().alias("_s_trea"),
)
# Step 2: Floor threshold = x * S-TREA + OF-ADJ
.with_columns(
(pl.col("_s_trea") * floor_pct + pl.lit(of_adj)).alias("_floor_threshold"),
pl.max_horizontal(
pl.col("_s_trea") * floor_pct + pl.lit(of_adj) - pl.col("_u_trea"),
pl.lit(0.0),
).alias("_shortfall"),
(pl.col("_s_trea") * floor_pct + pl.lit(of_adj) > pl.col("_u_trea")).alias(
"_portfolio_floor_binds"
),
)
# Step 3: Each eligible exposure's share of total S-TREA
.with_columns(
pl.when(is_eligible_finite & (pl.col("_s_trea") > 0))
.then(sa_rwa_filled / pl.col("_s_trea"))
.otherwise(0.0)
.alias("_sa_share"),
)
# Step 4: Per-exposure floor columns
.with_columns(
(sa_rwa_filled * floor_pct).alias("floor_rwa"),
pl.lit(floor_pct).alias("output_floor_pct"),
# Pro-rata add-on: shortfall × this exposure's S-TREA share
pl.when(is_eligible_finite)
.then(pl.col("_shortfall") * pl.col("_sa_share"))
.otherwise(0.0)
.alias("floor_impact_rwa"),
# Portfolio-level binding flag (same for all eligible rows)
pl.when(is_eligible_finite)
.then(pl.col("_portfolio_floor_binds"))
.otherwise(pl.lit(False))
.alias("is_floor_binding"),
)
# Step 5: Final RWA = pre-floor + pro-rata add-on. A non-finite
# pre-floor row keeps its own (AGG001-flagged) value untouched.
.with_columns(
pl.when(is_eligible_finite)
.then(pl.col("rwa_pre_floor") + pl.col("floor_impact_rwa"))
.otherwise(pl.col("rwa_pre_floor"))
.alias("rwa_final"),
)
)
# Extract portfolio-level summary (requires one collect — acceptable at
# the aggregator boundary per project convention). fill_null handles
# the edge case of zero-row input (all sums are null → 0.0).
#
# SA and equity row totals are computed from the same frame so that the
# genuine portfolio total (total_rwa_post_floor) reflects every approach,
# not just the floor-eligible (modelled) subset. See P2.20.
sa_approaches = list(SA_APPROACHES)
equity_approaches = list(EQUITY_APPROACHES)
is_sa = pl.col("approach_applied").is_in(sa_approaches)
is_equity = pl.col("approach_applied").is_in(equity_approaches)
summary_row = result.select(
pl.col("_u_trea").first().fill_null(0.0),
pl.col("_s_trea").first().fill_null(0.0),
pl.col("_floor_threshold").first().fill_null(0.0),
pl.col("_shortfall").first().fill_null(0.0),
pl.col("_portfolio_floor_binds").first().fill_null(False),
pl.when(is_sa & pre_floor_finite)
.then(pl.col("rwa_pre_floor"))
.otherwise(0.0)
.sum()
.fill_null(0.0)
.alias("_sa_rwa_total"),
pl.when(is_equity & pre_floor_finite)
.then(pl.col("rwa_pre_floor"))
.otherwise(0.0)
.sum()
.fill_null(0.0)
.alias("_equity_rwa_total"),
).collect()
u_trea = float(summary_row["_u_trea"][0])
s_trea = float(summary_row["_s_trea"][0])
floor_threshold = float(summary_row["_floor_threshold"][0])
shortfall = float(summary_row["_shortfall"][0])
binding = bool(summary_row["_portfolio_floor_binds"][0])
sa_rwa_total = float(summary_row["_sa_rwa_total"][0])
equity_rwa_total = float(summary_row["_equity_rwa_total"][0])
floored_modelled_rwa = u_trea + shortfall
summary = OutputFloorSummary(
u_trea=u_trea,
s_trea=s_trea,
floor_pct=floor_pct,
floor_threshold=floor_threshold,
shortfall=shortfall,
portfolio_floor_binding=binding,
floored_modelled_rwa=floored_modelled_rwa,
of_adj=of_adj,
irb_t2_credit=irb_t2_credit,
irb_cet1_deduction=irb_cet1_deduction,
gcra_amount=gcra_amount,
sa_t2_credit=sa_t2_credit,
sa_rwa_total=sa_rwa_total,
equity_rwa_total=equity_rwa_total,
total_rwa_post_floor=floored_modelled_rwa + sa_rwa_total + equity_rwa_total,
)
# Drop internal columns
result = result.drop(
[
"_u_trea",
"_s_trea",
"_floor_threshold",
"_shortfall",
"_portfolio_floor_binds",
"_sa_share",
],
strict=False,
)
# Generate floor impact analysis (floor-eligible rows only)
result_cols = set(result.collect_schema().names())
floor_impact = result.select(
pl.col("exposure_reference"),
pl.col("approach_applied"),
col_or_default("exposure_class", result_cols),
pl.col("rwa_pre_floor"),
pl.col("floor_rwa"),
pl.col("is_floor_binding"),
pl.col("floor_impact_rwa"),
pl.col("rwa_final").alias("rwa_post_floor"),
pl.col("output_floor_pct"),
).filter(pl.col("approach_applied").is_in(floor_eligible_approaches))
return result, floor_impact, summary
aggregate — src/rwa_calc/engine/aggregator/aggregator.py:86
@cites("PS1/26, paragraph 92")
def aggregate(
self,
sa_results: pl.LazyFrame,
irb_results: pl.LazyFrame,
slotting_results: pl.LazyFrame,
equity_bundle: EquityResultBundle | None,
config: CalculationConfig,
securitisation_audit: pl.LazyFrame | None = None,
*,
pack: ResolvedRulepack | None = None,
) -> AggregatedResultBundle:
"""
Aggregate calculator outputs into final result bundle.
Args:
sa_results: SA branch results (already collected and re-lazied).
irb_results: IRB branch results.
slotting_results: Slotting branch results.
equity_bundle: Equity result bundle (optional, separate path).
config: Calculation configuration.
securitisation_audit: Resolved securitisation lookup from the
allocator stage (one row per securitised exposure carrying
residual_pct + pool_allocations + audit_status). None when
no allocations were supplied.
pack: Resolved rulepack for the run's regime/date (Phase 5 — sources
the ``output_floor`` / ``supporting_factors`` regime gates).
Production threads the orchestrator's pack; direct callers may
omit it, in which case one is resolved from ``config``.
Returns:
AggregatedResultBundle with all summaries and adjustments. Every
frame field is eager-backed: the summary views are collected once
here (in two ``_collect_views`` batches, pre- and post-floor) and
wrapped back with ``.lazy()``, so a downstream collect call is a
near-free shallow collect rather than a plan re-execution.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
# Combine for summaries (data already materialised — cheap concat).
# ``combined_unmultiplied`` retains the full ead_final / rwa_final
# values so the per-pool securitisation summary can multiply by each
# pool's allocation_pct against the un-multiplied parent total. The
# main ``combined`` then gets the residual multiplier applied so the
# existing summaries (by class, by approach, floor, EL, supporting
# factors) naturally reflect the on-balance-sheet residual only --
# ``ead_final × (1 - securitisation_pct)`` in the user's words.
combined_unmultiplied = pl.concat(
[sa_results, irb_results, slotting_results], how="diagonal_relaxed"
)
# Concat equity if present
equity_results = None
if equity_bundle and equity_bundle.results is not None:
# ``include_sa_equivalent`` mirrors the SA calculator's ``output_floor``
# gate on ``sa_rwa``: under Basel 3.1 equity is standardised-only
# (Art. 147A), so its sa_rwa = its own pre-floor RWA and must reach the
# disclosed S-TREA (OF 02.01 / C 02.00 / CMS). Equity is not
# floor-eligible, so the floor base and rwa_final are unaffected.
equity_prepared = prepare_equity_results(
equity_bundle.results,
include_sa_equivalent=resolved_pack.feature("output_floor"),
)
combined_unmultiplied = pl.concat(
[combined_unmultiplied, equity_prepared], how="diagonal_relaxed"
)
equity_results = equity_bundle.results
# Applied reporting class (recon + COREP class dimension). Pure function
# of columns already present on every branch exit, so it is added once
# here and flows through the residual multiplier, output floor, post-CRM
# views and the sealed results frame. ``exposure_class_post_crm`` is its
# post-guarantee twin (guaranteed slice under the guarantor's class) that
# the reconciliation ties out on; ``approach_post_crm`` is the matching
# post-guarantee approach, so the two partition the same money the same way.
combined_unmultiplied = _add_exposure_class_applied(combined_unmultiplied)
combined_unmultiplied = _add_post_crm_reporting_class(combined_unmultiplied)
combined_unmultiplied = _add_post_crm_reporting_approach(combined_unmultiplied)
# Build the per-pool summary and the per-exposure reconciliation
# BEFORE applying the residual multiplier -- the pool slice needs
# the un-multiplied parent EAD.
securitisation_summary = generate_securitisation_summary(combined_unmultiplied)
sec_audit_view = generate_securitisation_audit(combined_unmultiplied, securitisation_audit)
# Apply the residual multiplier in-place so every downstream
# summary, floor calc, and EL roll-up reflects only the on-balance-
# sheet portion. When no allocations are present, the multiplier
# column is a uniform 1.0 and this is a no-op.
combined = apply_residual_multiplier(combined_unmultiplied)
# Materialise the pre-floor views ONCE. The calculator branches are
# already eager (collected by materialise_branches at the calculator
# edge), so these are plans over in-memory data; one pl.collect_all
# shares the common subplan (concat + residual multiplier) across the
# views. Each frame is wrapped back with ``.lazy()`` so the bundle
# fields stay LazyFrame-typed (migration Phase 1 — no bundle type
# changes until the Phase 3 producer seal). The by-class / by-approach
# summaries are deferred until AFTER the output floor is applied below,
# so they reflect the floored per-row RWA (P1.130).
pre_floor_views: dict[str, pl.LazyFrame] = {
"combined": combined,
}
if securitisation_summary is not None:
pre_floor_views["securitisation_summary"] = securitisation_summary
if sec_audit_view is not None:
pre_floor_views["securitisation_audit"] = sec_audit_view
pre_floor_dfs = _collect_views(pre_floor_views)
combined_df = pre_floor_dfs["combined"]
combined = combined_df.lazy()
# CCR Art. 308/309 default-fund-contribution roll-up: sum rwa_final over
# the synthetic ``CCR_DEFAULT_FUND`` rows. Guarded for the column's
# absence on CCR-free portfolios (risk_type is null/absent there).
rwa_ccr_default_fund: float | None = None
if {"risk_type", "rwa_final"} <= set(combined_df.columns):
dfc_total = float(
combined_df.filter(pl.col("risk_type") == "CCR_DEFAULT_FUND")
.select(pl.col("rwa_final").fill_null(0.0).sum())
.item()
)
if dfc_total > 0.0:
rwa_ccr_default_fund = dfc_total
# P8.52 CCR reporting roll-ups (COREP / Pillar-III scalars). Each is a
# filtered sum over the already-materialised ``combined_df``; column-
# presence-guarded so a CCR-free portfolio yields ``None`` not a raise.
#
# ead_ccr_total — CRR Art. 274(2): sum of ead_final over the synthetic
# ``ccr__``-prefixed CCR derivative / SFT rows.
ead_ccr_total: float | None = None
if {"exposure_reference", "ead_final"} <= set(combined_df.columns):
ead_total = float(
combined_df.filter(pl.col("exposure_reference").str.starts_with("ccr__"))
.select(pl.col("ead_final").sum())
.item()
)
if ead_total > 0.0:
ead_ccr_total = ead_total
# rwa_ccr_default / rwa_ccr_qccp_trade — partition of the ``ccr__`` row
# set by the QCCP trade-leg discriminator (cp_entity_type == "ccp" AND
# cp_is_qccp.fill_null(True), mirroring the SA QCCP override). Default
# is the non-QCCP complement (CRR Art. 107(2)(a)); qccp_trade is the
# QCCP partition (CRR Art. 306(1)/(4)).
rwa_ccr_default: float | None = None
rwa_ccr_qccp_trade: float | None = None
if {
"exposure_reference",
"rwa_final",
"cp_entity_type",
"cp_is_qccp",
} <= set(combined_df.columns):
ccr_rows = combined_df.filter(pl.col("exposure_reference").str.starts_with("ccr__"))
is_qccp_trade = (pl.col("cp_entity_type") == "ccp") & pl.col("cp_is_qccp").fill_null(
True
)
default_total = float(
ccr_rows.filter(~is_qccp_trade).select(pl.col("rwa_final").sum()).item()
)
if default_total > 0.0:
rwa_ccr_default = default_total
qccp_total = float(
ccr_rows.filter(is_qccp_trade).select(pl.col("rwa_final").sum()).item()
)
if qccp_total > 0.0:
rwa_ccr_qccp_trade = qccp_total
# failed_trades_rwa — CRR Art. 378-380 / Art. 92(3)(ca): sum of
# rwa_final over the synthetic ``SETTLEMENT_FAILED_TRADE`` rows.
failed_trades_rwa: float | None = None
if {"risk_type", "rwa_final"} <= set(combined_df.columns):
ft_total = float(
combined_df.filter(pl.col("risk_type") == "SETTLEMENT_FAILED_TRADE")
.select(pl.col("rwa_final").sum())
.item()
)
if ft_total > 0.0:
failed_trades_rwa = ft_total
if securitisation_summary is not None:
securitisation_summary = pre_floor_dfs["securitisation_summary"].lazy()
if sec_audit_view is not None:
sec_audit_view = pre_floor_dfs["securitisation_audit"].lazy()
# EL portfolio summary (T2 credit cap, CET1/T2 deductions)
# Computed BEFORE the output floor because OF-ADJ depends on EL summary
# results (IRB T2 credit and IRB CET1 deduction).
#
# IMPORTANT: The T2 credit cap (Art. 62(d)) uses un-floored IRB RWA,
# not post-floor TREA. Art. 62(d) references "risk-weighted exposure
# amounts calculated under Chapter 3 of Title II of Part Three" — the
# IRB chapter — not the portfolio-level floor from Art. 92(2A).
# We intentionally pass the original irb_results / slotting_results
# (which are unaffected by the floor applied to `combined` above),
# NOT the floored `combined` LazyFrame. Using post-floor TREA would
# also create a circular dependency with the OF-ADJ formula.
#
# Securitisation: feed the residual-multiplied views so EL / PoolB /
# T2 cap arithmetic reflects only the on-balance-sheet portion. The
# IRB EL formula scales linearly with EAD, so this is equivalent to
# multiplying the final EL summary by the residual fraction.
el_summary = compute_el_portfolio_summary(
apply_residual_multiplier(irb_results),
apply_residual_multiplier(slotting_results),
)
# CRR Art. 164(4)/(5) portfolio-level A-IRB retail-RE LGD-floor backstop.
# CRR-only monitoring WARNING (never an RWA/LGD adjustment); Basel 3.1
# disables the Feature — its per-exposure airb_lgd_floor supersedes it.
# Reads the already-materialised ``combined_df`` (no extra collect).
retail_re_lgd_floor_warnings: list[CalculationError] = []
if resolved_pack.feature("crr_retail_re_portfolio_lgd_floor"):
retail_re_lgd_floor_warnings = check_retail_re_portfolio_lgd_floors(
combined_df, resolved_pack
)
# Apply portfolio-level output floor if applicable (Art. 92 para 2A)
# Floor only applies to specific (institution_type, reporting_basis)
# combinations — exempt entities use U-TREA with no floor add-on.
floor_impact = None
output_floor_summary = None
if resolved_pack.feature("output_floor") and config.output_floor.is_entity_in_scope():
floor_pct = float(
_output_floor_pct(resolved_pack, config.output_floor, config.reporting_date)
)
# Compute OF-ADJ from EL summary + capital-tier config inputs
# OF-ADJ = 12.5 * (IRB_T2 - IRB_CET1 - GCRA + SA_T2)
# ELPortfolioSummary stores Decimal; convert to float for floor arithmetic.
irb_t2 = float(el_summary.t2_credit) if el_summary else 0.0
irb_cet1 = (
float(el_summary.cet1_deduction) if el_summary else 0.0
) + config.output_floor.art_40_deductions
gcra = config.output_floor.gcra_amount
sa_t2 = config.output_floor.sa_t2_credit
# S-TREA is needed for GCRA cap — pre-compute it here.
# We need a quick aggregate of SA-equivalent RWA for floor-eligible
# exposures. This duplicates some work in apply_floor_with_impact
# but avoids restructuring the floor module's internal flow.
# Computed eagerly from ``combined_df`` (materialised above) so no
# extra plan execution is needed.
from rwa_calc.engine.aggregator._schemas import FLOOR_ELIGIBLE_APPROACHES
if "approach_applied" in combined_df.columns:
sa_rwa_col = "sa_rwa" if "sa_rwa" in combined_df.columns else "rwa_final"
s_trea_pre = float(
combined_df.filter(
pl.col("approach_applied").is_in(list(FLOOR_ELIGIBLE_APPROACHES))
)
.select(pl.col(sa_rwa_col).fill_null(0.0).sum())
.item()
)
else:
s_trea_pre = 0.0
of_adj_val, gcra_capped = compute_of_adj(
irb_t2, irb_cet1, gcra, sa_t2, s_trea_pre, pack=resolved_pack
)
combined, floor_impact, output_floor_summary = apply_floor_with_impact(
combined,
combined, # SA-equivalent RW already joined by SA calculator
floor_pct,
of_adj=of_adj_val,
irb_t2_credit=irb_t2,
irb_cet1_deduction=irb_cet1,
gcra_amount=gcra_capped,
sa_t2_credit=sa_t2,
)
# Canonical reporting projection (Phase 7 S2): name the per-leg
# substitution ledger on the frame that gets sealed. Applied AFTER the
# residual multiplier and the output floor so the ``reporting_ead`` /
# ``reporting_rw`` aliases mirror the sealed final values. No consumer
# reads these columns yet (S4+ retarget the summaries/recon/reporting),
# so this is provably cell-neutral.
combined = _add_reporting_projection(combined)
# Generate the persisted summaries as pure group-bys of the sealed
# per-leg reporting ledger (Phase 7 S4) — the SINGLE by-class /
# by-approach source. ``combined`` carries the reporting projection
# and, when the floor bound, the per-row ``floor_impact_rwa`` add-on,
# which ``total_rwa`` folds in so the reported totals reconcile with
# ``output_floor_summary.total_rwa_post_floor`` (P1.130).
summary_by_class = generate_summary_by_class(combined)
summary_by_approach = generate_summary_by_approach(combined)
summary_by_class_method = generate_summary_by_class_method(combined)
# Supporting factor impact. The regime gate is pack Feature-sourced; the
# pack is threaded into aggregate() (S11d), so this reads the run's
# resolved pack directly rather than re-deriving one from config.
supporting_factor_impact = None
if resolved_pack.feature("supporting_factors"):
supporting_factor_impact = generate_supporting_factor_impact(combined)
# Materialise the post-floor views ONCE (same single-collect pattern
# as the pre-floor batch). ``None`` fields stay None — only frames
# that were actually built are collected.
post_floor_views: dict[str, pl.LazyFrame] = {
"results": combined,
"summary_by_class": summary_by_class,
"summary_by_approach": summary_by_approach,
"summary_by_class_method": summary_by_class_method,
}
if floor_impact is not None:
post_floor_views["floor_impact"] = floor_impact
if supporting_factor_impact is not None:
post_floor_views["supporting_factor_impact"] = supporting_factor_impact
post_floor_dfs = _collect_views(post_floor_views)
return AggregatedResultBundle(
# Producer seal (Phase 3): the aggregator's combined results
# frame is the reporting input contract — pure plan ops over
# the eager-backed wrap.
results=seal(post_floor_dfs["results"].lazy(), AGGREGATOR_EXIT_EDGE),
sa_results=sa_results,
irb_results=irb_results,
slotting_results=slotting_results,
equity_results=equity_results,
# Producer seals for the consumer-read summary / floor / factor
# frames (SEALED_FRAME_FIELDS-registered), same eager-backed wrap as
# ``results``: the UI cards / results cache / analyses receive a
# brand-validated frame, never a reshaped or partially-built one.
floor_impact=(
seal(post_floor_dfs["floor_impact"].lazy(), FLOOR_IMPACT_EDGE)
if floor_impact is not None
else None
),
output_floor_summary=output_floor_summary,
supporting_factor_impact=(
seal(
post_floor_dfs["supporting_factor_impact"].lazy(), SUPPORTING_FACTOR_IMPACT_EDGE
)
if supporting_factor_impact is not None
else None
),
summary_by_class=seal(post_floor_dfs["summary_by_class"].lazy(), SUMMARY_BY_CLASS_EDGE),
summary_by_approach=seal(
post_floor_dfs["summary_by_approach"].lazy(), SUMMARY_BY_APPROACH_EDGE
),
summary_by_class_method=seal(
post_floor_dfs["summary_by_class_method"].lazy(), SUMMARY_BY_CLASS_METHOD_EDGE
),
el_summary=el_summary,
securitisation_summary=securitisation_summary,
securitisation_audit=sec_audit_view,
rwa_ccr_default_fund=rwa_ccr_default_fund,
ead_ccr_total=ead_ccr_total,
rwa_ccr_default=rwa_ccr_default,
rwa_ccr_qccp_trade=rwa_ccr_qccp_trade,
failed_trades_rwa=failed_trades_rwa,
errors=(
_detect_non_finite_errors(post_floor_dfs["results"]) + retail_re_lgd_floor_warnings
),
)
PS1/26, paragraph 110A — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
apply_due_diligence_override — src/rwa_calc/engine/sa/rw_adjustments.py:572
@cites("PS1/26, paragraph 110A")
def apply_due_diligence_override(
lf: pl.LazyFrame,
config: CalculationConfig,
*,
errors: list[CalculationError] | None = None,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Apply due diligence risk weight override (Basel 3.1 Art. 110A).
Under Basel 3.1, firms must perform due diligence on all SA exposures.
Where due diligence reveals that the risk weight does not adequately
reflect the risk, the firm must apply a higher risk weight.
The override only increases the risk weight — it can never reduce it.
This is applied as the final risk weight modification before RWA
calculation, after all standard RW determination, CRM, and currency
mismatch adjustments.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
if not resolved_pack.feature("sa_due_diligence_override"):
return lf
schema = lf.collect_schema()
cols = schema.names()
# Warn if due_diligence_performed column is absent under Basel 3.1
if "due_diligence_performed" not in cols and errors is not None:
errors.append(
CalculationError(
code=ERROR_DUE_DILIGENCE_NOT_PERFORMED,
message=(
"Due diligence assessment status not provided "
"(due_diligence_performed column absent). "
"Art. 110A requires firms to perform due diligence "
"on all SA exposures to ensure risk weights "
"appropriately reflect exposure risk."
),
severity=ErrorSeverity.WARNING,
category=ErrorCategory.DATA_QUALITY,
regulatory_reference="PRA PS1/26 Art. 110A",
field_name="due_diligence_performed",
)
)
# Apply override RW where provided and higher than calculated RW
if "due_diligence_override_rw" not in cols:
return lf
override_applies = pl.col("due_diligence_override_rw").is_not_null() & (
pl.col("due_diligence_override_rw") > pl.col("risk_weight")
)
return lf.with_columns(
[
pl.when(override_applies)
.then(pl.col("due_diligence_override_rw"))
.otherwise(pl.col("risk_weight"))
.alias("risk_weight"),
override_applies.alias("due_diligence_override_applied"),
]
)
PS1/26, paragraph 111 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_compute_ccf — src/rwa_calc/engine/ccf.py:420
@cites("CRR Art. 111")
@cites("PS1/26, paragraph 111")
def _compute_ccf(
self,
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Compute CCF based on risk type and approach.
Determines SA and F-IRB CCFs from risk_type, then selects the final CCF
based on the exposure's approach (SA/F-IRB/A-IRB).
CRR Annex I / Art. 111(1) obs_product fill: before resolving CCFs, any row
whose ``risk_type`` is null/empty has its ``risk_type`` resolved from the
concrete ``obs_product`` key via ANNEX1_PRODUCT_RISK_TYPE (framework-
invariant). An explicit ``risk_type`` always wins — the fill is gated on
the existing value being null/empty.
Applies the PRA PS1/26 Art. 111(1) Table A1 Row 4(b) override: a UK
residential-property commitment (``is_uk_residential_mortgage_commitment``)
gets a 50% CCF under Basel 3.1 — on the SA and the F-IRB / Slotting
carrier alike (Art. 166C(1)) — except where the otherwise-resolved CCF is
10% (Row 7 UCC) or 100% (Row 1/2) — the Row 4(b) carve-out.
References:
- CRR Art. 111(1) / Annex I: SA CCF buckets.
- CRR Art. 166(8)(a)-(d): F-IRB bespoke supervisory CCFs.
- CRR Art. 166(9): own-estimate (modelled) A-IRB CCFs are admissible
only within the Art. 166(8) product scope.
- CRR Art. 166(10): residual supervisory fallback for issued OBS items
outside the Art. 166(8) scope.
"""
# CRR Annex I / Art. 111(1): resolve risk_type from the concrete OBS
# product when (and only when) no explicit risk_type was supplied. Explicit
# risk_type always wins; an unmapped/null product yields null and leaves
# risk_type unchanged.
risk_type_is_blank = (
pl.col("risk_type").cast(pl.Utf8, strict=False).fill_null("").str.len_chars() == 0
)
product_risk_type = build_product_to_risk_type_expr("obs_product")
exposures = exposures.with_columns(
pl.when(risk_type_is_blank & product_risk_type.is_not_null())
.then(product_risk_type)
.otherwise(pl.col("risk_type"))
.alias("risk_type"),
)
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
# S9c: the F-IRB-uses-SA-CCF routing gate (Art. 166C) reads the cited pack
# Feature; sa_ccf_expression / _firb_ccf_for_col keep their is_basel_3_1 bool
# plumbing params (Option B). All CCF VALUES stay static data-layer tables.
is_b31 = resolved_pack.feature("firb_uses_sa_ccf")
if is_b31:
# Basel 3.1 Art. 166C: F-IRB uses SA CCFs (PRA PS1/26 Art. 111 Table A1)
# FR=100%, MR=50%, MLR=20%, LR(UCC)=10%
firb_ccf = sa_ccf_expression(is_basel_3_1=True)
else:
# CRR F-IRB: Art. 166(8)(d) -> 75% for credit lines / NIFs / RUFs
# (is_obs_commitment=True); Art. 166(10) -> 100/50/20/0% fallback for
# issued OBS items not in scope of paragraphs 1-8.
firb_ccf = _firb_ccf_for_col("risk_type")
exposures = exposures.with_columns(
sa_ccf_expression(is_basel_3_1=is_b31).alias("_sa_ccf_from_risk_type"),
firb_ccf.alias("_firb_ccf_from_risk_type"),
(pl.col("nominal_amount").cast(pl.Float64, strict=False).abs() < 1e-10).alias(
"_nominal_is_zero"
),
)
# CRR maturity-dependent OC override (Art. 111(1) / Annex I items 2(b),
# 3(b)): "other commitments" attract the MR 50% CCF when their ORIGINAL
# maturity is > 1yr (item 2(b)) and the MLR 20% CCF when it is <= 1yr
# (item 3(b)). The split keys on ORIGINAL maturity, not residual: the
# explicit ``original_maturity_years`` when present, else the
# (maturity_date - value_date) start-date fallback. With no origination
# source the conservative MR 50% default (from sa_ccf_expression) stands.
if not is_b31:
exposures = self._apply_oc_original_maturity_ccf(exposures)
# PRA PS1/26 Art. 111(1) Table A1 Row 4(b): commitments to extend credit
# secured by residential property attract a 50% CCF — "to the extent
# that they are not subject to a conversion factor of 10% or 100%". The
# override lands on BOTH the SA and the F-IRB carrier because Art. 166C(1)
# defines the F-IRB / Slotting CCF as the Art. 111 SA CCF (P1.251).
# No effect under CRR (Table A1 is Basel 3.1 only) — see the gate below.
if is_b31:
exposures = self._apply_uk_residential_mortgage_ccf(exposures)
exposures = self._apply_purchased_receivable_ccf(exposures)
# Art. 111(1)(c): commitment-to-issue lower-of rule.
# When underlying_risk_type is specified, cap CCFs at the underlying item's CCF.
# "the lower of (i) the CCF applicable to the underlying OBS item and
# (ii) the CCF applicable to the commitment type"
has_underlying = pl.col("underlying_risk_type").fill_null("").str.len_chars() > 0
underlying_sa = sa_ccf_expression("underlying_risk_type", is_basel_3_1=is_b31)
exposures = exposures.with_columns(
pl.when(has_underlying)
.then(pl.min_horizontal(pl.col("_sa_ccf_from_risk_type"), underlying_sa))
.otherwise(pl.col("_sa_ccf_from_risk_type"))
.alias("_sa_ccf_from_risk_type"),
pl.when(has_underlying)
.then(
pl.min_horizontal(
pl.col("_firb_ccf_from_risk_type"),
sa_ccf_expression("underlying_risk_type", is_basel_3_1=True)
if is_b31
else _firb_ccf_for_col("underlying_risk_type"),
)
)
.otherwise(pl.col("_firb_ccf_from_risk_type"))
.alias("_firb_ccf_from_risk_type"),
)
# A-IRB CCF: use modelled value, with Basel 3.1 restrictions
ccf_modelled_expr = pl.col("ccf_modelled").cast(pl.Float64, strict=False)
if is_b31:
# Basel 3.1 Art. 166D(1)(a): own-estimate CCFs only for revolving
# facilities whose SA CCF is not 100% (Table A1 Row 2 carve-out).
# Non-revolving A-IRB must use SA CCFs from Table A1.
# Revolving with SA CCF < 100%: own CCF with 50% SA floor (CRE32.27).
airb_revolving_ccf = pl.max_horizontal(
ccf_modelled_expr.fill_null(pl.col("_sa_ccf_from_risk_type")),
pl.col("_sa_ccf_from_risk_type")
* scalar_value(resolved_pack.scalar_param("airb_revolving_ccf_floor_multiplier")),
)
is_eligible_for_own_ccf = pl.col("is_revolving").fill_null(False) & (
pl.col("_sa_ccf_from_risk_type") < 1.0
)
airb_ccf = (
pl.when(is_eligible_for_own_ccf)
.then(airb_revolving_ccf)
.otherwise(pl.col("_sa_ccf_from_risk_type"))
)
else:
# CRR Art. 166(9): own-estimate (modelled) A-IRB CCFs are admissible
# only within the Art. 166(8) product scope — undrawn commitments
# (``is_obs_commitment``) and short-term trade LCs
# (``is_short_term_trade_lc``) — and never for FR/FRC full-risk
# substitutes. Out-of-scope rows (issued OBS items governed by
# Art. 166(10), full-risk items) take the supervisory F-IRB CCF
# unconditionally, so a spuriously low modelled value is ignored.
# In scope, a null modelled CCF falls back to the Art. 166(8)/(10)
# supervisory value (``_firb_ccf_from_risk_type``), NOT the SA
# Art. 111 CCF.
in_166_8_scope = (
pl.col("is_obs_commitment").fill_null(True)
| pl.col("is_short_term_trade_lc").fill_null(False)
) & ~_normalize_risk_type("risk_type").is_in(["FR", "FRC"])
airb_ccf = (
pl.when(in_166_8_scope)
.then(ccf_modelled_expr.fill_null(pl.col("_firb_ccf_from_risk_type")))
.otherwise(pl.col("_firb_ccf_from_risk_type"))
)
# Select final CCF based on approach
return exposures.with_columns(
pl.when(pl.col("_nominal_is_zero"))
.then(pl.lit(0.0))
.when(pl.col("approach") == ApproachType.AIRB.value)
.then(airb_ccf)
.when(pl.col("approach") == ApproachType.FIRB.value)
.then(pl.col("_firb_ccf_from_risk_type"))
# CRR Art. 147(8): specialised-lending slotting is a corporate IRB
# exposure, so its OBS EAD is governed by Art. 166(8) — the F-IRB CCF
# (e.g. MR -> 75%), not the SA 50%. Under Basel 3.1, Art. 166C makes
# F-IRB CCFs equal SA CCFs, so slotting stays on the SA path below.
.when((pl.col("approach") == ApproachType.SLOTTING.value) & (not is_b31))
.then(pl.col("_firb_ccf_from_risk_type"))
.otherwise(pl.col("_sa_ccf_from_risk_type"))
.alias("ccf"),
)
_apply_uk_residential_mortgage_ccf — src/rwa_calc/engine/ccf.py:664
@cites("PS1/26, paragraph 111")
@cites("PS1/26, paragraph 166.1")
def _apply_uk_residential_mortgage_ccf(
self,
exposures: pl.LazyFrame,
) -> pl.LazyFrame:
"""Apply the Table A1 Row 4(b) UK residential-mortgage commitment CCF.
PRA PS1/26 Art. 111(1) Table A1 Row 4(b): "UK residential mortgage
commitments that are not subject to a conversion factor of 10% or 100%"
attract a 50% conversion factor. When
``is_uk_residential_mortgage_commitment`` is set, the otherwise-resolved
CCF is overridden to that Row 4 rate (50%), unless the row already sits in
the carve-out — the Row 7 UCC 10% or the Row 1/2 100% — in which case it
is left untouched. The carve-out is tested per carrier, against that
carrier's own resolved value.
Both the SA and the F-IRB carrier are patched: Art. 166C(1) sets the
F-IRB and Slotting off-balance-sheet exposure value using "the conversion
factor that would be applicable to the off-balance sheet item under the
Standardised Approach, as set out in ... Article 111", so Row 4(b)
governs the F-IRB conversion factor exactly as it governs the SA one
(P1.251). Slotting reads the SA carrier under Basel 3.1.
Basel-3.1-only: callers gate this on the ``firb_uses_sa_ccf`` pack Feature
(S9c); Table A1 Row 4(b) is a PRA construct with no CRR Annex I
equivalent, so the flag is a no-op under CRR.
"""
row_4b_ccf = _SA_CCF_B31_MAP["MR"]
carve_out_ccfs = (_SA_CCF_B31_MAP["LR"], _SA_CCF_B31_MAP["FR"])
is_resi_commitment = pl.col("is_uk_residential_mortgage_commitment").fill_null(False)
sa_not_in_carve_out = ~pl.col("_sa_ccf_from_risk_type").is_in(carve_out_ccfs)
firb_not_in_carve_out = ~pl.col("_firb_ccf_from_risk_type").is_in(carve_out_ccfs)
return exposures.with_columns(
pl.when(is_resi_commitment & sa_not_in_carve_out)
.then(pl.lit(row_4b_ccf))
.otherwise(pl.col("_sa_ccf_from_risk_type"))
.alias("_sa_ccf_from_risk_type"),
pl.when(is_resi_commitment & firb_not_in_carve_out)
.then(pl.lit(row_4b_ccf))
.otherwise(pl.col("_firb_ccf_from_risk_type"))
.alias("_firb_ccf_from_risk_type"),
)
PS1/26, paragraph 114 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
ecb_rw_expr — src/rwa_calc/engine/sa/central_bank.py:56
@cites("CRR Art. 114(3)")
@cites("PS1/26, paragraph 114")
def ecb_rw_expr() -> pl.Expr:
"""Art. 114(3): the ECB 0% risk weight, read from the common pack.
Exposed as an expression builder rather than a module-scope constant so the
regulatory value stays in the rulepack (arch_check check 5) — the pack-binding
shim ``crr_risk_weight_tables`` is its only engine-side home.
"""
return pl.lit(float(ECB_ZERO_RW))
is_ecb_expr — src/rwa_calc/engine/sa/central_bank.py:68
@cites("CRR Art. 114(3)")
@cites("PS1/26, paragraph 114")
def is_ecb_expr() -> pl.Expr:
"""Art. 114(3): identify exposures to the ECB (0% RW, unconditionally).
``eq_missing`` returns False rather than null for a null ``cp_entity_type``,
so no ``fill_null`` is needed and a missing entity type can never be read as
the ECB.
"""
return pl.col("cp_entity_type").eq_missing(_ECB_ENTITY_TYPE)
lift_central_bank_cqs — src/rwa_calc/engine/sa/central_bank.py:79
@cites("PS1/26, paragraph 114")
def lift_central_bank_cqs(exposures: pl.LazyFrame, pack: ResolvedRulepack) -> pl.LazyFrame:
"""PS1/26 Art. 114(2A): an unrated central bank takes its government's CQS.
"Exposures to a central bank for which a credit assessment by a nominated
ECAI is not available shall be treated in accordance with paragraph 2 if a
credit assessment by a nominated ECAI is available for the central government
of the jurisdiction of the central bank. In this case, the central
government's credit assessment shall be used to determine the risk weight for
exposures to the central bank."
Implemented as a lift of ``cp_sovereign_cqs`` into ``cqs``, mirroring the
MDB / non-QCCP ``cp_institution_cqs`` lift in ``risk_weights.py``, so the
ordinary Art. 114(2) Table 1 ladder then applies unchanged.
Scope is narrow on three axes:
- ``central_bank`` exactly — not ``sovereign`` (a central government's own
assessment already IS the Table 1 input, so there is nothing to
substitute) and not ``central_bank_ecb`` (Art. 114(3) gives the ECB 0%
ahead of any CQS ladder).
- ``cqs`` null only — the central bank's own assessment wins where it
exists; Art. 114(2A) fires only where one "is not available".
- CRR has no paragraph 2A (Art. 114 runs 1, 2, 3, 4, 7), so the lift is
gated on the cited ``central_bank_uses_sovereign_cqs`` pack Feature.
A null ``cp_sovereign_cqs`` fabricates nothing: ``cqs`` stays null and the row
keeps the Art. 114(1) unrated 100% fallback.
``cp_sovereign_cqs`` is declared ``Int32`` while ``cqs`` is ``Int8`` (its
sibling ``cp_institution_cqs`` is already Int8, which is why the MDB lift
needs no cast). The explicit cast keeps ``cqs`` Int8 — without it Polars
widens the ``when/then/otherwise`` result and the ``sa_branch`` edge contract
fails on every Basel 3.1 run. A credit quality step is 1-6, so Int8 is lossless.
"""
if not pack.feature("central_bank_uses_sovereign_cqs"):
return exposures
is_unrated_central_bank = (
pl.col("cp_entity_type").eq_missing(_CENTRAL_BANK_ENTITY_TYPE) & pl.col("cqs").is_null()
)
return exposures.with_columns(
pl.when(is_unrated_central_bank)
.then(pl.col("cp_sovereign_cqs").cast(pl.Int8))
.otherwise(pl.col("cqs"))
.alias("cqs")
)
PS1/26, paragraph 115 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
is_rgla_sovereign_expr — src/rwa_calc/engine/sa/rgla.py:75
@cites("CRR Art. 115")
@cites("PS1/26, paragraph 115")
def is_rgla_sovereign_expr(upper_class: pl.Expr) -> pl.Expr:
"""Select RGLA rows that Art. 115(2)/(4) price as a central government.
Scoped so the branch never captures a row it cannot price better than the
existing chain: a GB row (which previously took the flat 0% and must keep a
defined answer) or any row carrying a usable sovereign CQS. A non-GB
``rgla_sovereign`` with no sovereign assessment is left to fall through to
the ordinary Art. 115(1) ladder exactly as before, so this change cannot
silently re-price rows it has no better basis for.
``eq_missing`` returns False rather than null for a null ``cp_entity_type``,
so a missing entity type can never be read as sovereign-equivalent.
"""
is_sovereign_rgla = (upper_class == "RGLA") & pl.col("cp_entity_type").eq_missing(
_RGLA_SOVEREIGN_ENTITY_TYPE
)
has_sovereign_cqs = pl.col("cp_sovereign_cqs").is_not_null() & (pl.col("cp_sovereign_cqs") > 0)
return is_sovereign_rgla & ((pl.col("cp_country_code") == "GB") | has_sovereign_cqs)
rgla_sovereign_rw_expr — src/rwa_calc/engine/sa/rgla.py:98
@cites("CRR Art. 115")
@cites("CRR Art. 114")
@cites("PS1/26, paragraph 115")
def rgla_sovereign_rw_expr(is_uk_domestic: pl.Expr) -> pl.Expr:
"""Price an Art. 115(2)/(4) RGLA on the Art. 114 central-government ladder.
Order matters and mirrors Art. 114 itself:
1. ``is_uk_domestic`` (GB counterparty, sterling) keeps 0% — that is
Art. 114(4) reached through Art. 115(2), and it is why the GB/sterling
base case is untouched by P1.282.
2. Otherwise the Art. 114(2) Table 1 ladder on the counterparty's sovereign
CQS. This is the limb the old code was missing: a non-sterling devolved
exposure follows the UK's own assessment, so it stops being 0% the moment
the UK leaves CQS1.
3. The residual is the devolved 0%, reachable only for a GB row with no
usable sovereign CQS (``is_rgla_sovereign_expr`` excludes every other
row from this branch), so behaviour there is unchanged.
No cast is needed on ``cp_sovereign_cqs`` here: it is compared against
integer literals and never written into the Int8 ``cqs`` column, unlike the
Art. 114(2A) lift in ``central_bank.py``.
"""
devolved_rw = pl.lit(float(RGLA_UK_DEVOLVED_RW))
ladder = pl.when(pl.col("cp_sovereign_cqs") == int(_CQS_LADDER[0])).then(
pl.lit(float(CENTRAL_GOVT_CENTRAL_BANK_RISK_WEIGHTS[_CQS_LADDER[0]]))
)
for cqs_val in _CQS_LADDER[1:]:
ladder = ladder.when(pl.col("cp_sovereign_cqs") == int(cqs_val)).then(
pl.lit(float(CENTRAL_GOVT_CENTRAL_BANK_RISK_WEIGHTS[cqs_val]))
)
return pl.when(is_uk_domestic).then(devolved_rw).otherwise(ladder.otherwise(devolved_rw))
PS1/26, paragraph 116 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
pse_jurisdiction_not_permitted_expr — src/rwa_calc/engine/sa/jurisdiction.py:59
@cites("CRR Art. 116(5)")
@cites("PS1/26, paragraph 116")
def pse_jurisdiction_not_permitted_expr() -> pl.Expr:
"""Art. 116(5) third-country PSE jurisdiction gate (True = blocked).
CRR Art. 116(5): a third-country PSE may take the Art. 116(1)/(2)
treatments only where the Treasury has determined that the jurisdiction
"applies supervisory and regulatory arrangements at least equivalent to
those applied in the United Kingdom"; "Otherwise the institutions shall
apply a risk weight of 100 %".
Two limbs are permitted (predicate returns False):
- a UK PSE — Art. 116(1)-(3) apply directly and the equivalence flag is
never consulted, because a UK PSE is not a third-country PSE;
- a third-country PSE whose ``cp_is_equivalent_jurisdiction`` is True.
Regime-invariant, so there is no pack Feature and no regime branch: PS1/26
Art. 116 scopes paragraphs 1-3 to "UK public sector entities" and admits
third-country PSEs only via Art. 116(3A) "[f]or the purpose of Article
116(5) of CRR", recording Art. 116(5) itself as "[Note: Provision not in
PRA Rulebook]" because the Treasury equivalence power stays in CRR.
References:
CRR Art. 116(5); PRA PS1/26 Art. 116(1)-(3) and Art. 116(3A)
"""
# ``is_not_null() &`` on both limbs: a null country code cannot prove
# UK-ness (the convention used by the model-permission geography filter in
# engine/stages/classify/permissions.py) and a null flag is not an
# assertion. See the module docstring for why nulls must not stay Kleene.
#
# The ``cast`` calls are load-bearing, not cosmetic: a frame whose column is
# entirely null carries Polars dtype ``Null`` rather than String/Boolean, and
# ``Null`` propagates through the comparison and the ``|`` so that the final
# ``~`` raises "dtype Null not supported in 'not' operation". Casting pins
# both operands to their declared dtype so an all-null column degrades to a
# clean False instead of blowing up. This is a dtype coercion, NOT a null
# fill — the null-VALUE semantics stay with ``is_not_null()`` above.
equivalent = pl.col("cp_is_equivalent_jurisdiction").cast(pl.Boolean)
equivalence_asserted = equivalent.is_not_null() & equivalent
return ~(_is_uk_counterparty_expr() | equivalence_asserted)
pse_short_term_eligible_expr — src/rwa_calc/engine/sa/jurisdiction.py:101
@cites("CRR Art. 116(3)")
@cites("PS1/26, paragraph 116")
def pse_short_term_eligible_expr(short_term_threshold_years: float) -> pl.Expr:
"""Art. 116(3) short-term PSE eligibility — UK PSEs only (True = 20% applies).
Art. 116(3) grants a flat 20% to PSE exposures "with an original maturity of
three months or less". Two conditions, both required:
1. **Jurisdiction — UK only.** PS1/26 Art. 116(3) reads "exposures to **UK**
public sector entities", and Art. 116(3A) redirects "UK public sector
entities" to mean third-country PSEs **for paragraphs 1 and 2 only** —
paragraph 3 keeps its literal UK scope. CRR Art. 116(5) points the same
way: a third-country PSE may be weighted in the same manner only "in
accordance with paragraph 1 or 2". So an *equivalent* third-country PSE
still falls through to its Table 2 / Table 2A weight and does NOT take
the 20%; a *non-equivalent* one is already caught by
``pse_jurisdiction_not_permitted_expr``. This is the conservative reading
under both regimes and is mandated outright under Basel 3.1 — a 20%
against a Table 2/2A weight of 50%, 100% or 150% is a material
understatement, and splitting the regimes here would leave an
anti-conservative divergence on the same population.
2. **ORIGINAL maturity**, not residual — a seasoned long-dated PSE bond with
a short residual does not qualify.
Args:
short_term_threshold_years: the "three months or less" bound in years,
passed by the caller so the numeric stays with the risk-weight
chain rather than being declared at this module's scope.
"""
original_maturity = pl.col("original_maturity_years")
return (
_is_uk_counterparty_expr()
& original_maturity.is_not_null()
& (original_maturity <= short_term_threshold_years)
)
PS1/26, paragraph 117 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
lift_institution_cqs — src/rwa_calc/engine/sa/cqs_lift.py:49
@cites("CRR Art. 117(1)")
@cites("CRR Art. 107(2)")
@cites("PS1/26, paragraph 117")
def lift_institution_cqs(exposures: pl.LazyFrame, upper_class: pl.Expr) -> pl.LazyFrame:
"""Lift ``cp_institution_cqs`` into ``cqs`` for MDB / non-QCCP counterparties.
``upper_class`` is the caller's cached ``exposure_class`` uppercase expression,
passed in rather than recomputed so the MDB test stays identical to the one the
rest of the lookup preparation uses.
"""
# CRR Art. 117(1) / PRA PS1/26 Art. 117(1)(a): non-named MDBs are treated
# as institutions, so their primary CQS source is ``cp_institution_cqs``
# (the MDB's own ECAI rating expressed as a CQS). When the exposure has
# no top-level ``cqs`` (no rating attached at the rating-mapping stage)
# but the counterparty carries an ``institution_cqs``, lift it into
# ``cqs`` here so the downstream CQS-keyed branches and joins see it.
# Named MDBs (mdb_named) bypass CQS entirely later — coalescing here is
# harmless for them.
is_mdb_class = upper_class == _MDB_UPPER_CLASS
# CRR Art. 107(2)(a): a non-qualifying CCP counterparty (entity_type "ccp"
# demoted past the Art. 306(1) 2%/4% pin by cp_is_qccp=False) is treated as
# an ordinary institution. Its own ECAI rating is carried on the synthetic
# CCR row as ``cp_institution_cqs`` (the CCR adapter surfaces no top-level
# ``cqs``), so lift it into ``cqs`` here — mirroring the MDB treatment —
# so the Art. 120(1) Table 3 institution ladder resolves (e.g. CQS 2 -> 50%)
# instead of the unrated 100% fallback. Scoped to ``ccp`` entity_type with a
# null ``cqs`` so rated institutions and lending rows are untouched.
is_non_qccp_institution = (
pl.col("cp_entity_type").fill_null("") == _CCP_ENTITY_TYPE
) & ~pl.col("cp_is_qccp").fill_null(True)
return exposures.with_columns(
pl.when((is_mdb_class | is_non_qccp_institution) & pl.col("cqs").is_null())
.then(pl.col("cp_institution_cqs"))
.otherwise(pl.col("cqs"))
.alias("cqs")
)
build_guarantor_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:133
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("PS1/26, paragraph 117")
@cites("CRR Art. 118")
@cites("CRR Art. 235")
def build_guarantor_rw_expr(
*,
exposure_class_col: str,
entity_type_col: str,
cqs_col: str,
country_code_col: str,
ccp_client_cleared_col: str,
scra_grade_col: str,
is_basel_3_1: bool,
domestic_cgcb_expr: pl.Expr | None = None,
short_term_flag_col: str | None = None,
no_guarantee_expr: pl.Expr | None = None,
) -> pl.Expr:
"""Build the full when/then chain that maps a guarantor to its SA RW.
Dispatches on the guarantor's SA exposure class (derived from
``ENTITY_TYPE_TO_SA_CLASS`` by the caller — e.g. the CRM processor)
rather than regex on entity_type, ensuring all valid entity types are
covered. Reproduces the SA-side reference chain
(``engine/sa/rw_adjustments.py::_build_guarantor_rw_expr``) branch-for-branch.
Branch order (first match wins):
no guarantee -> null (only when ``no_guarantee_expr`` is supplied)
domestic CGCB sovereign (Art. 114(4)/(7)) -> 0%
CGCB CQS table (Art. 114 Table 1)
CCP (CRR Art. 306, CRE54.14-15)
International Organisation (Art. 118) -> 0%
Named MDB (Art. 117(2)) -> 0%
Non-named MDB (Art. 117(1)) — PS1/26 Table 2B under Basel 3.1;
institution treatment (Art. 120 Table 3 / Art. 121 unrated,
no short-term preferential) under CRR
Institution (ECRA / SCRA via build_institution_guarantor_rw_expr —
short-term Art. 120(2) Table 4 when ``short_term_flag_col``
evaluates True, otherwise long-term Table 3)
PSE (Art. 116(2) Table 2A, sovereign-derived for unrated)
RGLA (Art. 115(1)(b) Table 1B, sovereign-derived for unrated)
Corporate (Art. 122 corporate CQS table)
else -> null (no substitution)
The unrated PSE / RGLA fallback is the documented SA-side approximation
(no guarantor sovereign-CQS join exists in the CRM column production):
a GB guarantor receives the 20% RGLA / PSE domestic-currency treatment,
any other country the conservative 100% unrated default — NOT the full
Art. 116(1) Table 2 / Art. 115(1)(a) Table 1A sovereign-derived lookup.
Args:
exposure_class_col: Name of the guarantor SA exposure-class column
(e.g. ``guarantor_exposure_class``).
entity_type_col: Name of the guarantor entity-type column — used for
the CCP override and the named-MDB (``mdb_named``) carve-out.
cqs_col: Name of the integer guarantor CQS column.
country_code_col: Name of the guarantor country-code column — drives
the unrated PSE / RGLA GB-vs-other approximation.
ccp_client_cleared_col: Name of the Boolean client-cleared flag
column (null -> proprietary 2%).
scra_grade_col: Name of the guarantor SCRA-grade column threaded
into ``build_institution_guarantor_rw_expr`` for the B31 unrated
institution dispatch.
is_basel_3_1: Select PS1/26 tables (institution ECRA / corporate
Table 6 / MDB Table 2B) when True, CRR tables when False — for
MDBs that means the Art. 117(1) institution treatment, since CRR
has no MDB table. PSE / RGLA / IO / CCP values are
framework-identical. Threaded by both live call sites from the
cited ``sa_revised_risk_weight_tables`` pack Feature.
domestic_cgcb_expr: Caller-supplied Art. 114(4)/(7) domestic-currency
test (SA and IRB derive domesticity differently). ``None``
disables the domestic 0% branch (treated as never-domestic).
short_term_flag_col: Optional Boolean column routing institution
guarantors to the Art. 120(2) Table 4 short-term dicts. The IRB
chain passes ``None`` today.
no_guarantee_expr: Caller-owned leading guard — rows where it
evaluates True yield null (no substitution priced). ``None``
omits the guard (the chain prices every row).
Returns:
Float64 Polars expression evaluating to the guarantor's SA RW, or
null where no substitution treatment exists.
"""
gec = pl.col(exposure_class_col).fill_null("")
# PSE/RGLA Art. 116(2)/115(1)(b) unrated fallback: domestic-GB guarantors
# get the RGLA/PSE 20% domestic-currency treatment; otherwise the
# conservative 100% PSE/RGLA unrated default applies.
sovereign_derived_unrated = _pse_rgla_unrated_fallback_expr(country_code_col)
cgcb_unrated = float(_CGCB_RW[CQS.UNRATED])
is_domestic_guarantor = domestic_cgcb_expr if domestic_cgcb_expr is not None else pl.lit(False)
skip_substitution = no_guarantee_expr if no_guarantee_expr is not None else pl.lit(False)
return (
pl.when(skip_substitution)
.then(pl.lit(None).cast(pl.Float64))
# Art. 114(4)/(7): Domestic sovereign -> 0% regardless of CQS.
.when((gec == "central_govt_central_bank") & is_domestic_guarantor)
.then(pl.lit(0.0))
# CGCB guarantors via CQS (Table 1 — sovereign weights).
.when(gec == "central_govt_central_bank")
.then(
_cqs_table_lookup_expr(
cqs_col,
_CGCB_RW,
cgcb_unrated,
)
)
# CCP guarantors: 2% proprietary / 4% client-cleared
# (CRR Art. 306, CRE54.14-15) — overrides institution CQS weights.
.when(pl.col(entity_type_col) == "ccp")
.then(
pl.when(pl.col(ccp_client_cleared_col).fill_null(False))
.then(pl.lit(_QCCP_CLIENT_CLEARED_RW))
.otherwise(pl.lit(_QCCP_PROPRIETARY_RW))
)
# International Organisation (Art. 118): 0% unconditional.
.when(gec == "international_organisation")
.then(pl.lit(float(_IO_ZERO_RW)))
# Named MDB (Art. 117(2)): 0% unconditional.
.when((gec == "mdb") & (pl.col(entity_type_col).fill_null("") == "mdb_named"))
.then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
# Rated / unrated non-named MDB (Art. 117(1)) — framework-divergent:
# PS1/26 Art. 117(1)(a)/(b): the dedicated Basel 3.1 MDB Table 2B
# (CQS2 30%, unrated 50%).
# CRR Art. 117(1): non-named MDBs "shall be treated in the same manner
# as exposures to institutions" — Art. 120 Table 3 when rated, the
# Art. 121 unrated institution fallback (100%) otherwise. There is no
# MDB table in CRR. The Art. 119(2)/120(2)/121(3) short-term
# preferential "shall not be applied", so ``short_term_flag_col`` is
# deliberately NOT threaded into this branch (unlike the institution
# branch below). Mirrors the direct, non-guarantor CRR MDB path in
# ``sa/risk_weights.py::_apply_crr_risk_weight_overrides`` (P1.253).
.when(gec == "mdb")
.then(
_cqs_table_lookup_expr(
cqs_col,
_MDB_RW,
float(_MDB_UNRATED_RW),
)
if is_basel_3_1
else build_institution_guarantor_rw_expr(cqs_col, is_basel_3_1=False)
)
# Institution guarantors — RW driven from institution_rw_crr /
# institution_rw_b31_ecra so the pack remains the single source of
# truth. When the short-term flag evaluates True (CRR/PS1/26
# Art. 120(2)), the short-term Table 4 dicts apply instead.
.when(gec == "institution")
.then(
build_institution_guarantor_rw_expr(
cqs_col,
is_basel_3_1,
short_term_flag_col=short_term_flag_col,
scra_grade_col=scra_grade_col,
)
)
# PSE guarantors — Art. 116(2) Table 2A for rated, sovereign-derived for unrated.
.when(gec == "pse")
.then(
_cqs_table_lookup_expr(
cqs_col,
_PSE_OWN_RW,
sovereign_derived_unrated,
)
)
# RGLA guarantors — Art. 115(1)(b) Table 1B for rated, sovereign-derived for unrated.
.when(gec == "rgla")
.then(
_cqs_table_lookup_expr(
cqs_col,
_RGLA_OWN_RW,
sovereign_derived_unrated,
)
)
# Corporate guarantors — Art. 122 corporate CQS table.
# Basel 3.1 (PRA PS1/26 Art. 122(2) Table 6): CQS3 = 75% (CRR: 100%);
# PRA retains CQS5 = 150%. Gated on framework so CRR runs are
# unchanged.
.when(gec.is_in(["corporate", "corporate_sme"]))
.then(build_corporate_guarantor_rw_expr(cqs_col, is_basel_3_1))
.otherwise(pl.lit(None).cast(pl.Float64))
)
PS1/26, paragraph 122 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
get_b31_combined_cqs_risk_weights — src/rwa_calc/engine/sa/b31_risk_weight_tables.py:453
@cites("PS1/26, paragraph 122")
def get_b31_combined_cqs_risk_weights() -> pl.DataFrame:
"""
Get combined CQS-based risk weight table for Basel 3.1 joins.
Uses Basel 3.1 corporate weights (CQS3=75%, CQS5=150%) and PRA PS1/26
Art. 120 ECRA institution weights (CQS 2 = 30%).
Returns:
Combined DataFrame with columns: exposure_class, cqs, risk_weight
"""
from rwa_calc.engine.sa.crr_risk_weight_tables import (
_create_cgcb_df,
_create_institution_df,
_create_mdb_df,
_create_pse_df,
_create_rgla_df,
)
return pl.concat(
[
_create_cgcb_df().select(["exposure_class", "cqs", "risk_weight"]),
_create_rgla_df().select(["exposure_class", "cqs", "risk_weight"]),
_create_pse_df().select(["exposure_class", "cqs", "risk_weight"]),
_create_mdb_df().select(["exposure_class", "cqs", "risk_weight"]),
_create_institution_df(is_basel_3_1=True).select(
["exposure_class", "cqs", "risk_weight"]
),
_create_b31_corporate_df().select(["exposure_class", "cqs", "risk_weight"]),
_create_b31_covered_bond_df().select(["exposure_class", "cqs", "risk_weight"]),
]
)
_prepare_risk_weight_lookup — src/rwa_calc/engine/sa/risk_weights.py:887
@cites("PS1/26, paragraph 139")
@cites("PS1/26, paragraph 122")
def _prepare_risk_weight_lookup(
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> tuple[pl.LazyFrame, pl.Expr, pl.Expr, pl.Expr]:
"""Ensure required columns, classify for join, and attach CQS risk weights.
Returns the exposures frame (with ``_lookup_class`` / ``_lookup_cqs`` /
``_upper_class`` / ``risk_weight`` columns added), the uppercase class
expression reused by override chains, the composite domestic-currency flag
(UK or EU domestic currency) used for the Art. 114(4)/(7) CGCB zero-weight
treatment and the Art. 121(6) sovereign floor, and the UK-only
domestic-currency flag (``GB`` counterparty denominated in ``GBP``) that
scopes the Art. 115(5) flat-20% RGLA branch — UK RGLAs funded in sterling
only; EU-domestic RGLAs fall through to the Art. 115(1) rating tables.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
# CQS-based risk weight table — Basel 3.1 uses revised corporate weights
if resolved_pack.feature("sa_revised_risk_weight_tables"):
rw_table = get_b31_combined_cqs_risk_weights().lazy()
else:
rw_table = get_combined_cqs_risk_weights().lazy()
# Fill missing optional columns (counterparty attrs, CRM outputs,
# classifier flags, defensive input-schema fallbacks) from the
# declarative contract.
exposures = ensure_columns(exposures, SA_INPUT_CONTRACT)
# Derive original_maturity_years from (maturity_date - value_date) when
# not supplied directly. Required by Art. 116(3) PSE short-term,
# Art. 120(2)/(2A) B31 rated institution short-term, Art. 121(3) unrated
# institution short-term, and Art. 121(6) trade-goods sovereign floor
# exception — all of which key off "original" maturity, not residual.
derived_original = (
pl.col("maturity_date").cast(pl.Int32) - pl.col("value_date").cast(pl.Int32)
).cast(pl.Float64) / 365.0
exposures = exposures.with_columns(
pl.when(pl.col("original_maturity_years").is_null())
.then(derived_original)
.otherwise(pl.col("original_maturity_years"))
.alias("original_maturity_years")
)
schema = exposures.collect_schema()
# CRR Art. 114(4)/(7): Domestic CGCB exposures -> 0% RW. Must compare
# against the exposure's ORIGINAL denomination — the FX converter
# overwrites `currency` with the reporting currency, so using it
# directly would reject legitimate Art. 114(4) 0% treatment for any
# non-base-currency exposure.
ccy_expr = denomination_currency_expr(schema.names())
is_uk_domestic = (pl.col("cp_country_code") == "GB") & (ccy_expr == "GBP")
is_eu_domestic = build_eu_domestic_currency_expr("cp_country_code", ccy_expr)
is_domestic_currency = is_uk_domestic | is_eu_domestic
# Cache uppercase-class once and map detailed classes onto CQS-lookup
# classes. Sentinel -1 for null CQS so the left join matches.
upper = pl.col("exposure_class").str.to_uppercase()
# CRR Art. 117(1) / Art. 107(2)(a) — non-named MDBs and demoted non-QCCPs
# take the institution ladder; see engine/sa/cqs_lift.py.
exposures = lift_institution_cqs(exposures, upper)
# PS1/26 Art. 114(2A) — B31-Feature-gated; see engine/sa/central_bank.py.
exposures = lift_central_bank_cqs(exposures, resolved_pack)
# PRA PS1/26 Art. 139(2B): for the purposes of Art. 122B(1) (the SA
# specialised-lending routing), inferred / issuer-level (non-issue-specific)
# ECAI assessments are disapplied. An SL exposure whose only resolved
# external rating is not issue-specific must be treated as unrated, so we
# null its CQS here. This re-routes it through the unrated SL override
# (``b31_sa_sl_rw_expr``) instead of the rated-corporate CQS table. Scoped
# to Basel 3.1 SL exposures only — ordinary rated corporates (Art. 122(2))
# are untouched.
if resolved_pack.feature("sa_sl_inferred_rating_disapplied"):
is_sl_exposure = pl.col("sl_type").fill_null("").str.len_chars() > 0
rating_not_issue_specific = (
pl.col("external_rating_is_issue_specific").fill_null(True) == False # noqa: E712
)
exposures = exposures.with_columns(
pl.when(is_sl_exposure & rating_not_issue_specific)
.then(pl.lit(None, dtype=pl.Int8))
.otherwise(pl.col("cqs"))
.alias("cqs")
)
exposures = exposures.with_columns(
[
pl.when(upper.str.contains("CENTRAL_GOVT", literal=True))
.then(pl.lit("CENTRAL_GOVT_CENTRAL_BANK"))
.when(upper == "RGLA")
.then(pl.lit("RGLA"))
.when(upper == "PSE")
.then(pl.lit("PSE"))
.when(upper == "MDB")
.then(pl.lit("MDB"))
.when(upper.str.contains("INSTITUTION", literal=True))
.then(pl.lit("INSTITUTION"))
.when(upper.str.contains("CORPORATE", literal=True))
.then(pl.lit("CORPORATE"))
# Rated SL uses corporate CQS table (Art. 122A(3))
.when(upper.str.contains("SPECIALISED", literal=True))
.then(pl.lit("CORPORATE"))
.when(upper.str.contains("COVERED_BOND", literal=True))
.then(pl.lit("COVERED_BOND"))
.otherwise(upper)
.alias("_lookup_class"),
pl.col("cqs").fill_null(-1).cast(pl.Int8).alias("_lookup_cqs"),
upper.alias("_upper_class"),
]
)
rw_table = rw_table.with_columns(
pl.col("cqs").fill_null(-1).cast(pl.Int8).alias("cqs"),
)
exposures = exposures.join(
rw_table.select(["exposure_class", "cqs", "risk_weight"]),
left_on=["_lookup_class", "_lookup_cqs"],
right_on=["exposure_class", "cqs"],
how="left",
suffix="_rw",
)
return exposures, pl.col("_upper_class"), is_domestic_currency, is_uk_domestic
PS1/26, paragraph 123 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_b31_append_retail_branches — src/rwa_calc/engine/sa/risk_weights.py:520
@cites("PS1/26, paragraph 123")
def _b31_append_retail_branches(chain: _RWChain, uc: pl.Expr) -> ChainedThen:
"""Append Basel 3.1 retail-class risk-weight branches (Art. 123).
Covers the regulatory retail class only (uc contains "RETAIL"):
- QRRE transactor: 45% (Art. 123(2)).
- Payroll/pension loans: 35% (Art. 123(4)).
- Non-regulatory retail (fails Art. 123A criteria): 100% (Art. 123(3)(c)).
- Regulatory retail (non-mortgage): 75% flat.
The SME-managed-as-retail and corporate-SME branches stay in the parent
override (they gate on SME class membership rather than RETAIL).
"""
return (
# QRRE transactor: 45% (Art. 123(2)).
chain.when(
uc.str.contains("RETAIL", literal=True) & pl.col("is_qrre_transactor").fill_null(False)
)
.then(pl.lit(_SA_B31_RW["qrre_transactor"]))
# Payroll/pension loans: 35% (Art. 123(4)).
.when(uc.str.contains("RETAIL", literal=True) & pl.col("is_payroll_loan").fill_null(False))
.then(pl.lit(_SA_B31_RW["payroll"]))
# Non-regulatory retail (fails Art. 123A criteria): 100%.
.when(
uc.str.contains("RETAIL", literal=True)
& (pl.col("qualifies_as_retail").fill_null(False) == False) # noqa: E712
)
.then(pl.lit(_SA_B31_RW["non_reg_retail"]))
# Regulatory retail (non-mortgage): 75% flat.
.when(uc.str.contains("RETAIL", literal=True))
.then(pl.lit(_SA_SHARED_RW["retail"]))
)
PS1/26, paragraph 123A — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_build_qualifies_as_retail_expr — src/rwa_calc/engine/stages/classify/attributes.py:643
@cites("CRR Art. 123")
@cites("PS1/26, paragraph 123A")
def _build_qualifies_as_retail_expr(
config: CalculationConfig,
max_retail_exposure: float,
*,
pack: ResolvedRulepack | None = None,
) -> pl.Expr:
"""Build qualifies_as_retail expression with Art. 123A enforcement.
CRR: Threshold check only — aggregated exposure ≤ EUR 1m.
Basel 3.1 Art. 123A adds two-path qualifying criteria:
- Art. 123A(1)(a): SME entities (revenue > 0 and < GBP 44m) auto-qualify
without needing pool management attestation.
- Art. 123A(1)(b)(ii): an obligor's aggregate exposure must not exceed
GBP 880k (threshold limb) AND no single obligor's aggregate exposure may
exceed 0.2% of the total regulatory-retail portfolio (granularity limb,
BCBS CRE20.66). Both limbs are Basel-3.1-only. The granularity limb is
gated on ``config.enforce_retail_granularity`` (default True) so it can
be suppressed under CRE20.66's national-discretion clause.
- Art. 123A(1)(b)(iii): Non-SME entities must be managed as part of a
retail pool (cp_is_managed_as_retail=True) to qualify. Null values
default to True for backward compatibility.
References:
PRA PS1/26 Art. 123A(1)(a)-(b), CRR Art. 123
"""
# Hierarchy resolver now populates lending_group_adjusted_exposure with the
# counterparty aggregate when no lending group exists, so the threshold
# check is a single comparison across both cases.
threshold_fail = pl.col("lending_group_adjusted_exposure") > max_retail_exposure
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
if not resolved_pack.feature("retail_art_123a_two_path_applicable"):
# CRR: threshold check only
return (
pl.when(threshold_fail)
.then(pl.lit(False))
.otherwise(pl.lit(True))
.alias("qualifies_as_retail")
)
# Basel 3.1: Art. 123A two-path qualifying criteria.
# Art. 123A(1)(a): SME auto-qualification — counterparty meets the
# Art. 4(1)(128D) SME size test (turnover < EUR 50m OR balance-sheet
# total < EUR 43m when turnover null).
is_sme_for_art_123a = is_sme_by_size_expr(config, pack=resolved_pack)
# Art. 123A(1)(b)(ii) granularity limb (BCBS CRE20.66): no single obligor's
# aggregate exposure may exceed 0.2% of the total regulatory-retail
# portfolio. Candidate-retail rows are the entity-type RETAIL_OTHER
# population (``_sa_class``); the denominator counts each obligor once by
# dividing the per-obligor aggregate (``lending_group_adjusted_exposure``)
# by the obligor's line-count, masking non-retail rows to 0, then summing.
granularity_limit = float(_RETAIL_GRANULARITY_LIMIT)
is_retail_candidate = pl.col("_sa_class") == ExposureClass.RETAIL_OTHER.value
obligor_agg = pl.col("lending_group_adjusted_exposure")
# Guard the nullable ``counterparty_reference`` partition: a null key would
# otherwise pool all unmapped rows into a single bucket (see
# ``partition_by_nullable`` / ``NULLABLE_PARTITION_KEYS``). Null-keyed rows
# count as their own single-line obligor.
obligor_line_count = partition_by_nullable(
pl.len().over("counterparty_reference"),
"counterparty_reference",
pl.lit(1),
)
portfolio_total = (
pl.when(is_retail_candidate).then(obligor_agg / obligor_line_count).otherwise(pl.lit(0.0))
).sum()
granularity_fail = (
is_retail_candidate
& (portfolio_total > 0)
& (obligor_agg / portfolio_total > granularity_limit)
)
expr = (
pl.when(threshold_fail)
.then(pl.lit(False))
# Art. 123A(1)(a): SMEs auto-qualify — no condition 3 needed
.when(is_sme_for_art_123a)
.then(pl.lit(True))
)
# Art. 123A(1)(b)(ii) granularity limb: > 0.2% of the retail portfolio.
# Gated on config.enforce_retail_granularity (default True) so the limb
# can be suppressed where granularity is assessed by another method under
# CRE20.66's national-discretion clause, or to isolate the other limbs.
if config.enforce_retail_granularity:
expr = expr.when(granularity_fail).then(pl.lit(False))
# Art. 123A(1)(b)(iii): Non-SME must be managed as retail pool.
# Null defaults to True (Art. 123A — documented KEEP: a null pool-
# management flag preserves backward-compatible qualifying behaviour).
expr = expr.when(
pl.col("cp_is_managed_as_retail").fill_null(True) == False # noqa: E712
).then(pl.lit(False))
return expr.otherwise(pl.lit(True)).alias("qualifies_as_retail")
PS1/26, paragraph 123B — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
apply_currency_mismatch_multiplier — src/rwa_calc/engine/sa/rw_adjustments.py:443
@cites("PS1/26, paragraph 123B")
@cites("PS1/26, paragraph 123B.3")
def apply_currency_mismatch_multiplier(
lf: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Apply 1.5x RW multiplier for retail/RE currency mismatch (Basel 3.1 only).
When the exposure currency differs from the borrower's income currency,
a 1.5x multiplier is applied to the risk weight for retail and real estate
exposure classes.
Basel 3.1 Art. 123B / CRE20.93.
Art. 123B(3) transitional: the multiplier is a Basel-3.1-only measure that
commences on ``_B31_EFFECTIVE_DATE`` (1 January 2027). Reporting dates strictly
before that fall under the pre-Basel-3.1 portfolio treatment and the frame is
returned unchanged. The boundary date 1 January 2027 is in scope (strict ``<``).
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
if not resolved_pack.feature("sa_currency_mismatch_multiplier"):
return lf
# Art. 123B(3) transitional: pre-commencement reporting dates suppress the
# multiplier entirely. Emit the reporting column as ``False`` (consistent with
# the no-mismatch branch below) so downstream reporting always sees the flag.
if config.reporting_date < _B31_EFFECTIVE_DATE:
return lf.with_columns(pl.lit(False).alias("currency_mismatch_multiplier_applied"))
schema = lf.collect_schema()
cols = schema.names()
# Need both exposure currency and borrower income currency
income_col = (
"cp_borrower_income_currency"
if "cp_borrower_income_currency" in cols
else "borrower_income_currency"
if "borrower_income_currency" in cols
else None
)
if income_col is None or "currency" not in cols:
return lf
# PRA PS1/26 Art. 123B: the 1.5x currency-mismatch multiplier is in scope
# ONLY for retail (Art. 112(h)) and residential RE (Art. 112(i)) exposures.
# Commercial RE (Art. 112(j) per Art. 124H/124I) and corporate are OUT of
# scope. Use exact-match against ExposureClass enum string values rather
# than substring matching to avoid COMMERCIAL_MORTGAGE matching "COMMERCIAL".
is_retail_or_re = (
pl.col("exposure_class")
.fill_null("")
.is_in(
[
"retail_other",
"retail_qrre",
"retail_mortgage",
"residential_mortgage",
]
)
)
has_mismatch = pl.col(income_col).is_not_null() & (pl.col(income_col) != pl.col("currency"))
# Art. 123B(2) / CRE20.93: the 1.5x mismatch multiplier is suppressed when
# the exposure is hedged against currency risk. A full hedge can be signalled
# either by ``is_hedged=True`` OR by ``hedge_coverage_ratio >= 0.90`` (the
# Art. 123B(2) partial-hedge coverage floor). Both columns default to their
# "no hedge" sentinel when missing or null (False / 0.0).
is_hedged_flag = pl.col("is_hedged").fill_null(False) if "is_hedged" in cols else pl.lit(False)
# Art. 123B(2A): for revolving facilities the 90%-coverage test denominator is
# the fully-drawn committed amount (the "instalment amount" = greater of the
# contractual minimum and the fully-drawn contractual amount; leg (b) here,
# there being no contractual-minimum field). The firm-supplied
# ``hedge_coverage_ratio`` measures coverage of the CURRENT drawn balance, so
# for revolving rows it is rescaled onto the full-draw base:
# full_draw_base = max(drawn_amount, facility_limit)
# effective_coverage = (hedge_coverage_ratio * drawn_amount) / full_draw_base
# Non-revolving rows are unchanged (effective_coverage = hedge_coverage_ratio).
# is_revolving / facility_limit / drawn_amount may be absent on production SA
# frames — default safely so the rescale is a no-op and legacy behaviour holds.
if "hedge_coverage_ratio" in cols:
raw_coverage = pl.col("hedge_coverage_ratio").fill_null(0.0)
is_revolving_flag = (
pl.col("is_revolving").fill_null(False) if "is_revolving" in cols else pl.lit(False)
)
drawn_amount = (
pl.col("drawn_amount").fill_null(0.0) if "drawn_amount" in cols else pl.lit(0.0)
)
# Absent facility_limit -> use drawn_amount so full_draw_base == drawn_amount
# and the rescale collapses to the legacy coverage ratio.
facility_limit = (
pl.col("facility_limit").fill_null(drawn_amount)
if "facility_limit" in cols
else drawn_amount
)
full_draw_base = pl.max_horizontal(drawn_amount, facility_limit)
effective_coverage = (
pl.when(is_revolving_flag & (full_draw_base > 0.0))
.then((raw_coverage * drawn_amount) / full_draw_base)
.otherwise(raw_coverage)
)
hedge_coverage_ok = effective_coverage >= _SA_B31_RW["currency_mismatch_hedge_floor"]
else:
hedge_coverage_ok = pl.lit(False)
waive_expr = is_hedged_flag | hedge_coverage_ok
mismatch_applies = is_retail_or_re & has_mismatch & ~waive_expr
return lf.with_columns(
[
# Snapshot pre-multiplier RW for audit/reporting (mirrors the
# pre_fcsm_risk_weight pattern). For non-mismatch rows this equals
# the unchanged risk_weight; CR5 buckets EAD on this column.
pl.col("risk_weight").alias("risk_weight_pre_currency_mismatch"),
pl.when(mismatch_applies)
.then(
(pl.col("risk_weight") * _SA_B31_RW["currency_mismatch_multiplier"]).clip(
upper_bound=pl.lit(_SA_B31_RW["currency_mismatch_cap"])
)
)
.otherwise(pl.col("risk_weight"))
.alias("risk_weight"),
mismatch_applies.alias("currency_mismatch_multiplier_applied"),
]
)
PS1/26, paragraph 123B.3 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
apply_currency_mismatch_multiplier — src/rwa_calc/engine/sa/rw_adjustments.py:444
@cites("PS1/26, paragraph 123B")
@cites("PS1/26, paragraph 123B.3")
def apply_currency_mismatch_multiplier(
lf: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Apply 1.5x RW multiplier for retail/RE currency mismatch (Basel 3.1 only).
When the exposure currency differs from the borrower's income currency,
a 1.5x multiplier is applied to the risk weight for retail and real estate
exposure classes.
Basel 3.1 Art. 123B / CRE20.93.
Art. 123B(3) transitional: the multiplier is a Basel-3.1-only measure that
commences on ``_B31_EFFECTIVE_DATE`` (1 January 2027). Reporting dates strictly
before that fall under the pre-Basel-3.1 portfolio treatment and the frame is
returned unchanged. The boundary date 1 January 2027 is in scope (strict ``<``).
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
if not resolved_pack.feature("sa_currency_mismatch_multiplier"):
return lf
# Art. 123B(3) transitional: pre-commencement reporting dates suppress the
# multiplier entirely. Emit the reporting column as ``False`` (consistent with
# the no-mismatch branch below) so downstream reporting always sees the flag.
if config.reporting_date < _B31_EFFECTIVE_DATE:
return lf.with_columns(pl.lit(False).alias("currency_mismatch_multiplier_applied"))
schema = lf.collect_schema()
cols = schema.names()
# Need both exposure currency and borrower income currency
income_col = (
"cp_borrower_income_currency"
if "cp_borrower_income_currency" in cols
else "borrower_income_currency"
if "borrower_income_currency" in cols
else None
)
if income_col is None or "currency" not in cols:
return lf
# PRA PS1/26 Art. 123B: the 1.5x currency-mismatch multiplier is in scope
# ONLY for retail (Art. 112(h)) and residential RE (Art. 112(i)) exposures.
# Commercial RE (Art. 112(j) per Art. 124H/124I) and corporate are OUT of
# scope. Use exact-match against ExposureClass enum string values rather
# than substring matching to avoid COMMERCIAL_MORTGAGE matching "COMMERCIAL".
is_retail_or_re = (
pl.col("exposure_class")
.fill_null("")
.is_in(
[
"retail_other",
"retail_qrre",
"retail_mortgage",
"residential_mortgage",
]
)
)
has_mismatch = pl.col(income_col).is_not_null() & (pl.col(income_col) != pl.col("currency"))
# Art. 123B(2) / CRE20.93: the 1.5x mismatch multiplier is suppressed when
# the exposure is hedged against currency risk. A full hedge can be signalled
# either by ``is_hedged=True`` OR by ``hedge_coverage_ratio >= 0.90`` (the
# Art. 123B(2) partial-hedge coverage floor). Both columns default to their
# "no hedge" sentinel when missing or null (False / 0.0).
is_hedged_flag = pl.col("is_hedged").fill_null(False) if "is_hedged" in cols else pl.lit(False)
# Art. 123B(2A): for revolving facilities the 90%-coverage test denominator is
# the fully-drawn committed amount (the "instalment amount" = greater of the
# contractual minimum and the fully-drawn contractual amount; leg (b) here,
# there being no contractual-minimum field). The firm-supplied
# ``hedge_coverage_ratio`` measures coverage of the CURRENT drawn balance, so
# for revolving rows it is rescaled onto the full-draw base:
# full_draw_base = max(drawn_amount, facility_limit)
# effective_coverage = (hedge_coverage_ratio * drawn_amount) / full_draw_base
# Non-revolving rows are unchanged (effective_coverage = hedge_coverage_ratio).
# is_revolving / facility_limit / drawn_amount may be absent on production SA
# frames — default safely so the rescale is a no-op and legacy behaviour holds.
if "hedge_coverage_ratio" in cols:
raw_coverage = pl.col("hedge_coverage_ratio").fill_null(0.0)
is_revolving_flag = (
pl.col("is_revolving").fill_null(False) if "is_revolving" in cols else pl.lit(False)
)
drawn_amount = (
pl.col("drawn_amount").fill_null(0.0) if "drawn_amount" in cols else pl.lit(0.0)
)
# Absent facility_limit -> use drawn_amount so full_draw_base == drawn_amount
# and the rescale collapses to the legacy coverage ratio.
facility_limit = (
pl.col("facility_limit").fill_null(drawn_amount)
if "facility_limit" in cols
else drawn_amount
)
full_draw_base = pl.max_horizontal(drawn_amount, facility_limit)
effective_coverage = (
pl.when(is_revolving_flag & (full_draw_base > 0.0))
.then((raw_coverage * drawn_amount) / full_draw_base)
.otherwise(raw_coverage)
)
hedge_coverage_ok = effective_coverage >= _SA_B31_RW["currency_mismatch_hedge_floor"]
else:
hedge_coverage_ok = pl.lit(False)
waive_expr = is_hedged_flag | hedge_coverage_ok
mismatch_applies = is_retail_or_re & has_mismatch & ~waive_expr
return lf.with_columns(
[
# Snapshot pre-multiplier RW for audit/reporting (mirrors the
# pre_fcsm_risk_weight pattern). For non-mismatch rows this equals
# the unchanged risk_weight; CR5 buckets EAD on this column.
pl.col("risk_weight").alias("risk_weight_pre_currency_mismatch"),
pl.when(mismatch_applies)
.then(
(pl.col("risk_weight") * _SA_B31_RW["currency_mismatch_multiplier"]).clip(
upper_bound=pl.lit(_SA_B31_RW["currency_mismatch_cap"])
)
)
.otherwise(pl.col("risk_weight"))
.alias("risk_weight"),
mismatch_applies.alias("currency_mismatch_multiplier_applied"),
]
)
PS1/26, paragraph 124 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_b31_append_real_estate_branches — src/rwa_calc/engine/sa/risk_weights.py:554
@cites("PS1/26, paragraph 124")
def _b31_append_real_estate_branches(chain: _RWChain, uc: pl.Expr) -> ChainedThen:
"""Append Basel 3.1 real-estate branches (ADC / other-RE / CRE / resi)."""
is_re_class = (
is_commercial_re_class(uc)
| _is_residential_re_class(uc)
| (pl.col("property_type").fill_null("").is_in(["residential", "commercial"]))
)
is_non_qualifying = pl.col("is_qualifying_re").fill_null(True) == False # noqa: E712
return (
chain.when(pl.col("is_adc").fill_null(False))
.then(b31_adc_rw_expr())
# Art. 124J: non-qualifying RE that fails Art. 124A criteria.
# Null is_qualifying_re defaults to qualifying — backward compatible.
.when(is_non_qualifying & is_re_class)
.then(b31_other_re_rw_expr("_cqs_risk_weight"))
# Commercial RE must precede residential — see is_commercial_re_class.
.when(is_commercial_re_class(uc))
.then(b31_commercial_rw_expr("_cqs_risk_weight"))
.when(_is_residential_re_class(uc))
.then(b31_residential_rw_expr("_cqs_risk_weight"))
)
PS1/26, paragraph 124.4 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_re_split_per_component_eligibility — src/rwa_calc/engine/stages/re_split/flagging.py:230
@cites("PS1/26, paragraph 124.4")
def _re_split_per_component_eligibility(
primitives: dict[str, pl.Expr],
gates: dict[str, pl.Expr],
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> dict[str, pl.Expr]:
"""Build per-component eligibility flags for the RE loan splitter.
Implements the PRA PS1/26 Art. 124(4) mixed-RE rule (and CRR Art.
124(1) "any part of an exposure" wording): each property component
is evaluated against its own regime gate. Under CRR, CRE additionally
requires the rental-coverage test. ``is_mixed`` flags rows where
both components are eligible — the splitter materialises one secured
row per eligible component plus a single residual.
Art. 124(4) all-or-nothing qualifying gate (Basel 3.1 only): the
preferential Art. 124F-124I tables apply to a mixed-RE exposure only
when BOTH components separately qualify under Art. 124A. If either
component fails (``re_collateral_non_qualifying``), ``force_other_re``
fires and the splitter routes BOTH secured rows through Art. 124J
(Other RE) — no partial preference. CRR has no Art. 124(4) limb, so
the gate is suppressed on the CRR path.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
rre_eligible = gates["is_candidate"] & primitives["has_rre"]
if resolved_pack.feature("sa_re_split_cre_rental_coverage_required"):
# CRR Art. 126(2)(d): CRE eligibility additionally requires the
# rental-coverage test (>= 1.5x interest).
cre_eligible = (
gates["is_candidate"] & primitives["has_cre"] & primitives["cre_rental_coverage_met"]
)
else:
# PS1/26 Art. 124H: Basel 3.1 removes the CRE rental-coverage requirement.
cre_eligible = gates["is_candidate"] & primitives["has_cre"]
is_mixed = rre_eligible & cre_eligible
# PS1/26 Art. 124(4) all-or-nothing mixed-RE gate — no CRR equivalent.
force_other_re = (
is_mixed & primitives["re_collateral_non_qualifying"]
if resolved_pack.feature("sa_re_split_art_124_4_all_or_nothing")
else pl.lit(False)
)
return {
"rre_eligible": rre_eligible,
"cre_eligible": cre_eligible,
"is_mixed": is_mixed,
"force_other_re": force_other_re,
}
PS1/26, paragraph 124E — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_build_has_income_cover_expr — src/rwa_calc/engine/stages/classify/attributes.py:560
@cites("PS1/26, paragraph 124E")
def _build_has_income_cover_expr() -> pl.Expr:
"""Build ``has_income_cover`` with the Art. 124E three-property re-route.
PRA PS1/26 Art. 124E(1)(b) restricts the owner-occupied preferential
residential treatment (Art. 124F loan-split / Art. 124L) to natural-person
borrowers whose total residential RE exposure is secured on no more than
three residential properties. When the count strictly exceeds three
(``cp_qualifying_property_count > _RRE_THREE_PROPERTY_LIMIT``), the
exposure is materially dependent on property cash flows (Art. 124E(2))
and routes to the income-producing whole-loan track (Art. 124G).
Boundary: the comparison is strict ``> 3`` — count=3 stays owner-occupied,
count=4 re-routes.
Coalesce precedence: any explicit upstream ``has_income_cover=True`` (set
from collateral ``is_income_producing`` in the hierarchy stage) wins, so a
caller-supplied income flag is never overridden by a low property count.
Returns a ``pl.Expr`` aliased ``has_income_cover`` (Boolean). The
gating columns are sealed-lookup joins (``cp_qualifying_property_count``
/ ``cp_is_natural_person``) — always present; null counts never breach
the limit and null natural-person flags fail the gate.
"""
is_natural_person = pl.col("cp_is_natural_person").fill_null(False)
# Strict > 3: count=3 stays owner-occupied; count=4 re-routes (Art. 124E(1)(b)).
breaches_limit = pl.col("cp_qualifying_property_count") > _RRE_THREE_PROPERTY_LIMIT
materially_dependent = is_natural_person & breaches_limit
explicit = pl.col("has_income_cover").fill_null(False)
# Explicit upstream income flag wins; otherwise the derived re-route applies.
return (explicit | materially_dependent).alias("has_income_cover")
PS1/26, paragraph 124F — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
split — src/rwa_calc/engine/stages/re_split/splitter.py:205
@cites("CRR Art. 125")
@cites("CRR Art. 126")
@cites("PS1/26, paragraph 124F")
def split(
self,
data: CRMAdjustedBundle,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> CRMAdjustedBundle:
"""Apply RE loan-splitting to candidate rows.
See module docstring for the regime-specific decision matrix.
"""
# S9g: the RE-split regime gate reads the cited pack Feature; the split
# parameter VALUES (LTV caps / RW) stay in data/tables/re_split_parameters.py,
# and re_split_parameters / _split_unified_frame keep their is_basel_3_1 bool
# plumbing params (Option B). One read feeds both the params lookup and the
# allocation control flow.
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
is_b31 = resolved_pack.feature("sa_re_split_revised_parameters")
params = re_split_parameters(is_basel_3_1=is_b31)
rrep = params["residential"]
crep = params["commercial"]
unified, audit, errors = _split_unified_frame(
data.exposures,
rrep=rrep,
crep=crep,
is_basel_3_1=is_b31,
)
# Producer seal (Phase 3): pure plan-level conform + brand — the
# orchestrator materialises and re-seals at the re_split_exit stage
# edge. Contract selected by the input frame's brand (CCR runs carry
# the SA-CCR provenance columns through the split).
exit_edge = (
RE_SPLIT_EXIT_CCR_EDGE
if sealed_edge_of(data.exposures) == "crm_exit_ccr"
else RE_SPLIT_EXIT_EDGE
)
return CRMAdjustedBundle(
exposures=seal(unified, exit_edge),
equity_exposures=data.equity_exposures,
ciu_holdings=data.ciu_holdings,
collateral_allocation=data.collateral_allocation,
collateral_link_allocation=data.collateral_link_allocation,
re_split_audit=audit,
securitisation_audit=data.securitisation_audit,
crm_errors=list(data.crm_errors) + errors,
)
PS1/26, paragraph 127 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_crr_defaulted_re_secured_share — src/rwa_calc/engine/sa/risk_weights.py:1510
@cites("CRR Art. 127")
@cites("PS1/26, paragraph 127")
@cites("CRR Art. 127")
def _crr_defaulted_re_secured_share(upper_class: pl.Expr) -> pl.Expr:
"""CRR Art. 127(3): the Art. 125-secured share of a defaulted RRE exposure.
Art. 127(3) gives a flat 100% to "the exposure value remaining after
specific credit risk adjustments of exposures **fully and completely
secured** by mortgages on residential property **in accordance with
Article 125**". Art. 124(1) defines that phrase as a capped PART of the
exposure — "the part treated as fully and completely secured shall not be
higher than the pledged amount of the market value" — with Art. 125(2)(d)
setting the cap at 80% of value. The remainder is not "fully and completely
secured" and stays on Art. 127(1), which by its own words governs only
"the unsecured part". Hence a share, blended by the caller, rather than a
flat override of the whole row.
Returns **null** where the rule does not apply, so the caller keeps the
Art. 127(1) provision RW untouched. Null is also the answer for a null or
non-positive ``ltv``: without a usable LTV there is no defensible secured
share, and defaulting it to "fully secured" would hand out the 100% leg on
missing data — the anti-conservative failure mode this batch keeps finding.
**Art. 127(4) (commercial property) is deliberately NOT implemented here.**
Its trigger is "secured … in accordance with Article 126", and the engine's
only proxy for the Art. 126(2) qualifying test is ``has_income_cover``,
which **P1.263 records as carrying the INVERTED sense** on the CRR branch.
Because this blend REDUCES RWA, building the commercial limb on a flag that
is known to be backwards would grant relief to the wrong population.
Gating on the CRE class alone would be broader than the article allows,
also in the relieving direction. Deferred to P1.315, after P1.263 settles
the flag's meaning.
"""
ltv = pl.col("ltv")
is_rre_only = _is_residential_re_class(upper_class) & ~is_commercial_re_class(upper_class)
return (
pl.when(is_rre_only & ltv.is_not_null() & (ltv > 0.0))
.then(pl.min_horizontal(pl.lit(1.0), pl.lit(_SA_CRR_RW["resi_ltv_threshold"]) / ltv))
.otherwise(pl.lit(None).cast(pl.Float64))
)
PS1/26, paragraph 128 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_b31_append_high_risk_branch — src/rwa_calc/engine/sa/risk_weights.py:505
@cites("PS1/26, paragraph 128")
def _b31_append_high_risk_branch(chain: _RWChain, uc: pl.Expr) -> ChainedThen:
"""Append Basel 3.1 Art. 128 high-risk items branch (150% flat).
Items associated with particularly high risk — venture capital, private
equity, speculative immovable property financing, and other
PRA-designated high-risk items — receive a 150% risk weight under
PRA PS1/26 Art. 128. CRR has no parallel branch: Art. 128 was omitted
from UK CRR by SI 2021/1078 reg. 6(3)(a) effective 1 January 2022, so
HIGH_RISK exposures fall through to the residual OTHER class (100%)
on the CRR path.
"""
return chain.when(uc == "HIGH_RISK").then(pl.lit(_SA_B31_RW["high_risk"]))
PS1/26, paragraph 129 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_create_b31_covered_bond_df — src/rwa_calc/engine/sa/b31_risk_weight_tables.py:431
@cites("PS1/26, paragraph 129")
def _create_b31_covered_bond_df() -> pl.DataFrame:
"""Create Basel 3.1 covered bond risk weight lookup DataFrame.
PRA PS1/26 Art. 129(4) Table 7 — identical to CRR Table 6A.
"""
return _build_int_cqs_rw_df(
B31_COVERED_BOND_RISK_WEIGHTS,
"COVERED_BOND",
order=_B31_CQS_RATED_ORDER,
)
b31_unrated_cb_rw_expr — src/rwa_calc/engine/sa/covered_bond.py:89
@cites("CRR Art. 129")
@cites("PS1/26, paragraph 129")
def b31_unrated_cb_rw_expr(scra_default_rw: float) -> pl.Expr:
"""PS1/26 Art. 129(5): as CRR, but the issuer weight may come from SCRA.
Art. 129(5) operates on the resulting issuer weight regardless of its
source, so the ECRA ladder (``cp_institution_cqs``) is tried first and an
unrated issuer falls through to the SCRA grades (``cp_scra_grade``).
``scra_default_rw`` is the conservative Grade-C-equivalent residual, passed
in from the caller's pack binding rather than re-read here.
"""
cqs_to_cb_rw = _cqs_to_cb_rw(
INSTITUTION_RISK_WEIGHTS_B31_ECRA, COVERED_BOND_UNRATED_DERIVATION_B31
)
expr = _ecra_chain(cqs_to_cb_rw)
for grade, cb_rw in B31_COVERED_BOND_UNRATED_FROM_SCRA.items():
expr = expr.when(pl.col("cp_scra_grade") == grade).then(pl.lit(float(cb_rw)))
return expr.otherwise(pl.lit(scra_default_rw))
PS1/26, paragraph 132 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_ciu_computed_rw_expr — src/rwa_calc/engine/equity/calculator.py:134
@cites("PS1/26, paragraph 132")
def _ciu_computed_rw_expr(rw_col: str) -> pl.Expr:
"""Art. 132(4): a CIU approach's own RW, uplifted 1.2x for third-party calcs.
Two things the previous shape got wrong, both fixed here:
- **The uplift reaches look-through, not just mandate-based.** Art. 132(4)(b)
requires the third party to calculate "in accordance with the approaches set
out in Article 132A(1), (2) or (3)", and 132A(1) *is* the look-through
approach. Gating the multiplier on ``mandate_based`` recognised
third-party look-through RW 20% too low.
- **The fall-back is never uplifted.** Art. 132(4) multiplies the RWEA
"resulting from those calculations". A null ``rw_col`` means there is no
third-party calculation to multiply, so the row takes the Art. 132(2)
1,250% fall-back flat. The old ``fill_null(CIU_FALLBACK_RW) * 1.2``
produced 1,500% -- a weight no provision authorises, reachable purely
from missing data.
The derogation in the final sub-paragraph ("where the institution has
unrestricted access to the detailed calculations carried out by the third
party, the factor of 1.2 shall not apply") is an affirmative carve-out: a
null ``ciu_unrestricted_access`` keeps the uplift.
"""
# Both operands are filled before combining: a frame that carries the column
# as an all-null literal has dtype Null, and `~` on Null raises rather than
# returning null. Production frames always arrive Boolean via the contract
# default, so this only guards synthetic single-row test frames.
is_third_party = pl.col("ciu_third_party_calc").fill_null(False)
has_unrestricted_access = pl.col("ciu_unrestricted_access").fill_null(False)
multiplier = (
pl.when(is_third_party & ~has_unrestricted_access)
.then(pl.lit(_CIU_THIRD_PARTY_MULTIPLIER))
.otherwise(pl.lit(_CIU_INTERNAL_MULTIPLIER))
)
return (
pl.when(pl.col(rw_col).is_null())
.then(pl.lit(CIU_FALLBACK_RW))
.otherwise(pl.col(rw_col) * multiplier)
)
_append_ciu_branches — src/rwa_calc/engine/equity/calculator.py:175
@cites("PS1/26, paragraph 132")
def _append_ciu_branches(chain: pl.Expr) -> ChainedThen:
"""Append CIU approach-aware risk weight branches to a when/then chain (Art. 132-132C).
Covers: fallback (1,250%), mandate_based and look_through (each x1.2 where the
calculation is a third party's and Art. 132(4)'s unrestricted-access
derogation does not apply), and unclassified CIU (1,250% default).
"""
_is_ciu = pl.col("equity_type").str.to_lowercase() == "ciu"
# The piped-in chain is an in-progress when/then; narrow for the checker.
then_chain = cast("Then | ChainedThen", chain)
return (
then_chain.when(_is_ciu & (pl.col("ciu_approach") == "fallback"))
.then(pl.lit(CIU_FALLBACK_RW))
.when(_is_ciu & (pl.col("ciu_approach") == "mandate_based"))
.then(_ciu_computed_rw_expr("ciu_mandate_rw"))
.when(_is_ciu & (pl.col("ciu_approach") == "look_through"))
.then(_ciu_computed_rw_expr("ciu_look_through_rw"))
.when(_is_ciu)
.then(pl.lit(CIU_FALLBACK_RW))
)
PS1/26, paragraph 133 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_apply_b31_equity_weights_sa — src/rwa_calc/engine/equity/calculator.py:645
@cites("PS1/26, paragraph 133")
def _apply_b31_equity_weights_sa(
self,
exposures: pl.LazyFrame,
config: CalculationConfig,
) -> pl.LazyFrame:
"""
Apply Basel 3.1 PRA PS1/26 Art. 133 SA equity risk weights.
Risk weights (in priority order per classification decision tree):
1. Central bank: 0% (sovereign treatment)
2. Subordinated debt / non-equity own funds: 150% (Art. 133(5))
3. Speculative / higher risk: 400% (Art. 133(4))
4. Higher-risk test (Art. 133(4) + Glossary p.5), for any equity that
is not central-bank / subordinated-debt / CIU / government-supported:
- unlisted (NOT is_exchange_traded) AND business_age_years < 5.0
(or null, treated conservatively) -> 400% (higher-risk)
- otherwise -> falls through to standard 250% (Art. 133(3))
5. CIU: approach-dependent (Art. 132-132C)
- CIU fallback: 1,250% (Art. 132(2))
6. All other standard equity (incl. government-supported, listed, and
long-established/exchange-traded equity): 250% (Art. 133(3))
Note: B31 Art. 133(6) is an exclusion clause (own funds deductions,
Art. 89(3), Art. 48(4)) — NOT a risk weight assignment. CRR's 100%
legislative equity (Art. 133(3)(c)) has no equivalent in B31.
"""
# Art. 133(4) / Glossary p.5 higher-risk test — unlisted equity whose
# underlying business has existed < 5 years. Two routings combine:
#
# (1) PE/VC legacy routing: unlisted PE/VC with business age < 5y OR
# unknown -> 400%. Null/missing age is treated conservatively as
# <5y (a firm cannot claim the long-established carve-out without
# evidence of business age >= 5), preserving the prior behaviour
# for callers that supply no business_age_years.
#
# (2) Generalised routing: ANY other equity that is NOT
# central-bank (0%), subordinated-debt (150%, Art. 133(5)), CIU
# (Art. 132 look-through/mandate/fallback) or government-supported
# (standard 250%, Art. 133(3)) is higher-risk only when it has an
# *evidenced* young business age (non-null AND < 5.0). Absent age
# data, such equity stays at the standard 250% — listed/unlisted/
# other without business-age evidence is not uplifted.
schema_names = exposures.collect_schema().names()
is_pe_or_pe_div = (
pl.col("equity_type")
.str.to_lowercase()
.is_in([EquityType.PRIVATE_EQUITY, EquityType.PRIVATE_EQUITY_DIVERSIFIED])
)
is_dedicated_treatment = (
pl.col("equity_type")
.str.to_lowercase()
.is_in(
[
EquityType.CENTRAL_BANK,
EquityType.SUBORDINATED_DEBT,
EquityType.CIU,
EquityType.GOVERNMENT_SUPPORTED,
]
)
)
is_unlisted = (
~pl.col("is_exchange_traded").fill_null(False)
if "is_exchange_traded" in schema_names
else pl.lit(True)
)
has_age = "business_age_years" in schema_names
is_young_or_unknown = (
pl.col("business_age_years").is_null() | (pl.col("business_age_years") < 5.0)
if has_age
else pl.lit(True)
)
is_young_evidenced = (
pl.col("business_age_years").is_not_null() & (pl.col("business_age_years") < 5.0)
if has_age
else pl.lit(False)
)
is_higher_risk = is_unlisted & (
(is_pe_or_pe_div & is_young_or_unknown) | (~is_dedicated_treatment & is_young_evidenced)
)
return exposures.with_columns(
[
pl.when(pl.col("equity_type").str.to_lowercase() == "central_bank")
.then(pl.lit(_B31_SA_RW[EquityType.CENTRAL_BANK]))
# Art. 133(5): subordinated debt / non-equity own funds = 150%
.when(pl.col("equity_type").str.to_lowercase() == "subordinated_debt")
.then(pl.lit(_B31_SA_RW[EquityType.SUBORDINATED_DEBT]))
.when(pl.col("is_speculative") == True) # noqa: E712
.then(pl.lit(_B31_SA_RW[EquityType.SPECULATIVE]))
.when(pl.col("equity_type").str.to_lowercase() == "speculative")
.then(pl.lit(_B31_SA_RW[EquityType.SPECULATIVE]))
# Art. 133(4) + Glossary p.5: unlisted equity with business age
# < 5y (or unknown) is higher-risk (400%); long-established or
# exchange-traded equity falls through to standard 250%.
.when(is_higher_risk)
.then(pl.lit(_B31_SA_RW[EquityType.PRIVATE_EQUITY]))
# CIU: approach-aware risk weights (Art. 132-132C)
.pipe(_append_ciu_branches)
.otherwise(pl.lit(_B31_SA_RW[EquityType.OTHER]))
.alias("risk_weight"),
]
)
_is_b31_subordinated_debt — src/rwa_calc/engine/sa/risk_weights.py:1551
@cites("PS1/26, paragraph 133")
def _is_b31_subordinated_debt(upper_class: pl.Expr) -> pl.Expr:
"""B31 subordinated-debt predicate — Art. 112 Table A2 priority 3.
Shared by the risk-weight chain and the defaulted override so the two
cannot drift, and so the single ``seniority.fill_null`` site is not
duplicated (check-11 ratchets ``engine_fill_null_sites``).
Scoped to institution/corporate deliberately: the Art. 133 150% is not a
universal subordinated weight, and widening it would re-price subordinated
sovereign and retail rows that today correctly take their class weight.
"""
return (pl.col("seniority").fill_null("senior") == "subordinated") & (
upper_class.str.contains("INSTITUTION", literal=True)
| upper_class.str.contains("CORPORATE", literal=True)
)
PS1/26, paragraph 139 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_prepare_risk_weight_lookup — src/rwa_calc/engine/sa/risk_weights.py:886
@cites("PS1/26, paragraph 139")
@cites("PS1/26, paragraph 122")
def _prepare_risk_weight_lookup(
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> tuple[pl.LazyFrame, pl.Expr, pl.Expr, pl.Expr]:
"""Ensure required columns, classify for join, and attach CQS risk weights.
Returns the exposures frame (with ``_lookup_class`` / ``_lookup_cqs`` /
``_upper_class`` / ``risk_weight`` columns added), the uppercase class
expression reused by override chains, the composite domestic-currency flag
(UK or EU domestic currency) used for the Art. 114(4)/(7) CGCB zero-weight
treatment and the Art. 121(6) sovereign floor, and the UK-only
domestic-currency flag (``GB`` counterparty denominated in ``GBP``) that
scopes the Art. 115(5) flat-20% RGLA branch — UK RGLAs funded in sterling
only; EU-domestic RGLAs fall through to the Art. 115(1) rating tables.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
# CQS-based risk weight table — Basel 3.1 uses revised corporate weights
if resolved_pack.feature("sa_revised_risk_weight_tables"):
rw_table = get_b31_combined_cqs_risk_weights().lazy()
else:
rw_table = get_combined_cqs_risk_weights().lazy()
# Fill missing optional columns (counterparty attrs, CRM outputs,
# classifier flags, defensive input-schema fallbacks) from the
# declarative contract.
exposures = ensure_columns(exposures, SA_INPUT_CONTRACT)
# Derive original_maturity_years from (maturity_date - value_date) when
# not supplied directly. Required by Art. 116(3) PSE short-term,
# Art. 120(2)/(2A) B31 rated institution short-term, Art. 121(3) unrated
# institution short-term, and Art. 121(6) trade-goods sovereign floor
# exception — all of which key off "original" maturity, not residual.
derived_original = (
pl.col("maturity_date").cast(pl.Int32) - pl.col("value_date").cast(pl.Int32)
).cast(pl.Float64) / 365.0
exposures = exposures.with_columns(
pl.when(pl.col("original_maturity_years").is_null())
.then(derived_original)
.otherwise(pl.col("original_maturity_years"))
.alias("original_maturity_years")
)
schema = exposures.collect_schema()
# CRR Art. 114(4)/(7): Domestic CGCB exposures -> 0% RW. Must compare
# against the exposure's ORIGINAL denomination — the FX converter
# overwrites `currency` with the reporting currency, so using it
# directly would reject legitimate Art. 114(4) 0% treatment for any
# non-base-currency exposure.
ccy_expr = denomination_currency_expr(schema.names())
is_uk_domestic = (pl.col("cp_country_code") == "GB") & (ccy_expr == "GBP")
is_eu_domestic = build_eu_domestic_currency_expr("cp_country_code", ccy_expr)
is_domestic_currency = is_uk_domestic | is_eu_domestic
# Cache uppercase-class once and map detailed classes onto CQS-lookup
# classes. Sentinel -1 for null CQS so the left join matches.
upper = pl.col("exposure_class").str.to_uppercase()
# CRR Art. 117(1) / Art. 107(2)(a) — non-named MDBs and demoted non-QCCPs
# take the institution ladder; see engine/sa/cqs_lift.py.
exposures = lift_institution_cqs(exposures, upper)
# PS1/26 Art. 114(2A) — B31-Feature-gated; see engine/sa/central_bank.py.
exposures = lift_central_bank_cqs(exposures, resolved_pack)
# PRA PS1/26 Art. 139(2B): for the purposes of Art. 122B(1) (the SA
# specialised-lending routing), inferred / issuer-level (non-issue-specific)
# ECAI assessments are disapplied. An SL exposure whose only resolved
# external rating is not issue-specific must be treated as unrated, so we
# null its CQS here. This re-routes it through the unrated SL override
# (``b31_sa_sl_rw_expr``) instead of the rated-corporate CQS table. Scoped
# to Basel 3.1 SL exposures only — ordinary rated corporates (Art. 122(2))
# are untouched.
if resolved_pack.feature("sa_sl_inferred_rating_disapplied"):
is_sl_exposure = pl.col("sl_type").fill_null("").str.len_chars() > 0
rating_not_issue_specific = (
pl.col("external_rating_is_issue_specific").fill_null(True) == False # noqa: E712
)
exposures = exposures.with_columns(
pl.when(is_sl_exposure & rating_not_issue_specific)
.then(pl.lit(None, dtype=pl.Int8))
.otherwise(pl.col("cqs"))
.alias("cqs")
)
exposures = exposures.with_columns(
[
pl.when(upper.str.contains("CENTRAL_GOVT", literal=True))
.then(pl.lit("CENTRAL_GOVT_CENTRAL_BANK"))
.when(upper == "RGLA")
.then(pl.lit("RGLA"))
.when(upper == "PSE")
.then(pl.lit("PSE"))
.when(upper == "MDB")
.then(pl.lit("MDB"))
.when(upper.str.contains("INSTITUTION", literal=True))
.then(pl.lit("INSTITUTION"))
.when(upper.str.contains("CORPORATE", literal=True))
.then(pl.lit("CORPORATE"))
# Rated SL uses corporate CQS table (Art. 122A(3))
.when(upper.str.contains("SPECIALISED", literal=True))
.then(pl.lit("CORPORATE"))
.when(upper.str.contains("COVERED_BOND", literal=True))
.then(pl.lit("COVERED_BOND"))
.otherwise(upper)
.alias("_lookup_class"),
pl.col("cqs").fill_null(-1).cast(pl.Int8).alias("_lookup_cqs"),
upper.alias("_upper_class"),
]
)
rw_table = rw_table.with_columns(
pl.col("cqs").fill_null(-1).cast(pl.Int8).alias("cqs"),
)
exposures = exposures.join(
rw_table.select(["exposure_class", "cqs", "risk_weight"]),
left_on=["_lookup_class", "_lookup_cqs"],
right_on=["exposure_class", "cqs"],
how="left",
suffix="_rw",
)
return exposures, pl.col("_upper_class"), is_domestic_currency, is_uk_domestic
PS1/26, paragraph 140 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_apply_obligor_st_contamination_override — src/rwa_calc/engine/sa/risk_weights.py:402
@cites("CRR Art. 140")
@cites("PS1/26, paragraph 140")
def _apply_obligor_st_contamination_override(exposures: pl.LazyFrame) -> pl.LazyFrame:
"""Apply the Art. 140(2) obligor-level short-term contamination RW override.
CRR Art. 140(2) / PRA PS1/26 Art. 140(2) (CRE21.17-18), reading the two
per-obligor flags from ``_apply_obligor_st_contamination_flags``:
(a) 150% broadcast (Table 7 CQS 4+) onto ALL the obligor's unrated unsecured
claims (short- OR long-term); (b) 100% floor (max(RW, 100%)) on its unrated
unsecured SHORT-TERM claims when a 50%-attracting (Table 7 CQS 2) assessment
exists. The 150% hard override dominates the floor (checked first).
A target is UNSECURED (``~is_guaranteed`` — a guaranteed leg keeps its
guarantor RW), lacks its OWN short-term assessment (``~has_own_short_term_ecai``
— the directly-rated leg is the SOURCE, never a target), and is either
genuinely unrated (``cqs`` null) OR was handed a short-term cqs by the
Art. 120(3)(c) spillover (``has_short_term_ecai``). Including the spilled arm
is the P1.225 co-fire fix: the spillover overwrites ``cqs`` /
``has_short_term_ecai``, so the pre-fix ``cqs.is_null() & ~has_short_term_ecai``
predicate dropped spilled legs and Art. 140(2) never bound when both fired.
A leg with only an inherited long-term cqs stays excluded as before.
"""
is_target = pl.col("has_own_short_term_ecai").not_() & (
pl.col("cqs").is_null() | pl.col("has_short_term_ecai")
)
is_unsecured = pl.col("is_guaranteed").not_()
# Reuse the SA short-term window (original maturity <= 3m, <= 6m for
# self-liquidating trade LCs) used by the institution ST branches. No
# fill_null: a null maturity yields a null gate the when-chain treats as
# "not short-term".
original_mty = pl.col("original_maturity_years")
is_st = (original_mty <= 0.25) | (pl.col("is_short_term_trade_lc") & (original_mty <= 0.5))
return exposures.with_columns(
pl.when(pl.col("obligor_st_150_contamination") & is_target & is_unsecured)
.then(pl.lit(1.50))
.when(pl.col("obligor_st_50_floor") & is_target & is_unsecured & is_st)
.then(pl.max_horizontal(pl.col("risk_weight"), pl.lit(1.00)))
.otherwise(pl.col("risk_weight"))
.alias("risk_weight")
)
_apply_obligor_st_contamination_flags — src/rwa_calc/engine/stages/hierarchy/enrich.py:959
@cites("CRR Art. 140")
@cites("PS1/26, paragraph 140")
def _apply_obligor_st_contamination_flags(exposures: pl.LazyFrame) -> pl.LazyFrame:
"""Flag obligor-level short-term rating contamination (Art. 140(2)).
CRR Art. 140(2) / PRA PS1/26 Art. 140(2) (CRE21.17-18): a short-term ECAI
assessment on ANY of an obligor's facilities contaminates that obligor's
unrated UNSECURED exposures:
- (a) an assessment attracting 150% (Table 7 CQS 4+) broadcasts 150% to ALL
the obligor's unrated unsecured claims — short- OR long-term;
- (b) an assessment attracting 50% (Table 7 CQS 2) floors the obligor's
unrated SHORT-TERM unsecured claims at 100%.
Emits two obligor-broadcast Boolean flags plus one per-exposure flag, all
read by the SA risk-weight override (engine/sa/risk_weights.py). Reads the
pristine ``_st_assessment_cqs`` scratch (non-null only on the directly-rated
exposure) BEFORE it is dropped — a spilled row carries a null assessment cqs
and so never contributes. This is a DISTINCT mechanism from the
Art. 120(3)(c) short-term spillover above, which modifies
``has_short_term_ecai`` / ``cqs``; this helper touches neither. Regime-
independent (Table 7 is identical across CRR and Basel 3.1), mirroring
``_apply_obligor_short_term_spillover``. Called immediately after that
spillover in ``apply_short_term_rating_override``, where the three inputs
(``counterparty_reference`` / ``has_short_term_ecai`` / ``_st_assessment_cqs``)
are already materialised, so no presence guard is needed.
``has_own_short_term_ecai`` (per-exposure, non-broadcast) records whether
THIS leg carries its OWN issue-specific short-term assessment. It is the
discriminator the SA Art. 140(2) override needs to tell a directly-rated
trigger (contamination SOURCE — keeps its own weight) from a leg that only
INHERITED a short-term cqs via the Art. 120(3)(c) spillover. The spillover
overwrites ``has_short_term_ecai`` / ``cqs`` on a spilled leg, so without
this pristine flag the floor / 150% broadcast would evade the spilled leg
(P1.225 co-fire defect).
"""
# The directly-rated ST facility's assessment cqs drives the obligor flags;
# ``_st_assessment_cqs`` is non-null only there (a spilled row's is null and
# drops out of the ``.max()``). Table 7: CQS 4+ -> 150%, CQS 2 -> 50%.
st_150 = pl.col("has_short_term_ecai") & (pl.col("_st_assessment_cqs") >= 4)
st_50 = pl.col("has_short_term_ecai") & (pl.col("_st_assessment_cqs") == 2)
return exposures.with_columns(
[
partition_by_nullable(
st_150.max().over("counterparty_reference"),
"counterparty_reference",
pl.lit(False), # noqa: FBT003
).alias("obligor_st_150_contamination"),
partition_by_nullable(
st_50.max().over("counterparty_reference"),
"counterparty_reference",
pl.lit(False), # noqa: FBT003
).alias("obligor_st_50_floor"),
pl.col("_st_assessment_cqs").is_not_null().alias("has_own_short_term_ecai"),
]
)
PS1/26, paragraph 147 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_align_irb_exposure_class — src/rwa_calc/engine/stages/classify/approach.py:354
@cites("CRR Art. 147")
@cites("CRR Art. 147(5)")
@cites("PS1/26, paragraph 147")
def _align_irb_exposure_class(exposures: pl.LazyFrame) -> pl.LazyFrame:
"""Align exposure_class with exposure_class_irb for IRB-routed rows.
The IRB calculator reads ``exposure_class`` (not ``exposure_class_irb``)
for correlation / LGD / floor selection, so any IRB-routed row whose IRB
class legitimately differs from its SA class must have ``exposure_class``
rewritten to the IRB value. Three entity populations diverge after
``sync_irb_exposure_class``; the first two are aligned here, the third is
deliberately not:
- rgla_* / pse_* rows, whose SA labels RGLA / PSE differ from the IRB
CGCB / INSTITUTION class (CRR Art. 147(3)/147(4)(b)).
- natural persons expelled from retail to CORPORATE by the SA
regulatory-retail monetary cap / granularity limb but kept in the IRB
retail class (CRR Art. 147(5)(a)(i) / PS1/26 Art. 147(5)(a)(i) — the cap
conditions the SME limb only). ``sync_irb_exposure_class`` restores
their ``exposure_class_irb`` to RETAIL_OTHER; this step propagates it to
``exposure_class`` so the retail IRB formula applies.
- CRR non-named ``mdb`` rows, whose IRB class is INSTITUTION (Art.
147(4)(c)) while their SA class stays MDB — the ``preserve_derived_irb_class``
limb of ``sync_irb_exposure_class``, gated on the
``crr_non_named_mdb_institution_irb_class`` pack Feature (P1.276).
**This population is intentionally NOT aligned.** Under CRR every IRB
formula parameter is identical across MDB / INSTITUTION / CGCB — the
correlation tuple is the same (``CORRELATION_PARAMS``; MDB falls through
to CORPORATE), the FI 1.25x scalar reads ``requires_fi_scalar`` rather
than the class, the F-IRB supervisory LGD keys on
``(collateral_type, seniority, is_fse)``, and ``_pd_floor_expression``
selects on ``exposure_class`` (so an MDB takes the corporate floor arm
either way). Alignment would therefore be a parameter no-op that
needlessly moved the reported exposure class. If institution-specific IRB
treatment is ever introduced, this omission stops being a no-op and this
row population must be added to ``needs_alignment``.
The first two are gated on the ``exposure_class_irb != exposure_class``
difference (a no-op for every other IRB-routed row, where the two are
already equal), so QRRE / mortgage / SME subtyping is never reverted. Note
the gate is the ``is_rgla_pse | natural_person_diverged`` predicate below,
NOT the generic inequality — which is what keeps the MDB divergence
unpropagated.
"""
is_rgla_pse = pl.col("cp_entity_type").is_in(list(RGLA_PSE_ENTITY_TYPES))
natural_person_diverged = natural_person_expr() & (
pl.col("exposure_class_irb") != pl.col("exposure_class")
)
needs_alignment = is_rgla_pse | natural_person_diverged
return exposures.with_columns(
pl.when(
pl.col("approach").is_in([ApproachType.FIRB.value, ApproachType.AIRB.value])
& needs_alignment
)
.then(pl.col("exposure_class_irb"))
.otherwise(pl.col("exposure_class"))
.alias("exposure_class")
)
with_group_annual_revenue — src/rwa_calc/engine/stages/classify/attributes.py:216
@cites("PS1/26, paragraph 147")
def with_group_annual_revenue(counterparties: pl.LazyFrame) -> pl.LazyFrame:
"""Add ``group_annual_revenue`` — the highest-consolidation revenue signal.
PS1/26 Art. 147(4C)(b)(ii) assigns a corporate to the financial-/large-
corporates F-IRB-only subclass (Art. 147A(1)(e)) when its annual revenue
exceeds GBP 440m, "taken at the highest level of consolidation which is
performed and at which audited financial statements are available". The
engine approximates that group figure by rolling the counterparty's own
``annual_revenue`` up its resolved ``ultimate_parent_reference`` chain: a
parent's own ``annual_revenue`` is, by convention, its consolidated audited-
accounts turnover (which subsumes the subsidiary), so the ultimate parent —
the top of the group — carries the highest-consolidation figure.
The MAX of own and ultimate-parent revenue is taken (not a coalesce that
prefers one source), so the group can never be understated by a small
subsidiary figure AND a large subsidiary is never let off by a smaller
(data-anomalous) parent figure — both are the conservative direction for a
test that FORCES F-IRB (own-LGD A-IRB is typically lower RWA than the F-IRB
supervisory LGD). ``max_horizontal`` ignores nulls, so a null own revenue
under a revenue-bearing parent yields the parent figure, a top-level entity
(null ``ultimate_parent_reference`` → no self-join match) yields its own, and
both-null yields null (the caller's total_assets / conservative-large default
at ``_apply_b31_approach_restrictions`` then applies unchanged).
3-year averaging (Art. 147(4C)(b)(ii) second sentence — "average annual
amount over the last three years") is NOT applied: the counterparty schema
carries a single point-in-time ``annual_revenue``. The most-recent-figure
convention is used; supplying multi-year revenue would be new schema
machinery and is a documented deferral.
The lookup keys on the (unique) ``counterparty_reference``, so the
ultimate-parent self-join matches at most one row and never fans out. When
the ultimate parent is absent from the counterparty table the join misses
and the own figure stands.
"""
parent_revenue = counterparties.select(
pl.col("counterparty_reference").alias("_grp_parent_ref"),
pl.col("annual_revenue").alias("_grp_parent_revenue"),
)
return (
counterparties.join(
parent_revenue,
left_on="ultimate_parent_reference",
right_on="_grp_parent_ref",
how="left",
)
.with_columns(
pl.max_horizontal(
pl.col("annual_revenue"),
pl.col("_grp_parent_revenue"),
).alias("group_annual_revenue")
)
.drop("_grp_parent_revenue")
)
derive_independent_flags — src/rwa_calc/engine/stages/classify/attributes.py:302
@cites("CRR Art. 147")
@cites("PS1/26, paragraph 147")
def derive_independent_flags(
exposures: pl.LazyFrame,
config: CalculationConfig,
schema_names: set[str],
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""
Compute all flags that depend only on raw input columns.
Uses two .with_columns() batches: the first pre-computes shared
intermediates (uppercase strings, entity-type mapping) that the
second batch references, avoiding redundant str.to_uppercase()
and replace_strict() calls.
Sets: exposure_class_sa, exposure_class_irb, exposure_class, is_mortgage,
is_defaulted, is_infrastructure,
qualifies_as_retail, retail_threshold_exclusion_applied, is_adc
Art. 123A enforcement (Basel 3.1 only):
- Art. 123A(1)(a): SME entities (revenue > 0 and < threshold) auto-qualify
for retail treatment without needing conditions 1/3.
- Art. 123A(1)(b)(iii): Non-SME entities must be managed as part of a
retail pool (cp_is_managed_as_retail=True). Null defaults to True for
backward compatibility.
- CRR: threshold check only (no Art. 123A).
ADC derivation (PRA PS1/26 Art. 124(3) / Art. 124K):
- Derives ``is_adc=True`` for corporate (non-natural-person) exposures
whose financed property is under construction (``is_under_construction``
on the loan/facility) or whose product type signals development finance.
- Natural persons fail the corporate gate even when
``is_under_construction=True``.
- Any pre-existing non-null ``is_adc`` on the input row (e.g. propagated
from collateral by upstream stages) takes precedence via
``pl.coalesce`` so the derivation cannot override an explicit
user-supplied flag.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
max_retail_exposure = float(
regulatory_threshold(resolved_pack, "retail_max_exposure", config.eur_gbp_rate)
)
# SL override: exposures with sl_type (from specialised_lending join) get
# SPECIALISED_LENDING class regardless of counterparty entity_type.
sl_override = pl.col("sl_type").is_not_null()
# Batch 1: Pre-compute shared intermediates to avoid redundant work.
# - _sa_class: entity type → SA class mapping (used 3× below)
# - _irb_class: entity type → IRB class mapping
# - _pt_upper: product_type uppercased (used in is_mortgage, infrastructure)
exposures = exposures.with_columns(
[
pl.col("cp_entity_type")
.replace_strict(ENTITY_TYPE_TO_SA_CLASS, default=ExposureClass.OTHER.value)
.alias("_sa_class"),
pl.col("cp_entity_type")
.replace_strict(ENTITY_TYPE_TO_IRB_CLASS, default=ExposureClass.OTHER.value)
.alias("_irb_class"),
pl.col("product_type").str.to_uppercase().alias("_pt_upper"),
]
)
# CRR Art. 128 (high-risk class, 150%) was OMITTED from the UK onshored
# CRR text by SI 2021/1078 reg. 6(3)(a) with effect from 1 January 2022.
# Under CRR, entity types that map to HIGH_RISK fall through to the
# residual OTHER class. The 150% high-risk treatment is re-introduced
# under PRA PS1/26 Basel 3.1 (Art. 128), so the SA-class label is
# preserved as HIGH_RISK in that regime.
if not resolved_pack.feature("b31_high_risk_class_applicable"):
exposures = exposures.with_columns(
pl.when(pl.col("_sa_class") == ExposureClass.HIGH_RISK.value)
.then(pl.lit(ExposureClass.OTHER.value))
.otherwise(pl.col("_sa_class"))
.alias("_sa_class"),
)
# CRR Art. 147(3)(b) admits only the Art. 117(2) named (0% RW) MDBs to the
# central-government IRB class; Art. 147(4)(c) assigns "exposures to
# multilateral development banks which are not assigned a 0 % risk weight
# under Article 117" to the INSTITUTIONS class. PS1/26 Art. 147(3)(f) drops
# the split — every MDB is quasi-sovereign there — so the reroute reads the
# cited CRR-only pack Feature and the base map stays framework-invariant
# (same shape as the high-risk demotion above). ``mdb_named`` is untouched
# in both regimes (P1.276).
if resolved_pack.feature("crr_non_named_mdb_institution_irb_class"):
exposures = exposures.with_columns(
pl.when(pl.col("cp_entity_type") == "mdb")
.then(pl.lit(ExposureClass.INSTITUTION.value))
.otherwise(pl.col("_irb_class"))
.alias("_irb_class"),
)
sl_class = pl.lit(ExposureClass.SPECIALISED_LENDING.value)
# Art. 112 Table A2: Under SA, specialised lending is a corporate sub-type
# (Art. 112(1)(g)), not a separate exposure class. exposure_class_sa reflects
# this by mapping SL → CORPORATE. exposure_class retains SPECIALISED_LENDING
# because approach routing needs it for slotting/AIRB selection.
sl_sa_class = pl.lit(ExposureClass.CORPORATE.value)
# Batch 2: Derive all flags from pre-computed intermediates.
exposures = exposures.with_columns(
[
# --- Exposure class mappings (SL table overrides entity_type) ---
# SA class: SL is a corporate sub-type (Art. 112(1)(g))
pl.when(sl_override)
.then(sl_sa_class)
.otherwise(pl.col("_sa_class"))
.alias("exposure_class_sa"),
# IRB class: SL is a legitimate sub-class (Art. 147(8))
pl.when(sl_override)
.then(sl_class)
.otherwise(pl.col("_irb_class"))
.alias("exposure_class_irb"),
# Primary class: retains SPECIALISED_LENDING for approach routing
pl.when(sl_override)
.then(sl_class)
.otherwise(pl.col("_sa_class"))
.alias("exposure_class"),
# --- Mortgage flag ---
_build_is_mortgage_expr(),
# --- Default flags ---
# Per-exposure default detection per CRR Art. 178: an exposure
# is defaulted when EITHER (a) the counterparty is in default
# (cp_default_status — propagates to all that counterparty's
# exposures), OR (b) a row-level ``is_defaulted`` flag has been
# set upstream (e.g. by the loan parquet, letting a single
# defaulted exposure on an otherwise-performing counterparty
# trigger Art. 153(1)(ii) / 154(1)(i)). ``beel`` is consumed by
# the A-IRB defaulted formula (Art. 154(1)(i)) and Pool C of
# Art. 158(5) but is NOT itself a trigger — see
# ``_build_is_defaulted_expr`` and the DQ008 companion check.
_build_is_defaulted_expr(),
# --- Infrastructure flag (uses _pt_upper) ---
pl.col("_pt_upper").str.contains("INFRASTRUCTURE").alias("is_infrastructure"),
# --- ADC classification (PRA PS1/26 Art. 124(3) / Art. 124K) ---
# Derive ``is_adc`` from the loan/facility ``is_under_construction``
# flag (or a development-finance product_type) gated on a corporate
# / non-natural-person counterparty. Coalesce with any pre-existing
# ``is_adc`` value so an explicit user-supplied flag wins.
_build_is_adc_expr(schema_names),
# --- Retail threshold check + Art. 123A conditions (B31) ---
_build_qualifies_as_retail_expr(config, max_retail_exposure, pack=resolved_pack),
pl.when(pl.col("residential_collateral_value") > 0)
.then(pl.lit(True))
.otherwise(pl.lit(False))
.alias("retail_threshold_exclusion_applied"),
]
).drop(["_sa_class", "_irb_class", "_pt_upper"])
# PRA PS1/26 Art. 124E(1)(b)/(2) — Basel 3.1 only: re-route natural-person
# residential exposures to the income-producing whole-loan track (Art. 124G)
# when the borrower breaches the three-property limit. An explicit upstream
# income flag still wins (coalesce precedence). CRR routing is untouched.
if resolved_pack.feature("b31_art_124e_three_property_limit_applies"):
exposures = exposures.with_columns(
_build_has_income_cover_expr(),
)
return exposures
natural_person_expr — src/rwa_calc/engine/stages/classify/attributes.py:501
@cites("CRR Art. 147(5)")
@cites("PS1/26, paragraph 147")
def natural_person_expr() -> pl.Expr:
"""Return an expression flagging a counterparty as a natural person.
CRR Art. 147(5)(a)(i) / PS1/26 Art. 147(5)(a)(i): exposures to natural
persons enter the IRB retail exposure class with NO monetary cap, unlike
the SME limb (a)(ii). The signal is the explicit ``is_natural_person``
flag OR one of the documented natural-person ``entity_type`` aliases
(``individual`` / ``natural_person`` / ``retail`` — all mapping to
RETAIL_OTHER). A null flag AND a non-natural entity type resolve to
False, so an unknown obligor is treated as NOT a natural person and the
monetary cap keeps binding (conservative direction of error).
"""
return pl.col("cp_is_natural_person").fill_null(False) | pl.col("cp_entity_type").is_in(
list(NATURAL_PERSON_ENTITY_TYPES)
)
classify_exposure_subtypes — src/rwa_calc/engine/stages/classify/subtypes.py:68
@cites("CRR Art. 153(2)")
@cites("CRR Art. 142(1)(4)")
@cites("CRR Art. 154(4)")
@cites("PS1/26, paragraph 153")
@cites("PS1/26, paragraph 147")
def classify_exposure_subtypes(
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""
Merge SME, retail, and QRRE classification into a single .with_columns().
Works because they operate on non-overlapping initial exposure_class values:
SME only touches "corporate", retail only touches "retail_other",
QRRE specialises qualifying revolving retail.
Also derives ``requires_fi_scalar`` — the gate for the 1.25x asset-value
correlation multiplier (CRR Art. 153(2) / PS1/26 Art. 153(2)). This is a
MANDATORY treatment for large financial sector entities, not a user
election, so it is DERIVED from the entity-type flag and total assets:
requires_fi_scalar = apply_fi_scalar
OR (is_financial_sector_entity
AND total_assets >= threshold)
The threshold is the LFSE size test (CRR Art. 142(1)(4): EUR 70bn on an
individual/consolidated basis, converted GBP via the FX seam; PS1/26 IRB
Part glossary: GBP 79bn native, at the highest level of consolidation).
``total_assets`` is a GBP figure, mirroring the SME balance-sheet gate.
The user-supplied ``apply_fi_scalar`` is retained as an authoritative
True-OVERRIDE (a firm may know an entity is a large or UNREGULATED FSE
even when size data says otherwise) — it can never SUPPRESS a derived
True. A null ``total_assets`` on a flagged FSE leaves largeness
undetermined: the scalar is NOT applied (the whole-FSE population mostly
sits below the threshold), and ``audit.collect_input_warnings`` emits
CLS009 so the data gap is never a silent under-statement. The unregulated
FSE limb (Art. 142(1)(5), size-independent) needs a regulated-status input
the schema does not carry and is deferred to a schema-enablement change;
``apply_fi_scalar`` is the interim override for known unregulated FSEs.
Sets: exposure_class (updated), is_sme, requires_fi_scalar, is_hvcre
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
qrre_max_limit = float(
regulatory_threshold(resolved_pack, "qrre_max_limit", config.eur_gbp_rate)
)
lfse_total_assets_threshold = float(
regulatory_threshold(resolved_pack, "lfse_total_assets_threshold", config.eur_gbp_rate)
)
is_sme_by_size = is_sme_by_size_expr(config, pack=resolved_pack)
# PRA PS1/26 Art. 124(3) / Art. 124K: ADC exposures retain the CORPORATE
# class and route to the 150% Art. 124K(1) ADC RW — they must not be
# reclassified to CORPORATE_SME. ``is_adc`` is always present after
# ``_derive_independent_flags``.
is_adc = pl.col("is_adc").fill_null(False)
# Conditions reused across expressions. ``is_sme_by_size`` evaluates
# CRR Art. 4(1)(128D) / Commission Rec 2003/361/EC using turnover when
# present and total assets as a fallback. Art. 501 supporting factor
# eligibility is handled separately in sa/supporting_factors.py and
# remains turnover-only per Art. 501(2)(c).
is_corporate_sme = (
(pl.col("exposure_class") == ExposureClass.CORPORATE.value) & is_sme_by_size & ~is_adc
)
is_retail_sme = (
(pl.col("exposure_class") == ExposureClass.RETAIL_OTHER.value)
& (pl.col("qualifies_as_retail") == False) # noqa: E712
& is_sme_by_size
)
# Specialised lending is a corporate sub-type (Art. 112(1)(g)) and is
# flagged as SME when the counterparty meets the size test. The
# exposure_class must remain SPECIALISED_LENDING so approach assignment
# routes it to the slotting calculator; only the is_sme flag is set.
# Art. 501 supporting-factor eligibility is gated separately on
# turnover non-null in sa/supporting_factors.py.
is_sl_sme = (
pl.col("exposure_class") == ExposureClass.SPECIALISED_LENDING.value
) & is_sme_by_size
# QRRE qualification (CRR Art. 154(4)(a)-(c) / PS1/26 Art. 147(5A)(a)-(c)):
# (a) the exposures are to individuals (natural persons);
# (b) they are revolving, UNSECURED, and — to the extent they are not
# drawn — immediately and unconditionally cancellable; and
# (c) the largest per-individual aggregate nominal exposure across the
# sub-portfolio is <= the limit (EUR 100k CRR / GBP 90k B31).
# The same conditions apply under both regimes (only the (c) limit value
# differs, resolved from the pack), so the gates are NOT regime-Featured.
# Conditions (5A)(d) low loss-rate volatility and (5A)(e) consistency with
# the sub-portfolio's underlying risk characteristics are supervisory,
# portfolio-level attestations — not per-exposure inputs — and are out of
# scope for row-level classification.
#
# (a) individuals; (b) unsecured + unconditionally-cancellable-when-undrawn.
# Each is a reusable module-level predicate (also read by the CLS010
# demotion-warning collector in ``audit.py``) — see the helpers below.
is_qrre_individual = natural_person_expr()
is_qrre_unsecured = qrre_unsecured_expr()
is_qrre_cancellable = qrre_undrawn_cancellable_expr()
# CRR Art. 154(4)(c) / PS1/26 Art. 147(5A)(c) cap the *aggregate* nominal
# exposure to any single individual across the QRRE sub-portfolio at the
# limit (EUR 100k / GBP 90k), not each facility individually. Aggregate
# ``facility_limit`` (the committed/nominal basis) per
# ``counterparty_reference`` before comparing. The driver columns
# (``is_revolving`` / ``facility_limit`` / ``is_secured`` / ``risk_type`` /
# ``undrawn_amount``) are hierarchy_exit contract columns — always present,
# null-gated by value.
#
# The QRRE sub-portfolio is the qualifying revolving retail population.
# Only those rows contribute to the per-individual aggregate; non-QRRE
# facilities (e.g. a term loan to the same obligor) are masked to 0.
is_qrre_candidate = (
(pl.col("exposure_class") == ExposureClass.RETAIL_OTHER.value)
& (pl.col("qualifies_as_retail") == True) # noqa: E712
& (pl.col("is_revolving") == True) # noqa: E712
& is_qrre_individual
& is_qrre_unsecured
& is_qrre_cancellable
)
facility_limit = pl.col("facility_limit").fill_null(float("inf"))
candidate_limit = pl.when(is_qrre_candidate).then(facility_limit).otherwise(pl.lit(0.0))
# Guard the nullable ``counterparty_reference`` partition: a null key
# would otherwise pool all unmapped rows into a single bucket (see
# ``partition_by_nullable`` / ``NULLABLE_PARTITION_KEYS``). Null-keyed
# rows fall back to their own per-row candidate limit.
obligor_aggregate_limit = partition_by_nullable(
candidate_limit.sum().over("counterparty_reference"),
"counterparty_reference",
candidate_limit,
)
is_qrre = is_qrre_candidate & (obligor_aggregate_limit <= qrre_max_limit)
# FI scalar (1.25x correlation) — mandatory for large FSEs (Art. 153(2)).
# An FSE is "large" when total assets meet the Art. 142(1)(4) / PS1/26
# glossary threshold. Null total_assets -> the >= test is null -> False:
# size undetermined, no scalar (CLS009 flags the gap in audit.py). The
# user flag is OR-ed in as an authoritative override that can never
# suppress a derived True.
is_large_fse = pl.col("cp_is_financial_sector_entity").fill_null(False) & (
pl.col("cp_total_assets") >= lfse_total_assets_threshold
).fill_null(False)
requires_fi_scalar = pl.col("cp_apply_fi_scalar").fill_null(False) | is_large_fse
return exposures.with_columns(
[
# --- exposure_class update (SME + retail + QRRE combined) ---
# Priority order: mortgage, QRRE, SME retail, non-qualifying retail,
# corporate SME, keep current.
pl.when(
# Retail mortgage — stays RETAIL_MORTGAGE regardless of threshold
(pl.col("is_mortgage") == True) # noqa: E712
& (
(pl.col("exposure_class") == ExposureClass.RETAIL_OTHER.value)
| (pl.col("cp_entity_type") == "individual")
)
)
.then(pl.lit(ExposureClass.RETAIL_MORTGAGE.value))
.when(
# QRRE: qualifying revolving retail under QRRE limit (Art. 147(5))
is_qrre
)
.then(pl.lit(ExposureClass.RETAIL_QRRE.value))
.when(
# SME retail that doesn't qualify → CORPORATE_SME
is_retail_sme
)
.then(pl.lit(ExposureClass.CORPORATE_SME.value))
.when(
# Other retail that doesn't qualify → CORPORATE
(pl.col("exposure_class") == ExposureClass.RETAIL_OTHER.value)
& (pl.col("qualifies_as_retail") == False) # noqa: E712
)
.then(pl.lit(ExposureClass.CORPORATE.value))
.when(
# Corporate with SME revenue → CORPORATE_SME
is_corporate_sme
)
.then(pl.lit(ExposureClass.CORPORATE_SME.value))
.otherwise(pl.col("exposure_class"))
.alias("exposure_class"),
# --- is_sme flag ---
# True for: corporate SME, retail reclassified to CORPORATE_SME,
# or specialised lending with SME counterparty (keeps SPECIALISED_LENDING class).
(is_corporate_sme | is_retail_sme | is_sl_sme).alias("is_sme"),
# --- FI scalar: derived (large FSE) OR user override (Art. 153(2)) ---
requires_fi_scalar.alias("requires_fi_scalar"),
# --- HVCRE flag (from specialised lending join, null → False) ---
pl.col("is_hvcre").fill_null(False).alias("is_hvcre"),
]
)
qrre_unsecured_expr — src/rwa_calc/engine/stages/classify/subtypes.py:265
@cites("CRR Art. 154(4)")
@cites("PS1/26, paragraph 147")
def qrre_unsecured_expr() -> pl.Expr:
"""Return the Art. 147(5A)(b) / Art. 154(4)(b) "unsecured" QRRE predicate.
A revolving retail facility flagged ``is_secured`` is NOT a QRRE. A null
attestation resolves to unsecured (``fill_null(False)``) — consistent with
how the pipeline treats absent collateral everywhere else, and with the
reality that revolving retail credit is unsecured by nature. The classifier
runs before CRMProcessor, so general (non-property) collateral is not yet
allocated; this is a firm attestation rather than a pledge-presence join
(which would replicate CRM's multi-level beneficiary cascade at classify
time). The Art. 147(5A) second-sub-paragraph wage-account derogation is
applied via input semantics — see ``FACILITY_SCHEMA.is_secured``.
"""
return ~pl.col("is_secured").fill_null(False)
qrre_undrawn_cancellable_expr — src/rwa_calc/engine/stages/classify/subtypes.py:283
@cites("CRR Art. 154(4)")
@cites("PS1/26, paragraph 147")
def qrre_undrawn_cancellable_expr() -> pl.Expr:
"""Return the Art. 147(5A)(b) / Art. 154(4)(b) cancellability QRRE predicate.
QRRE must be, "to the extent they are not drawn, immediately and
unconditionally cancellable". A row carrying an undrawn commitment
(``undrawn_amount > 0``) must have the CCF unconditionally-cancellable
(LR / low-risk) ``risk_type``; a fully-drawn row has nothing undrawn to
cancel and satisfies the limb trivially. Reuses the CCF machinery's UC
signal (``risk_type`` == LR, engine/ccf.py) rather than minting a duplicate
flag. A null/non-LR ``risk_type`` on an undrawn row -> not cancellable ->
not QRRE, mirroring the CCF null convention (a null risk_type resolves to
the MR-equivalent CCF, never the LR benefit — no divergence). A null
``undrawn_amount`` propagates (never QRRE), which is the conservative
direction — no ``fill_null(0.0)`` on the Float column.
"""
has_undrawn_commitment = pl.col("undrawn_amount") > 0.0
is_uncond_cancellable = (
pl.col("risk_type")
.cast(pl.Utf8, strict=False)
.fill_null("")
.str.to_lowercase()
.is_in(["lr", "low_risk"])
)
return ~has_undrawn_commitment | is_uncond_cancellable
sync_irb_exposure_class — src/rwa_calc/engine/stages/classify/subtypes.py:481
@cites("CRR Art. 147(5)")
@cites("CRR Art. 147(4)")
@cites("PS1/26, paragraph 147")
def sync_irb_exposure_class(
exposures: pl.LazyFrame,
*,
pack: ResolvedRulepack,
) -> pl.LazyFrame:
"""Sync exposure_class_irb with the (possibly mutated) exposure_class.
Subtype classification and corporate→retail reclassification mutate
``exposure_class`` in place without touching ``exposure_class_irb``,
which was set once in ``_add_counterparty_attributes``. Re-align them
so downstream IRB permission lookups and approach filters see the
reclassified class.
rgla_* / pse_* entity types are excluded because their SA and IRB
classes are definitionally different (CRR Art. 147(3)/147(4)(b)) —
``exposure_class_irb`` already carries the correct CGCB / INSTITUTION
value from ``ENTITY_TYPE_TO_IRB_CLASS`` and must not be overwritten.
Non-named MDBs join that exclusion under CRR only (P1.276): CRR
Art. 147(4)(c) assigns "exposures to multilateral development banks which
are not assigned a 0 % risk weight under Article 117" to the INSTITUTIONS
class while Art. 112 keeps them in their own SA class, so the two classes
are definitionally different in exactly the rgla_* / pse_* sense and the
derived ``exposure_class_irb`` must survive. Gated on the cited
``crr_non_named_mdb_institution_irb_class`` pack Feature — PS1/26
Art. 147(3)(f) has no such split (all MDBs are quasi-sovereign there), so
under Basel 3.1 the MDB rows keep syncing to their SA class as before.
Natural-person IRB retail restoration (CRR Art. 147(5)(a)(i) / PS1/26
Art. 147(5)(a)(i)): the SA regulatory-retail test (``qualifies_as_retail``,
Art. 123 / 123A) applies the EUR 1,000,000 / GBP 880,000 monetary cap AND
(under B31) the Art. 123A(1)(b)(ii) 0.2% granularity limb to natural
persons, expelling large or portfolio-dominant individuals to CORPORATE.
Neither condition exists in the IRB retail class: Art. 147(5)(a) caps the
SME limb (ii) only, and Art. 147(5) has no granularity limb. So a natural
person expelled to CORPORATE keeps the IRB retail class, provided the
Art. 147(5)(c) management-basis condition holds — i.e. the obligor is not
managed individually as a corporate (``is_managed_as_retail`` not
explicitly False; a null flag defaults to True, matching the
Art. 123A(1)(b)(iii) backward-compatible KEEP). This leaves the SA
``exposure_class`` and ``qualifies_as_retail`` untouched — the SA/IRB
divergence lives only in ``exposure_class_irb``.
"""
# Art. 147(5)(c): a natural person managed individually as corporate
# (is_managed_as_retail explicitly False) is NOT IRB retail. Null → True
# (documented KEEP, mirrors _build_qualifies_as_retail_expr).
managed_as_retail = pl.col("cp_is_managed_as_retail").fill_null(True)
restore_retail_irb = (
natural_person_expr()
& (pl.col("exposure_class") == ExposureClass.CORPORATE.value)
& managed_as_retail
)
# Entity types whose derived IRB class is definitionally distinct from the
# SA class and must not be overwritten by the sync.
preserve_derived_irb_class = pl.col("cp_entity_type").is_in(list(RGLA_PSE_ENTITY_TYPES))
if pack.feature("crr_non_named_mdb_institution_irb_class"):
preserve_derived_irb_class = preserve_derived_irb_class | (
pl.col("cp_entity_type") == "mdb"
)
return exposures.with_columns(
pl.when(preserve_derived_irb_class)
.then(pl.col("exposure_class_irb"))
.when(restore_retail_irb)
.then(pl.lit(ExposureClass.RETAIL_OTHER.value))
.otherwise(pl.col("exposure_class"))
.alias("exposure_class_irb")
)
PS1/26, paragraph 147A — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
allocate — src/rwa_calc/engine/securitisation/allocator.py:118
@cites("CRR Art. 109")
@cites(CRR_ART_244)
@cites("PS1/26, paragraph 147A")
def allocate(
self,
data: RawDataBundle,
config: CalculationConfig, # noqa: ARG002 -- config reserved for future use
) -> tuple[RawDataBundle, pl.LazyFrame | None, list[CalculationError]]:
"""Resolve allocations into a per-exposure lookup.
Args:
data: Raw data bundle from loader.
config: Calculation configuration (currently unused; reserved
so the SRT validation gate can later read framework flags).
Returns:
Tuple of (original raw bundle, resolved lookup or None, list
of validation errors). The lookup is None when no allocations
were supplied; an empty input frame returns an empty lookup.
"""
if data.securitisation_allocations is None:
return data, None, []
# Materialise once -- the allocator runs row-level validation that
# is far easier to reason about on a concrete frame than on a
# lazy plan, and the input table is by definition small (one row
# per exposure-pool pair).
raw = data.securitisation_allocations.collect()
if raw.height == 0:
return data, empty_resolved_lookup(), []
errors: list[CalculationError] = []
# ------------------------------------------------------------------
# Step 1: SEC002 -- drop rows with invalid allocation_pct.
# ------------------------------------------------------------------
invalid_pct = raw.filter(
(pl.col("allocation_pct").is_null())
| (pl.col("allocation_pct") <= 0.0)
| (pl.col("allocation_pct") > 1.0)
)
if invalid_pct.height > 0:
errors.append(
securitisation_warning(
code=ERROR_SEC_INVALID_PCT,
message=(
f"{invalid_pct.height} securitisation allocation row(s) had "
"allocation_pct outside (0, 1] or null; rows dropped."
),
severity=ErrorSeverity.ERROR,
regulatory_reference=CRR_ART_244,
)
)
raw = raw.filter(
(pl.col("allocation_pct").is_not_null())
& (pl.col("allocation_pct") > 0.0)
& (pl.col("allocation_pct") <= 1.0)
)
if raw.height == 0:
return data, empty_resolved_lookup(), errors
# ------------------------------------------------------------------
# Step 2: SEC003 -- orphan exposure_reference (unknown to any of
# loans / contingents / facilities). Each row is checked against
# the source table matching its exposure_type to keep the lookup
# surface narrow.
# ------------------------------------------------------------------
known_refs = _collect_known_references(data)
raw = raw.with_columns(
pl.struct(["exposure_reference", "exposure_type"])
.map_elements(
lambda row: (row["exposure_reference"], row["exposure_type"]) in known_refs,
return_dtype=pl.Boolean,
)
.alias("_is_known"),
)
unknown = raw.filter(~pl.col("_is_known"))
if unknown.height > 0:
errors.append(
securitisation_warning(
code=ERROR_SEC_UNKNOWN_REFERENCE,
message=(
f"{unknown.height} securitisation allocation row(s) referenced "
"an exposure that does not exist in loans / contingents / "
"facilities; rows dropped."
),
severity=ErrorSeverity.WARNING,
regulatory_reference=CRR_ART_244,
)
)
raw = raw.filter(pl.col("_is_known")).drop("_is_known")
if raw.height == 0:
return data, empty_resolved_lookup(), errors
# ------------------------------------------------------------------
# Step 3: SEC004 -- duplicate (exposure_reference, pool_reference).
# Keep first, drop subsequent.
# ------------------------------------------------------------------
before_dedup = raw.height
raw = raw.unique(
subset=["exposure_reference", "exposure_type", "pool_reference"],
keep="first",
)
dropped = before_dedup - raw.height
if dropped > 0:
errors.append(
securitisation_warning(
code=ERROR_SEC_DUPLICATE,
message=(
f"{dropped} duplicate (exposure_reference, pool_reference) "
"securitisation allocation row(s) dropped; first row kept."
),
severity=ErrorSeverity.WARNING,
regulatory_reference=CRR_ART_244,
)
)
# ------------------------------------------------------------------
# Step 4: per-exposure aggregation. Group into struct list and
# compute total_allocated_pct.
# ------------------------------------------------------------------
aggregated = (
raw.lazy()
.group_by(["exposure_reference", "exposure_type"])
.agg(
[
pl.struct(
[
pl.col("pool_reference"),
pl.col("allocation_pct"),
]
).alias("securitisation_pool_allocations"),
pl.col("allocation_pct").sum().alias("total_allocated_pct"),
]
)
).collect()
# ------------------------------------------------------------------
# Step 5: SEC001 -- per-exposure sum > 1. Drop the allocations
# entirely for those rows; the exposure is treated as fully
# on-balance-sheet (residual_pct = 1.0) with audit_status =
# "over_allocated" so the audit row still surfaces the issue.
# ------------------------------------------------------------------
# Use a small tolerance to absorb floating-point summation noise --
# ``0.4 + 0.3 + 0.3`` is not exactly 1.0 in IEEE-754.
_SUM_TOLERANCE = 1e-9
over = aggregated.filter(pl.col("total_allocated_pct") > 1.0 + _SUM_TOLERANCE)
if over.height > 0:
errors.append(
securitisation_warning(
code=ERROR_SEC_OVER_ALLOCATED,
message=(
f"{over.height} exposure(s) had securitisation allocations "
"summing to > 1.0; all pool slices dropped, exposure(s) "
"kept fully on-balance-sheet."
),
severity=ErrorSeverity.ERROR,
regulatory_reference=CRR_ART_244,
)
)
# ------------------------------------------------------------------
# Step 6: SEC005 -- per-exposure sum == 1 (residual = 0). Inform-
# ational only -- the exposure flows through the pipeline with
# zero on-balance-sheet contribution.
# ------------------------------------------------------------------
fully = aggregated.filter(
(pl.col("total_allocated_pct") >= 1.0 - _SUM_TOLERANCE)
& (pl.col("total_allocated_pct") <= 1.0 + _SUM_TOLERANCE)
)
if fully.height > 0:
errors.append(
securitisation_warning(
code=ERROR_SEC_FULLY_SECURITISED,
message=(
f"{fully.height} exposure(s) fully securitised "
"(residual = 0); zero on-balance-sheet contribution."
),
severity=ErrorSeverity.WARNING,
regulatory_reference=CRR_ART_244,
)
)
# ------------------------------------------------------------------
# Step 7: build the resolved lookup. Over-allocated rows keep
# residual_pct = 1.0 and an empty pool_allocations list so the
# aggregator does not double-count them.
# ------------------------------------------------------------------
is_over = pl.col("total_allocated_pct") > 1.0 + _SUM_TOLERANCE
is_fully = (pl.col("total_allocated_pct") >= 1.0 - _SUM_TOLERANCE) & (
pl.col("total_allocated_pct") <= 1.0 + _SUM_TOLERANCE
)
empty_struct_list = pl.lit([]).cast(
pl.List(
pl.Struct(
{
"pool_reference": pl.String,
"allocation_pct": pl.Float64,
}
)
)
)
resolved = aggregated.with_columns(
[
pl.when(is_over)
.then(pl.lit(1.0))
.otherwise((pl.lit(1.0) - pl.col("total_allocated_pct")).clip(lower_bound=0.0))
.alias("securitisation_residual_pct"),
pl.when(is_over)
.then(empty_struct_list)
.otherwise(pl.col("securitisation_pool_allocations"))
.alias("securitisation_pool_allocations"),
pl.when(is_over)
.then(pl.lit("over_allocated"))
.when(is_fully)
.then(pl.lit("fully_securitised"))
.otherwise(pl.lit("ok"))
.alias("audit_status"),
]
).select(list(RESOLVED_SECURITISATION_SCHEMA.keys()))
logger.info(
"securitisation_allocator resolved %d exposure(s); %d error(s)",
resolved.height,
len(errors),
)
return data, resolved.lazy(), errors
PS1/26, paragraph 147A.1 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
derive_exposure_subclass — src/rwa_calc/engine/stages/classify/subtypes.py:552
@cites("PS1/26, paragraph 147A.1")
def derive_exposure_subclass(
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Derive the Basel 3.1 corporate ``exposure_subclass`` (PRA PS1/26 Art. 147A(1)).
Basel 3.1 only — under CRR the column is null. For rows whose
``exposure_class`` is corporate / corporate_sme, the three-way split is:
- ``corporate_financial_large`` — FSE (``cp_is_financial_sector_entity``)
OR large corporate (``cp_annual_revenue`` > the Art. 147A(1)(d) GBP 440m
threshold). Art. 147A(1)(e).
- ``corporate_sme`` — ``is_sme`` (turnover <= GBP 44m). Art. 147A(1)(f).
- ``corporate_other`` — otherwise. Art. 147A(1)(f).
Reuses the FSE predicate and the large-corporate revenue threshold
(``regulatory_threshold(pack, "large_corporate_revenue_threshold", …)``) shared
with ``_apply_b31_approach_restrictions``; non-corporate rows stay null.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
null_subclass = pl.lit(None, dtype=pl.String).alias("exposure_subclass")
if not resolved_pack.feature("b31_exposure_subclass_reporting_applies"):
return exposures.with_columns(null_subclass)
is_corporate = pl.col("exposure_class").is_in(
[ExposureClass.CORPORATE.value, ExposureClass.CORPORATE_SME.value]
)
is_fse = (pl.col("cp_is_financial_sector_entity") == True).fill_null(False) # noqa: E712
is_large_by_revenue = (
pl.col("cp_annual_revenue")
> float(
regulatory_threshold(
resolved_pack, "large_corporate_revenue_threshold", config.eur_gbp_rate
)
)
).fill_null(False)
is_sme = pl.col("is_sme").fill_null(False)
subclass = (
pl.when(~is_corporate)
.then(pl.lit(None, dtype=pl.String))
.when(is_fse | is_large_by_revenue)
.then(pl.lit(ExposureSubclass.CORPORATE_FINANCIAL_LARGE.value))
.when(is_sme)
.then(pl.lit(ExposureSubclass.CORPORATE_SME.value))
.otherwise(pl.lit(ExposureSubclass.CORPORATE_OTHER.value))
.alias("exposure_subclass")
)
return exposures.with_columns(subclass)
PS1/26, paragraph 153 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
classify_exposure_subtypes — src/rwa_calc/engine/stages/classify/subtypes.py:67
@cites("CRR Art. 153(2)")
@cites("CRR Art. 142(1)(4)")
@cites("CRR Art. 154(4)")
@cites("PS1/26, paragraph 153")
@cites("PS1/26, paragraph 147")
def classify_exposure_subtypes(
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""
Merge SME, retail, and QRRE classification into a single .with_columns().
Works because they operate on non-overlapping initial exposure_class values:
SME only touches "corporate", retail only touches "retail_other",
QRRE specialises qualifying revolving retail.
Also derives ``requires_fi_scalar`` — the gate for the 1.25x asset-value
correlation multiplier (CRR Art. 153(2) / PS1/26 Art. 153(2)). This is a
MANDATORY treatment for large financial sector entities, not a user
election, so it is DERIVED from the entity-type flag and total assets:
requires_fi_scalar = apply_fi_scalar
OR (is_financial_sector_entity
AND total_assets >= threshold)
The threshold is the LFSE size test (CRR Art. 142(1)(4): EUR 70bn on an
individual/consolidated basis, converted GBP via the FX seam; PS1/26 IRB
Part glossary: GBP 79bn native, at the highest level of consolidation).
``total_assets`` is a GBP figure, mirroring the SME balance-sheet gate.
The user-supplied ``apply_fi_scalar`` is retained as an authoritative
True-OVERRIDE (a firm may know an entity is a large or UNREGULATED FSE
even when size data says otherwise) — it can never SUPPRESS a derived
True. A null ``total_assets`` on a flagged FSE leaves largeness
undetermined: the scalar is NOT applied (the whole-FSE population mostly
sits below the threshold), and ``audit.collect_input_warnings`` emits
CLS009 so the data gap is never a silent under-statement. The unregulated
FSE limb (Art. 142(1)(5), size-independent) needs a regulated-status input
the schema does not carry and is deferred to a schema-enablement change;
``apply_fi_scalar`` is the interim override for known unregulated FSEs.
Sets: exposure_class (updated), is_sme, requires_fi_scalar, is_hvcre
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
qrre_max_limit = float(
regulatory_threshold(resolved_pack, "qrre_max_limit", config.eur_gbp_rate)
)
lfse_total_assets_threshold = float(
regulatory_threshold(resolved_pack, "lfse_total_assets_threshold", config.eur_gbp_rate)
)
is_sme_by_size = is_sme_by_size_expr(config, pack=resolved_pack)
# PRA PS1/26 Art. 124(3) / Art. 124K: ADC exposures retain the CORPORATE
# class and route to the 150% Art. 124K(1) ADC RW — they must not be
# reclassified to CORPORATE_SME. ``is_adc`` is always present after
# ``_derive_independent_flags``.
is_adc = pl.col("is_adc").fill_null(False)
# Conditions reused across expressions. ``is_sme_by_size`` evaluates
# CRR Art. 4(1)(128D) / Commission Rec 2003/361/EC using turnover when
# present and total assets as a fallback. Art. 501 supporting factor
# eligibility is handled separately in sa/supporting_factors.py and
# remains turnover-only per Art. 501(2)(c).
is_corporate_sme = (
(pl.col("exposure_class") == ExposureClass.CORPORATE.value) & is_sme_by_size & ~is_adc
)
is_retail_sme = (
(pl.col("exposure_class") == ExposureClass.RETAIL_OTHER.value)
& (pl.col("qualifies_as_retail") == False) # noqa: E712
& is_sme_by_size
)
# Specialised lending is a corporate sub-type (Art. 112(1)(g)) and is
# flagged as SME when the counterparty meets the size test. The
# exposure_class must remain SPECIALISED_LENDING so approach assignment
# routes it to the slotting calculator; only the is_sme flag is set.
# Art. 501 supporting-factor eligibility is gated separately on
# turnover non-null in sa/supporting_factors.py.
is_sl_sme = (
pl.col("exposure_class") == ExposureClass.SPECIALISED_LENDING.value
) & is_sme_by_size
# QRRE qualification (CRR Art. 154(4)(a)-(c) / PS1/26 Art. 147(5A)(a)-(c)):
# (a) the exposures are to individuals (natural persons);
# (b) they are revolving, UNSECURED, and — to the extent they are not
# drawn — immediately and unconditionally cancellable; and
# (c) the largest per-individual aggregate nominal exposure across the
# sub-portfolio is <= the limit (EUR 100k CRR / GBP 90k B31).
# The same conditions apply under both regimes (only the (c) limit value
# differs, resolved from the pack), so the gates are NOT regime-Featured.
# Conditions (5A)(d) low loss-rate volatility and (5A)(e) consistency with
# the sub-portfolio's underlying risk characteristics are supervisory,
# portfolio-level attestations — not per-exposure inputs — and are out of
# scope for row-level classification.
#
# (a) individuals; (b) unsecured + unconditionally-cancellable-when-undrawn.
# Each is a reusable module-level predicate (also read by the CLS010
# demotion-warning collector in ``audit.py``) — see the helpers below.
is_qrre_individual = natural_person_expr()
is_qrre_unsecured = qrre_unsecured_expr()
is_qrre_cancellable = qrre_undrawn_cancellable_expr()
# CRR Art. 154(4)(c) / PS1/26 Art. 147(5A)(c) cap the *aggregate* nominal
# exposure to any single individual across the QRRE sub-portfolio at the
# limit (EUR 100k / GBP 90k), not each facility individually. Aggregate
# ``facility_limit`` (the committed/nominal basis) per
# ``counterparty_reference`` before comparing. The driver columns
# (``is_revolving`` / ``facility_limit`` / ``is_secured`` / ``risk_type`` /
# ``undrawn_amount``) are hierarchy_exit contract columns — always present,
# null-gated by value.
#
# The QRRE sub-portfolio is the qualifying revolving retail population.
# Only those rows contribute to the per-individual aggregate; non-QRRE
# facilities (e.g. a term loan to the same obligor) are masked to 0.
is_qrre_candidate = (
(pl.col("exposure_class") == ExposureClass.RETAIL_OTHER.value)
& (pl.col("qualifies_as_retail") == True) # noqa: E712
& (pl.col("is_revolving") == True) # noqa: E712
& is_qrre_individual
& is_qrre_unsecured
& is_qrre_cancellable
)
facility_limit = pl.col("facility_limit").fill_null(float("inf"))
candidate_limit = pl.when(is_qrre_candidate).then(facility_limit).otherwise(pl.lit(0.0))
# Guard the nullable ``counterparty_reference`` partition: a null key
# would otherwise pool all unmapped rows into a single bucket (see
# ``partition_by_nullable`` / ``NULLABLE_PARTITION_KEYS``). Null-keyed
# rows fall back to their own per-row candidate limit.
obligor_aggregate_limit = partition_by_nullable(
candidate_limit.sum().over("counterparty_reference"),
"counterparty_reference",
candidate_limit,
)
is_qrre = is_qrre_candidate & (obligor_aggregate_limit <= qrre_max_limit)
# FI scalar (1.25x correlation) — mandatory for large FSEs (Art. 153(2)).
# An FSE is "large" when total assets meet the Art. 142(1)(4) / PS1/26
# glossary threshold. Null total_assets -> the >= test is null -> False:
# size undetermined, no scalar (CLS009 flags the gap in audit.py). The
# user flag is OR-ed in as an authoritative override that can never
# suppress a derived True.
is_large_fse = pl.col("cp_is_financial_sector_entity").fill_null(False) & (
pl.col("cp_total_assets") >= lfse_total_assets_threshold
).fill_null(False)
requires_fi_scalar = pl.col("cp_apply_fi_scalar").fill_null(False) | is_large_fse
return exposures.with_columns(
[
# --- exposure_class update (SME + retail + QRRE combined) ---
# Priority order: mortgage, QRRE, SME retail, non-qualifying retail,
# corporate SME, keep current.
pl.when(
# Retail mortgage — stays RETAIL_MORTGAGE regardless of threshold
(pl.col("is_mortgage") == True) # noqa: E712
& (
(pl.col("exposure_class") == ExposureClass.RETAIL_OTHER.value)
| (pl.col("cp_entity_type") == "individual")
)
)
.then(pl.lit(ExposureClass.RETAIL_MORTGAGE.value))
.when(
# QRRE: qualifying revolving retail under QRRE limit (Art. 147(5))
is_qrre
)
.then(pl.lit(ExposureClass.RETAIL_QRRE.value))
.when(
# SME retail that doesn't qualify → CORPORATE_SME
is_retail_sme
)
.then(pl.lit(ExposureClass.CORPORATE_SME.value))
.when(
# Other retail that doesn't qualify → CORPORATE
(pl.col("exposure_class") == ExposureClass.RETAIL_OTHER.value)
& (pl.col("qualifies_as_retail") == False) # noqa: E712
)
.then(pl.lit(ExposureClass.CORPORATE.value))
.when(
# Corporate with SME revenue → CORPORATE_SME
is_corporate_sme
)
.then(pl.lit(ExposureClass.CORPORATE_SME.value))
.otherwise(pl.col("exposure_class"))
.alias("exposure_class"),
# --- is_sme flag ---
# True for: corporate SME, retail reclassified to CORPORATE_SME,
# or specialised lending with SME counterparty (keeps SPECIALISED_LENDING class).
(is_corporate_sme | is_retail_sme | is_sl_sme).alias("is_sme"),
# --- FI scalar: derived (large FSE) OR user override (Art. 153(2)) ---
requires_fi_scalar.alias("requires_fi_scalar"),
# --- HVCRE flag (from specialised lending join, null → False) ---
pl.col("is_hvcre").fill_null(False).alias("is_hvcre"),
]
)
PS1/26, paragraph 160 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
derive_purchased_receivables_pd — src/rwa_calc/engine/stages/classify/subtypes.py:317
@cites("CRR Art. 160(2)")
@cites("CRR Art. 160(6)")
@cites("PS1/26, paragraph 160")
def derive_purchased_receivables_pd(
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""
Derive the Art. 160(2)/(6) top-down PD for purchased corporate receivables.
Where an institution "is not able to estimate PDs or an institution's PD
estimates do not meet the requirements set out in Section 6", the PD is
prescribed rather than modelled:
- Art. 160(2)(a) — senior claims: ``PD = EL / LGD`` for those receivables.
- Art. 160(2)(b) — subordinated claims: ``PD = EL`` (no division).
- Art. 160(6) first sentence — dilution risk: ``PD = EL`` for dilution risk,
taken from the separate ``el_dilution_estimate`` input.
The (a) denominator is not a free choice. CRR Art. 161(1)(e)/(f)/(g) fix the
supervisory purchased-receivables LGDs for exactly the population that cannot
estimate PDs, and PS1/26 Art. 161(1)(e)-(g) with Art. 161(2)(a) bind the same
values to "where PD is determined in accordance with point (a) of Article
160(2)" for Foundation *and* Advanced IRB alike. So the denominator is the
subtype's supervisory LGD read from the same pack table the LGD side uses —
never a firm-supplied LGD, which removes any divide-by-null/zero surface.
Runs before the approach ladder because the IRB gate is
``internal_pd.is_not_null()``: without this the pool has no PD at all and
falls to the Standardised Approach.
Null semantics (conservative): a null, zero or negative EL estimate derives
nothing, leaving ``internal_pd`` / ``pd`` exactly as they were — an absent
estimate must never become PD 0%. A firm-supplied PD always wins, because
Art. 160(2) applies only where the institution cannot produce one.
The derived PD is capped at 1.0 — ``EL / LGD`` is unbounded above (an EL rate
of 60% over a 45% LGD gives 1.33) but a PD is a probability, and 100% is the
Art. 160(3) value for a defaulted obligor. No floor is applied here: the
Art. 160(1) 0.03% (PS1/26 0.05%) input floor is applied downstream by
``engine/irb/transforms.py::apply_pd_floor`` for every PD alike.
Class scope: purchased *corporate* receivables only — CORPORATE /
CORPORATE_SME on ``exposure_class_irb``. Art. 160(2) and Art. 160(6) both name
the corporate population, and the (a) denominator is a corporate supervisory
LGD (Art. 161(1)(e)); retail IRB is own-estimate only, with no supervisory LGD
and no Art. 163 senior EL/LGD limb to authorise the division. A retail row
carrying a subtype therefore derives nothing and keeps its existing route.
Regime scope: both. PS1/26 Art. 160(2)(a)-(c) and 160(6) carry the CRR text
over, so there is no regime Feature — only the pack's regime-keyed LGD values.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
lgd_table = firb_supervisory_lgd_values(resolved_pack)
# Art. 161(1)(e): the senior supervisory LGD — a cited pack value, always
# non-zero, so the Art. 160(2)(a) division is total.
senior_lgd = float(lgd_table["purchased_receivables_senior"])
subtype = pl.col("purchased_receivables_subtype")
# Art. 160(2) and Art. 160(6) both read "purchased CORPORATE receivables", and
# the Art. 161(1)(e)-(g) LGDs that supply the (a) denominator are likewise
# corporate rates. Retail IRB is own-estimate only — Art. 163 has no senior
# EL/LGD limb and no supervisory retail LGD exists — so without this gate a
# retail row carrying a subtype would divide its EL by the CORPORATE senior
# LGD and manufacture an unauthorised retail PD. Gating on the IRB class keeps
# the derivation inside the population the article names (``exposure_class_irb``
# is already synced by ``sync_irb_exposure_class``, which runs immediately
# before this transform in the classifier).
is_corporate = pl.col("exposure_class_irb").is_in(
[ExposureClass.CORPORATE.value, ExposureClass.CORPORATE_SME.value]
)
# A usable estimate is strictly positive: 0.0 is "not supplied", not "no loss".
default_risk_el = pl.when(pl.col("el_estimate") > 0.0).then(pl.col("el_estimate"))
dilution_el = pl.when(pl.col("el_dilution_estimate") > 0.0).then(pl.col("el_dilution_estimate"))
top_down_pd = (
pl.when(~is_corporate)
.then(pl.lit(None, dtype=pl.Float64))
.when(subtype == "senior")
.then(default_risk_el / pl.lit(senior_lgd))
.when(subtype == "subordinated")
.then(default_risk_el)
.when(subtype == "dilution_risk")
.then(dilution_el)
.otherwise(pl.lit(None, dtype=pl.Float64))
.clip(upper_bound=1.0)
)
# coalesce, not fill_null: the firm's own PD outranks the derivation, and a
# null derivation leaves the column untouched (no Float null ever filled).
derived_pd = pl.coalesce([pl.col("internal_pd"), top_down_pd])
return exposures.with_columns(
[derived_pd.alias("internal_pd"), pl.coalesce([pl.col("pd"), top_down_pd]).alias("pd")]
)
PS1/26, paragraph 161 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_lgd_floor_blended_expression — src/rwa_calc/engine/irb/formulas.py:335
@cites("PS1/26, paragraph 161")
@cites("PS1/26, paragraph 164")
def _lgd_floor_blended_expression(
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.Expr:
"""
Build Polars expression for the blended (LGD*) A-IRB LGD input floor.
Where the institution takes recognised funded credit protection into
account, the A-IRB LGD input floor is the Art. 230/231 LGD* — a weighted
average of per-type LGDS floors and the unsecured LGDU, weighted by the
proportion of the Art. 230(1) exposure basis E' = E × (1 + HE) covered by
each collateral type:
LGD_floor = (E_unsecured / E') × LGDU
+ Σ_i (E_i / E') × LGDS_i
Where E_i comes from the Art. 231 sequential waterfall (crm_alloc_* columns)
and E_unsecured = E' - total_collateral_for_lgd. E is ``ead_for_crm``, the
CCF=100% exposure value (Art. 223(4)) — dividing by the post-CCF
``ead_gross`` instead understates the floor on every off-balance-sheet row.
This is the SAME formula for both limbs of the Basel 3.1 A-IRB floor — only
LGDU differs by exposure class:
- corporate / institution (and any other non-retail class): 25%
(Art. 161(5)(b)(iii)); the article covers "secured and partially
secured exposures", so a part-covered corporate is floored on the
blend, not on the flat 25% and not on the bare secured-type LGDS.
- retail_other: 30% (Art. 164(4)(c))
- retail_qrre: 50% (Art. 164(4)(b)(i))
Does NOT apply to:
- retail_mortgage (flat 5% floor per Art. 164(4)(a))
- exposures with no recognised protection — Art. 161(5)(a) /
164(4)(b) flat unsecured floors govern, so the expression returns
null and the caller falls back to the single-type floor
- CRR (no LGD floors)
Recorded decision — what counts as the firm's Art. 161(5) election: the
engine treats the PRESENCE of eligible recognised collateral
(``total_collateral_for_lgd > 0``, i.e. protection that survived the
Art. 199/207-210 eligibility gates and the Art. 231 waterfall) as the firm
"choosing to take into account funded credit protection", and hence as the
(b) limb. There is currently NO input by which a firm can elect the flat
limb (a) while still supplying that collateral — to sit on limb (a) it must
withhold the collateral row (or fail its eligibility flags). This is
deliberate: the (b) blend is bounded above by LGDU, so the election could
only ever lower the floor, and an unflagged election would be
indistinguishable from missing data.
Deferred: the blend deny-list is ``["retail_mortgage"]`` only, so
``residential_mortgage`` / ``commercial_mortgage`` would blend if they ever
reached an IRB row. Unreachable today — both are SA re-splitter outputs
(PS1/26 Art. 124C-124K) and never carry an IRB approach.
Returns null — deferring to whichever single-type / flat floor expression the
caller built (``_lgd_floor_expression_with_collateral`` when
``collateral_type`` is on the frame, else ``_lgd_floor_expression``) — in
exactly two cases: the exposure is ``retail_mortgage``, or it carries no
recognised collateral. There is NO third "columns absent" case.
Required columns — NOT runtime-guarded. This expression references ten
columns unconditionally, and a frame missing any of them raises
``ColumnNotFoundError`` at collect rather than falling back:
ead_for_crm, exposure_volatility_haircut, total_collateral_for_lgd,
exposure_class, crm_alloc_{financial,covered_bond,receivables,
real_estate,other_physical,life_insurance}
(``ead_gross`` is no longer among them — the Art. 230(1) basis replaced it.)
Production safety comes from the **crm_exit edge contract**, not from a
guard: all ten are declared REQUIRED on ``CRM_EXIT_EDGE`` (inherited by
``RE_SPLIT_EXIT_EDGE``), so the sealed IRB branch input always carries them.
Unlike the sibling single-type expressions — which the callers gate behind
``if "collateral_type" in schema_names`` — neither call site
(``apply_irb_formulas`` here, ``apply_lgd_floor`` / ``_lgd_floored_expr`` in
``engine/irb/transforms.py``) guards this set. Direct callers that hand-roll
a narrow frame must therefore supply all ten themselves; the test helpers
``tests/fixtures/contract_columns.py::pad_crm_exit_defaults`` and
``tests/fixtures/single_exposure.py`` exist for that and pad every one.
No fallback is offered deliberately: the only sensible substitute for an
absent ``ead_for_crm`` is ``ead_gross``, which is precisely the
anti-conservative denominator Art. 230(1) forbids (see below), so a degraded
mode would silently reinstate the defect. A ``ColumnNotFoundError`` naming
the absent column is the better failure. The edge guarantee above is pinned
by ``tests/contracts/test_edge_contracts.py::
TestUnguardedBlendedLGDFloorColumns``, which derives this column set from
the live expression so widening it cannot go unnoticed.
References:
PRA PS1/26 Art. 161(5)(b) — corporates and institutions
PRA PS1/26 Art. 164(4)(c) — retail
PRA PS1/26 Art. 230(1) / 231(1) — the LGD* formula being floored on
CRR Art. 223(4) — E is the CCF=100% basis for off-balance-sheet items
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
if not resolved_pack.feature("airb_lgd_floor"):
return pl.lit(0.0)
floors = formula_float_map(resolved_pack.formula("lgd_floors"))
# Art. 230(1) denominator: E' = E × (1 + HE) with E = ead_for_crm, the
# CCF=100% basis (Art. 223(4)) — NOT the post-CCF ead_gross (G), which is
# ≤ E' whenever CCF < 100%. Dividing by G was wrong in BOTH directions, the
# sign turning on whether the collateral C fits inside G:
# C ≤ G: pre − post = (N − C·LGDU)(1/G − 1/E') where N = Σ alloc·LGDS.
# Every LGDS (0/10/10/15%) is below every LGDU (25/30/50%) and
# Σ alloc == C, so N ≤ C·LGDU and the difference is ≤ 0 — the old
# floor sat BELOW the article (anti-conservative). This is the
# common case and the reason this fix exists.
# C > G: the old unsecured weight clipped to 0, leaving N/G — above the
# article, and not a convex combination at all (weights summed to
# C/G > 1, so a fully collateralised OBS row returned 0.375,
# exceeding even the 25% LGDU ceiling).
# Shared with the F-IRB / A-IRB LGD* itself (engine/crm/collateral.py).
ead = lgd_star_exposure_basis_expr()
total_coll = pl.col("total_collateral_for_lgd").fill_null(0.0)
# Convexity: the Art. 231 waterfall caps its allocations at ead_for_crm, so
# Σ crm_alloc_* == total_collateral_for_lgd ≤ ead_for_crm ≤ E'. The secured
# weights and this clipped unsecured weight therefore sum to exactly 1 and
# each lies in [0, 1] — including the overcollateralised case, where the
# waterfall has already absorbed the excess collateral.
unsecured_portion = (ead - total_coll).clip(lower_bound=0.0)
alloc_fin = pl.col("crm_alloc_financial").fill_null(0.0)
alloc_cb = pl.col("crm_alloc_covered_bond").fill_null(0.0)
alloc_rec = pl.col("crm_alloc_receivables").fill_null(0.0)
alloc_re = pl.col("crm_alloc_real_estate").fill_null(0.0)
alloc_op = pl.col("crm_alloc_other_physical").fill_null(0.0)
alloc_li = pl.col("crm_alloc_life_insurance").fill_null(0.0)
# Per-type LGDS floors. The Art. 161(5)(b)(iv) (corporate / institution) and
# Art. 164(4)(c) (retail) LGDS tables carry identical values, so one set
# serves both limbs.
lgds_fin = floors["financial_collateral"] # 0%
lgds_cb = floors["financial_collateral"] # 0% (treated as financial)
lgds_rec = floors["receivables"] # 10%
lgds_re = floors["commercial_real_estate"] # 10% (immovable property)
lgds_op = floors["other_physical"] # 15%
lgds_li = floors["financial_collateral"] # 0% (treated as financial)
numerator = (
alloc_fin * lgds_fin
+ alloc_cb * lgds_cb
+ alloc_rec * lgds_rec
+ alloc_re * lgds_re
+ alloc_op * lgds_op
+ alloc_li * lgds_li
)
# LGDU depends on exposure class:
# - retail_qrre: 50% (Art. 164(4)(b)(i))
# - retail_other: 30% (Art. 164(4)(c))
# - everything else (corporate, corporate_sme, institution, ...): 25%
# substituted for LGDU per Art. 161(5)(b)(iii)
# A null exposure_class leaves every comparison below null, so the
# eligibility gate evaluates null and the caller falls back to the flat /
# single-type floor — the conservative branch. No null fill here.
exp_class = pl.col("exposure_class").cast(pl.String).str.to_lowercase()
lgdu_expr = (
pl.when(exp_class.is_in(["retail_qrre"]))
.then(pl.lit(floors["retail_qrre_unsecured"])) # 50%
.when(exp_class.is_in(["retail_other"]))
.then(pl.lit(floors["retail_lgdu"])) # 30%
.otherwise(pl.lit(floors["unsecured"])) # 25% Art. 161(5)(b)(iii)
)
numerator_with_unsecured = numerator + unsecured_portion * lgdu_expr
blended = pl.when(ead > 0).then(numerator_with_unsecured / ead).otherwise(pl.lit(0.0))
# Art. 161(5)(b) (corporates and institutions) and Art. 164(4)(c) (retail
# other / QRRE) both mandate the Art. 230/231 LGD* blend for secured AND
# PARTIALLY secured exposures wherever the firm takes the funded credit
# protection into account. ``retail_mortgage`` is the sole carve-out — a
# flat 5% floor per Art. 164(4)(a), independent of collateral composition.
#
# ``has_collateral`` is exactly the Art. 161(5)(a)/(b) fork: nothing
# recognised means the firm "chooses not to take into account funded credit
# protection", so the flat unsecured floor governs and this expression
# returns null for the caller's single-type fallback.
is_blended_eligible = ~exp_class.is_in(["retail_mortgage"])
has_collateral = total_coll > 0
return pl.when(is_blended_eligible & has_collateral).then(blended).otherwise(pl.lit(None))
apply_firb_lgd — src/rwa_calc/engine/irb/transforms.py:128
@cites("CRR Art. 161")
@cites("PS1/26, paragraph 161")
def apply_firb_lgd(
lf: pl.LazyFrame, config: CalculationConfig, *, pack: ResolvedRulepack | None = None
) -> pl.LazyFrame:
"""
Apply F-IRB supervisory LGD for Foundation IRB exposures.
CRR Art. 161(1)(a): Senior unsecured 45%, subordinated 75%
Basel 3.1 Art. 161(1)(a)/(aa): FSE senior 45%, non-FSE senior 40%, sub 75%
For F-IRB exposures with collateral, the CRM processor calculates
the effective LGD (lgd_post_crm) based on collateral type and coverage.
This function uses lgd_post_crm as the input LGD for risk weight calculation.
A-IRB exposures retain their own LGD estimates.
Args:
lf: IRB exposures frame
config: Calculation configuration
Returns:
LazyFrame with F-IRB LGD applied
"""
# Use framework-appropriate supervisory LGD values (rulepack-sourced).
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
lgd_table = firb_supervisory_lgd_values(resolved_pack)
default_lgd = float(lgd_table["unsecured_senior"])
sub_lgd = float(lgd_table["subordinated"])
# PRA PS1/26 / CRR Art. 161(1)(e)/(f)/(g): purchased-receivables sub-type LGDs.
# Takes precedence over the seniority-based selector when populated.
pr_senior_lgd = float(lgd_table["purchased_receivables_senior"])
pr_sub_lgd = float(lgd_table["purchased_receivables_subordinated"])
pr_dilution_lgd = float(lgd_table["dilution_risk"])
# Under Basel 3.1, FSE senior unsecured = 45% (Art. 161(1)(a));
# non-FSE = 40% (Art. 161(1)(aa)). Under CRR, all = 45% (no FSE split).
if resolved_pack.feature("firb_fse_senior_lgd_split"):
fse_lgd = float(lgd_table["unsecured_senior_fse"])
default_lgd_expr = (
pl.when(pl.col("cp_is_financial_sector_entity").fill_null(False))
.then(pl.lit(fse_lgd))
.otherwise(pl.lit(default_lgd))
)
else:
default_lgd_expr = pl.lit(default_lgd)
# Build the seniority-based supervisory LGD expression (used both as the
# F-IRB fallback for null lgd and as the override base for purchased
# receivables routing below).
seniority_based_lgd_expr = (
pl.when(pl.col("seniority").fill_null("senior").str.to_lowercase().str.contains("sub"))
.then(pl.lit(sub_lgd))
.otherwise(default_lgd_expr)
)
# Art. 161(1)(e)/(f)/(g) routing: when purchased_receivables_subtype is set
# the engine MUST dispatch via the subtype (not via seniority), because
# subordinated purchased receivables (100%) and dilution risk (100% B3.1
# / 75% CRR) deviate from the standard subordinated (75%) and senior
# (40%/45%) supervisory LGDs respectively.
pr_subtype = pl.col("purchased_receivables_subtype")
firb_lgd_expr = (
pl.when(pr_subtype == "senior")
.then(pl.lit(pr_senior_lgd))
.when(pr_subtype == "subordinated")
.then(pl.lit(pr_sub_lgd))
.when(pr_subtype == "dilution_risk")
.then(pl.lit(pr_dilution_lgd))
.otherwise(seniority_based_lgd_expr)
)
# Guarantee the P1.215 A-IRB own-estimate LGD carrier exists as a typed null
# so the coalesce below never errors on lending-only / direct-call frames
# (it is a CCR_EXIT_EDGE-only column). ensure_columns uses pl.lit().cast() —
# no fill_null — so the check-11 baseline is untouched.
lf = ensure_columns(
lf, {"ccr_modelled_lgd": ColumnSpec(pl.Float64, default=None, required=False)}
)
# The Art. 161(1)(e)-(g) subtype LGDs bind on BOTH IRB approaches for the
# Art. 160(2)/(6) top-down population — they are not a Foundation-only rate:
# - CRR Art. 161(1) opens "Institutions shall use the following LGD values"
# with no approach qualifier, and Art. 161(2) is its only escape, open
# where the institution "can decompose its EL estimates for purchased
# corporate receivables into PDs and LGDs" reliably.
# - PS1/26 Art. 161(2)(a) is explicit the other way round: an institution
# using the Advanced IRB Approach "shall apply" points (e)/(f)/(g) of
# paragraph 1 where PD is determined under Art. 160(2)(a)/(b) or the
# first sentence of Art. 160(6).
# A row carrying a subtype with no own AND no modelled LGD is by construction
# that population: the decomposition escape presupposes an LGD estimate, so a
# firm holding one supplies it and keeps it (CRR Art. 161(2) / PS1/26
# Art. 161(2)(b)(i)-(ii)). Gating this on ``approach == FIRB`` let an A-IRB
# row reach the generic senior-unsecured value — 45%/40% in place of the 100%
# subordinated and 75%/100% dilution rates, i.e. anti-conservative.
supervisory_subtype_applies = (
pr_subtype.is_not_null() & pl.col("lgd").is_null() & pl.col("ccr_modelled_lgd").is_null()
)
lf = lf.with_columns(
[
# FIRB rows with a cleared LGD take the supervisory value (the FIRB
# branch is checked first, so ``firb_clear_expr`` wins and the
# coalesce never applies to them). A-IRB rows keep their own LGD;
# a synthetic CCR/SFT A-IRB row carries it on ``ccr_modelled_lgd``
# (P1.215) rather than the lending ``lgd``, so coalesce those before
# the supervisory default-fill (CRR Art. 143 own-estimate LGD).
pl.when(
((pl.col("approach") == ApproachType.FIRB.value) & pl.col("lgd").is_null())
| supervisory_subtype_applies
)
.then(firb_lgd_expr)
.otherwise(
pl.coalesce([pl.col("lgd"), pl.col("ccr_modelled_lgd")]).fill_null(default_lgd)
)
.alias("lgd"),
]
)
# For lgd_input, use lgd_post_crm (from CRM processor).
# This ensures collateral-adjusted LGD is used for F-IRB risk weight calculation.
# Purchased-receivables sub-type LGDs (Art. 161(1)(e)/(f)/(g)) override the
# CRM-derived lgd_post_crm because they are unsecured supervisory rates that
# do not benefit from generic seniority/collateral adjustments.
lgd_input_expr = (
pl.when((pl.col("approach") == ApproachType.FIRB.value) & pr_subtype.is_not_null())
.then(pl.col("lgd"))
.when(pl.col("approach") == ApproachType.FIRB.value)
.then(pl.col("lgd_post_crm"))
.otherwise(pl.col("lgd"))
)
return lf.with_columns([lgd_input_expr.alias("lgd_input")])
PS1/26, paragraph 162 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_derive_ccr_sft_maturity_years — src/rwa_calc/engine/sft/fccm.py:233
@cites("CRR Art. 162")
@cites("PS1/26, paragraph 162")
def _derive_ccr_sft_maturity_years(
*,
remaining_years: float | None,
under_mna: bool,
qualifies_one_day_floor: bool,
qualifies_mna_intermediate_floor: bool,
pack: ResolvedRulepack,
) -> float | None:
"""Return the Art. 162 effective maturity M for one SFT netting set, or None.
The carrier is the FULL M = ``clip(remaining_years, floor, 5.0)`` — the floor
is a MINIMUM on the remaining maturity (Art. 162(2)(d)/(3)), never a fixed
replacement value. For a long-dated MNA exposure the floor does not bite and
M = ``remaining_years``. Returns ``None`` (the date-derived 1-year catch-all,
Art. 162(2)(f) / PS1/26 162(2A)(f)) when the row is not under a master netting
agreement or carries no maturity.
Floor precedence (all sub-1y floors require the MNA precondition):
- not under an MNA, or ``remaining_years is None`` -> ``None`` (1y catch-all).
- ``qualifies_one_day_floor`` (the three conjunctive Art. 162(3) conditions —
daily re-margin AND revaluation AND prompt-liquidation docs) -> the one-day
(~1/365 y) floor.
- else the 5BD repo/SFT floor (Art. 162(2)(d) / PS1/26 162(2A)(d)). Under B31
the intermediate floor additionally requires the 162(2A)(c)/(d) daily
documentation condition (gated by the
``mna_intermediate_floor_requires_daily_condition`` feature); without it the
row falls to the 1-year catch-all (``None``). Under CRR the floor applies on
MNA alone (the feature is off).
Floors / feature are read from the RUN ``pack`` (not the module ``_PACK``) so
the derivation is regime-correct.
Args:
remaining_years: Exact /365 fractional years to maturity, or None.
under_mna: Art. 162(2) master-netting-agreement precondition.
qualifies_one_day_floor: All three Art. 162(3) conditions hold.
qualifies_mna_intermediate_floor: The B31 162(2A)(c)/(d) daily condition.
pack: The resolved run rulepack supplying the cited maturity floors / gate.
Returns:
M as a float, or ``None`` for the date-derived 1-year catch-all.
"""
if not under_mna or remaining_years is None:
return None
cap = 5.0
if qualifies_one_day_floor:
floor = float(pack.scalar_param("one_day_maturity_floor_years").value)
else:
requires_daily = pack.feature("mna_intermediate_floor_requires_daily_condition")
intermediate_available = (not requires_daily) or qualifies_mna_intermediate_floor
if not intermediate_available:
return None
floor = float(pack.scalar_param("irb_maturity_floor_repo_sft_years").value)
return min(max(remaining_years, floor), cap)
PS1/26, paragraph 163 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_pd_floor_expression — src/rwa_calc/engine/irb/formulas.py:112
@cites("CRR Art. 160")
@cites("CRR Art. 163")
@cites("PS1/26, paragraph 163")
def _pd_floor_expression(
config: CalculationConfig,
*,
has_transactor_col: bool = True,
exposure_class_col: str = "exposure_class",
transactor_col: str = "is_qrre_transactor",
pack: ResolvedRulepack | None = None,
) -> pl.Expr:
"""
Build Polars expression for per-exposure-class PD floor.
Under CRR the 0.03% floor has two separate homes and one gap:
- Art. 160(1): corporates and institutions ("The PD of an exposure to a
corporate or an institution shall be at least 0,03 %").
- Art. 163(1): retail (its own sub-section article).
- Central governments / central banks: NO floor — neither article reaches
them, so the pack's ``sovereign`` floor is 0 (P1.277).
Under Basel 3.1 (CRE30.55): Differentiated floors:
- Corporate/SME: 0.05%
- Retail mortgage: 0.10% (Art. 163(1)(b))
- QRRE transactors: 0.05%, revolvers: 0.10% (Art. 163(1)(c))
- Retail other: 0.05%
Args:
config: Calculation configuration
has_transactor_col: Whether the LazyFrame has the transactor column.
When True (pipeline path), uses per-row transactor/revolver distinction.
When False (isolated expressions), defaults to conservative revolver floor.
exposure_class_col: Name of the column to read the exposure class from.
Defaults to ``exposure_class`` (the borrower's class). For guarantor
PD substitution (CRR Art. 161(3) / B31 CRE22.70-85, Art. 160(4)),
pass ``guarantor_exposure_class`` so the floor reads the guarantor's
own class — the guaranteed portion is treated as a direct exposure
to the guarantor, so the guarantor's class floor governs.
transactor_col: Name of the QRRE transactor flag column. For guarantor
PD floors this is normally not relevant (guarantors are typically
not QRRE), but the parameter is exposed for symmetry with
``exposure_class_col``.
Required columns (no presence guard — see below):
- ``exposure_class_col``: always dereferenced.
- ``transactor_col``: dereferenced only when ``has_transactor_col`` is
True (the default). Isolated / hand-built frames that do not carry it
must pass ``has_transactor_col=False``, which selects the conservative
revolver floor.
Both columns are declared on every edge contract that feeds this builder —
``classifier_exit``, ``crm_exit`` and ``irb_branch`` carry ``exposure_class``,
``is_qrre_transactor`` and ``guarantor_exposure_class`` — so safety comes from
the producer seal rather than from a runtime presence check. A presence guard
here would be worse than a loud failure: falling back to a scalar floor would
silently reinstate the pre-P1.277 behaviour of ignoring the exposure class
(and with it the absent CGCB floor).
Returns a Polars expression evaluating to the per-row PD floor value.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
floors = formula_float_map(resolved_pack.formula("pd_floors"))
# Per-exposure-class floors (CRR Art. 160(1) / 163(1); B31 differentiated)
exp_class = pl.col(exposure_class_col).cast(pl.String).fill_null("CORPORATE").str.to_uppercase()
# QRRE transactor/revolver distinction (CRE30.55):
# Transactors (repay in full each period) get 0.03% floor;
# revolvers (carry balance) get 0.10% floor.
if has_transactor_col:
qrre_floor = (
pl.when(pl.col(transactor_col).fill_null(False))
.then(pl.lit(floors["retail_qrre_transactor"]))
.otherwise(pl.lit(floors["retail_qrre_revolver"]))
)
else:
# Conservative default: revolver floor (0.10% under Basel 3.1)
qrre_floor = pl.lit(floors["retail_qrre_revolver"])
sovereign_value = ExposureClass.CENTRAL_GOVT_CENTRAL_BANK.value.upper()
institution_value = ExposureClass.INSTITUTION.value.upper()
return (
pl.when(exp_class.str.contains("QRRE"))
.then(qrre_floor)
.when(exp_class.str.contains("MORTGAGE") | exp_class.str.contains("RESIDENTIAL"))
.then(pl.lit(floors["retail_mortgage"]))
.when(exp_class.str.contains("RETAIL"))
.then(pl.lit(floors["retail_other"]))
.when(exp_class == "CORPORATE_SME")
.then(pl.lit(floors["corporate_sme"]))
.when(exp_class == sovereign_value)
.then(pl.lit(floors["sovereign"]))
.when(exp_class == institution_value)
.then(pl.lit(floors["institution"]))
.otherwise(pl.lit(floors["corporate"]))
)
apply_pd_floor — src/rwa_calc/engine/irb/transforms.py:321
@cites("CRR Art. 163")
@cites("PS1/26, paragraph 163")
def apply_pd_floor(
lf: pl.LazyFrame, config: CalculationConfig, *, pack: ResolvedRulepack | None = None
) -> pl.LazyFrame:
"""
Apply PD floor based on configuration.
CRR (Art. 163): 0.03% for all classes
Basel 3.1 (CRE30.55): Differentiated by class
- Corporate/SME: 0.05%
- Retail mortgage: 0.05%
- QRRE revolvers: 0.10%, transactors: 0.03%
- Retail other: 0.05%
Args:
lf: IRB exposures frame
config: Calculation configuration
pack: Resolved rulepack (falls back to ``config`` when omitted)
Returns:
LazyFrame with pd_floored column
"""
pd_floor_expr = _pd_floor_expression(config, pack=pack)
# fill_nan(None) so a NaN PD is treated as null and raised to the floor
# (max_horizontal/clip do not scrub NaN); see apply_all_formulas.
return lf.with_columns(
pl.max_horizontal(pl.col("pd").fill_nan(None), pd_floor_expr).alias("pd_floored")
)
PS1/26, paragraph 164 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_lgd_floor_expression — src/rwa_calc/engine/irb/formulas.py:209
@cites("CRR Art. 164")
@cites("PS1/26, paragraph 164")
def _lgd_floor_expression(
config: CalculationConfig,
*,
has_seniority: bool = False,
has_exposure_class: bool = False,
pack: ResolvedRulepack | None = None,
) -> pl.Expr:
"""
Build Polars expression for LGD floor (no collateral_type column).
Under CRR: No LGD floors (returns 0.0).
Under Basel 3.1: Differentiated floors for A-IRB by exposure class:
Corporate (Art. 161(5)): 25% unsecured (senior & subordinated alike)
Retail (Art. 164(4)):
- retail_mortgage: 5% (assumed RRE-secured)
- retail_qrre: 50% (Art. 164(4)(b)(i))
- retail_other: 30% (Art. 164(4)(b)(ii))
Without exposure_class, falls back to seniority-based logic (conservative).
Without either, defaults to 25% unsecured floor.
Returns a Polars expression evaluating to the per-row LGD floor value.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
if not resolved_pack.feature("airb_lgd_floor"):
return pl.lit(0.0)
floors = formula_float_map(resolved_pack.formula("lgd_floors"))
if has_exposure_class:
# Route by exposure class — retail gets Art. 164(4) floors
exp_class = pl.col("exposure_class").cast(pl.String).str.to_lowercase()
return (
pl.when(exp_class.is_in(["retail_mortgage"]))
.then(pl.lit(floors["retail_rre"])) # 5% Art. 164(4)(a)
.when(exp_class.is_in(["retail_qrre"]))
.then(pl.lit(floors["retail_qrre_unsecured"])) # 50% Art. 164(4)(b)(i)
.when(exp_class.is_in(["retail_other"]))
.then(pl.lit(floors["retail_other_unsecured"])) # 30% Art. 164(4)(b)(ii)
.otherwise(pl.lit(floors["unsecured"])) # 25% Art. 161(5)
)
if has_seniority:
# Fallback without exposure_class: corporate A-IRB applies a single 25%
# unsecured floor regardless of seniority (Art. 161(5)). The 50%
# subordinated_unsecured value is the F-IRB supervisory LGD per
# Art. 161(1)(b), not an A-IRB floor — do not branch on seniority here.
return pl.lit(floors["unsecured"])
# Default to unsecured floor (25%) — most conservative for senior
return pl.lit(floors["unsecured"])
_lgd_floor_expression_with_collateral — src/rwa_calc/engine/irb/formulas.py:263
@cites("PS1/26, paragraph 164")
def _lgd_floor_expression_with_collateral(
config: CalculationConfig,
*,
has_seniority: bool = False,
has_exposure_class: bool = False,
pack: ResolvedRulepack | None = None,
) -> pl.Expr:
"""
Build Polars expression for per-collateral-type LGD floor when collateral_type
column is available.
When has_exposure_class=True, applies retail-specific floors (Art. 164(4)):
- retail_mortgage + RRE collateral: 5% (Art. 164(4)(a))
- retail_qrre unsecured: 50% (Art. 164(4)(b)(i))
- retail_other unsecured: 30% (Art. 164(4)(b)(ii))
- retail + other collateral: same LGDS as corporate (0%/10%/10%/15%)
Corporate floors use Art. 161(5): 25% unsecured, collateral-type LGDS.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
if not resolved_pack.feature("airb_lgd_floor"):
return pl.lit(0.0)
floors = formula_float_map(resolved_pack.formula("lgd_floors"))
coll = pl.col("collateral_type").fill_null("unsecured").str.to_lowercase()
# Determine unsecured floor based on exposure class (retail vs corporate)
if has_exposure_class:
exp_class = pl.col("exposure_class").cast(pl.String).str.to_lowercase()
unsecured_floor = (
pl.when(exp_class.is_in(["retail_mortgage"]))
.then(pl.lit(floors["retail_rre"])) # 5% Art. 164(4)(a)
.when(exp_class.is_in(["retail_qrre"]))
.then(pl.lit(floors["retail_qrre_unsecured"])) # 50% Art. 164(4)(b)(i)
.when(exp_class.is_in(["retail_other"]))
.then(pl.lit(floors["retail_other_unsecured"])) # 30% Art. 164(4)(b)(ii)
.otherwise(pl.lit(floors["unsecured"])) # 25% Art. 161(5)
)
# RRE collateral floor: 5% for retail_mortgage, 10% for corporate
rre_floor = (
pl.when(exp_class.is_in(["retail_mortgage"]))
.then(pl.lit(floors["retail_rre"])) # 5% Art. 164(4)(a)
.otherwise(pl.lit(floors["residential_real_estate"])) # 10% Art. 161(5)
)
elif has_seniority:
# Fallback without exposure_class: corporate A-IRB applies a single 25%
# unsecured floor regardless of seniority (Art. 161(5)). The 50%
# subordinated_unsecured value is the F-IRB supervisory LGD per
# Art. 161(1)(b), not an A-IRB floor — do not branch on seniority here.
unsecured_floor = pl.lit(floors["unsecured"])
rre_floor = pl.lit(floors["residential_real_estate"])
else:
unsecured_floor = pl.lit(floors["unsecured"])
rre_floor = pl.lit(floors["residential_real_estate"])
return (
pl.when(coll.is_in(["financial_collateral", "cash", "deposit", "gold", "financial"]))
.then(pl.lit(floors["financial_collateral"]))
.when(coll.is_in(["receivables", "trade_receivables"]))
.then(pl.lit(floors["receivables"]))
.when(coll.is_in(["residential_re", "rre", "residential", "residential_property"]))
.then(rre_floor)
.when(coll.is_in(["commercial_re", "cre", "commercial", "commercial_property"]))
.then(pl.lit(floors["commercial_real_estate"]))
.when(coll.is_in(["real_estate", "property", "immovable"]))
.then(rre_floor) # Routes to 5% for retail_mortgage, 10% for corporate (P1.8)
.when(coll.is_in(["other_physical", "equipment", "inventory"]))
.then(pl.lit(floors["other_physical"]))
.otherwise(unsecured_floor)
)
_lgd_floor_blended_expression — src/rwa_calc/engine/irb/formulas.py:336
@cites("PS1/26, paragraph 161")
@cites("PS1/26, paragraph 164")
def _lgd_floor_blended_expression(
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.Expr:
"""
Build Polars expression for the blended (LGD*) A-IRB LGD input floor.
Where the institution takes recognised funded credit protection into
account, the A-IRB LGD input floor is the Art. 230/231 LGD* — a weighted
average of per-type LGDS floors and the unsecured LGDU, weighted by the
proportion of the Art. 230(1) exposure basis E' = E × (1 + HE) covered by
each collateral type:
LGD_floor = (E_unsecured / E') × LGDU
+ Σ_i (E_i / E') × LGDS_i
Where E_i comes from the Art. 231 sequential waterfall (crm_alloc_* columns)
and E_unsecured = E' - total_collateral_for_lgd. E is ``ead_for_crm``, the
CCF=100% exposure value (Art. 223(4)) — dividing by the post-CCF
``ead_gross`` instead understates the floor on every off-balance-sheet row.
This is the SAME formula for both limbs of the Basel 3.1 A-IRB floor — only
LGDU differs by exposure class:
- corporate / institution (and any other non-retail class): 25%
(Art. 161(5)(b)(iii)); the article covers "secured and partially
secured exposures", so a part-covered corporate is floored on the
blend, not on the flat 25% and not on the bare secured-type LGDS.
- retail_other: 30% (Art. 164(4)(c))
- retail_qrre: 50% (Art. 164(4)(b)(i))
Does NOT apply to:
- retail_mortgage (flat 5% floor per Art. 164(4)(a))
- exposures with no recognised protection — Art. 161(5)(a) /
164(4)(b) flat unsecured floors govern, so the expression returns
null and the caller falls back to the single-type floor
- CRR (no LGD floors)
Recorded decision — what counts as the firm's Art. 161(5) election: the
engine treats the PRESENCE of eligible recognised collateral
(``total_collateral_for_lgd > 0``, i.e. protection that survived the
Art. 199/207-210 eligibility gates and the Art. 231 waterfall) as the firm
"choosing to take into account funded credit protection", and hence as the
(b) limb. There is currently NO input by which a firm can elect the flat
limb (a) while still supplying that collateral — to sit on limb (a) it must
withhold the collateral row (or fail its eligibility flags). This is
deliberate: the (b) blend is bounded above by LGDU, so the election could
only ever lower the floor, and an unflagged election would be
indistinguishable from missing data.
Deferred: the blend deny-list is ``["retail_mortgage"]`` only, so
``residential_mortgage`` / ``commercial_mortgage`` would blend if they ever
reached an IRB row. Unreachable today — both are SA re-splitter outputs
(PS1/26 Art. 124C-124K) and never carry an IRB approach.
Returns null — deferring to whichever single-type / flat floor expression the
caller built (``_lgd_floor_expression_with_collateral`` when
``collateral_type`` is on the frame, else ``_lgd_floor_expression``) — in
exactly two cases: the exposure is ``retail_mortgage``, or it carries no
recognised collateral. There is NO third "columns absent" case.
Required columns — NOT runtime-guarded. This expression references ten
columns unconditionally, and a frame missing any of them raises
``ColumnNotFoundError`` at collect rather than falling back:
ead_for_crm, exposure_volatility_haircut, total_collateral_for_lgd,
exposure_class, crm_alloc_{financial,covered_bond,receivables,
real_estate,other_physical,life_insurance}
(``ead_gross`` is no longer among them — the Art. 230(1) basis replaced it.)
Production safety comes from the **crm_exit edge contract**, not from a
guard: all ten are declared REQUIRED on ``CRM_EXIT_EDGE`` (inherited by
``RE_SPLIT_EXIT_EDGE``), so the sealed IRB branch input always carries them.
Unlike the sibling single-type expressions — which the callers gate behind
``if "collateral_type" in schema_names`` — neither call site
(``apply_irb_formulas`` here, ``apply_lgd_floor`` / ``_lgd_floored_expr`` in
``engine/irb/transforms.py``) guards this set. Direct callers that hand-roll
a narrow frame must therefore supply all ten themselves; the test helpers
``tests/fixtures/contract_columns.py::pad_crm_exit_defaults`` and
``tests/fixtures/single_exposure.py`` exist for that and pad every one.
No fallback is offered deliberately: the only sensible substitute for an
absent ``ead_for_crm`` is ``ead_gross``, which is precisely the
anti-conservative denominator Art. 230(1) forbids (see below), so a degraded
mode would silently reinstate the defect. A ``ColumnNotFoundError`` naming
the absent column is the better failure. The edge guarantee above is pinned
by ``tests/contracts/test_edge_contracts.py::
TestUnguardedBlendedLGDFloorColumns``, which derives this column set from
the live expression so widening it cannot go unnoticed.
References:
PRA PS1/26 Art. 161(5)(b) — corporates and institutions
PRA PS1/26 Art. 164(4)(c) — retail
PRA PS1/26 Art. 230(1) / 231(1) — the LGD* formula being floored on
CRR Art. 223(4) — E is the CCF=100% basis for off-balance-sheet items
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
if not resolved_pack.feature("airb_lgd_floor"):
return pl.lit(0.0)
floors = formula_float_map(resolved_pack.formula("lgd_floors"))
# Art. 230(1) denominator: E' = E × (1 + HE) with E = ead_for_crm, the
# CCF=100% basis (Art. 223(4)) — NOT the post-CCF ead_gross (G), which is
# ≤ E' whenever CCF < 100%. Dividing by G was wrong in BOTH directions, the
# sign turning on whether the collateral C fits inside G:
# C ≤ G: pre − post = (N − C·LGDU)(1/G − 1/E') where N = Σ alloc·LGDS.
# Every LGDS (0/10/10/15%) is below every LGDU (25/30/50%) and
# Σ alloc == C, so N ≤ C·LGDU and the difference is ≤ 0 — the old
# floor sat BELOW the article (anti-conservative). This is the
# common case and the reason this fix exists.
# C > G: the old unsecured weight clipped to 0, leaving N/G — above the
# article, and not a convex combination at all (weights summed to
# C/G > 1, so a fully collateralised OBS row returned 0.375,
# exceeding even the 25% LGDU ceiling).
# Shared with the F-IRB / A-IRB LGD* itself (engine/crm/collateral.py).
ead = lgd_star_exposure_basis_expr()
total_coll = pl.col("total_collateral_for_lgd").fill_null(0.0)
# Convexity: the Art. 231 waterfall caps its allocations at ead_for_crm, so
# Σ crm_alloc_* == total_collateral_for_lgd ≤ ead_for_crm ≤ E'. The secured
# weights and this clipped unsecured weight therefore sum to exactly 1 and
# each lies in [0, 1] — including the overcollateralised case, where the
# waterfall has already absorbed the excess collateral.
unsecured_portion = (ead - total_coll).clip(lower_bound=0.0)
alloc_fin = pl.col("crm_alloc_financial").fill_null(0.0)
alloc_cb = pl.col("crm_alloc_covered_bond").fill_null(0.0)
alloc_rec = pl.col("crm_alloc_receivables").fill_null(0.0)
alloc_re = pl.col("crm_alloc_real_estate").fill_null(0.0)
alloc_op = pl.col("crm_alloc_other_physical").fill_null(0.0)
alloc_li = pl.col("crm_alloc_life_insurance").fill_null(0.0)
# Per-type LGDS floors. The Art. 161(5)(b)(iv) (corporate / institution) and
# Art. 164(4)(c) (retail) LGDS tables carry identical values, so one set
# serves both limbs.
lgds_fin = floors["financial_collateral"] # 0%
lgds_cb = floors["financial_collateral"] # 0% (treated as financial)
lgds_rec = floors["receivables"] # 10%
lgds_re = floors["commercial_real_estate"] # 10% (immovable property)
lgds_op = floors["other_physical"] # 15%
lgds_li = floors["financial_collateral"] # 0% (treated as financial)
numerator = (
alloc_fin * lgds_fin
+ alloc_cb * lgds_cb
+ alloc_rec * lgds_rec
+ alloc_re * lgds_re
+ alloc_op * lgds_op
+ alloc_li * lgds_li
)
# LGDU depends on exposure class:
# - retail_qrre: 50% (Art. 164(4)(b)(i))
# - retail_other: 30% (Art. 164(4)(c))
# - everything else (corporate, corporate_sme, institution, ...): 25%
# substituted for LGDU per Art. 161(5)(b)(iii)
# A null exposure_class leaves every comparison below null, so the
# eligibility gate evaluates null and the caller falls back to the flat /
# single-type floor — the conservative branch. No null fill here.
exp_class = pl.col("exposure_class").cast(pl.String).str.to_lowercase()
lgdu_expr = (
pl.when(exp_class.is_in(["retail_qrre"]))
.then(pl.lit(floors["retail_qrre_unsecured"])) # 50%
.when(exp_class.is_in(["retail_other"]))
.then(pl.lit(floors["retail_lgdu"])) # 30%
.otherwise(pl.lit(floors["unsecured"])) # 25% Art. 161(5)(b)(iii)
)
numerator_with_unsecured = numerator + unsecured_portion * lgdu_expr
blended = pl.when(ead > 0).then(numerator_with_unsecured / ead).otherwise(pl.lit(0.0))
# Art. 161(5)(b) (corporates and institutions) and Art. 164(4)(c) (retail
# other / QRRE) both mandate the Art. 230/231 LGD* blend for secured AND
# PARTIALLY secured exposures wherever the firm takes the funded credit
# protection into account. ``retail_mortgage`` is the sole carve-out — a
# flat 5% floor per Art. 164(4)(a), independent of collateral composition.
#
# ``has_collateral`` is exactly the Art. 161(5)(a)/(b) fork: nothing
# recognised means the firm "chooses not to take into account funded credit
# protection", so the flat unsecured floor governs and this expression
# returns null for the caller's single-type fallback.
is_blended_eligible = ~exp_class.is_in(["retail_mortgage"])
has_collateral = total_coll > 0
return pl.when(is_blended_eligible & has_collateral).then(blended).otherwise(pl.lit(None))
apply_lgd_floor — src/rwa_calc/engine/irb/transforms.py:352
@cites("CRR Art. 164")
@cites("PS1/26, paragraph 164")
def apply_lgd_floor(
lf: pl.LazyFrame, config: CalculationConfig, *, pack: ResolvedRulepack | None = None
) -> pl.LazyFrame:
"""
Apply LGD floor for Basel 3.1 A-IRB exposures.
Uses lgd_input (which contains collateral-adjusted LGD for F-IRB)
as the base for flooring.
CRR: No LGD floor (A-IRB models LGD freely)
Basel 3.1: Differentiated floors by collateral type and exposure class:
- Corporate unsecured (senior & subordinated): 25% (Art. 161(5)(a))
- Retail QRRE unsecured: 50% (Art. 164(4)(b)(i))
- Financial: 0%, Receivables: 10%
- RRE: 10%, CRE: 10%, Other physical: 15%
- Secured / PARTIALLY secured (all classes bar retail_mortgage): the
Art. 230/231 LGD* blend of those LGDS values with the class LGDU
(Art. 161(5)(b) corporates & institutions, Art. 164(4)(c) retail)
LGD floors only apply to A-IRB own-estimate LGDs. F-IRB supervisory
LGDs are regulatory values and don't need flooring.
Args:
lf: IRB exposures frame
config: Calculation configuration
pack: Resolved rulepack (falls back to ``config`` when omitted)
Returns:
LazyFrame with lgd_floored column
"""
schema = lf.collect_schema()
schema_names = schema.names()
lgd_col = "lgd_input" if "lgd_input" in schema_names else "lgd"
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
if resolved_pack.feature("airb_lgd_floor"):
if "collateral_type" in schema_names:
lgd_floor_expr = _lgd_floor_expression_with_collateral(
config,
has_seniority=True,
has_exposure_class=True,
pack=resolved_pack,
)
else:
lgd_floor_expr = _lgd_floor_expression(
config,
has_seniority=True,
has_exposure_class=True,
pack=resolved_pack,
)
# Art. 161(5)(b) / 164(4)(c) LGD* blend: applies to every class except
# retail_mortgage once recognised collateral is present; falls back to
# the single-type / flat floor otherwise
blended_expr = _lgd_floor_blended_expression(config, pack=resolved_pack)
lgd_floor_expr = (
pl.when(blended_expr.is_not_null()).then(blended_expr).otherwise(lgd_floor_expr)
)
# LGD floors only apply to A-IRB (CRE30.41); F-IRB uses supervisory LGD
is_airb = pl.col("is_airb").fill_null(False) if "is_airb" in schema_names else pl.lit(False)
# fill_nan(None): a NaN own-estimate LGD passes through max_horizontal;
# treat it as null so the A-IRB regulatory LGD floor governs (conservative).
floored_lgd = pl.max_horizontal(pl.col(lgd_col).fill_nan(None), lgd_floor_expr)
return lf.with_columns(
pl.when(is_airb).then(floored_lgd).otherwise(pl.col(lgd_col)).alias("lgd_floored")
)
return lf.with_columns(pl.col(lgd_col).alias("lgd_floored"))
PS1/26, paragraph 166.1 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_apply_uk_residential_mortgage_ccf — src/rwa_calc/engine/ccf.py:665
@cites("PS1/26, paragraph 111")
@cites("PS1/26, paragraph 166.1")
def _apply_uk_residential_mortgage_ccf(
self,
exposures: pl.LazyFrame,
) -> pl.LazyFrame:
"""Apply the Table A1 Row 4(b) UK residential-mortgage commitment CCF.
PRA PS1/26 Art. 111(1) Table A1 Row 4(b): "UK residential mortgage
commitments that are not subject to a conversion factor of 10% or 100%"
attract a 50% conversion factor. When
``is_uk_residential_mortgage_commitment`` is set, the otherwise-resolved
CCF is overridden to that Row 4 rate (50%), unless the row already sits in
the carve-out — the Row 7 UCC 10% or the Row 1/2 100% — in which case it
is left untouched. The carve-out is tested per carrier, against that
carrier's own resolved value.
Both the SA and the F-IRB carrier are patched: Art. 166C(1) sets the
F-IRB and Slotting off-balance-sheet exposure value using "the conversion
factor that would be applicable to the off-balance sheet item under the
Standardised Approach, as set out in ... Article 111", so Row 4(b)
governs the F-IRB conversion factor exactly as it governs the SA one
(P1.251). Slotting reads the SA carrier under Basel 3.1.
Basel-3.1-only: callers gate this on the ``firb_uses_sa_ccf`` pack Feature
(S9c); Table A1 Row 4(b) is a PRA construct with no CRR Annex I
equivalent, so the flag is a no-op under CRR.
"""
row_4b_ccf = _SA_CCF_B31_MAP["MR"]
carve_out_ccfs = (_SA_CCF_B31_MAP["LR"], _SA_CCF_B31_MAP["FR"])
is_resi_commitment = pl.col("is_uk_residential_mortgage_commitment").fill_null(False)
sa_not_in_carve_out = ~pl.col("_sa_ccf_from_risk_type").is_in(carve_out_ccfs)
firb_not_in_carve_out = ~pl.col("_firb_ccf_from_risk_type").is_in(carve_out_ccfs)
return exposures.with_columns(
pl.when(is_resi_commitment & sa_not_in_carve_out)
.then(pl.lit(row_4b_ccf))
.otherwise(pl.col("_sa_ccf_from_risk_type"))
.alias("_sa_ccf_from_risk_type"),
pl.when(is_resi_commitment & firb_not_in_carve_out)
.then(pl.lit(row_4b_ccf))
.otherwise(pl.col("_firb_ccf_from_risk_type"))
.alias("_firb_ccf_from_risk_type"),
)
PS1/26, paragraph 166.5 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_apply_purchased_receivable_ccf — src/rwa_calc/engine/ccf.py:713
@cites("PS1/26, paragraph 166.5")
def _apply_purchased_receivable_ccf(
self,
exposures: pl.LazyFrame,
) -> pl.LazyFrame:
"""Apply the Art. 166E(5) revolving purchased-receivables CCF routing.
PRA PS1/26 Art. 166E(5): the undrawn purchase commitment of a *revolving*
purchased-receivables facility receives a fixed CCF — 40% by default
(Art. 111(1) Table A1 Row 5 "Other Commitments" / OC), dropping to 10%
where the commitment also meets the Table A1 Row 7 UCC criteria
(``risk_type == "LR"``). When ``is_purchased_receivable_commitment`` and
``is_revolving`` are both True, the otherwise-resolved SA / F-IRB CCF is
overridden to this rate regardless of the row's generic risk_type bucket
(e.g. a flagged MR row routes to 40%, not the generic 50%).
Basel-3.1-only: callers gate this on the ``firb_uses_sa_ccf`` pack Feature
(S9c); there is no equivalent CRR purchased-receivables undrawn-commitment
CCF, so the flag is a no-op under CRR.
"""
oc_ccf = _SA_CCF_B31_MAP["OC"]
ucc_ccf = _SA_CCF_B31_MAP["LR"]
is_pr_commitment = pl.col("is_purchased_receivable_commitment").fill_null(False) & pl.col(
"is_revolving"
).fill_null(False)
# Table A1 Row 7 UCC criterion: the commitment is unconditionally
# cancellable (LR risk_type) -> 10%; otherwise the Row 5 OC 40% default.
is_ucc = pl.col("risk_type").fill_null("").str.to_lowercase().is_in(["lr", "low_risk"])
pr_ccf = pl.when(is_ucc).then(pl.lit(ucc_ccf)).otherwise(pl.lit(oc_ccf))
return exposures.with_columns(
pl.when(is_pr_commitment)
.then(pr_ccf)
.otherwise(pl.col("_sa_ccf_from_risk_type"))
.alias("_sa_ccf_from_risk_type"),
pl.when(is_pr_commitment)
.then(pr_ccf)
.otherwise(pl.col("_firb_ccf_from_risk_type"))
.alias("_firb_ccf_from_risk_type"),
)
PS1/26, paragraph 169A — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
route_other_funded_protection — src/rwa_calc/engine/crm/ofcp_routing.py:77
@cites("CRR Art. 232")
@cites("PS1/26, paragraph 169A")
def route_other_funded_protection(
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Split the Art. 200(1) amounts between the substitution and LGD blocks.
Reads the two amounts the earlier CRM sub-steps already produced —
``third_party_deposit_value`` (Art. 200(1)(a)) and
``life_ins_collateral_value`` (Art. 200(1)(b)) — and emits:
============================== ==== ==================================
carrier cell content
============================== ==== ==================================
``ofcp_lgd_cash_deposit`` 0171 deposit, on the LGD-Modelling route
``ofcp_lgd_life_insurance`` 0172 policy, on the LGD-Modelling route
``ofcp_substitution_amount`` 0060 both, on the Art. 232 route
============================== ==== ==================================
One boolean selects all three branches, so for any leg a positive
``ofcp_lgd_*`` implies a zero ``ofcp_substitution_amount`` and vice versa —
the exclusivity is structural, not a convention a consumer must uphold.
Col 0173 (Art. 200(1)(c), instruments repurchased on request) has no engine
carrier and stays 0.0 downstream.
The two LGD carriers are capped per exposure: PS1/26 p.107 repeats "The
value of collateral reported shall be limited to the value of the exposure
at the level of an individual exposure" for each of 0171/0172/0173.
``ofcp_substitution_amount`` is NOT capped here — the whole substitution
block (cols 0040+0050+0060) is capped jointly at the leg's gross exposure
downstream by ``reporting/corep/crm_substitution.py::irb_block_cap_scale``,
which sheds the over-run proportionally across the block; capping a single
limb first would double-count the shed.
Both source columns are producer-sealed non-null — each sub-step emits
either a computed value or an explicit ``0.0`` default — so no null fill is
needed or performed here.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
# The route exists only where the firm both CAN and DID elect LGD Modelling.
# ``airb_lgd_collateral_method_applicable`` is a Basel-3.1-only Feature, so
# under CRR this is False and every amount stays on the Art. 232 route —
# today's behaviour, unchanged. Read as a Feature, never a regime bool.
lgd_modelling_elected = (
bool(resolved_pack.feature("airb_lgd_collateral_method_applicable"))
and config.airb_collateral_method == AIRBCollateralMethod.LGD_MODELLING
)
deposit = pl.col("third_party_deposit_value")
life_insurance = pl.col("life_ins_collateral_value")
substitution_total = pl.sum_horizontal(deposit, life_insurance)
if not lgd_modelling_elected:
logger.debug("Art. 200(1) protection routed wholly to the Art. 232 substitution block")
return exposures.with_columns(
pl.lit(0.0).alias("ofcp_lgd_cash_deposit"),
pl.lit(0.0).alias("ofcp_lgd_life_insurance"),
substitution_total.alias("ofcp_substitution_amount"),
)
# Per-row limb test. ``airb_lgd_preserved_expr`` is the SAME expression that
# defines the A-IRB collateral pool, so the routing and the pool can never
# drift: it is True only for an A-IRB row whose modelled LGD actually stands,
# which excludes an Art. 169B insufficient-data row that has fallen back to
# the supervisory formula and therefore reports on the substitution limb.
#
# It inspects ``schema_names`` for exactly one column, so seal that column
# onto the frame rather than probing the schema here: ``ensure_columns``
# injects a typed NULL when absent, which the callee's ``.fill_null(True)``
# resolves to the same value as its column-absent branch returns. The two
# paths are therefore behaviourally identical, and the set below is exact.
exposures = ensure_columns(
exposures,
{"has_sufficient_collateral_data": ColumnSpec(pl.Boolean, required=False)},
)
on_lgd_route = airb_lgd_preserved_expr(
config, {"has_sufficient_collateral_data"}, pack=resolved_pack
)
exposure_cap = pl.col("ead_gross")
logger.debug("Art. 200(1) protection routed per-leg (LGD Modelling Collateral Method elected)")
return exposures.with_columns(
pl.when(on_lgd_route)
.then(pl.min_horizontal(deposit, exposure_cap))
.otherwise(pl.lit(0.0))
.alias("ofcp_lgd_cash_deposit"),
pl.when(on_lgd_route)
.then(pl.min_horizontal(life_insurance, exposure_cap))
.otherwise(pl.lit(0.0))
.alias("ofcp_lgd_life_insurance"),
pl.when(on_lgd_route)
.then(pl.lit(0.0))
.otherwise(substitution_total)
.alias("ofcp_substitution_amount"),
)
PS1/26, paragraph 201 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_assign_guarantor_approach — src/rwa_calc/engine/crm/guarantees.py:418
@cites("CRR Art. 201")
@cites("PS1/26, paragraph 201")
def _assign_guarantor_approach(
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
errors: list[CalculationError] | None = None,
) -> pl.LazyFrame:
"""
Determine guarantor approach (IRB / SA) and rating provenance.
A guarantor is treated under IRB only if:
1. The beneficiary exposure is itself on FIRB/AIRB (CRR Art. 161 /
Basel 3.1 CRE22.70-85: parameter substitution applies only to IRB
beneficiaries; SA beneficiaries always substitute via guarantor's
SA risk weight regardless of the guarantor's internal rating —
SLOTTING beneficiaries are deliberately excluded, so the Art. 201(2)
internal-rating eligibility limb does not reach them either), AND
2. The firm has IRB permission for the guarantor's exposure class, AND
3. The guarantor has an internal rating (PD) — indicating the firm
actively rates this counterparty under its IRB model.
Counterparties with only external ratings (CQS) are treated under SA.
CRR/PS1-26 Art. 201(1)(g)/(2) eligibility gate: a CORPORATE guarantor is an
eligible protection provider only if it has an ECAI credit assessment
(``guarantor_cqs``) or — Art. 201(2), IRB-beneficiary-only — an internal
rating (``guarantor_internal_pd``) when the beneficiary is itself IRB. An
ineligible corporate guarantor is rejected: its ``guarantor_exposure_class``
is cleared so the SA guarantor-RW lookup returns null (non-beneficial), the
covered leg reverts to the borrower's own basis, and a CRM013 warning is
raised. Non-corporate classes are governed by other Art. 201 limbs and are
not gated here.
"""
# irb_permissions is derived non-None in CalculationConfig.__post_init__.
irb_exposure_class_values = {
ec.value
for ec, approaches in config.irb_permissions.permissions.items() # ty: ignore[unresolved-attribute]
if ApproachType.FIRB in approaches or ApproachType.AIRB in approaches
}
irb_beneficiary_approaches = [ApproachType.FIRB.value, ApproachType.AIRB.value]
schema_names = exposures.collect_schema().names()
beneficiary_is_irb = (
pl.col("approach").fill_null("").is_in(irb_beneficiary_approaches)
if "approach" in schema_names
else pl.lit(False)
)
is_domestic_cgcb_guarantor = _build_domestic_cgcb_flag(schema_names)
# Art. 201(1)(g)/(2) gate. All inputs are non-null booleans (is_not_null /
# == on the default-"" class), so no Kleene-null leaks into the gate. The
# class column can only ever say "corporate" (never "corporate_sme" — the
# entity->SA-class map has no such entity_type; SME-ness is derived later).
is_corporate_guarantor = pl.col("guarantor_exposure_class") == "corporate"
corporate_eligible = pl.col("guarantor_cqs").is_not_null() | (
beneficiary_is_irb & pl.col("guarantor_internal_pd").is_not_null()
)
guarantor_ineligible = (
is_corporate_guarantor & corporate_eligible.not_() & (pl.col("guaranteed_portion") > 0)
)
if errors is not None:
_record_ineligible_guarantors(exposures, guarantor_ineligible, errors)
return exposures.with_columns(
pl.when(is_domestic_cgcb_guarantor)
.then(pl.lit("sa"))
.when(
beneficiary_is_irb
& (pl.col("guarantor_exposure_class") != "")
& pl.col("guarantor_exposure_class").is_in(list(irb_exposure_class_values))
& pl.col("guarantor_internal_pd").is_not_null()
)
.then(pl.lit("irb"))
# SA fallback — gated: an ineligible corporate guarantor takes "" (the
# existing no-substitution value) rather than "sa".
.when((pl.col("guarantor_exposure_class") != "") & guarantor_ineligible.not_())
.then(pl.lit("sa"))
.otherwise(pl.lit(""))
.alias("guarantor_approach"),
# Audit: track whether guarantor approach was derived from internal or
# external rating (spec output field per CRR Art. 153(3) / Art. 233A).
pl.when(pl.col("guarantor_internal_pd").is_not_null())
.then(pl.lit("internal"))
.when(pl.col("guarantor_cqs").is_not_null())
.then(pl.lit("external"))
.otherwise(pl.lit(None).cast(pl.String))
.alias("guarantor_rating_type"),
# Explicit revert (Art. 201): clear the guarantor class for an ineligible
# corporate so ``build_guarantor_rw_expr`` returns null -> non-beneficial
# -> the covered leg reverts to the borrower's own basis. Mirrors the
# existing unmapped-guarantor (class "") no-substitution path.
pl.when(guarantor_ineligible)
.then(pl.lit(""))
.otherwise(pl.col("guarantor_exposure_class"))
.alias("guarantor_exposure_class"),
)
PS1/26, paragraph 213 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_gate_unilateral_protection — src/rwa_calc/engine/crm/guarantees.py:257
@cites("CRR Art. 213")
@cites("PS1/26, paragraph 213")
def _gate_unilateral_protection(
guarantees: pl.LazyFrame,
pack: ResolvedRulepack,
errors: list[CalculationError] | None,
) -> pl.LazyFrame:
"""
Drop guarantees ineligible under Art. 213(1)(c)(i) (unilateral cancel / change).
A guarantee the protection provider can unilaterally CANCEL is ineligible
under both regimes; one whose terms the provider can unilaterally CHANGE
(increasing the effective cost of protection) is additionally ineligible
under Basel 3.1 — the "or change" limb is new in PS1/26, gated by the
``ucp_unilateral_change_ineligible`` pack Feature. Dropped rows leave the
exposure un-guaranteed and each raises one CRM012 warning.
Both flags are null-permissive: a null means "no known defect => eligible",
mirroring the Art. 237(2)(a) original-maturity fallback in the caller.
References:
CRR Art. 213(1)(c)(i): unfunded credit protection eligibility.
PS1/26 Art. 213(1)(c)(i): adds the unilateral-change arm.
"""
guarantees = ensure_columns(
guarantees,
{
"is_unilaterally_cancellable": ColumnSpec(pl.Boolean, required=False),
"is_unilaterally_changeable": ColumnSpec(pl.Boolean, required=False),
},
)
change_gated = pack.feature("ucp_unilateral_change_ineligible")
ineligible = pl.col("is_unilaterally_cancellable")
if change_gated:
ineligible = ineligible | pl.col("is_unilaterally_changeable")
# Null is permissive (no known defect => eligible): coalesce the Kleene-OR
# result to False so a null flag never drops the guarantee.
ineligible = ineligible.fill_null(False)
if errors is not None:
_record_ucp_ineligibility(guarantees, ineligible, change_gated, errors)
return guarantees.filter(~ineligible)
PS1/26, paragraph 235 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
build_domestic_cgcb_guarantor_expr — src/rwa_calc/engine/eu_sovereign.py:84
@cites("CRR Art. 114")
@cites("CRR Art. 235")
@cites("PS1/26, paragraph 235")
def build_domestic_cgcb_guarantor_expr(
country_col: str,
currency_col: str | pl.Expr,
funding_currency_col: str | pl.Expr | None = None,
) -> pl.Expr:
"""
Build a Polars expression that identifies a domestic-currency CGCB guarantor
under CRR Art. 114(4) and Art. 114(7) (Basel 3.1 preservation).
Combines the UK (GB/GBP) and EU (member state / member-state-domestic-currency)
branches into a single boolean expression.
Callers pass the guarantor's country code column and the currency column to
test against. For guarantee substitution (Art. 215-217) the currency column
should be the **guarantee** currency — the Art. 233(3) 8% FX haircut handles
any mismatch between the guarantee and the underlying exposure separately.
Art. 235(3) funding limb: the Art. 114(4)/(7) 0% extension to a centrally-
guaranteed exposure requires the exposure to be BOTH denominated in the
guarantor's domestic currency (the ``currency_col`` limb) AND *funded* in
that same currency. When ``funding_currency_col`` is supplied, the limb
``funding == currency`` is ANDed in — because ``currency`` has already passed
the domestic-currency test, equality with it is equivalent to "funded in the
domestic currency", and holds uniformly across the UK/GBP and EU branches.
When it is None (the frame carries no funding source) the funding limb is
omitted, preserving the pure-denomination behaviour. Callers should pass a
null-PERMISSIVE funding expression (see :func:`funding_currency_expr`) so an
unreported funding currency reuses the denomination and keeps the exposure's
existing 0% treatment.
Args:
country_col: Column name containing the guarantor's ISO country code.
currency_col: Column name (str) or Polars expression for the currency
to test against the guarantor's domestic currency.
funding_currency_col: Column name (str) or Polars expression for the
exposure's funding currency. When None, the Art. 235(3) funding limb
is not applied.
Returns:
Boolean Polars expression: True when the guarantor is UK CGCB in GBP or
an EU-member CGCB in that member state's domestic currency, and — when a
funding currency is supplied — the exposure is funded in that currency.
"""
currency_expr = pl.col(currency_col) if isinstance(currency_col, str) else currency_col
is_uk_domestic = (pl.col(country_col).fill_null("") == "GB") & (currency_expr == "GBP")
is_eu_domestic = build_eu_domestic_currency_expr(country_col, currency_expr)
denominated_domestic = is_uk_domestic | is_eu_domestic
if funding_currency_col is None:
return denominated_domestic
funding_expr = (
pl.col(funding_currency_col)
if isinstance(funding_currency_col, str)
else funding_currency_col
)
return denominated_domestic & funding_expr.eq(currency_expr)
apply_guarantee_substitution — src/rwa_calc/engine/slotting/transforms.py:196
@cites("CRR Art. 235")
@cites("PS1/26, paragraph 235")
def apply_guarantee_substitution(
lf: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Risk-Weight Substitution Method on slotting legs (Art. 235(1)).
The CRM stage already splits guaranteed slotting exposures into
physical ``__G_``/``__REM`` legs and assigns ``guarantor_approach="sa"``
(slotting has no PD, so parameter substitution never applies); this
step is the previously-MISSING consumer: the covered leg takes the
guarantor's SA risk weight when beneficial, via the SAME shared
substitution step the SA branch runs (identical beneficial gate,
multi-guarantor redistribution and audit columns — the F8
``guarantee_benefit_rw`` snapshot lands here, on the SLOTTING borrower
basis, before supporting factors and the portfolio floor).
Gated by the cited pack Feature ``slotting_guarantee_substitution``
(recorded decision 2026-07-12: enabled under BOTH regimes — PS1/26
mandates RWSM; the CRR-side basis is recorded as unsettled on the
Feature's citation). Runs only when the CRM guarantee sub-step ran
(the SA step's ``guarantor_entity_type`` sentinel gate).
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
if not resolved_pack.feature("slotting_guarantee_substitution"):
return lf
return sa_apply_guarantee_substitution(lf, config, pack=resolved_pack)
zero_covered_expected_loss — src/rwa_calc/engine/slotting/transforms.py:227
@cites("PS1/26, paragraph 235")
def zero_covered_expected_loss(
lf: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Zero the covered leg's slotting EL (Art. 235(1A)).
The substituted covered part is an exposure to a guarantor treated
under SA, which carries no slotting EL — leaving the borrower's
Art. 158(6) EL on the ``__G_`` leg would double-count it into the
Art. 159 shortfall pool (mirrors the IRB SA-guarantor precedent,
``engine/irb/guarantee.py::_adjust_expected_loss``). Applies ONLY to
beneficially-substituted legs; non-beneficial and retained legs keep
the borrower slotting EL.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
if not resolved_pack.feature("slotting_guarantee_substitution"):
return lf
cols = lf.collect_schema().names()
if "is_guarantee_beneficial" not in cols or "guaranteed_portion" not in cols:
return lf
covered = pl.col("is_guarantee_beneficial") & (pl.col("guaranteed_portion") > 0)
return lf.with_columns(
pl.when(covered)
.then(pl.lit(0.0))
.otherwise(pl.col("slotting_el_rate"))
.alias("slotting_el_rate"),
pl.when(covered)
.then(pl.lit(0.0))
.otherwise(pl.col("expected_loss"))
.alias("expected_loss"),
)
PS1/26, paragraph 237 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_apply_maturity_mismatch_to_guarantees — src/rwa_calc/engine/crm/guarantees.py:1511
@cites("CRR Art. 217")
@cites("CRR Art. 237")
@cites("PS1/26, paragraph 237")
def _apply_maturity_mismatch_to_guarantees(
guarantees: pl.LazyFrame,
exposures: pl.LazyFrame,
config: CalculationConfig,
) -> pl.LazyFrame:
"""
Apply CRR Art. 237/239(3) maturity mismatch treatment to guarantee amounts.
When the protection's residual maturity ``t`` is shorter than the
exposure's effective maturity ``T``, the covered amount ``G`` is scaled:
GA = G* × (t - 0.25) / (T - 0.25)
with ``T`` capped at 5.0 years and both ``t`` and ``T`` floored at 0.25.
Scaling is applied to ``amount_covered`` and ``percentage_covered`` before
the split, so the reduced nominal protection value propagates through
cap-at-EAD.
Three Art. 237 eligibility gates ZERO coverage (rather than merely scaling
it) before the 239(3) formula, mirroring the collateral sibling in
``engine/crm/haircuts.py``. All three bind ONLY WHERE a maturity mismatch
exists (Art. 237(2) chapeau) — matched / protection-outlives-exposure
guarantees stay recognised:
- Art. 237(1): credit protection whose RAW residual maturity is < 3 months
AND shorter than the exposure is not recognised. The test runs on the
pre-floor residuals so a short exposure — whose ``T`` also floors to 0.25
and would mask the mismatch under the scaling formula — no longer retains
full coverage.
- Art. 162(3)/237(2)(b): where the exposure is subject to the one-day IRB
maturity floor (daily-margined repos/SFTs), ANY maturity mismatch makes
the protection ineligible. The ``has_one_day_maturity_floor`` flag is
joined from the exposure; null/absent is PERMISSIVE (treated as no floor).
- Art. 237(2)(a): protection whose ORIGINAL maturity is < 1 year is ineligible
where a mismatch exists. Relocated here from an unconditional pre-filter
(P1.232) so matched short-dated (e.g. trade-finance) guarantees are no
longer discarded. Reads ``original_maturity_years`` (the original term, NOT
the residual ``t``); null is PERMISSIVE (>= 1y).
The protection residual maturity ``t`` is derived from the guarantee
row's ``maturity_date`` if present, otherwise from
``original_maturity_years``. The exposure residual ``T`` is derived
from the exposure's ``maturity_date``.
References:
CRR / PS1-26 Art. 237(1): <3-month-and-shorter protection ineligibility.
CRR / PS1-26 Art. 237(2)(a): <1y-original protection, mismatch-conditioned.
CRR / PS1-26 Art. 237(2)(b) with Art. 162(3): one-day-floor exposures
+ any mismatch => ineligible.
CRR Art. 238(1): maturity of credit protection — ``t`` is the RESIDUAL
maturity (time remaining to protection maturity), not the original
contract term; the residual from ``maturity_date`` therefore wins
and ``original_maturity_years`` is only a fallback.
CRR Art. 239(3): maturity mismatch adjustment formula.
"""
guar_schema = guarantees.collect_schema()
guar_cols = guar_schema.names()
exp_schema = exposures.collect_schema()
exp_cols = exp_schema.names()
# Need exposure maturity_date and at least one of guarantee maturity_date
# / original_maturity_years to compute t and T.
if "maturity_date" not in exp_cols:
return guarantees
has_guar_maturity_date = "maturity_date" in guar_cols
has_guar_original_maturity = "original_maturity_years" in guar_cols
if not (has_guar_maturity_date or has_guar_original_maturity):
return guarantees
# Bring exposure residual maturity (years) and the Art. 162(3) one-day
# maturity-floor flag onto each guarantee row.
exp_t_expr = exact_fractional_years_expr(config.reporting_date, "maturity_date").alias("_exp_T")
exp_select = [pl.col("exposure_reference"), exp_t_expr]
has_1d_floor_col = "has_one_day_maturity_floor" in exp_cols
if has_1d_floor_col:
exp_select.append(
pl.col("has_one_day_maturity_floor").fill_null(False).alias("_has_1d_floor")
)
exp_lookup = exposures.select(exp_select)
guarantees = guarantees.join(
exp_lookup,
left_on="beneficiary_reference",
right_on="exposure_reference",
how="left",
)
# Null-PERMISSIVE: an exposure with no flag, an absent column, or a
# join-miss beneficiary is treated as NOT subject to the one-day floor
# (mirrors the collateral sibling's default).
if has_1d_floor_col:
guarantees = guarantees.with_columns(pl.col("_has_1d_floor").fill_null(False))
else:
guarantees = guarantees.with_columns(pl.lit(False).alias("_has_1d_floor"))
# Compute t = RESIDUAL maturity (Art. 238(1)): the time REMAINING to
# protection maturity, derived from the guarantee ``maturity_date`` minus
# the reporting date. ``original_maturity_years`` is the ORIGINAL contract
# term and must NOT override the residual — otherwise a seasoned guarantee
# (long original term, short residual) is over-recognised. It is used for
# ``t`` only as a fallback when ``maturity_date`` is null. The separate
# Art. 237(2)(a) >=1y eligibility gate upstream still reads
# ``original_maturity_years`` (the original term). A null PROTECTION maturity
# t stays PERMISSIVE — no scaling, no gate, full coverage (t-side unknown =>
# no basis to reduce). This is asymmetric with the EXPOSURE maturity T, which
# a null defaults CONSERVATIVELY to 5y below (Art. 237 targets short
# protection on longer exposures, so an unknown exposure horizon must not
# defeat the gates).
if has_guar_maturity_date and has_guar_original_maturity:
t_from_date = exact_fractional_years_expr(config.reporting_date, "maturity_date")
t_raw = (
pl.when(pl.col("maturity_date").is_not_null())
.then(t_from_date)
.otherwise(pl.col("original_maturity_years"))
)
elif has_guar_maturity_date:
t_raw = exact_fractional_years_expr(config.reporting_date, "maturity_date")
else:
t_raw = pl.col("original_maturity_years")
# Art. 239(3) floors / caps: t and T floored at 0.25, T capped at 5.0.
# A null / join-miss exposure maturity defaults to a 5y exposure (the most
# conservative recognised maturity), aligning with the collateral twin
# (haircuts.py) so a guarantee on a null-maturity exposure is still subject
# to the mismatch gates and the 239(3) scaling rather than silently keeping
# full coverage.
floor = pl.lit(0.25)
cap = pl.lit(5.0)
exp_T = pl.col("_exp_T").fill_null(5.0) # raw exposure residual; null -> 5y
t_eff_safe = (
pl.when(t_raw.is_null())
.then(pl.lit(None, dtype=pl.Float64))
.otherwise(pl.max_horizontal(t_raw, floor))
)
# The 0.25 floor lives ONLY on the scaling denominator (its purpose); the
# eligibility gates below compare the RAW residuals.
exp_t_eff = pl.max_horizontal(pl.min_horizontal(exp_T, cap), floor)
# Mismatch (floored) drives the Art. 239(3) scaling.
is_mismatch = t_eff_safe.is_not_null() & (t_eff_safe < exp_t_eff)
scale = (t_eff_safe - floor) / (exp_t_eff - floor)
# Art. 237(1): a RAW protection residual < 3 months that is also shorter than
# the exposure is not recognised. Tested pre-floor (audit: "raw t < 0.25 AND
# t < raw T") so a short exposure — whose T also floors to 0.25 and would
# mask the mismatch under the scaling formula — no longer retains full
# coverage, while a protection that OUTLIVES a sub-3-month exposure (t >= T)
# stays recognised (Art. 238: no adjustment when protection >= exposure).
# (The collateral twin labels this sub-point 237(2)(a) and floors the
# exposure maturity at 0.25 for its mismatch test; per the audit we compare
# the raw T so the outlives case is not spuriously zeroed.)
raw_mismatch = t_raw.is_not_null() & (t_raw < exp_T)
short_protection = raw_mismatch & (t_raw < floor)
# Art. 162(3)/237(2)(b): a one-day-M-floor exposure (daily-margined repo/SFT)
# with ANY maturity mismatch makes the protection ineligible.
one_day_floor_gate = pl.col("_has_1d_floor") & raw_mismatch
# Art. 237(2)(a): unfunded protection whose ORIGINAL maturity is < 1 year is
# ineligible ONLY where a maturity mismatch exists (Art. 237(2) chapeau) — a
# matched or protection-outlives-exposure short-dated guarantee stays
# recognised. Relocated here (P1.232) from the former UNCONDITIONAL pre-filter
# in _prepare_guarantees, mirroring the collateral twin's conditioning
# (haircuts.py). Null original maturity is PERMISSIVE (treated as >= 1y => not
# ineligible), preserving the P1.10 policy. Reads the ORIGINAL term
# (original_maturity_years), NOT the residual t that feeds the scaling (P1.219).
orig_maturity = (
pl.col("original_maturity_years").fill_null(10.0)
if has_guar_original_maturity
else pl.lit(10.0)
)
short_original_gate = raw_mismatch & (orig_maturity < 1.0)
# Zero-gates take priority over the scaling; otherwise scale on mismatch,
# else full coverage. Mirrors the collateral sibling (engine/crm/haircuts.py).
scale_safe = (
pl.when(short_protection | one_day_floor_gate | short_original_gate)
.then(pl.lit(0.0))
.when(is_mismatch)
.then(scale)
.otherwise(pl.lit(1.0))
)
scale_exprs: list[pl.Expr] = []
if "amount_covered" in guar_cols:
scale_exprs.append((pl.col("amount_covered") * scale_safe).alias("amount_covered"))
if "percentage_covered" in guar_cols:
scale_exprs.append((pl.col("percentage_covered") * scale_safe).alias("percentage_covered"))
if scale_exprs:
guarantees = guarantees.with_columns(scale_exprs)
return _drop_columns_if_present(guarantees, ["_exp_T", "_has_1d_floor"])
PS1/26 Art. 199 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_apply_collateral_unified — src/rwa_calc/engine/crm/collateral.py:893
@cites("CRR Art. 199")
@cites("CRR Art. 211")
@cites("PS1/26 Art. 199")
@cites("PS1/26 Art. 211")
def _apply_collateral_unified(
exposures: pl.LazyFrame,
adjusted_collateral: pl.LazyFrame,
config: CalculationConfig,
cp_ead_totals: pl.LazyFrame,
*,
pack: ResolvedRulepack | None = None,
errors: list[CalculationError] | None = None,
) -> pl.LazyFrame:
"""
Unified EAD + LGD collateral allocation in a single pass.
Performs a single group_by over all collateral levels (direct, facility,
counterparty) and joins back to exposures for both SA EAD reduction and
F-IRB LGD calculation.
Art. 231 sequential fill: when multiple collateral types secure an
exposure, each type absorbs exposure starting from the lowest LGDS.
The institution receives the most favourable ordering (lowest LGDS first):
financial (0%) -> covered_bond (11.25%) -> receivables -> real_estate
-> other_physical. This replaces the former pro-rata allocation.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
# S9h: regime branches read honest cited Features off the resolved pack.
# firb_fse_senior_lgd_split → FSE 45/40 split; firb_overcollateralisation_divisor_
# applies → CRR Art. 230(2) subordinated secured-portion LGDS rows (B31 LGD* drops
# them); airb_lgd_collateral_method_applicable → B31 Art. 169A/169B AIRB method.
fse_senior_lgd_split = resolved_pack.feature("firb_fse_senior_lgd_split")
overcollateralisation_step_function = resolved_pack.feature(
"firb_overcollateralisation_divisor_applies"
)
airb_collateral_method_applies = resolved_pack.feature("airb_lgd_collateral_method_applicable")
lgd_values = supervisory_lgd_values(resolved_pack)
lgd_subordinated = subordinated_unsecured_lgd(resolved_pack)
lgd_unsecured = lgd_values["unsecured"]
# LGDS values per waterfall category (Art. 230/231)
lgds = {key: lgd_values[key] for _, key, _ in WATERFALL_ORDER}
# Under Basel 3.1, FSE senior unsecured LGDU = 45% (Art. 161(1)(a));
# non-FSE = 40% (Art. 161(1)(aa)). Under CRR, all = 45%.
exposure_schema = exposures.collect_schema()
_has_fse_col = (
fse_senior_lgd_split and "cp_is_financial_sector_entity" in exposure_schema.names()
)
if _has_fse_col:
lgd_unsecured_fse = lgd_values["unsecured_fse"]
# Defensive: fill in pool-aware columns when callers (typically unit tests)
# construct ead-total frames or exposures without them. Missing pool flag
# → all exposures treated as non-AIRB pool, which matches legacy behaviour
# (unflagged collateral pro-rates over the full population).
if "_is_airb_pool" not in exposure_schema.names():
exposures = exposures.with_columns(pl.lit(False).alias("_is_airb_pool"))
# CRR Art. 223(4) override: ead_for_crm is the CCF=100% basis. Production
# always supplies it via _initialize_ead; direct unit-test callers may
# not, in which case we fall back to ead_gross (correct for pure on-BS
# rows where the two are equal by construction).
if "ead_for_crm" not in exposure_schema.names():
exposures = exposures.with_columns(pl.col("ead_gross").alias("ead_for_crm"))
if "effective_ccf" not in exposure_schema.names():
exposures = exposures.with_columns(pl.lit(1.0).alias("effective_ccf"))
# Facility-ancestor closure for the multi-level facility collateral cascade.
# Production supplies ``ancestor_facilities`` (parent + all ancestors up to
# root, incl. self) from the HierarchyResolver. Direct unit-test callers and
# single-level inputs fall back to the 1-element [parent] list, which makes
# the cascade in ``_cascade_facility_collateral`` reduce exactly to the
# legacy single-level allocation.
if "ancestor_facilities" not in exposure_schema.names():
if "parent_facility_reference" in exposure_schema.names():
exposures = exposures.with_columns(
pl.concat_list("parent_facility_reference").alias("ancestor_facilities")
)
else:
exposures = exposures.with_columns(
pl.lit(None, dtype=pl.List(pl.String)).alias("ancestor_facilities")
)
cp_totals_schema = cp_ead_totals.collect_schema().names()
cp_total_fills: list[pl.Expr] = []
if "_cp_ead_total_non_airb" not in cp_totals_schema:
cp_total_fills.append(pl.col("_cp_ead_total").alias("_cp_ead_total_non_airb"))
if "_cp_ead_total_airb" not in cp_totals_schema:
cp_total_fills.append(pl.lit(0.0).alias("_cp_ead_total_airb"))
if cp_total_fills:
cp_ead_totals = cp_ead_totals.with_columns(cp_total_fills)
collateral_schema = adjusted_collateral.collect_schema()
# --- Determine eligible expression for EAD reduction ---
if "is_eligible_financial_collateral" in collateral_schema:
is_eligible = pl.col("is_eligible_financial_collateral")
else:
is_eligible = ~pl.col("collateral_type").str.to_lowercase().is_in(NON_ELIGIBLE_RE_TYPES)
# --- Annotate collateral with LGD categories (using shared expressions) ---
# Ensure the AIRB-model flag is present (default False) so the pool-aware
# aggregation below can rely on it. Backward-compatible with collateral
# frames built before the column existed.
if "is_airb_model_collateral" in collateral_schema.names():
airb_flag_expr = pl.col("is_airb_model_collateral").fill_null(False)
else:
airb_flag_expr = pl.lit(False)
annotated = adjusted_collateral.with_columns(
[
collateral_lgd_expr(resolved_pack).alias("collateral_lgd"),
overcollateralisation_ratio_expr(resolved_pack).alias("overcollateralisation_ratio"),
is_financial_collateral_type_expr().alias("is_financial_collateral_type"),
collateral_category_expr().alias("_coll_category"),
airb_flag_expr.alias("_is_airb_model_collateral"),
pl.coalesce(
pl.col("value_after_maturity_adj")
if "value_after_maturity_adj" in collateral_schema.names()
else pl.lit(None),
pl.col("value_after_haircut")
if "value_after_haircut" in collateral_schema.names()
else pl.lit(None),
pl.col("market_value"),
).alias("adjusted_value"),
]
)
annotated = annotated.with_columns(
(pl.col("adjusted_value") / pl.col("overcollateralisation_ratio")).alias(
"effectively_secured"
),
)
# CRR/PS1-26 Art. 199(2)/(5)/(6): FIRB Foundation Collateral Method non-
# financial collateral (real estate, receivables, other physical) is
# recognised on the LGD*-substitution path only where the institution ATTESTS
# eligibility via the pre-existing ``is_eligible_irb_collateral`` flag. Default
# False => ineligible — the flag IS the attestation, so the P1.10 new-field
# null-permissive precedent does NOT apply. Art. 199(5): a receivable whose
# ORIGINAL maturity is populated > 1 year is ineligible even if attested
# (explicit data contradicting the attestation wins conservatively); a NULL
# original maturity is PERMISSIVE (recorded deviation — the attestation covers
# the maturity condition, absence doesn't contradict it). Ineligible rows are
# zeroed on ``effectively_secured`` (the Art. 231 waterfall feed) with one
# CRM014 warning each. Scope: FIRB FCM non-financial only — financial
# collateral (Art. 197), SA EAD reduction, and exposure classification are
# untouched. Art. 199(7)/211 (P1.273): a leased asset attested via
# ``is_lease_collateral_attested`` is an alternative attestation route (OR-ed
# below), so a lessor row is recognised without the general IRB flag.
_non_financial = ~pl.col("is_financial_collateral_type")
_attested = (
pl.col("is_eligible_irb_collateral").fill_null(False)
if "is_eligible_irb_collateral" in collateral_schema.names()
else pl.lit(False)
)
# CRR Art. 199(7) with Art. 211 / PS1/26 Art. 199(7) with Art. 211 (P1.273):
# a leased asset supplied as a non-financial collateral row is recognised when
# the lessor attests the lease-specific Art. 211 conditions (b)/(c)/(d). This
# is an INDEPENDENT eligibility route — Art. 211(a) subsumes the Art. 208/210
# property-eligibility that is_eligible_irb_collateral otherwise attests — so it
# is OR-ed into the attestation. Art. 211 concerns leased PROPERTY, so the route
# is scoped to the real_estate / other_physical categories (Art. 208 immovable
# property / Art. 210 other physical): a lease attestation on a receivables row
# confers NO eligibility — it must still carry its own is_eligible_irb_collateral.
# Null -> False (conservative), leaving all existing non-lease collateral untouched.
if "is_lease_collateral_attested" in collateral_schema.names():
_lease_attested = pl.col("is_lease_collateral_attested").fill_null(False) & pl.col(
"_coll_category"
).is_in(["real_estate", "other_physical"])
_attested = _attested | _lease_attested
_not_attested = _non_financial & ~_attested
if "original_maturity_years" in collateral_schema.names():
# NULL original maturity is PERMISSIVE (recorded deviation — the
# attestation covers the maturity condition, absence doesn't contradict
# it), so fill the *Boolean* > 1y test to False rather than the float
# column to 0.0 (the latter would be an anti-conservative float fill).
_receivables_too_long = (pl.col("_coll_category") == "receivables") & (
pl.col("original_maturity_years") > 1.0
).fill_null(False)
else:
_receivables_too_long = pl.lit(False)
if errors is not None:
_record_ineligible_irb_collateral(annotated, _not_attested, _receivables_too_long, errors)
annotated = annotated.with_columns(
pl.when(_not_attested | _receivables_too_long)
.then(pl.lit(0.0))
.otherwise(pl.col("effectively_secured"))
.alias("effectively_secured")
)
# --- Single group_by: EAD + LGD aggregates in one pass, split by AIRB pool ---
# Each metric is split into a non-AIRB-pool variant (suffix ``_n``,
# collateral with is_airb_model_collateral=False) and an AIRB-pool variant
# (suffix ``_a``, collateral with is_airb_model_collateral=True). The two
# variants are pro-rata-allocated against disjoint exposure pools so that
# collateral incorporated in the AIRB internal LGD model never reaches
# non-AIRB exposures (CRR Art. 181 / Basel 3.1 Art. 169A).
val_expr = pl.coalesce(
pl.col("value_after_maturity_adj"),
pl.col("value_after_haircut"),
)
is_fin = pl.col("is_financial_collateral_type")
cat = pl.col("_coll_category")
is_flagged = pl.col("_is_airb_model_collateral")
is_unflagged = ~is_flagged
def _split_aggs(base_alias: str, value: pl.Expr, value_filter: pl.Expr) -> list[pl.Expr]:
return [
value.filter(value_filter & is_unflagged).sum().alias(f"{base_alias}_n"),
value.filter(value_filter & is_flagged).sum().alias(f"{base_alias}_a"),
]
# Build per-category effectively_secured aggregates for Art. 231 waterfall
waterfall_aggs: list[pl.Expr] = []
for cat_values, _lgds_key, suffix in WATERFALL_ORDER:
waterfall_aggs.extend(
_split_aggs(f"_e{suffix}", pl.col("effectively_secured"), cat.is_in(cat_values))
)
# Per-category MARKET-value aggregates, metric -> (category, carrier). These
# mirror the ``_adj_*`` set one-for-one through the same multi-level blend but
# sum the pre-haircut ``market_value``, and are pure reporting carriers —
# nothing in engine/ consumes them.
#
# PS1/26 Annex II col 0190 (likewise 0180/0200/0210): "Where exposures are
# subject to the Foundation Collateral Method … the adjusted value of
# collateral Ci … Where exposures are subject to the AIRB approach, the amount
# to be reported shall be the estimated market value." CRR Annex II cols
# 0150-0210 make the same split on whether own LGD estimates are used. The
# ``_adj_*`` twins serve the Foundation limb; these serve the AIRB limb, which
# the adjusted basis understates wherever a supervisory haircut applies (40%
# on real estate under Basel 3.1, 0% under CRR).
market_value_carriers = {
"_mv_fin": ("financial", "collateral_financial_market_value"),
"_mv_cash": ("cash", "collateral_cash_market_value"),
"_mv_re": ("real_estate", "collateral_re_market_value"),
"_mv_rec": ("receivables", "collateral_receivables_market_value"),
"_mv_oth": ("other_physical", "collateral_other_physical_market_value"),
"_mv_li": ("life_insurance", "collateral_life_insurance_market_value"),
}
market_value_aggs: list[pl.Expr] = []
for metric, (category, _) in market_value_carriers.items():
market_value_aggs.extend(_split_aggs(metric, pl.col("market_value"), cat == category))
all_coll = (
annotated.with_columns(
beneficiary_level_expr().alias("_level"),
)
.group_by(["_level", "beneficiary_reference"])
.agg(
_split_aggs("_cv", val_expr, is_eligible)
+ _split_aggs("_mv", pl.col("market_value"), is_eligible)
+ _split_aggs("_rn", pl.col("adjusted_value"), ~is_fin)
+ _split_aggs("_adj_fin", pl.col("adjusted_value"), cat == "financial")
+ _split_aggs("_adj_cash", pl.col("adjusted_value"), cat == "cash")
+ _split_aggs("_adj_re", pl.col("adjusted_value"), cat == "real_estate")
+ _split_aggs("_adj_rec", pl.col("adjusted_value"), cat == "receivables")
+ _split_aggs("_adj_oth", pl.col("adjusted_value"), cat == "other_physical")
+ market_value_aggs
+ waterfall_aggs
)
)
_wf_suffixes = [suffix for _, _, suffix in WATERFALL_ORDER]
# The market-value metrics allocate on a POOL-AGNOSTIC basis (see
# ``_pool_agnostic_metrics`` below); every other metric keeps the
# pool-gated allocation that drives LGD / EAD.
_pool_agnostic_metrics = list(market_value_carriers)
_metrics = (
[
"_cv",
"_mv",
"_rn",
"_adj_fin",
"_adj_cash",
"_adj_re",
"_adj_rec",
"_adj_oth",
]
+ _pool_agnostic_metrics
+ [f"_e{s}" for s in _wf_suffixes]
)
# Each metric has both _n (non-AIRB pool) and _a (AIRB pool) variants in the
# aggregated frame; the level suffix (_d/_f/_c) is appended on rename below.
_agg = [f"{m}_{p}" for m in _metrics for p in ("n", "a")]
# Split the small aggregated result for per-level joins
coll_direct = (
all_coll.filter(pl.col("_level") == "direct")
.drop("_level")
.rename({c: f"{c}_d" for c in _agg})
)
coll_facility = (
all_coll.filter(pl.col("_level") == "facility")
.drop("_level")
.rename({c: f"{c}_f" for c in _agg})
)
coll_counterparty = (
all_coll.filter(pl.col("_level") == "counterparty")
.drop("_level")
.rename({c: f"{c}_c" for c in _agg})
)
# --- Join direct + counterparty levels to exposures ---
exposures = exposures.join(
coll_direct,
left_on="exposure_reference",
right_on="beneficiary_reference",
how="left",
)
# Facility level: cascade collateral over each exposure's full ancestor set
# so a pledge at any ancestor facility (parent, grandparent, ... root) flows
# pro-rata to every descendant exposure (CRR Art. 230-231 pooling over the
# facility subtree). Produces pre-weighted, ancestor-summed ``{m}_{p}_f``
# columns that ``_sum6`` adds in directly (the pro-rata weight is already
# baked in, so no further ``_fw`` multiply is needed).
exposures = _cascade_facility_collateral(
exposures, coll_facility, _metrics, _pool_agnostic_metrics
)
exposures = exposures.join(
coll_counterparty,
left_on="counterparty_reference",
right_on="beneficiary_reference",
how="left",
).join(
cp_ead_totals,
on="counterparty_reference",
how="left",
)
# --- Fill nulls + counterparty pro-rata weights ---
# Facility ``{c}_f`` columns are already filled + pre-weighted by
# ``_cascade_facility_collateral``; only the direct (``_d``) and
# counterparty (``_c``) families plus the CP EAD totals need filling here.
fill_exprs = []
for sfx in ["d", "c"]:
for c in _agg:
fill_exprs.append(pl.col(f"{c}_{sfx}").fill_null(0.0))
fill_exprs.extend(
[
pl.col("_cp_ead_total").fill_null(0.0),
pl.col("_cp_ead_total_airb").fill_null(0.0),
pl.col("_cp_ead_total_non_airb").fill_null(0.0),
]
)
exposures = exposures.with_columns(fill_exprs)
# Pool-aware counterparty pro-rata weights. ``_is_airb_pool`` was tagged on
# exposures in ``apply_collateral`` via ``airb_lgd_preserved_expr``; weights
# bake in the pool-match gate so non-matching pools always contribute zero.
in_airb = pl.col("_is_airb_pool").fill_null(False)
in_non_airb = ~in_airb
# Pro-rata weights use ead_for_crm (CRR Art. 223(4) / PS1/26 Art. 223(4):
# off-BS items at CCF=100% for CRM allocation purposes), so the share
# an exposure receives of a CP collateral pool is proportional to its full
# pre-CCF basis rather than its post-CCF EAD.
# ``_cw_n_all`` is the pool-AGNOSTIC counterparty weight used by the
# market-value reporting carriers only: it drops the ``in_non_airb`` gate and
# shares over the whole counterparty population (``_cp_ead_total``) rather
# than the non-AIRB sub-pool. Dropping the gate while keeping the sub-pool
# denominator would allocate the pledge in full to BOTH pools; sharing on
# ``_cp_ead_total`` keeps it conserved. See ``_sum6_pool_agnostic``.
exposures = exposures.with_columns(
[
pl.when(in_non_airb & (pl.col("_cp_ead_total_non_airb") > 0))
.then(pl.col("ead_for_crm") / pl.col("_cp_ead_total_non_airb"))
.otherwise(pl.lit(0.0))
.alias("_cw_n"),
pl.when(pl.col("_cp_ead_total") > 0)
.then(pl.col("ead_for_crm") / pl.col("_cp_ead_total"))
.otherwise(pl.lit(0.0))
.alias("_cw_n_all"),
pl.when(in_airb & (pl.col("_cp_ead_total_airb") > 0))
.then(pl.col("ead_for_crm") / pl.col("_cp_ead_total_airb"))
.otherwise(pl.lit(0.0))
.alias("_cw_a"),
in_airb.cast(pl.Float64).alias("_airb_match"),
]
)
# --- Combine all levels for EAD + LGD ---
# Non-AIRB-flagged collateral (``_n`` family) flows to non-AIRB-pool
# exposures: facility via the ancestor cascade (``_n_f`` pre-weighted) and
# counterparty via the ``_cw_n`` weight (both gated to that pool); direct
# unflagged is unconditional (1:1, no pro-rata). AIRB-flagged collateral
# (``_a`` family) flows only to AIRB-pool exposures — facility via the
# cascade (``_a_f``), counterparty via ``_cw_a``, and direct gated by
# ``_airb_match``. Direct flagged collateral on a non-AIRB exposure is a
# data-quality issue surfaced as CRM006 by the validation pass.
def _sum6(metric: str) -> pl.Expr:
# Facility terms (``_f``) are already pro-rata-weighted and summed over
# the exposure's ancestor facilities by ``_cascade_facility_collateral``,
# so they enter the blend without a further weight multiply.
return (
pl.col(f"{metric}_n_d")
+ pl.col(f"{metric}_a_d") * pl.col("_airb_match")
+ pl.col(f"{metric}_n_f")
+ pl.col(f"{metric}_a_f")
+ pl.col(f"{metric}_n_c") * pl.col("_cw_n")
+ pl.col(f"{metric}_a_c") * pl.col("_cw_a")
)
# POOL-AGNOSTIC blend, for the market-value reporting carriers only. Same six
# terms and the same flagged (``_a``) weights — flagged collateral is in the
# firm's internal LGD model and still never reaches a non-AIRB exposure — but
# the two UNFLAGGED indirect terms share over the whole population instead of
# the non-AIRB sub-pool: ``_cw_n_all`` at counterparty level, and at facility
# level the ``{m}_n_f`` column, which ``_cascade_facility_collateral`` has
# already pre-weighted with the all-descendants subtree weight for exactly
# these metrics. Direct (``_n_d``) is unconditional on both blends.
# PS1/26 Art. 169A(1)-(2): recognition is an institution-level election, so an
# A-IRB row reports collateral pledged against it whether or not that pledge
# moved the modelled LGD. The Foundation election is applied below.
def _sum6_pool_agnostic(metric: str) -> pl.Expr:
return (
pl.col(f"{metric}_n_d")
+ pl.col(f"{metric}_a_d") * pl.col("_airb_match")
+ pl.col(f"{metric}_n_f")
+ pl.col(f"{metric}_a_f")
+ pl.col(f"{metric}_n_c") * pl.col("_cw_n_all")
+ pl.col(f"{metric}_a_c") * pl.col("_cw_a")
)
combine_exprs = [
_sum6("_cv").alias("collateral_adjusted_value"),
_sum6("_mv").alias("collateral_market_value"),
_sum6("_adj_fin").alias("collateral_financial_value"),
_sum6("_adj_cash").alias("collateral_cash_value"),
_sum6("_adj_re").alias("collateral_re_value"),
_sum6("_adj_rec").alias("collateral_receivables_value"),
_sum6("_adj_oth").alias("collateral_other_physical_value"),
_sum6("_rn").alias("_raw_nf_a"),
]
# RD-5 / PS1/26 Art. 169A(1)-(2): recognition of collateral in LGD estimates is
# an institution-level ELECTION, so the AIRB market-value limb of Annex II cols
# 0180-0210 (RD-1) is only open to an A-IRB row whose modelled LGD actually
# stands. ``_is_airb_pool`` IS ``airb_lgd_preserved_expr`` materialised on the
# frame, so reading it here keeps this gate and the pool definition on one
# expression: an A-IRB row loses the market-value limb exactly when that
# expression says its modelled LGD does not survive — the firm elected the
# Foundation Collateral Method, or Art. 169B insufficient-data drops the row
# back to the supervisory formula. Both cases report through the ``_adj_*``
# twins instead. Non-A-IRB rows (FIRB / SA / slotting) are unaffected.
_mv_limb_open = in_airb | (pl.col("approach") != ApproachType.AIRB.value)
for _mv_metric, (_, _mv_carrier) in market_value_carriers.items():
combine_exprs.append(
pl.when(_mv_limb_open)
.then(_sum6_pool_agnostic(_mv_metric))
.otherwise(pl.lit(0.0))
.alias(_mv_carrier)
)
# Per-category effectively_secured after multi-level combination
for suffix in _wf_suffixes:
combine_exprs.append(_sum6(f"_e{suffix}").alias(f"_eff_{suffix}_a"))
exposures = exposures.with_columns(combine_exprs)
# Per-type minimum collateralisation thresholds (CRR Art. 230)
# Art. 230 requires the threshold to apply per collateral type, not across
# the combined non-financial pool. Each type (real_estate, other_physical)
# must independently meet its 30% threshold to be eligible for LGDS
# reduction. Financial, covered_bond, and receivables have no threshold.
#
# PS1/26 Art. 230(1) replaces the CRR step-function with a continuous LGD*
# formula and removes the C* / C** thresholds entirely — under Basel 3.1
# any positive eligible non-financial collateral is recognised at LGDS.
if resolved_pack.feature("firb_min_collateralisation_threshold_applies"):
_min_thresholds = lookup_float_map(resolved_pack.lookup("min_collateralisation_thresholds"))
_type_threshold: dict[str, tuple[float, str]] = {
"re": (_min_thresholds["real_estate"], "collateral_re_value"),
"op": (
_min_thresholds["other_physical"],
"collateral_other_physical_value",
),
}
nf_threshold_exprs = []
for suffix in _wf_suffixes:
if suffix not in _type_threshold:
continue # No threshold for fin/cb/rec
threshold, raw_col = _type_threshold[suffix]
if threshold <= 0:
continue
col_name = f"_eff_{suffix}_a"
# Art. 230 minimum-collateralisation threshold uses E with CCF=100%
# per Art. 223(4) — the threshold is a fraction of the pre-CCF basis.
nf_threshold_exprs.append(
pl.when(pl.col(raw_col) >= threshold * pl.col("ead_for_crm"))
.then(pl.col(col_name))
.otherwise(pl.lit(0.0))
.alias(col_name)
)
if nf_threshold_exprs:
exposures = exposures.with_columns(nf_threshold_exprs)
# --- Art. 231 sequential fill (waterfall) ---
# Allocate from lowest LGDS to highest. Each category absorbs up to
# min(category_total, remaining_exposure). Uses the cumulative-cap
# trick: es_i = min(cum_through_i, EAD) - min(cum_through_i-1, EAD).
# EAD here is ead_for_crm (CCF=100% basis per Art. 223(4)) — the
# actual post-CCF EAD is recoupled later for SA via effective_ccf.
ead = pl.col("ead_for_crm")
cum = pl.lit(0.0)
es_exprs: list[pl.Expr] = []
for suffix in _wf_suffixes:
prev_cum = cum
cum = cum + pl.col(f"_eff_{suffix}_a")
es_i = pl.min_horizontal(cum, ead) - pl.min_horizontal(prev_cum, ead)
es_exprs.append(es_i.alias(f"_es_{suffix}"))
total_secured_expr = pl.min_horizontal(cum, ead)
# Blended lgd_secured = sum(lgds_i * es_i) / total_secured
# CRR Art. 230 Table 5: subordinated exposures use higher LGDS for the
# secured portion (receivables 65%, RE 65%, other physical 70%).
# Basel 3.1 Art. 230(2) removes the subordinated LGDS column entirely.
_has_seniority = "seniority" in exposure_schema.names()
_build_sub = overcollateralisation_step_function and _has_seniority
lgd_num = pl.lit(0.0)
lgd_num_sub = pl.lit(0.0) if _build_sub else None
for _, lgds_key, suffix in WATERFALL_ORDER:
es_col = pl.col(f"_es_{suffix}")
lgd_num = lgd_num + pl.lit(lgds[lgds_key]) * es_col
if _build_sub:
sub_lgds = lgd_values.get(f"{lgds_key}_subordinated", lgd_values[lgds_key])
lgd_num_sub = lgd_num_sub + pl.lit(sub_lgds) * es_col
if _build_sub:
is_sub = (
pl.col("seniority").fill_null("").str.to_lowercase().is_in(["subordinated", "junior"])
)
lgd_num_final = pl.when(is_sub).then(lgd_num_sub).otherwise(lgd_num)
else:
lgd_num_final = lgd_num
# Compute sequential allocations, then total + lgd_secured
exposures = exposures.with_columns(es_exprs)
exposures = exposures.with_columns(
[
total_secured_expr.alias("total_collateral_for_lgd"),
pl.when(total_secured_expr > 0)
.then(lgd_num_final / total_secured_expr)
.otherwise(pl.lit(lgd_unsecured))
.alias("lgd_secured"),
]
)
# --- Drop intermediate allocation columns ---
# Preserve _es_* columns (renamed to crm_alloc_*) for the A-IRB blended
# LGD floor (Art. 164(4)(c)). These encode the dollar amount of EAD
# absorbed by each collateral category in the Art. 231 waterfall.
drop_cols = (
[f"{c}_{sfx}" for sfx in ["d", "f", "c"] for c in _agg]
+ [
"_cp_ead_total",
"_cp_ead_total_airb",
"_cp_ead_total_non_airb",
"_cw_n",
"_cw_n_all",
"_cw_a",
"_airb_match",
"_is_airb_pool",
"_raw_nf_a",
]
+ [f"_eff_{s}_a" for s in _wf_suffixes]
)
exposures = exposures.drop(drop_cols)
exposures = exposures.rename({f"_es_{s}": CRM_ALLOC_COLUMNS[s] for s in _wf_suffixes})
# --- Apply EAD reduction + determine seniority-based LGDU ---
# Supervisory LGDU for unsecured portion: FSE-aware under Basel 3.1
# (Art. 161(1)(a) vs (aa))
if _has_fse_col:
supervisory_lgdu_expr = (
pl.when(pl.col("cp_is_financial_sector_entity").fill_null(False))
.then(pl.lit(lgd_unsecured_fse))
.otherwise(pl.lit(lgd_unsecured))
)
else:
supervisory_lgdu_expr = pl.lit(lgd_unsecured)
# --- Determine which AIRB exposures use the Foundation formula ---
# Art. 169A/169B (Basel 3.1 only): AIRB exposures may use the Foundation
# Collateral Method formula under two scenarios:
# (1) Foundation election: firm opts for FCM instead of LGD Modelling
# (2) Art. 169B fallback: insufficient data → FCM formula with own LGDU
# Under CRR, AIRB is free-form — own LGD always kept unchanged.
exposure_schema = exposures.collect_schema()
_has_lgd_unsecured_col = "lgd_unsecured" in exposure_schema.names()
schema_names = set(exposure_schema.names())
airb_method = config.airb_collateral_method
is_airb = pl.col("approach") == ApproachType.AIRB.value
# ``_airb_uses_formula`` is the negation of the LGD-preserved condition:
# AIRB rows that fall back to the supervisory formula under Foundation
# election or Art. 169B insufficient-data fallback.
_airb_uses_formula = is_airb & ~airb_lgd_preserved_expr(
config, schema_names, pack=resolved_pack
)
# Art. 169B(2)(c): use firm's own unsecured LGD when LGD-modelling falls back
_airb_own_lgdu = (
airb_collateral_method_applies and airb_method == AIRBCollateralMethod.LGD_MODELLING
)
# Combined condition: FIRB OR qualifying AIRB exposures use the formula
_uses_formula = (pl.col("approach") == ApproachType.FIRB.value) | _airb_uses_formula
# Build per-exposure LGDU expression
# For AIRB Art. 169B: LGDU = own lgd_unsecured (Art. 169B(2)(c))
# For FIRB and AIRB Foundation: LGDU = supervisory value
is_subordinated = pl.col("seniority").str.to_lowercase().is_in(["subordinated", "junior"])
if _airb_own_lgdu and _has_lgd_unsecured_col:
# Art. 169B: AIRB exposures with insufficient data use own lgd_unsecured,
# falling back to lgd_pre_crm if lgd_unsecured not provided.
own_lgdu = pl.coalesce(pl.col("lgd_unsecured"), pl.col("lgd_pre_crm"))
lgdu_expr = (
pl.when(is_subordinated)
.then(pl.lit(lgd_subordinated))
.when(_airb_uses_formula)
.then(own_lgdu)
.otherwise(supervisory_lgdu_expr)
)
else:
lgdu_expr = (
pl.when(is_subordinated).then(pl.lit(lgd_subordinated)).otherwise(supervisory_lgdu_expr)
)
# SA EAD reduction (CRR Art. 228(1) / PS1/26 Art. 228(1)) with the
# CRR Art. 223(5) FCCM exposure-side gross-up:
# E* = max(0, ead_for_crm × (1 + HE) − collateral_adjusted_value)
# EAD = E* × CCF_actual (i.e. × effective_ccf for blended rows)
# The CCF is applied to E*, not to the pre-collateral nominal — this is
# the regulatorily mandated ordering and reverses the previous
# implementation (which netted collateral against post-CCF ead_gross).
# FIRB / Slotting / AIRB keep ead_gross because under those approaches
# collateral modifies LGD (via lgd_post_crm), not EAD.
schema_for_he = exposures.collect_schema().names()
_has_he_col = "exposure_volatility_haircut" in schema_for_he
# E' = ead_for_crm × (1 + HE), shared with the A-IRB LGD input floor blend.
e_for_lgd_star = lgd_star_exposure_basis_expr(has_volatility_haircut=_has_he_col)
exposures = exposures.with_columns(
[
pl.when(pl.col("approach") == ApproachType.SA.value)
.then(
(e_for_lgd_star - pl.col("collateral_adjusted_value")).clip(lower_bound=0)
* pl.col("effective_ccf")
)
.otherwise(pl.col("ead_gross"))
.alias("ead_after_collateral"),
lgdu_expr.alias("lgd_unsecured"),
]
)
# --- Calculate LGD post-CRM + audit ---
# LGD* formula (Art. 230/231) applies to FIRB and qualifying AIRB exposures.
# Non-qualifying AIRB and SA keep lgd_pre_crm.
#
# CRR Art. 223(4) / PS1/26 Art. 223(4): the exposure value E used in the
# LGD* formula is the CCF=100% basis (ead_for_crm) for off-balance-sheet
# items, NOT the post-CCF EAD. For pure on-BS rows ead_for_crm == ead_gross.
#
# PS1/26 Art. 230(1) / CRR Art. 228(2) (P1.272): the exposure basis is
# grossed up by its own volatility haircut HE — E' = E(1 + HE) — so
# LGD* = (LGDS · min(C, E') + LGDU · max(0, E' - C)) / E'.
# HE (exposure_volatility_haircut, Art. 223(5)) is non-zero only for SFT rows
# lending out a debt security, so the HE factor == 1 for every other row and
# E' == E; the SFT-FCCM path is unaffected (it emits E* directly).
# ``e_for_lgd_star`` is built above from ``lgd_star_exposure_basis_expr``.
lgd_star_expr = (
(
pl.col("lgd_secured")
* pl.col("total_collateral_for_lgd").clip(upper_bound=e_for_lgd_star)
)
+ (
pl.col("lgd_unsecured")
* (e_for_lgd_star - pl.col("total_collateral_for_lgd")).clip(lower_bound=0)
)
) / e_for_lgd_star
exposures = exposures.with_columns(
[
pl.when(
_uses_formula
& (pl.col("ead_for_crm") > 0)
& (pl.col("total_collateral_for_lgd") > 0)
)
.then(lgd_star_expr)
.when(_uses_formula & (pl.col("ead_for_crm") > 0))
.then(pl.col("lgd_unsecured"))
.otherwise(pl.col("lgd_pre_crm"))
.alias("lgd_post_crm"),
# collateral_coverage_pct is the C/E ratio used for the Art. 230
# threshold tests, so it also uses ead_for_crm.
pl.when(pl.col("ead_for_crm") > 0)
.then(
pl.col("total_collateral_for_lgd").clip(upper_bound=pl.col("ead_for_crm"))
/ pl.col("ead_for_crm")
* 100
)
.otherwise(pl.lit(0.0))
.alias("collateral_coverage_pct"),
]
)
return exposures
PS1/26 Art. 211 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_apply_collateral_unified — src/rwa_calc/engine/crm/collateral.py:894
@cites("CRR Art. 199")
@cites("CRR Art. 211")
@cites("PS1/26 Art. 199")
@cites("PS1/26 Art. 211")
def _apply_collateral_unified(
exposures: pl.LazyFrame,
adjusted_collateral: pl.LazyFrame,
config: CalculationConfig,
cp_ead_totals: pl.LazyFrame,
*,
pack: ResolvedRulepack | None = None,
errors: list[CalculationError] | None = None,
) -> pl.LazyFrame:
"""
Unified EAD + LGD collateral allocation in a single pass.
Performs a single group_by over all collateral levels (direct, facility,
counterparty) and joins back to exposures for both SA EAD reduction and
F-IRB LGD calculation.
Art. 231 sequential fill: when multiple collateral types secure an
exposure, each type absorbs exposure starting from the lowest LGDS.
The institution receives the most favourable ordering (lowest LGDS first):
financial (0%) -> covered_bond (11.25%) -> receivables -> real_estate
-> other_physical. This replaces the former pro-rata allocation.
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
# S9h: regime branches read honest cited Features off the resolved pack.
# firb_fse_senior_lgd_split → FSE 45/40 split; firb_overcollateralisation_divisor_
# applies → CRR Art. 230(2) subordinated secured-portion LGDS rows (B31 LGD* drops
# them); airb_lgd_collateral_method_applicable → B31 Art. 169A/169B AIRB method.
fse_senior_lgd_split = resolved_pack.feature("firb_fse_senior_lgd_split")
overcollateralisation_step_function = resolved_pack.feature(
"firb_overcollateralisation_divisor_applies"
)
airb_collateral_method_applies = resolved_pack.feature("airb_lgd_collateral_method_applicable")
lgd_values = supervisory_lgd_values(resolved_pack)
lgd_subordinated = subordinated_unsecured_lgd(resolved_pack)
lgd_unsecured = lgd_values["unsecured"]
# LGDS values per waterfall category (Art. 230/231)
lgds = {key: lgd_values[key] for _, key, _ in WATERFALL_ORDER}
# Under Basel 3.1, FSE senior unsecured LGDU = 45% (Art. 161(1)(a));
# non-FSE = 40% (Art. 161(1)(aa)). Under CRR, all = 45%.
exposure_schema = exposures.collect_schema()
_has_fse_col = (
fse_senior_lgd_split and "cp_is_financial_sector_entity" in exposure_schema.names()
)
if _has_fse_col:
lgd_unsecured_fse = lgd_values["unsecured_fse"]
# Defensive: fill in pool-aware columns when callers (typically unit tests)
# construct ead-total frames or exposures without them. Missing pool flag
# → all exposures treated as non-AIRB pool, which matches legacy behaviour
# (unflagged collateral pro-rates over the full population).
if "_is_airb_pool" not in exposure_schema.names():
exposures = exposures.with_columns(pl.lit(False).alias("_is_airb_pool"))
# CRR Art. 223(4) override: ead_for_crm is the CCF=100% basis. Production
# always supplies it via _initialize_ead; direct unit-test callers may
# not, in which case we fall back to ead_gross (correct for pure on-BS
# rows where the two are equal by construction).
if "ead_for_crm" not in exposure_schema.names():
exposures = exposures.with_columns(pl.col("ead_gross").alias("ead_for_crm"))
if "effective_ccf" not in exposure_schema.names():
exposures = exposures.with_columns(pl.lit(1.0).alias("effective_ccf"))
# Facility-ancestor closure for the multi-level facility collateral cascade.
# Production supplies ``ancestor_facilities`` (parent + all ancestors up to
# root, incl. self) from the HierarchyResolver. Direct unit-test callers and
# single-level inputs fall back to the 1-element [parent] list, which makes
# the cascade in ``_cascade_facility_collateral`` reduce exactly to the
# legacy single-level allocation.
if "ancestor_facilities" not in exposure_schema.names():
if "parent_facility_reference" in exposure_schema.names():
exposures = exposures.with_columns(
pl.concat_list("parent_facility_reference").alias("ancestor_facilities")
)
else:
exposures = exposures.with_columns(
pl.lit(None, dtype=pl.List(pl.String)).alias("ancestor_facilities")
)
cp_totals_schema = cp_ead_totals.collect_schema().names()
cp_total_fills: list[pl.Expr] = []
if "_cp_ead_total_non_airb" not in cp_totals_schema:
cp_total_fills.append(pl.col("_cp_ead_total").alias("_cp_ead_total_non_airb"))
if "_cp_ead_total_airb" not in cp_totals_schema:
cp_total_fills.append(pl.lit(0.0).alias("_cp_ead_total_airb"))
if cp_total_fills:
cp_ead_totals = cp_ead_totals.with_columns(cp_total_fills)
collateral_schema = adjusted_collateral.collect_schema()
# --- Determine eligible expression for EAD reduction ---
if "is_eligible_financial_collateral" in collateral_schema:
is_eligible = pl.col("is_eligible_financial_collateral")
else:
is_eligible = ~pl.col("collateral_type").str.to_lowercase().is_in(NON_ELIGIBLE_RE_TYPES)
# --- Annotate collateral with LGD categories (using shared expressions) ---
# Ensure the AIRB-model flag is present (default False) so the pool-aware
# aggregation below can rely on it. Backward-compatible with collateral
# frames built before the column existed.
if "is_airb_model_collateral" in collateral_schema.names():
airb_flag_expr = pl.col("is_airb_model_collateral").fill_null(False)
else:
airb_flag_expr = pl.lit(False)
annotated = adjusted_collateral.with_columns(
[
collateral_lgd_expr(resolved_pack).alias("collateral_lgd"),
overcollateralisation_ratio_expr(resolved_pack).alias("overcollateralisation_ratio"),
is_financial_collateral_type_expr().alias("is_financial_collateral_type"),
collateral_category_expr().alias("_coll_category"),
airb_flag_expr.alias("_is_airb_model_collateral"),
pl.coalesce(
pl.col("value_after_maturity_adj")
if "value_after_maturity_adj" in collateral_schema.names()
else pl.lit(None),
pl.col("value_after_haircut")
if "value_after_haircut" in collateral_schema.names()
else pl.lit(None),
pl.col("market_value"),
).alias("adjusted_value"),
]
)
annotated = annotated.with_columns(
(pl.col("adjusted_value") / pl.col("overcollateralisation_ratio")).alias(
"effectively_secured"
),
)
# CRR/PS1-26 Art. 199(2)/(5)/(6): FIRB Foundation Collateral Method non-
# financial collateral (real estate, receivables, other physical) is
# recognised on the LGD*-substitution path only where the institution ATTESTS
# eligibility via the pre-existing ``is_eligible_irb_collateral`` flag. Default
# False => ineligible — the flag IS the attestation, so the P1.10 new-field
# null-permissive precedent does NOT apply. Art. 199(5): a receivable whose
# ORIGINAL maturity is populated > 1 year is ineligible even if attested
# (explicit data contradicting the attestation wins conservatively); a NULL
# original maturity is PERMISSIVE (recorded deviation — the attestation covers
# the maturity condition, absence doesn't contradict it). Ineligible rows are
# zeroed on ``effectively_secured`` (the Art. 231 waterfall feed) with one
# CRM014 warning each. Scope: FIRB FCM non-financial only — financial
# collateral (Art. 197), SA EAD reduction, and exposure classification are
# untouched. Art. 199(7)/211 (P1.273): a leased asset attested via
# ``is_lease_collateral_attested`` is an alternative attestation route (OR-ed
# below), so a lessor row is recognised without the general IRB flag.
_non_financial = ~pl.col("is_financial_collateral_type")
_attested = (
pl.col("is_eligible_irb_collateral").fill_null(False)
if "is_eligible_irb_collateral" in collateral_schema.names()
else pl.lit(False)
)
# CRR Art. 199(7) with Art. 211 / PS1/26 Art. 199(7) with Art. 211 (P1.273):
# a leased asset supplied as a non-financial collateral row is recognised when
# the lessor attests the lease-specific Art. 211 conditions (b)/(c)/(d). This
# is an INDEPENDENT eligibility route — Art. 211(a) subsumes the Art. 208/210
# property-eligibility that is_eligible_irb_collateral otherwise attests — so it
# is OR-ed into the attestation. Art. 211 concerns leased PROPERTY, so the route
# is scoped to the real_estate / other_physical categories (Art. 208 immovable
# property / Art. 210 other physical): a lease attestation on a receivables row
# confers NO eligibility — it must still carry its own is_eligible_irb_collateral.
# Null -> False (conservative), leaving all existing non-lease collateral untouched.
if "is_lease_collateral_attested" in collateral_schema.names():
_lease_attested = pl.col("is_lease_collateral_attested").fill_null(False) & pl.col(
"_coll_category"
).is_in(["real_estate", "other_physical"])
_attested = _attested | _lease_attested
_not_attested = _non_financial & ~_attested
if "original_maturity_years" in collateral_schema.names():
# NULL original maturity is PERMISSIVE (recorded deviation — the
# attestation covers the maturity condition, absence doesn't contradict
# it), so fill the *Boolean* > 1y test to False rather than the float
# column to 0.0 (the latter would be an anti-conservative float fill).
_receivables_too_long = (pl.col("_coll_category") == "receivables") & (
pl.col("original_maturity_years") > 1.0
).fill_null(False)
else:
_receivables_too_long = pl.lit(False)
if errors is not None:
_record_ineligible_irb_collateral(annotated, _not_attested, _receivables_too_long, errors)
annotated = annotated.with_columns(
pl.when(_not_attested | _receivables_too_long)
.then(pl.lit(0.0))
.otherwise(pl.col("effectively_secured"))
.alias("effectively_secured")
)
# --- Single group_by: EAD + LGD aggregates in one pass, split by AIRB pool ---
# Each metric is split into a non-AIRB-pool variant (suffix ``_n``,
# collateral with is_airb_model_collateral=False) and an AIRB-pool variant
# (suffix ``_a``, collateral with is_airb_model_collateral=True). The two
# variants are pro-rata-allocated against disjoint exposure pools so that
# collateral incorporated in the AIRB internal LGD model never reaches
# non-AIRB exposures (CRR Art. 181 / Basel 3.1 Art. 169A).
val_expr = pl.coalesce(
pl.col("value_after_maturity_adj"),
pl.col("value_after_haircut"),
)
is_fin = pl.col("is_financial_collateral_type")
cat = pl.col("_coll_category")
is_flagged = pl.col("_is_airb_model_collateral")
is_unflagged = ~is_flagged
def _split_aggs(base_alias: str, value: pl.Expr, value_filter: pl.Expr) -> list[pl.Expr]:
return [
value.filter(value_filter & is_unflagged).sum().alias(f"{base_alias}_n"),
value.filter(value_filter & is_flagged).sum().alias(f"{base_alias}_a"),
]
# Build per-category effectively_secured aggregates for Art. 231 waterfall
waterfall_aggs: list[pl.Expr] = []
for cat_values, _lgds_key, suffix in WATERFALL_ORDER:
waterfall_aggs.extend(
_split_aggs(f"_e{suffix}", pl.col("effectively_secured"), cat.is_in(cat_values))
)
# Per-category MARKET-value aggregates, metric -> (category, carrier). These
# mirror the ``_adj_*`` set one-for-one through the same multi-level blend but
# sum the pre-haircut ``market_value``, and are pure reporting carriers —
# nothing in engine/ consumes them.
#
# PS1/26 Annex II col 0190 (likewise 0180/0200/0210): "Where exposures are
# subject to the Foundation Collateral Method … the adjusted value of
# collateral Ci … Where exposures are subject to the AIRB approach, the amount
# to be reported shall be the estimated market value." CRR Annex II cols
# 0150-0210 make the same split on whether own LGD estimates are used. The
# ``_adj_*`` twins serve the Foundation limb; these serve the AIRB limb, which
# the adjusted basis understates wherever a supervisory haircut applies (40%
# on real estate under Basel 3.1, 0% under CRR).
market_value_carriers = {
"_mv_fin": ("financial", "collateral_financial_market_value"),
"_mv_cash": ("cash", "collateral_cash_market_value"),
"_mv_re": ("real_estate", "collateral_re_market_value"),
"_mv_rec": ("receivables", "collateral_receivables_market_value"),
"_mv_oth": ("other_physical", "collateral_other_physical_market_value"),
"_mv_li": ("life_insurance", "collateral_life_insurance_market_value"),
}
market_value_aggs: list[pl.Expr] = []
for metric, (category, _) in market_value_carriers.items():
market_value_aggs.extend(_split_aggs(metric, pl.col("market_value"), cat == category))
all_coll = (
annotated.with_columns(
beneficiary_level_expr().alias("_level"),
)
.group_by(["_level", "beneficiary_reference"])
.agg(
_split_aggs("_cv", val_expr, is_eligible)
+ _split_aggs("_mv", pl.col("market_value"), is_eligible)
+ _split_aggs("_rn", pl.col("adjusted_value"), ~is_fin)
+ _split_aggs("_adj_fin", pl.col("adjusted_value"), cat == "financial")
+ _split_aggs("_adj_cash", pl.col("adjusted_value"), cat == "cash")
+ _split_aggs("_adj_re", pl.col("adjusted_value"), cat == "real_estate")
+ _split_aggs("_adj_rec", pl.col("adjusted_value"), cat == "receivables")
+ _split_aggs("_adj_oth", pl.col("adjusted_value"), cat == "other_physical")
+ market_value_aggs
+ waterfall_aggs
)
)
_wf_suffixes = [suffix for _, _, suffix in WATERFALL_ORDER]
# The market-value metrics allocate on a POOL-AGNOSTIC basis (see
# ``_pool_agnostic_metrics`` below); every other metric keeps the
# pool-gated allocation that drives LGD / EAD.
_pool_agnostic_metrics = list(market_value_carriers)
_metrics = (
[
"_cv",
"_mv",
"_rn",
"_adj_fin",
"_adj_cash",
"_adj_re",
"_adj_rec",
"_adj_oth",
]
+ _pool_agnostic_metrics
+ [f"_e{s}" for s in _wf_suffixes]
)
# Each metric has both _n (non-AIRB pool) and _a (AIRB pool) variants in the
# aggregated frame; the level suffix (_d/_f/_c) is appended on rename below.
_agg = [f"{m}_{p}" for m in _metrics for p in ("n", "a")]
# Split the small aggregated result for per-level joins
coll_direct = (
all_coll.filter(pl.col("_level") == "direct")
.drop("_level")
.rename({c: f"{c}_d" for c in _agg})
)
coll_facility = (
all_coll.filter(pl.col("_level") == "facility")
.drop("_level")
.rename({c: f"{c}_f" for c in _agg})
)
coll_counterparty = (
all_coll.filter(pl.col("_level") == "counterparty")
.drop("_level")
.rename({c: f"{c}_c" for c in _agg})
)
# --- Join direct + counterparty levels to exposures ---
exposures = exposures.join(
coll_direct,
left_on="exposure_reference",
right_on="beneficiary_reference",
how="left",
)
# Facility level: cascade collateral over each exposure's full ancestor set
# so a pledge at any ancestor facility (parent, grandparent, ... root) flows
# pro-rata to every descendant exposure (CRR Art. 230-231 pooling over the
# facility subtree). Produces pre-weighted, ancestor-summed ``{m}_{p}_f``
# columns that ``_sum6`` adds in directly (the pro-rata weight is already
# baked in, so no further ``_fw`` multiply is needed).
exposures = _cascade_facility_collateral(
exposures, coll_facility, _metrics, _pool_agnostic_metrics
)
exposures = exposures.join(
coll_counterparty,
left_on="counterparty_reference",
right_on="beneficiary_reference",
how="left",
).join(
cp_ead_totals,
on="counterparty_reference",
how="left",
)
# --- Fill nulls + counterparty pro-rata weights ---
# Facility ``{c}_f`` columns are already filled + pre-weighted by
# ``_cascade_facility_collateral``; only the direct (``_d``) and
# counterparty (``_c``) families plus the CP EAD totals need filling here.
fill_exprs = []
for sfx in ["d", "c"]:
for c in _agg:
fill_exprs.append(pl.col(f"{c}_{sfx}").fill_null(0.0))
fill_exprs.extend(
[
pl.col("_cp_ead_total").fill_null(0.0),
pl.col("_cp_ead_total_airb").fill_null(0.0),
pl.col("_cp_ead_total_non_airb").fill_null(0.0),
]
)
exposures = exposures.with_columns(fill_exprs)
# Pool-aware counterparty pro-rata weights. ``_is_airb_pool`` was tagged on
# exposures in ``apply_collateral`` via ``airb_lgd_preserved_expr``; weights
# bake in the pool-match gate so non-matching pools always contribute zero.
in_airb = pl.col("_is_airb_pool").fill_null(False)
in_non_airb = ~in_airb
# Pro-rata weights use ead_for_crm (CRR Art. 223(4) / PS1/26 Art. 223(4):
# off-BS items at CCF=100% for CRM allocation purposes), so the share
# an exposure receives of a CP collateral pool is proportional to its full
# pre-CCF basis rather than its post-CCF EAD.
# ``_cw_n_all`` is the pool-AGNOSTIC counterparty weight used by the
# market-value reporting carriers only: it drops the ``in_non_airb`` gate and
# shares over the whole counterparty population (``_cp_ead_total``) rather
# than the non-AIRB sub-pool. Dropping the gate while keeping the sub-pool
# denominator would allocate the pledge in full to BOTH pools; sharing on
# ``_cp_ead_total`` keeps it conserved. See ``_sum6_pool_agnostic``.
exposures = exposures.with_columns(
[
pl.when(in_non_airb & (pl.col("_cp_ead_total_non_airb") > 0))
.then(pl.col("ead_for_crm") / pl.col("_cp_ead_total_non_airb"))
.otherwise(pl.lit(0.0))
.alias("_cw_n"),
pl.when(pl.col("_cp_ead_total") > 0)
.then(pl.col("ead_for_crm") / pl.col("_cp_ead_total"))
.otherwise(pl.lit(0.0))
.alias("_cw_n_all"),
pl.when(in_airb & (pl.col("_cp_ead_total_airb") > 0))
.then(pl.col("ead_for_crm") / pl.col("_cp_ead_total_airb"))
.otherwise(pl.lit(0.0))
.alias("_cw_a"),
in_airb.cast(pl.Float64).alias("_airb_match"),
]
)
# --- Combine all levels for EAD + LGD ---
# Non-AIRB-flagged collateral (``_n`` family) flows to non-AIRB-pool
# exposures: facility via the ancestor cascade (``_n_f`` pre-weighted) and
# counterparty via the ``_cw_n`` weight (both gated to that pool); direct
# unflagged is unconditional (1:1, no pro-rata). AIRB-flagged collateral
# (``_a`` family) flows only to AIRB-pool exposures — facility via the
# cascade (``_a_f``), counterparty via ``_cw_a``, and direct gated by
# ``_airb_match``. Direct flagged collateral on a non-AIRB exposure is a
# data-quality issue surfaced as CRM006 by the validation pass.
def _sum6(metric: str) -> pl.Expr:
# Facility terms (``_f``) are already pro-rata-weighted and summed over
# the exposure's ancestor facilities by ``_cascade_facility_collateral``,
# so they enter the blend without a further weight multiply.
return (
pl.col(f"{metric}_n_d")
+ pl.col(f"{metric}_a_d") * pl.col("_airb_match")
+ pl.col(f"{metric}_n_f")
+ pl.col(f"{metric}_a_f")
+ pl.col(f"{metric}_n_c") * pl.col("_cw_n")
+ pl.col(f"{metric}_a_c") * pl.col("_cw_a")
)
# POOL-AGNOSTIC blend, for the market-value reporting carriers only. Same six
# terms and the same flagged (``_a``) weights — flagged collateral is in the
# firm's internal LGD model and still never reaches a non-AIRB exposure — but
# the two UNFLAGGED indirect terms share over the whole population instead of
# the non-AIRB sub-pool: ``_cw_n_all`` at counterparty level, and at facility
# level the ``{m}_n_f`` column, which ``_cascade_facility_collateral`` has
# already pre-weighted with the all-descendants subtree weight for exactly
# these metrics. Direct (``_n_d``) is unconditional on both blends.
# PS1/26 Art. 169A(1)-(2): recognition is an institution-level election, so an
# A-IRB row reports collateral pledged against it whether or not that pledge
# moved the modelled LGD. The Foundation election is applied below.
def _sum6_pool_agnostic(metric: str) -> pl.Expr:
return (
pl.col(f"{metric}_n_d")
+ pl.col(f"{metric}_a_d") * pl.col("_airb_match")
+ pl.col(f"{metric}_n_f")
+ pl.col(f"{metric}_a_f")
+ pl.col(f"{metric}_n_c") * pl.col("_cw_n_all")
+ pl.col(f"{metric}_a_c") * pl.col("_cw_a")
)
combine_exprs = [
_sum6("_cv").alias("collateral_adjusted_value"),
_sum6("_mv").alias("collateral_market_value"),
_sum6("_adj_fin").alias("collateral_financial_value"),
_sum6("_adj_cash").alias("collateral_cash_value"),
_sum6("_adj_re").alias("collateral_re_value"),
_sum6("_adj_rec").alias("collateral_receivables_value"),
_sum6("_adj_oth").alias("collateral_other_physical_value"),
_sum6("_rn").alias("_raw_nf_a"),
]
# RD-5 / PS1/26 Art. 169A(1)-(2): recognition of collateral in LGD estimates is
# an institution-level ELECTION, so the AIRB market-value limb of Annex II cols
# 0180-0210 (RD-1) is only open to an A-IRB row whose modelled LGD actually
# stands. ``_is_airb_pool`` IS ``airb_lgd_preserved_expr`` materialised on the
# frame, so reading it here keeps this gate and the pool definition on one
# expression: an A-IRB row loses the market-value limb exactly when that
# expression says its modelled LGD does not survive — the firm elected the
# Foundation Collateral Method, or Art. 169B insufficient-data drops the row
# back to the supervisory formula. Both cases report through the ``_adj_*``
# twins instead. Non-A-IRB rows (FIRB / SA / slotting) are unaffected.
_mv_limb_open = in_airb | (pl.col("approach") != ApproachType.AIRB.value)
for _mv_metric, (_, _mv_carrier) in market_value_carriers.items():
combine_exprs.append(
pl.when(_mv_limb_open)
.then(_sum6_pool_agnostic(_mv_metric))
.otherwise(pl.lit(0.0))
.alias(_mv_carrier)
)
# Per-category effectively_secured after multi-level combination
for suffix in _wf_suffixes:
combine_exprs.append(_sum6(f"_e{suffix}").alias(f"_eff_{suffix}_a"))
exposures = exposures.with_columns(combine_exprs)
# Per-type minimum collateralisation thresholds (CRR Art. 230)
# Art. 230 requires the threshold to apply per collateral type, not across
# the combined non-financial pool. Each type (real_estate, other_physical)
# must independently meet its 30% threshold to be eligible for LGDS
# reduction. Financial, covered_bond, and receivables have no threshold.
#
# PS1/26 Art. 230(1) replaces the CRR step-function with a continuous LGD*
# formula and removes the C* / C** thresholds entirely — under Basel 3.1
# any positive eligible non-financial collateral is recognised at LGDS.
if resolved_pack.feature("firb_min_collateralisation_threshold_applies"):
_min_thresholds = lookup_float_map(resolved_pack.lookup("min_collateralisation_thresholds"))
_type_threshold: dict[str, tuple[float, str]] = {
"re": (_min_thresholds["real_estate"], "collateral_re_value"),
"op": (
_min_thresholds["other_physical"],
"collateral_other_physical_value",
),
}
nf_threshold_exprs = []
for suffix in _wf_suffixes:
if suffix not in _type_threshold:
continue # No threshold for fin/cb/rec
threshold, raw_col = _type_threshold[suffix]
if threshold <= 0:
continue
col_name = f"_eff_{suffix}_a"
# Art. 230 minimum-collateralisation threshold uses E with CCF=100%
# per Art. 223(4) — the threshold is a fraction of the pre-CCF basis.
nf_threshold_exprs.append(
pl.when(pl.col(raw_col) >= threshold * pl.col("ead_for_crm"))
.then(pl.col(col_name))
.otherwise(pl.lit(0.0))
.alias(col_name)
)
if nf_threshold_exprs:
exposures = exposures.with_columns(nf_threshold_exprs)
# --- Art. 231 sequential fill (waterfall) ---
# Allocate from lowest LGDS to highest. Each category absorbs up to
# min(category_total, remaining_exposure). Uses the cumulative-cap
# trick: es_i = min(cum_through_i, EAD) - min(cum_through_i-1, EAD).
# EAD here is ead_for_crm (CCF=100% basis per Art. 223(4)) — the
# actual post-CCF EAD is recoupled later for SA via effective_ccf.
ead = pl.col("ead_for_crm")
cum = pl.lit(0.0)
es_exprs: list[pl.Expr] = []
for suffix in _wf_suffixes:
prev_cum = cum
cum = cum + pl.col(f"_eff_{suffix}_a")
es_i = pl.min_horizontal(cum, ead) - pl.min_horizontal(prev_cum, ead)
es_exprs.append(es_i.alias(f"_es_{suffix}"))
total_secured_expr = pl.min_horizontal(cum, ead)
# Blended lgd_secured = sum(lgds_i * es_i) / total_secured
# CRR Art. 230 Table 5: subordinated exposures use higher LGDS for the
# secured portion (receivables 65%, RE 65%, other physical 70%).
# Basel 3.1 Art. 230(2) removes the subordinated LGDS column entirely.
_has_seniority = "seniority" in exposure_schema.names()
_build_sub = overcollateralisation_step_function and _has_seniority
lgd_num = pl.lit(0.0)
lgd_num_sub = pl.lit(0.0) if _build_sub else None
for _, lgds_key, suffix in WATERFALL_ORDER:
es_col = pl.col(f"_es_{suffix}")
lgd_num = lgd_num + pl.lit(lgds[lgds_key]) * es_col
if _build_sub:
sub_lgds = lgd_values.get(f"{lgds_key}_subordinated", lgd_values[lgds_key])
lgd_num_sub = lgd_num_sub + pl.lit(sub_lgds) * es_col
if _build_sub:
is_sub = (
pl.col("seniority").fill_null("").str.to_lowercase().is_in(["subordinated", "junior"])
)
lgd_num_final = pl.when(is_sub).then(lgd_num_sub).otherwise(lgd_num)
else:
lgd_num_final = lgd_num
# Compute sequential allocations, then total + lgd_secured
exposures = exposures.with_columns(es_exprs)
exposures = exposures.with_columns(
[
total_secured_expr.alias("total_collateral_for_lgd"),
pl.when(total_secured_expr > 0)
.then(lgd_num_final / total_secured_expr)
.otherwise(pl.lit(lgd_unsecured))
.alias("lgd_secured"),
]
)
# --- Drop intermediate allocation columns ---
# Preserve _es_* columns (renamed to crm_alloc_*) for the A-IRB blended
# LGD floor (Art. 164(4)(c)). These encode the dollar amount of EAD
# absorbed by each collateral category in the Art. 231 waterfall.
drop_cols = (
[f"{c}_{sfx}" for sfx in ["d", "f", "c"] for c in _agg]
+ [
"_cp_ead_total",
"_cp_ead_total_airb",
"_cp_ead_total_non_airb",
"_cw_n",
"_cw_n_all",
"_cw_a",
"_airb_match",
"_is_airb_pool",
"_raw_nf_a",
]
+ [f"_eff_{s}_a" for s in _wf_suffixes]
)
exposures = exposures.drop(drop_cols)
exposures = exposures.rename({f"_es_{s}": CRM_ALLOC_COLUMNS[s] for s in _wf_suffixes})
# --- Apply EAD reduction + determine seniority-based LGDU ---
# Supervisory LGDU for unsecured portion: FSE-aware under Basel 3.1
# (Art. 161(1)(a) vs (aa))
if _has_fse_col:
supervisory_lgdu_expr = (
pl.when(pl.col("cp_is_financial_sector_entity").fill_null(False))
.then(pl.lit(lgd_unsecured_fse))
.otherwise(pl.lit(lgd_unsecured))
)
else:
supervisory_lgdu_expr = pl.lit(lgd_unsecured)
# --- Determine which AIRB exposures use the Foundation formula ---
# Art. 169A/169B (Basel 3.1 only): AIRB exposures may use the Foundation
# Collateral Method formula under two scenarios:
# (1) Foundation election: firm opts for FCM instead of LGD Modelling
# (2) Art. 169B fallback: insufficient data → FCM formula with own LGDU
# Under CRR, AIRB is free-form — own LGD always kept unchanged.
exposure_schema = exposures.collect_schema()
_has_lgd_unsecured_col = "lgd_unsecured" in exposure_schema.names()
schema_names = set(exposure_schema.names())
airb_method = config.airb_collateral_method
is_airb = pl.col("approach") == ApproachType.AIRB.value
# ``_airb_uses_formula`` is the negation of the LGD-preserved condition:
# AIRB rows that fall back to the supervisory formula under Foundation
# election or Art. 169B insufficient-data fallback.
_airb_uses_formula = is_airb & ~airb_lgd_preserved_expr(
config, schema_names, pack=resolved_pack
)
# Art. 169B(2)(c): use firm's own unsecured LGD when LGD-modelling falls back
_airb_own_lgdu = (
airb_collateral_method_applies and airb_method == AIRBCollateralMethod.LGD_MODELLING
)
# Combined condition: FIRB OR qualifying AIRB exposures use the formula
_uses_formula = (pl.col("approach") == ApproachType.FIRB.value) | _airb_uses_formula
# Build per-exposure LGDU expression
# For AIRB Art. 169B: LGDU = own lgd_unsecured (Art. 169B(2)(c))
# For FIRB and AIRB Foundation: LGDU = supervisory value
is_subordinated = pl.col("seniority").str.to_lowercase().is_in(["subordinated", "junior"])
if _airb_own_lgdu and _has_lgd_unsecured_col:
# Art. 169B: AIRB exposures with insufficient data use own lgd_unsecured,
# falling back to lgd_pre_crm if lgd_unsecured not provided.
own_lgdu = pl.coalesce(pl.col("lgd_unsecured"), pl.col("lgd_pre_crm"))
lgdu_expr = (
pl.when(is_subordinated)
.then(pl.lit(lgd_subordinated))
.when(_airb_uses_formula)
.then(own_lgdu)
.otherwise(supervisory_lgdu_expr)
)
else:
lgdu_expr = (
pl.when(is_subordinated).then(pl.lit(lgd_subordinated)).otherwise(supervisory_lgdu_expr)
)
# SA EAD reduction (CRR Art. 228(1) / PS1/26 Art. 228(1)) with the
# CRR Art. 223(5) FCCM exposure-side gross-up:
# E* = max(0, ead_for_crm × (1 + HE) − collateral_adjusted_value)
# EAD = E* × CCF_actual (i.e. × effective_ccf for blended rows)
# The CCF is applied to E*, not to the pre-collateral nominal — this is
# the regulatorily mandated ordering and reverses the previous
# implementation (which netted collateral against post-CCF ead_gross).
# FIRB / Slotting / AIRB keep ead_gross because under those approaches
# collateral modifies LGD (via lgd_post_crm), not EAD.
schema_for_he = exposures.collect_schema().names()
_has_he_col = "exposure_volatility_haircut" in schema_for_he
# E' = ead_for_crm × (1 + HE), shared with the A-IRB LGD input floor blend.
e_for_lgd_star = lgd_star_exposure_basis_expr(has_volatility_haircut=_has_he_col)
exposures = exposures.with_columns(
[
pl.when(pl.col("approach") == ApproachType.SA.value)
.then(
(e_for_lgd_star - pl.col("collateral_adjusted_value")).clip(lower_bound=0)
* pl.col("effective_ccf")
)
.otherwise(pl.col("ead_gross"))
.alias("ead_after_collateral"),
lgdu_expr.alias("lgd_unsecured"),
]
)
# --- Calculate LGD post-CRM + audit ---
# LGD* formula (Art. 230/231) applies to FIRB and qualifying AIRB exposures.
# Non-qualifying AIRB and SA keep lgd_pre_crm.
#
# CRR Art. 223(4) / PS1/26 Art. 223(4): the exposure value E used in the
# LGD* formula is the CCF=100% basis (ead_for_crm) for off-balance-sheet
# items, NOT the post-CCF EAD. For pure on-BS rows ead_for_crm == ead_gross.
#
# PS1/26 Art. 230(1) / CRR Art. 228(2) (P1.272): the exposure basis is
# grossed up by its own volatility haircut HE — E' = E(1 + HE) — so
# LGD* = (LGDS · min(C, E') + LGDU · max(0, E' - C)) / E'.
# HE (exposure_volatility_haircut, Art. 223(5)) is non-zero only for SFT rows
# lending out a debt security, so the HE factor == 1 for every other row and
# E' == E; the SFT-FCCM path is unaffected (it emits E* directly).
# ``e_for_lgd_star`` is built above from ``lgd_star_exposure_basis_expr``.
lgd_star_expr = (
(
pl.col("lgd_secured")
* pl.col("total_collateral_for_lgd").clip(upper_bound=e_for_lgd_star)
)
+ (
pl.col("lgd_unsecured")
* (e_for_lgd_star - pl.col("total_collateral_for_lgd")).clip(lower_bound=0)
)
) / e_for_lgd_star
exposures = exposures.with_columns(
[
pl.when(
_uses_formula
& (pl.col("ead_for_crm") > 0)
& (pl.col("total_collateral_for_lgd") > 0)
)
.then(lgd_star_expr)
.when(_uses_formula & (pl.col("ead_for_crm") > 0))
.then(pl.col("lgd_unsecured"))
.otherwise(pl.col("lgd_pre_crm"))
.alias("lgd_post_crm"),
# collateral_coverage_pct is the C/E ratio used for the Art. 230
# threshold tests, so it also uses ead_for_crm.
pl.when(pl.col("ead_for_crm") > 0)
.then(
pl.col("total_collateral_for_lgd").clip(upper_bound=pl.col("ead_for_crm"))
/ pl.col("ead_for_crm")
* 100
)
.otherwise(pl.lit(0.0))
.alias("collateral_coverage_pct"),
]
)
return exposures
PS1/26 Art. 230 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
apply_collateral — src/rwa_calc/engine/crm/collateral.py:428
@cites("PS1/26 Art. 230(2)")
@cites("PS1/26 Art. 230(1)")
@cites("CRR Art. 223")
@cites("CRR Art. 230")
def apply_collateral(
exposures: pl.LazyFrame,
collateral: pl.LazyFrame,
config: CalculationConfig,
haircut_calculator: HaircutCalculator,
build_exposure_lookups_fn: Callable,
join_collateral_to_lookups_fn: Callable,
resolve_pledge_from_joined_fn: Callable,
*,
pack: ResolvedRulepack | None = None,
errors: list[CalculationError] | None = None,
) -> pl.LazyFrame:
"""
Apply collateral to reduce EAD (SA) or LGD (IRB).
Pre-computes shared exposure lookups once, then joins ALL lookup columns
(EAD, currency, maturity) in a single pass of 3 joins. Pledge resolution
and currency/maturity derivation operate on pre-joined columns — no
additional joins needed.
Args:
exposures: Exposures with ead_gross
collateral: Collateral data
config: Calculation configuration
haircut_calculator: HaircutCalculator instance
build_exposure_lookups_fn: Function to build exposure lookups
join_collateral_to_lookups_fn: Function to join collateral to lookups
resolve_pledge_from_joined_fn: Function to resolve pledge percentages
Returns:
Exposures with collateral effects applied
"""
# Tag each exposure with its AIRB-pool membership so downstream pro-rata
# bases can be split into AIRB and non-AIRB pools. CRR Art. 181 / Basel 3.1
# Art. 169A: AIRB own LGD already reflects collateral, so collateral
# incorporated in the model must not also be allocated to non-AIRB
# exposures of the same counterparty.
schema_names = set(exposures.collect_schema().names())
# Graceful fallback for direct unit-test callers that hand-build the
# exposures frame without going through _initialize_ead. In production
# both columns are always present. For pure on-BS rows the defaults
# produce identical behaviour to the explicit columns, so existing
# tests stay green without modification.
fallback_cols: list[pl.Expr] = []
if "ead_for_crm" not in schema_names:
fallback_cols.append(pl.col("ead_gross").alias("ead_for_crm"))
if "effective_ccf" not in schema_names:
fallback_cols.append(pl.lit(1.0).alias("effective_ccf"))
if fallback_cols:
exposures = exposures.with_columns(fallback_cols)
schema_names |= {expr.meta.output_name() for expr in fallback_cols}
# S9h: resolve the pack once; the collateral-LGD regime branches downstream
# (haircut maturity bands, AIRB pool membership, FSE split, Art. 230(2) sub-rows)
# read honest cited Features off it instead of a single config.is_basel_3_1 bool.
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
# CRR Art. 223(5) FCCM exposure volatility haircut (HE). Computed once on
# the exposure frame so the SA branch in ``_apply_collateral_unified`` can
# gross E by (1 + HE). Non-SFT / cash / standard-loan rows yield HE = 0.
exposures = haircut_calculator.apply_exposure_haircut(
exposures,
resolved_pack.feature("collateral_haircut_maturity_bands_revised"),
pack=resolved_pack,
)
exposures = exposures.with_columns(
airb_lgd_preserved_expr(config, schema_names, pack=resolved_pack).alias("_is_airb_pool")
)
# Pre-compute shared exposure lookups once
direct_lookup, facility_lookup, cp_lookup = build_exposure_lookups_fn(exposures)
# Materialise the small lookup frames in parallel to prevent plan-tree
# duplication. Each lookup is referenced in multiple downstream joins;
# without this, Polars re-evaluates the group_by/select at each reference.
# collect_all runs all 3 concurrently and enables CSE on shared upstream.
direct_df, facility_df, cp_df = pl.collect_all([direct_lookup, facility_lookup, cp_lookup])
direct_lookup = direct_df.lazy()
facility_lookup = facility_df.lazy()
cp_lookup = cp_df.lazy()
# Derive pool-aware counterparty EAD totals from the lookups. Unflagged
# collateral pro-rates over the non-AIRB pool only; flagged collateral
# (is_airb_model_collateral=True) pro-rates over the AIRB pool only.
# Facility-level subtree totals are derived per-ancestor inside
# ``_apply_collateral_unified`` (``_cascade_facility_collateral``) so that
# collateral pledged at any ancestor facility cascades over its whole
# descendant subtree for nested facility hierarchies.
cp_ead_totals = cp_lookup.select(
pl.col("_ben_ref_cp").alias("counterparty_reference"),
pl.col("_ead_cp").alias("_cp_ead_total"),
pl.col("_ead_cp_airb").alias("_cp_ead_total_airb"),
pl.col("_ead_cp_non_airb").alias("_cp_ead_total_non_airb"),
)
# Single pass: join all lookup columns (EAD, currency, maturity)
collateral = join_collateral_to_lookups_fn(
collateral, direct_lookup, facility_lookup, cp_lookup
)
# Resolve pledge_percentage → market_value (uses pre-joined _beneficiary_ead)
collateral = resolve_pledge_from_joined_fn(collateral)
# Apply haircuts to collateral (no longer needs exposures)
adjusted_collateral = haircut_calculator.apply_haircuts(collateral, config, pack=pack)
# CRR/PS1-26 Art. 197(1)(f)/198(1)(a) (P1.271): apply_haircuts has already
# zeroed non-main-index / non-listed equity collateral and cleared its
# eligibility flag; record one CRM018 warning per gated row.
if errors is not None:
_record_non_main_index_equity_ineligible(adjusted_collateral, errors)
# CRR/PS1-26 Art. 218 (P1.274): apply_haircuts has already zeroed a
# credit-linked note that is not attested own-issued; record one CRM019
# warning per gated row.
_record_credit_linked_note_not_own_issued(adjusted_collateral, errors)
# Apply maturity mismatch using actual exposure maturity (Art. 238)
adjusted_collateral = haircut_calculator.apply_maturity_mismatch(adjusted_collateral, config)
# Opt-in audit cache: persist the per-collateral haircut frame for inspection.
# No-op unless config.audit_cache_dir is set. Surfaces fx_haircut /
# collateral_haircut / value_after_haircut / value_after_maturity_adj — the
# diagnostic columns users need to confirm whether H_fx is firing on a row.
sink_audit(adjusted_collateral, config, "collateral_haircuts")
return _apply_collateral_unified(
exposures,
adjusted_collateral,
config,
cp_ead_totals,
pack=resolved_pack,
errors=errors,
)
apply_collateral — src/rwa_calc/engine/crm/collateral.py:429
@cites("PS1/26 Art. 230(2)")
@cites("PS1/26 Art. 230(1)")
@cites("CRR Art. 223")
@cites("CRR Art. 230")
def apply_collateral(
exposures: pl.LazyFrame,
collateral: pl.LazyFrame,
config: CalculationConfig,
haircut_calculator: HaircutCalculator,
build_exposure_lookups_fn: Callable,
join_collateral_to_lookups_fn: Callable,
resolve_pledge_from_joined_fn: Callable,
*,
pack: ResolvedRulepack | None = None,
errors: list[CalculationError] | None = None,
) -> pl.LazyFrame:
"""
Apply collateral to reduce EAD (SA) or LGD (IRB).
Pre-computes shared exposure lookups once, then joins ALL lookup columns
(EAD, currency, maturity) in a single pass of 3 joins. Pledge resolution
and currency/maturity derivation operate on pre-joined columns — no
additional joins needed.
Args:
exposures: Exposures with ead_gross
collateral: Collateral data
config: Calculation configuration
haircut_calculator: HaircutCalculator instance
build_exposure_lookups_fn: Function to build exposure lookups
join_collateral_to_lookups_fn: Function to join collateral to lookups
resolve_pledge_from_joined_fn: Function to resolve pledge percentages
Returns:
Exposures with collateral effects applied
"""
# Tag each exposure with its AIRB-pool membership so downstream pro-rata
# bases can be split into AIRB and non-AIRB pools. CRR Art. 181 / Basel 3.1
# Art. 169A: AIRB own LGD already reflects collateral, so collateral
# incorporated in the model must not also be allocated to non-AIRB
# exposures of the same counterparty.
schema_names = set(exposures.collect_schema().names())
# Graceful fallback for direct unit-test callers that hand-build the
# exposures frame without going through _initialize_ead. In production
# both columns are always present. For pure on-BS rows the defaults
# produce identical behaviour to the explicit columns, so existing
# tests stay green without modification.
fallback_cols: list[pl.Expr] = []
if "ead_for_crm" not in schema_names:
fallback_cols.append(pl.col("ead_gross").alias("ead_for_crm"))
if "effective_ccf" not in schema_names:
fallback_cols.append(pl.lit(1.0).alias("effective_ccf"))
if fallback_cols:
exposures = exposures.with_columns(fallback_cols)
schema_names |= {expr.meta.output_name() for expr in fallback_cols}
# S9h: resolve the pack once; the collateral-LGD regime branches downstream
# (haircut maturity bands, AIRB pool membership, FSE split, Art. 230(2) sub-rows)
# read honest cited Features off it instead of a single config.is_basel_3_1 bool.
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
# CRR Art. 223(5) FCCM exposure volatility haircut (HE). Computed once on
# the exposure frame so the SA branch in ``_apply_collateral_unified`` can
# gross E by (1 + HE). Non-SFT / cash / standard-loan rows yield HE = 0.
exposures = haircut_calculator.apply_exposure_haircut(
exposures,
resolved_pack.feature("collateral_haircut_maturity_bands_revised"),
pack=resolved_pack,
)
exposures = exposures.with_columns(
airb_lgd_preserved_expr(config, schema_names, pack=resolved_pack).alias("_is_airb_pool")
)
# Pre-compute shared exposure lookups once
direct_lookup, facility_lookup, cp_lookup = build_exposure_lookups_fn(exposures)
# Materialise the small lookup frames in parallel to prevent plan-tree
# duplication. Each lookup is referenced in multiple downstream joins;
# without this, Polars re-evaluates the group_by/select at each reference.
# collect_all runs all 3 concurrently and enables CSE on shared upstream.
direct_df, facility_df, cp_df = pl.collect_all([direct_lookup, facility_lookup, cp_lookup])
direct_lookup = direct_df.lazy()
facility_lookup = facility_df.lazy()
cp_lookup = cp_df.lazy()
# Derive pool-aware counterparty EAD totals from the lookups. Unflagged
# collateral pro-rates over the non-AIRB pool only; flagged collateral
# (is_airb_model_collateral=True) pro-rates over the AIRB pool only.
# Facility-level subtree totals are derived per-ancestor inside
# ``_apply_collateral_unified`` (``_cascade_facility_collateral``) so that
# collateral pledged at any ancestor facility cascades over its whole
# descendant subtree for nested facility hierarchies.
cp_ead_totals = cp_lookup.select(
pl.col("_ben_ref_cp").alias("counterparty_reference"),
pl.col("_ead_cp").alias("_cp_ead_total"),
pl.col("_ead_cp_airb").alias("_cp_ead_total_airb"),
pl.col("_ead_cp_non_airb").alias("_cp_ead_total_non_airb"),
)
# Single pass: join all lookup columns (EAD, currency, maturity)
collateral = join_collateral_to_lookups_fn(
collateral, direct_lookup, facility_lookup, cp_lookup
)
# Resolve pledge_percentage → market_value (uses pre-joined _beneficiary_ead)
collateral = resolve_pledge_from_joined_fn(collateral)
# Apply haircuts to collateral (no longer needs exposures)
adjusted_collateral = haircut_calculator.apply_haircuts(collateral, config, pack=pack)
# CRR/PS1-26 Art. 197(1)(f)/198(1)(a) (P1.271): apply_haircuts has already
# zeroed non-main-index / non-listed equity collateral and cleared its
# eligibility flag; record one CRM018 warning per gated row.
if errors is not None:
_record_non_main_index_equity_ineligible(adjusted_collateral, errors)
# CRR/PS1-26 Art. 218 (P1.274): apply_haircuts has already zeroed a
# credit-linked note that is not attested own-issued; record one CRM019
# warning per gated row.
_record_credit_linked_note_not_own_issued(adjusted_collateral, errors)
# Apply maturity mismatch using actual exposure maturity (Art. 238)
adjusted_collateral = haircut_calculator.apply_maturity_mismatch(adjusted_collateral, config)
# Opt-in audit cache: persist the per-collateral haircut frame for inspection.
# No-op unless config.audit_cache_dir is set. Surfaces fx_haircut /
# collateral_haircut / value_after_haircut / value_after_maturity_adj — the
# diagnostic columns users need to confirm whether H_fx is firing on a row.
sink_audit(adjusted_collateral, config, "collateral_haircuts")
return _apply_collateral_unified(
exposures,
adjusted_collateral,
config,
cp_ead_totals,
pack=resolved_pack,
errors=errors,
)
lgd_star_exposure_basis_expr — src/rwa_calc/engine/crm/expressions.py:107
@cites("CRR Art. 223(4)")
@cites("PS1/26 Art. 230(1)")
def lgd_star_exposure_basis_expr(*, has_volatility_haircut: bool = True) -> pl.Expr:
"""The Art. 230(1) exposure basis E' = E x (1 + HE) that LGD* divides by.
``E`` is ``ead_for_crm``, the CCF=100% exposure value (CRR Art. 223(4) /
PS1/26 Art. 223(4)) — NOT the post-CCF ``ead_gross``: an off-balance-sheet
item enters credit risk mitigation at 100% of nominal, so the collateral
shares that weight the LGD* blend are shares of the pre-CCF basis. ``HE``
is the exposure's own volatility haircut (Art. 223(5)), non-zero only where
the row lends out a debt security, so E' == E on every other row.
The single home for this quantity: the F-IRB / A-IRB LGD* formula and the
Art. 161(5)(b) / 164(4)(c) A-IRB LGD *input floor* blend must divide by the
same basis (``engine/crm/collateral.py``, ``engine/irb/formulas.py``).
Args:
has_volatility_haircut: False where the caller's frame predates the
``exposure_volatility_haircut`` column (pre-seal CRM inputs built
by direct unit-test callers), which is equivalent to HE = 0.
"""
if not has_volatility_haircut:
return pl.col("ead_for_crm")
he_factor = pl.lit(1.0) + pl.col("exposure_volatility_haircut").fill_null(0.0)
return pl.col("ead_for_crm") * he_factor
overcollateralisation_ratio_expr — src/rwa_calc/engine/crm/expressions.py:159
@cites("PS1/26 Art. 230(1)")
def overcollateralisation_ratio_expr(pack: ResolvedRulepack) -> pl.Expr:
"""Build expression mapping collateral_type to overcollateralisation ratio.
CRR Art. 230 (Table 5) requires explicit overcollateralisation divisors for
non-financial collateral (RE/other physical 1.4x, receivables 1.25x).
PS1/26 Art. 230(1) replaces the CRR step-function with a continuous LGD*
formula in which the haircut HC is applied multiplicatively at the haircut
stage; no overcollateralisation divisor is applied for non-financial
collateral under Basel 3.1. Whether the divisor applies is the regime
Feature ``firb_overcollateralisation_divisor_applies``; the ratios
themselves are the regime-invariant ``overcollateralisation_ratios`` lookup.
"""
if not pack.feature("firb_overcollateralisation_divisor_applies"):
# PS1/26 Art. 230(1): FCM HC is applied multiplicatively, no
# overcollateralisation divisor — the ratio is 1.0 for every type.
return pl.lit(1.0)
ratios = lookup_float_map(pack.lookup("overcollateralisation_ratios"))
ct = _coll_type_lower()
return (
pl.when(ct.is_in(LIFE_INSURANCE_COLLATERAL_TYPES))
.then(pl.lit(ratios["life_insurance"]))
.when(ct.is_in(FINANCIAL_COLLATERAL_TYPES))
.then(pl.lit(ratios["financial"]))
.when(ct.is_in(RECEIVABLE_COLLATERAL_TYPES))
.then(pl.lit(ratios["receivables"]))
.when(ct.is_in(REAL_ESTATE_COLLATERAL_TYPES))
.then(pl.lit(ratios["real_estate"]))
.when(ct.is_in(OTHER_PHYSICAL_COLLATERAL_TYPES))
.then(pl.lit(ratios["other_physical"]))
.otherwise(pl.lit(1.0))
)