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-06-21.
CRR (Capital Requirements Regulation)¶
CRR Art. 111 — Exposure value¶
build_product_to_risk_type_expr — src/rwa_calc/engine/ccf.py:120
@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:176
@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:286
@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:418
@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% SA CCF under Basel 3.1, except where the otherwise-resolved
CCF is 10% (Row 6 UCC) or 100% (Row 2) — the Row 4(b) carve-out.
"""
# 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: under CRR, "other commitments" mapped
# to MR (50%, >1yr) or MLR (20%, <=1yr). The sa_ccf_expression gives OC 50%
# as the conservative default; override to 20% when remaining maturity <= 1yr.
if not is_b31:
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()
if "maturity_date" in schema_names:
is_short_maturity = pl.col("maturity_date").is_not_null() & (
(
pl.col("maturity_date").cast(pl.Date) - pl.lit(config.reporting_date)
).dt.total_days()
<= _OC_SHORT_MATURITY_THRESHOLD_DAYS
)
exposures = exposures.with_columns(
pl.when(is_oc & is_short_maturity)
.then(pl.lit(_OC_SHORT_MATURITY_CCF))
.otherwise(pl.col("_sa_ccf_from_risk_type"))
.alias("_sa_ccf_from_risk_type"),
)
# 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%". When
# the flag is set under Basel 3.1, override the otherwise-resolved SA CCF
# to the MR / Row 4(b) rate (50%), unless that CCF is already 10% (Row 6
# UCC) or 100% (Row 2), in which case the carve-out leaves it untouched.
# No effect under CRR (Table A1 is Basel 3.1 only) — see the gate below.
if is_b31:
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)
exposures = 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"),
)
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:
airb_ccf = ccf_modelled_expr.fill_null(pl.col("_sa_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"),
)
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¶
apply_risk_weights — src/rwa_calc/engine/sa/risk_weights.py:307
@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 = _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, config)
else:
exposures = _apply_crr_risk_weight_overrides(exposures, uc, is_domestic_currency)
# 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:104
@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)
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))
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"),
)
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")
def build_domestic_cgcb_guarantor_expr(
country_col: str,
currency_col: str | pl.Expr,
) -> 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.
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.
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.
"""
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)
return is_uk_domestic | is_eu_domestic
build_guarantor_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:127
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 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%
MDB Table 2B (Art. 117(1))
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) when True, CRR tables when False. PSE / RGLA / MDB /
IO / CCP values are framework-identical.
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 — Table 2B (Art. 117(1)).
.when(gec == "mdb")
.then(
_cqs_table_lookup_expr(
cqs_col,
_MDB_RW,
float(_MDB_UNRATED_RW),
)
)
# 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:296
@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. All other preview branches are
framework-identical by construction (see corporate note above).
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 (Art. 117(1)).
.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. 115 — Exposures to regional governments or local authorities¶
_create_rgla_df — src/rwa_calc/engine/sa/crr_risk_weight_tables.py:277
@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:128
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 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%
MDB Table 2B (Art. 117(1))
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) when True, CRR tables when False. PSE / RGLA / MDB /
IO / CCP values are framework-identical.
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 — Table 2B (Art. 117(1)).
.when(gec == "mdb")
.then(
_cqs_table_lookup_expr(
cqs_col,
_MDB_RW,
float(_MDB_UNRATED_RW),
)
)
# 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:297
@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. All other preview branches are
framework-identical by construction (see corporate note above).
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 (Art. 117(1)).
.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. 116 — Exposures to public sector entities¶
_create_pse_df — src/rwa_calc/engine/sa/crr_risk_weight_tables.py:234
@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:129
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 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%
MDB Table 2B (Art. 117(1))
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) when True, CRR tables when False. PSE / RGLA / MDB /
IO / CCP values are framework-identical.
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 — Table 2B (Art. 117(1)).
.when(gec == "mdb")
.then(
_cqs_table_lookup_expr(
cqs_col,
_MDB_RW,
float(_MDB_UNRATED_RW),
)
)
# 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:298
@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. All other preview branches are
framework-identical by construction (see corporate note above).
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 (Art. 117(1)).
.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. 117 — Exposures to multilateral development banks¶
_create_mdb_df — src/rwa_calc/engine/sa/crr_risk_weight_tables.py:315
@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:130
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 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%
MDB Table 2B (Art. 117(1))
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) when True, CRR tables when False. PSE / RGLA / MDB /
IO / CCP values are framework-identical.
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 — Table 2B (Art. 117(1)).
.when(gec == "mdb")
.then(
_cqs_table_lookup_expr(
cqs_col,
_MDB_RW,
float(_MDB_UNRATED_RW),
)
)
# 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:299
@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. All other preview branches are
framework-identical by construction (see corporate note above).
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 (Art. 117(1)).
.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:339
@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:131
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 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%
MDB Table 2B (Art. 117(1))
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) when True, CRR tables when False. PSE / RGLA / MDB /
IO / CCP values are framework-identical.
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 — Table 2B (Art. 117(1)).
.when(gec == "mdb")
.then(
_cqs_table_lookup_expr(
cqs_col,
_MDB_RW,
float(_MDB_UNRATED_RW),
)
)
# 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:300
@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. All other preview branches are
framework-identical by construction (see corporate note above).
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 (Art. 117(1)).
.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:433
@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:434
@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:435
@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. 122 — Exposures to corporates¶
_compute_guarantor_rw_sa — src/rwa_calc/engine/irb/guarantee.py:200
@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 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).
_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)
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:301
@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. All other preview branches are
framework-identical by construction (see corporate note above).
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 (Art. 117(1)).
.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:522
@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¶
build_entity_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:302
@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. All other preview branches are
framework-identical by construction (see corporate note above).
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 (Art. 117(1)).
.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:722
@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:538
@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:756
@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:171
@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¶
split — src/rwa_calc/engine/stages/re_split/splitter.py:172
@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¶
_apply_defaulted_risk_weight — src/rwa_calc/engine/sa/risk_weights.py:1448
@cites("CRR Art. 127")
@cites("PS1/26, paragraph 127")
def _apply_defaulted_risk_weight(
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Apply Art. 127 defaulted risk weight to the full post-CRM exposure.
PS1/26 Art. 127(1)-(2) assign 100% or 150% to the part of a defaulted
exposure that is not secured by recognised collateral or covered by
recognised unfunded credit protection, where the unsecured part is
determined by the CRM method the institution applies (Art. 191A(2)).
Under the Financial Collateral Comprehensive Method (the default for
SA), eligible financial collateral has already reduced ``ead_final``
in the CRM stage and eligible residential/commercial real estate has
been routed via class reclassification — so ``ead_final`` already
represents the unsecured value and Art. 127(1) applies to it flat.
FCSM (Simple Method) is handled downstream by
``apply_fcsm_rw_substitution``, which blends the defaulted RW with
the collateral RW per the substitution rule.
Basel 3.1 Art. 127(3) / CRE20.88 exception: a residential RE default
that is not materially dependent on cash-flows of the property is
assigned 100% flat, regardless of provisions.
References:
- PS1/26 Art. 127(1): unsecured part 100%/150% by provision coverage
- PS1/26 Art. 127(2): unsecured part determined by the CRM method
- PS1/26 Art. 127(3) / CRE20.88: RESI RE non-income flat 100%
- CRR Art. 127(1)-(2): CRR predecessor (pre-provision denominator)
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
_uc = pl.col("exposure_class").fill_null("").str.to_uppercase()
ead = pl.col("ead_final")
if resolved_pack.feature("sa_revised_defaulted_treatment"):
# B31 RESI RE non-income-dependent: 100% flat (Art. 127(3) / CRE20.88).
is_resi_re_non_income = (
_is_residential_re_class(_uc)
& ~_is_commercial_re_class(_uc)
& ~pl.col("has_income_cover").fill_null(False)
)
# PS1/26 Art. 127(1): denominator is "the outstanding amount of the
# item or facility" — gross outstanding (pre-CRM, pre-provision).
# Reconstruct from ead_gross (post-CCF, post-provision, pre-CRM) plus
# provision_deducted.
gross_outstanding = pl.col("ead_gross") + pl.col("provision_deducted")
provision_rw = (
pl.when(
pl.col("provision_allocated")
>= _SA_B31_RW["defaulted_threshold"] * gross_outstanding
)
.then(pl.lit(_SA_B31_RW["defaulted_high"]))
.otherwise(pl.lit(_SA_B31_RW["defaulted_low"]))
)
defaulted_rw = (
pl.when(is_resi_re_non_income)
.then(pl.lit(_SA_B31_RW["defaulted_resi_re_non_income"]))
.otherwise(provision_rw)
)
else:
# CRR Art. 127(1): denominator is the pre-provision exposure value
# (ead_final is post-provision, so add provision_deducted back).
unsecured_pre_prov = ead + pl.col("provision_deducted")
defaulted_rw = (
pl.when(
pl.col("provision_allocated")
>= _SA_CRR_RW["defaulted_threshold"] * unsecured_pre_prov
)
.then(pl.lit(_SA_CRR_RW["defaulted_high"]))
.otherwise(pl.lit(_SA_CRR_RW["defaulted_low"]))
)
# Art. 128 (HIGH_RISK) takes precedence per Table A2 priority 4 > 5
is_defaulted = pl.col("is_defaulted").fill_null(False) & (_uc != "HIGH_RISK")
return exposures.with_columns(
pl.when(is_defaulted)
.then(defaulted_rw)
.otherwise(pl.col("risk_weight"))
.alias("risk_weight")
)
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¶
_create_covered_bond_df — src/rwa_calc/engine/sa/crr_risk_weight_tables.py:538
_crr_unrated_cb_rw_expr — src/rwa_calc/engine/sa/risk_weights.py:438
@cites("CRR Art. 129")
def _crr_unrated_cb_rw_expr() -> pl.Expr:
"""Build Polars expression for CRR Art. 129(5) unrated covered bond RW derivation.
Derives covered bond RW from the issuing institution's CQS via two-step lookup:
1. Institution CQS → institution RW (Art. 120 Table 3)
2. Institution RW → covered bond RW (Art. 129(5) derivation table)
When ``cp_institution_cqs`` is null (institution itself is unrated), uses
Art. 121 fallback institution RW (100%) → CB 50%.
References:
CRR Art. 120 Table 3: Institution risk weights (CQS 2 = 50%)
CRR Art. 129(5): Unrated covered bond derivation from institution RW
"""
inst_table = INSTITUTION_RISK_WEIGHTS_CRR
# Pre-compute CQS → CB RW by chaining institution RW through the derivation table.
# CRR Art. 129(5) admits only four sub-paragraphs (a)-(d); use the CRR-specific
# 4-key dict so (b) maps 0.50 -> 0.20, not the B31 value 0.25.
cqs_to_cb_rw: dict[int, float] = {}
for cqs_val in [CQS.CQS1, CQS.CQS2, CQS.CQS3, CQS.CQS4, CQS.CQS5, CQS.CQS6]:
inst_rw = inst_table[cqs_val]
cb_rw = COVERED_BOND_UNRATED_DERIVATION_CRR[inst_rw]
cqs_to_cb_rw[int(cqs_val)] = float(cb_rw)
# Unrated institution: sovereign-derived
unrated_inst_rw = inst_table[CQS.UNRATED]
unrated_cb_rw = float(COVERED_BOND_UNRATED_DERIVATION_CRR[unrated_inst_rw])
# Build when/then chain from cp_institution_cqs
expr = pl.when(pl.col("cp_institution_cqs") == 1).then(pl.lit(cqs_to_cb_rw[1]))
for cqs_int in [2, 3, 4, 5, 6]:
expr = expr.when(pl.col("cp_institution_cqs") == cqs_int).then(
pl.lit(cqs_to_cb_rw[cqs_int])
)
# Fallback: cp_institution_cqs is null (unrated institution) or unexpected value
return expr.otherwise(pl.lit(unrated_cb_rw))
_b31_unrated_cb_rw_expr — src/rwa_calc/engine/sa/risk_weights.py:478
@cites("CRR Art. 129")
@cites("PS1/26, paragraph 129")
def _b31_unrated_cb_rw_expr() -> pl.Expr:
"""Build Polars expression for B31 Art. 129(5) unrated covered bond RW derivation.
Derives covered bond RW from the issuing institution's senior unsecured RW,
which can come from either source:
1. ECRA (rated institution): cp_institution_cqs → institution RW → CB RW
2. SCRA (unrated institution): cp_scra_grade → CB RW
Art. 129(5) operates on the resulting institution RW regardless of source
(ECRA or SCRA). The ECRA path is checked first; if cp_institution_cqs is
null, falls back to the SCRA path.
References:
PRA PS1/26 Art. 120 Table 3 ECRA: Institution risk weights (CQS 2 = 30%)
PRA PS1/26 Art. 120A: SCRA institution risk weights
PRA PS1/26 Art. 129(5): Unrated covered bond derivation from institution RW
"""
inst_table = INSTITUTION_RISK_WEIGHTS_B31_ECRA
cqs_to_cb_rw: dict[int, float] = {}
for cqs_val in [CQS.CQS1, CQS.CQS2, CQS.CQS3, CQS.CQS4, CQS.CQS5, CQS.CQS6]:
inst_rw = inst_table[cqs_val]
cb_rw = COVERED_BOND_UNRATED_DERIVATION_B31[inst_rw]
cqs_to_cb_rw[int(cqs_val)] = float(cb_rw)
# Build when/then: ECRA first (cp_institution_cqs)
expr = pl.when(pl.col("cp_institution_cqs") == 1).then(pl.lit(cqs_to_cb_rw[1]))
for cqs_int in [2, 3, 4, 5, 6]:
expr = expr.when(pl.col("cp_institution_cqs") == cqs_int).then(
pl.lit(cqs_to_cb_rw[cqs_int])
)
# SCRA fallback (cp_scra_grade) for unrated issuers
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)))
# Conservative default: Grade C equivalent (B31_COVERED_BOND_UNRATED_FROM_SCRA["C"])
return expr.otherwise(pl.lit(_SA_B31_RW["unrated_cb_default"]))
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:158
@cites("CRR Art. 131")
@cites("CRR Art. 140")
def apply_short_term_rating_override(
exposures: pl.LazyFrame,
ratings: pl.LazyFrame | 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])
# Override: when the short-term cqs is non-null, 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.
has_st = st_cqs_expr.is_not_null()
exposures = exposures.with_columns(
[
has_st.alias("has_short_term_ecai"),
pl.when(has_st).then(st_cqs_expr).otherwise(pl.col("cqs")).cast(pl.Int8).alias("cqs"),
]
)
return exposures.drop([f"_st_{s}_cqs" for s in joined_scopes])
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:176
@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:210
@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:990
@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,
config: CalculationConfig,
) -> pl.LazyFrame:
"""Apply Basel 3.1 class-specific risk-weight overrides (CRE20, PRA PS1/26)."""
# 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(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(
(pl.col("seniority").fill_null("senior") == "subordinated")
& (
uc.str.contains("INSTITUTION", literal=True)
| uc.str.contains("CORPORATE", literal=True)
)
)
.then(pl.lit(_SA_B31_RW["sub_debt"]))
)
chain = _b31_append_real_estate_branches(chain, uc)
# Sovereign-like treatments (PSE then RGLA).
chain = (
# PSE short-term (Art. 116(3)): original maturity <= 3m -> 20% flat.
# Art. 116(3) keys on ORIGINAL maturity — a seasoned long-dated PSE
# bond with short residual does not qualify.
chain.when(
(uc == "PSE")
& pl.col("original_maturity_years").is_not_null()
& (pl.col("original_maturity_years") <= 0.25)
)
.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"],
)
)
# RGLA UK devolved govt -> 0% (PRA designation).
.when(
(uc == "RGLA")
& (pl.col("cp_entity_type").fill_null("") == "rgla_sovereign")
& (pl.col("cp_country_code") == "GB")
)
.then(pl.lit(_SA_SHARED_RW["rgla_uk_devolved"]))
# RGLA domestic currency -> 20% (Art. 115(5)).
.when((uc == "RGLA") & is_domestic_currency)
.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())
# 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:1200
@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,
) -> pl.LazyFrame:
"""Apply CRR class-specific risk-weight overrides (Art. 112-134)."""
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))
# 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"]))
# Corporate SME: 100% — unrated only (Art. 122). A rated SME (CQS 1-6)
# keeps its Art. 122 CQS-table weight from the rw_table join; SME relief
# is delivered separately via the Art. 501 supporting factor.
.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_CRR_RW["corporate_sme"]))
)
# Retail-class branches (Art. 123).
chain = _crr_append_retail_branches(chain, uc)
# Sovereign-like (PSE, RGLA, MDB, IO).
chain = (
# PSE short-term (Art. 116(3)): original maturity <= 3m -> 20%.
chain.when(
(uc == "PSE")
& pl.col("original_maturity_years").is_not_null()
& (pl.col("original_maturity_years") <= 0.25)
)
.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"],
)
)
# RGLA UK devolved govt -> 0% (PRA designation).
.when(
(uc == "RGLA")
& (pl.col("cp_entity_type").fill_null("") == "rgla_sovereign")
& (pl.col("cp_country_code") == "GB")
)
.then(pl.lit(_SA_SHARED_RW["rgla_uk_devolved"]))
# RGLA domestic currency -> 20% (Art. 115(5)).
.when((uc == "RGLA") & is_domestic_currency)
.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)
# 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:101
@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:102
@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:416
@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:991
@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,
config: CalculationConfig,
) -> pl.LazyFrame:
"""Apply Basel 3.1 class-specific risk-weight overrides (CRE20, PRA PS1/26)."""
# 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(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(
(pl.col("seniority").fill_null("senior") == "subordinated")
& (
uc.str.contains("INSTITUTION", literal=True)
| uc.str.contains("CORPORATE", literal=True)
)
)
.then(pl.lit(_SA_B31_RW["sub_debt"]))
)
chain = _b31_append_real_estate_branches(chain, uc)
# Sovereign-like treatments (PSE then RGLA).
chain = (
# PSE short-term (Art. 116(3)): original maturity <= 3m -> 20% flat.
# Art. 116(3) keys on ORIGINAL maturity — a seasoned long-dated PSE
# bond with short residual does not qualify.
chain.when(
(uc == "PSE")
& pl.col("original_maturity_years").is_not_null()
& (pl.col("original_maturity_years") <= 0.25)
)
.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"],
)
)
# RGLA UK devolved govt -> 0% (PRA designation).
.when(
(uc == "RGLA")
& (pl.col("cp_entity_type").fill_null("") == "rgla_sovereign")
& (pl.col("cp_country_code") == "GB")
)
.then(pl.lit(_SA_SHARED_RW["rgla_uk_devolved"]))
# RGLA domestic currency -> 20% (Art. 115(5)).
.when((uc == "RGLA") & is_domestic_currency)
.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())
# 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:1201
@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,
) -> pl.LazyFrame:
"""Apply CRR class-specific risk-weight overrides (Art. 112-134)."""
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))
# 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"]))
# Corporate SME: 100% — unrated only (Art. 122). A rated SME (CQS 1-6)
# keeps its Art. 122 CQS-table weight from the rw_table join; SME relief
# is delivered separately via the Art. 501 supporting factor.
.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_CRR_RW["corporate_sme"]))
)
# Retail-class branches (Art. 123).
chain = _crr_append_retail_branches(chain, uc)
# Sovereign-like (PSE, RGLA, MDB, IO).
chain = (
# PSE short-term (Art. 116(3)): original maturity <= 3m -> 20%.
chain.when(
(uc == "PSE")
& pl.col("original_maturity_years").is_not_null()
& (pl.col("original_maturity_years") <= 0.25)
)
.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"],
)
)
# RGLA UK devolved govt -> 0% (PRA designation).
.when(
(uc == "RGLA")
& (pl.col("cp_entity_type").fill_null("") == "rgla_sovereign")
& (pl.col("cp_country_code") == "GB")
)
.then(pl.lit(_SA_SHARED_RW["rgla_uk_devolved"]))
# RGLA domestic currency -> 20% (Art. 115(5)).
.when((uc == "RGLA") & is_domestic_currency)
.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)
# 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:103
@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: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. 140 — Long-term and short-term credit assessments¶
apply_short_term_rating_override — src/rwa_calc/engine/stages/hierarchy/enrich.py:159
@cites("CRR Art. 131")
@cites("CRR Art. 140")
def apply_short_term_rating_override(
exposures: pl.LazyFrame,
ratings: pl.LazyFrame | 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])
# Override: when the short-term cqs is non-null, 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.
has_st = st_cqs_expr.is_not_null()
exposures = exposures.with_columns(
[
has_st.alias("has_short_term_ecai"),
pl.when(has_st).then(st_cqs_expr).otherwise(pl.col("cqs")).cast(pl.Int8).alias("cqs"),
]
)
return exposures.drop([f"_st_{s}_cqs" for s in joined_scopes])
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 for the IRB approach¶
Definitions only — no calculation path. The terms are realised in src/rwa_calc/domain/enums.py (e.g. ExposureClass, ApproachType) and src/rwa_calc/data/schemas.py.
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:324
@cites("CRR Art. 147")
def _align_irb_exposure_class(exposures: pl.LazyFrame) -> pl.LazyFrame:
"""Align exposure_class with exposure_class_irb for rgla/pse rows.
For IRB-routed rgla_* / pse_* rows, the IRB calculator (which reads
exposure_class for correlation/LGD selection) needs CGCB / INSTITUTION
rather than the SA labels RGLA / PSE. Scoped to these entity types
because later phases (retail reclassification, SME/QRRE) mutate
exposure_class in place without updating exposure_class_irb — a
blanket rewrite would revert those legitimate adjustments.
"""
needs_alignment = pl.col("cp_entity_type").is_in(list(RGLA_PSE_ENTITY_TYPES))
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")
)
classify — src/rwa_calc/engine/stages/classify/classifier.py:105
@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)
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))
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. 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:413
@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)
pd_floor_expr = _pd_floor_expression(config, pack=resolved_pack)
exposures = exposures.with_columns(
pl.max_horizontal(pl.col("pd"), 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. 164(4)(c) blended floor for retail with mixed collateral
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)
floored_lgd = pl.max_horizontal(pl.col("lgd"), 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. 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:414
@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)
pd_floor_expr = _pd_floor_expression(config, pack=resolved_pack)
exposures = exposures.with_columns(
pl.max_horizontal(pl.col("pd"), 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. 164(4)(c) blended floor for retail with mixed collateral
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)
floored_lgd = pl.max_horizontal(pl.col("lgd"), 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:559
@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:701
@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:820
@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:841
@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:1030
@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:1072
@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:370
@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:411
@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:90
@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)
.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).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:156
@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:491
@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. 154 — Risk-weighted exposure amounts for retail exposures¶
apply_irb_formulas — src/rwa_calc/engine/irb/formulas.py:415
@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)
pd_floor_expr = _pd_floor_expression(config, pack=resolved_pack)
exposures = exposures.with_columns(
pl.max_horizontal(pl.col("pd"), 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. 164(4)(c) blended floor for retail with mixed collateral
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)
floored_lgd = pl.max_horizontal(pl.col("lgd"), 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. 155 — Risk-weighted exposure amounts for equity exposures¶
get_equity_result_bundle — src/rwa_calc/engine/equity/calculator.py:211
@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:277
@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:495
@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))
_net_short_positions — src/rwa_calc/engine/equity/calculator.py:736
@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:804
@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"),
)
CRR Art. 161 — Loss Given Default (LGD)¶
apply_firb_supervisory_lgd_no_collateral — src/rwa_calc/engine/crm/collateral.py:449
@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:867
@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:52
@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"
# Check if guarantee is beneficial (guarantor RW < borrower IRB RW)
# Non-beneficial guarantees should NOT be applied per CRR Art. 213
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
lf = lf.with_columns(
[
pl.when(
(pl.col("guaranteed_portion").fill_null(0) > 0)
& (pl.col("guarantor_rw").is_not_null())
& (pl.col("is_guarantee_beneficial"))
)
.then(
pl.col("rwa_irb_original")
* (pl.col("unguaranteed_portion") / pl.col(ead_col)).fill_null(1.0)
+ pl.col("guaranteed_portion") * pl.col("guarantor_rw")
)
.otherwise(pl.col("rwa_irb_original"))
.alias("rwa"),
]
)
# Calculate blended risk weight for reporting
lf = lf.with_columns(
[
(pl.col("rwa") / pl.col(ead_col)).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
CRR Art. 162 — Maturity¶
_maturity_adjustment_expr_from_pd — src/rwa_calc/engine/irb/formulas.py:750
@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:751
@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:1103
@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",
)
calculate_maturity_adjustment — src/rwa_calc/engine/irb/transforms.py:428
@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:109
@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 (Art. 163): Uniform 0.03% floor for all exposure classes.
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``.
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"))
# Optimisation: if all floors are the same (CRR case), return a scalar
all_values = set(floors.values())
if len(all_values) == 1:
return pl.lit(all_values.pop())
# Basel 3.1: differentiated floors by exposure class
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:276
@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)
return lf.with_columns(pl.max_horizontal(pl.col("pd"), pd_floor_expr).alias("pd_floored"))
CRR Art. 164 — Loss Given Default (LGD)¶
_lgd_floor_expression — src/rwa_calc/engine/irb/formulas.py:191
@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:303
@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))
- Retail QRRE unsecured: 50% (Art. 164(4)(b)(i))
- Financial: 0%, Receivables: 10%
- RRE: 10%, CRE: 10%, Other physical: 15%
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. 164(4)(c) blended floor for retail with mixed collateral
# Use blended floor where applicable (retail_other/qrre with collateral),
# fall back to single-type 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)
floored_lgd = pl.max_horizontal(pl.col(lgd_col), 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:805
@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"),
)
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:211
@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: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,
)
CRR Art. 178 — Default of an obligor¶
_build_is_defaulted_expr — src/rwa_calc/engine/stages/classify/attributes.py:490
@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. 194 — Principles governing the eligibility of credit risk mitigation techniques¶
get_crm_unified_bundle — src/rwa_calc/engine/crm/processor.py:492
@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)
# 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)
# 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,
)
CRR Art. 195 — On-balance sheet netting¶
generate_netting_collateral — src/rwa_calc/engine/crm/collateral.py:152
@cites("CRR Art. 195")
@cites("CRR Art. 219")
@cites("CRR Art. 223")
def generate_netting_collateral(
exposures: pl.LazyFrame,
) -> 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 — treated as synthetic cash collateral.
Netting is driven SOLELY by ``netting_agreement_reference``: only exposures
sharing the same reference net together. This reflects the legal right of
set-off, which is defined by the netting agreement itself — not by facility
hierarchy or counterparty. A deposit from one counterparty may net a loan to a
different counterparty (and across different facilities) iff both carry the
same reference; conversely two exposures in the same facility do NOT net unless
they share the reference.
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 are
grouped by (netting_agreement_reference, currency) so the haircut pipeline can
apply FX haircuts when the pool currency differs from the sibling's currency.
Args:
exposures: Exposures with ead_for_crm, on_bs_for_ead, exposure_type set
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
# 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"))
# 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)
)
# Sum abs(drawn_amount) per (netting_agreement_reference, currency) → netting pool.
# Currency is kept so the synthetic collateral carries the source currency,
# allowing the haircut pipeline to apply FX haircuts when currencies differ.
netting_pool = (
negative_loans.group_by(["netting_agreement_reference", "currency"])
.agg(
pl.col("drawn_amount").abs().sum().alias("netting_pool"),
)
.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 carries the same netting_agreement_reference.
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",
"currency",
"on_bs_for_ead",
"maturity_date",
)
# Match siblings to pools by shared netting agreement reference.
matched = positive_siblings.join(
netting_pool,
on="netting_agreement_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").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"],
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"
),
)
# Build synthetic collateral rows — currency from the pool (source of funds)
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("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"),
pl.lit(None).cast(pl.Float64).alias("residual_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. 213 — Requirements common to guarantees and credit derivatives¶
apply_guarantees — src/rwa_calc/engine/crm/guarantees.py:90
@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,
) -> 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
Returns:
Exposures with guarantee effects applied
"""
guarantees = _prepare_guarantees(guarantees, exposures, config, pack=pack)
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)
# 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_guarantee_substitution — src/rwa_calc/engine/sa/rw_adjustments.py:153
@cites("CRR Art. 213")
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. The final RWA is calculated using blended
risk weight based on guaranteed vs unguaranteed portions.
CRR Art. 213-217: Unfunded credit protection.
"""
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)
# Check if guarantee is beneficial (guarantor RW < borrower RW)
# Non-beneficial guarantees should NOT be applied per CRR Art. 213
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. 217 — Requirements to qualify for the treatment set out in Article 153(3)¶
apply_guarantees — src/rwa_calc/engine/crm/guarantees.py:91
@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,
) -> 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
Returns:
Exposures with guarantee effects applied
"""
guarantees = _prepare_guarantees(guarantees, exposures, config, pack=pack)
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)
# 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:1267
@cites("CRR Art. 217")
def _apply_maturity_mismatch_to_guarantees(
guarantees: pl.LazyFrame,
exposures: pl.LazyFrame,
config: CalculationConfig,
) -> pl.LazyFrame:
"""
Apply CRR Art. 239(3) maturity mismatch scaling 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
(so any t < 0.25 yields zero coverage; the >=1y original-maturity floor
in Art. 237(2)(a) is enforced separately upstream). Scaling is applied
to ``amount_covered`` and ``percentage_covered`` before the split, so
the reduced nominal protection value propagates through cap-at-EAD.
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 Art. 237(2): minimum maturity / mismatch eligibility
CRR Art. 238(1): maturity of credit protection
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) onto each guarantee row.
exp_t_expr = exact_fractional_years_expr(config.reporting_date, "maturity_date").alias("_exp_T")
exp_lookup = exposures.select(
pl.col("exposure_reference"),
exp_t_expr,
)
guarantees = guarantees.join(
exp_lookup,
left_on="beneficiary_reference",
right_on="exposure_reference",
how="left",
)
# Compute t (Art. 238(1)). Prefer the explicit regulatory input
# ``original_maturity_years`` when present (it is the authoritative
# contract term written by upstream loaders); fall back to
# ``maturity_date`` minus reporting date when missing. Null t means
# "no info" and yields no scaling.
if has_guar_original_maturity and has_guar_maturity_date:
t_from_date = exact_fractional_years_expr(config.reporting_date, "maturity_date")
t_raw = (
pl.when(pl.col("original_maturity_years").is_not_null())
.then(pl.col("original_maturity_years"))
.otherwise(t_from_date)
)
elif has_guar_original_maturity:
t_raw = pl.col("original_maturity_years")
else:
t_raw = exact_fractional_years_expr(config.reporting_date, "maturity_date")
# Apply Art. 239(3) floors / caps:
# t floored at 0.25, T capped at 5.0 and floored at 0.25.
floor = pl.lit(0.25)
cap = pl.lit(5.0)
t_eff = pl.max_horizontal(t_raw, floor)
t_eff_safe = pl.when(t_raw.is_null()).then(pl.lit(None, dtype=pl.Float64)).otherwise(t_eff)
exp_t_eff = pl.max_horizontal(pl.min_horizontal(pl.col("_exp_T"), cap), floor)
exp_t_eff_safe = (
pl.when(pl.col("_exp_T").is_null())
.then(pl.lit(None, dtype=pl.Float64))
.otherwise(exp_t_eff)
)
# Mismatch only applies when t < T (else no scaling).
is_mismatch = (
t_eff_safe.is_not_null() & exp_t_eff_safe.is_not_null() & (t_eff_safe < exp_t_eff_safe)
)
scale = (t_eff_safe - floor) / (exp_t_eff_safe - floor)
scale_safe = pl.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 guarantees.drop("_exp_T")
CRR Art. 219 — On-balance sheet netting¶
generate_netting_collateral — src/rwa_calc/engine/crm/collateral.py:153
@cites("CRR Art. 195")
@cites("CRR Art. 219")
@cites("CRR Art. 223")
def generate_netting_collateral(
exposures: pl.LazyFrame,
) -> 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 — treated as synthetic cash collateral.
Netting is driven SOLELY by ``netting_agreement_reference``: only exposures
sharing the same reference net together. This reflects the legal right of
set-off, which is defined by the netting agreement itself — not by facility
hierarchy or counterparty. A deposit from one counterparty may net a loan to a
different counterparty (and across different facilities) iff both carry the
same reference; conversely two exposures in the same facility do NOT net unless
they share the reference.
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 are
grouped by (netting_agreement_reference, currency) so the haircut pipeline can
apply FX haircuts when the pool currency differs from the sibling's currency.
Args:
exposures: Exposures with ead_for_crm, on_bs_for_ead, exposure_type set
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
# 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"))
# 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)
)
# Sum abs(drawn_amount) per (netting_agreement_reference, currency) → netting pool.
# Currency is kept so the synthetic collateral carries the source currency,
# allowing the haircut pipeline to apply FX haircuts when currencies differ.
netting_pool = (
negative_loans.group_by(["netting_agreement_reference", "currency"])
.agg(
pl.col("drawn_amount").abs().sum().alias("netting_pool"),
)
.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 carries the same netting_agreement_reference.
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",
"currency",
"on_bs_for_ead",
"maturity_date",
)
# Match siblings to pools by shared netting agreement reference.
matched = positive_siblings.join(
netting_pool,
on="netting_agreement_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").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"],
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"
),
)
# Build synthetic collateral rows — currency from the pool (source of funds)
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("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"),
pl.lit(None).cast(pl.Float64).alias("residual_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:67
@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:154
@cites("CRR Art. 195")
@cites("CRR Art. 219")
@cites("CRR Art. 223")
def generate_netting_collateral(
exposures: pl.LazyFrame,
) -> 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 — treated as synthetic cash collateral.
Netting is driven SOLELY by ``netting_agreement_reference``: only exposures
sharing the same reference net together. This reflects the legal right of
set-off, which is defined by the netting agreement itself — not by facility
hierarchy or counterparty. A deposit from one counterparty may net a loan to a
different counterparty (and across different facilities) iff both carry the
same reference; conversely two exposures in the same facility do NOT net unless
they share the reference.
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 are
grouped by (netting_agreement_reference, currency) so the haircut pipeline can
apply FX haircuts when the pool currency differs from the sibling's currency.
Args:
exposures: Exposures with ead_for_crm, on_bs_for_ead, exposure_type set
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
# 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"))
# 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)
)
# Sum abs(drawn_amount) per (netting_agreement_reference, currency) → netting pool.
# Currency is kept so the synthetic collateral carries the source currency,
# allowing the haircut pipeline to apply FX haircuts when currencies differ.
netting_pool = (
negative_loans.group_by(["netting_agreement_reference", "currency"])
.agg(
pl.col("drawn_amount").abs().sum().alias("netting_pool"),
)
.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 carries the same netting_agreement_reference.
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",
"currency",
"on_bs_for_ead",
"maturity_date",
)
# Match siblings to pools by shared netting agreement reference.
matched = positive_siblings.join(
netting_pool,
on="netting_agreement_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").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"],
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"
),
)
# Build synthetic collateral rows — currency from the pool (source of funds)
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("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"),
pl.lit(None).cast(pl.Float64).alias("residual_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:302
@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,
) -> 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)
# 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,
)
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:313
@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:327
@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:121
@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 bonds (Art. 197 — CQS 5-6 govt, CQS 4-6 corp)
if "_bond_ineligible" in collateral.collect_schema().names():
collateral = collateral.with_columns(
pl.when(pl.col("_bond_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 bonds
if "is_eligible_financial_collateral" in collateral.collect_schema().names():
collateral = collateral.with_columns(
pl.when(pl.col("_bond_ineligible"))
.then(pl.lit(False))
.otherwise(pl.col("is_eligible_financial_collateral"))
.alias("is_eligible_financial_collateral")
)
collateral = collateral.drop("_bond_ineligible")
# 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:303
@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,
) -> 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)
# 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,
)
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:76
@cites("CRR Art. 232")
def compute_life_insurance_columns(
exposures: pl.LazyFrame,
collateral: pl.LazyFrame | None,
config: CalculationConfig,
) -> pl.LazyFrame:
"""Compute life insurance CRM columns on the exposure frame.
Aggregates eligible life insurance collateral per exposure and sets:
- life_ins_collateral_value: total surrender value allocated to this exposure
- life_ins_secured_rw: value-weighted mapped risk weight per Art. 232
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.
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 mapped RW per item
li_coll = li_coll.with_columns(_map_insurer_rw_to_secured_rw_expr().alias("_li_item_rw"))
# Build exposure reference lookups for multi-level matching
exp_schema = exposures.collect_schema()
exp_ref_col = (
"exposure_reference" if "exposure_reference" in exp_schema.names() else "loan_reference"
)
ead_col = "ead_gross" if "ead_gross" in exp_schema.names() else "ead"
# Direct-level: aggregate life insurance value and weighted RW per beneficiary
li_agg = li_coll.group_by("beneficiary_reference").agg(
pl.col("market_value").fill_null(0.0).sum().alias("_li_total_value"),
(pl.col("market_value").fill_null(0.0) * pl.col("_li_item_rw"))
.sum()
.alias("_li_weighted_rw"),
)
# Join to exposures by beneficiary_reference = exposure_reference
exposures = exposures.join(
li_agg,
left_on=exp_ref_col,
right_on="beneficiary_reference",
how="left",
)
# Compute per-exposure life insurance columns
ead = pl.col(ead_col).fill_null(0.0)
li_value = pl.col("_li_total_value").fill_null(0.0)
li_wrw = pl.col("_li_weighted_rw").fill_null(0.0)
# Cap life insurance value at EAD
capped_value = pl.min_horizontal(li_value, ead)
# Weighted-average mapped RW
avg_rw = pl.when(li_value > 0).then(li_wrw / li_value).otherwise(pl.lit(0.0))
exposures = exposures.with_columns(
capped_value.alias("life_ins_collateral_value"),
avg_rw.alias("life_ins_secured_rw"),
).drop(["_li_total_value", "_li_weighted_rw"])
return exposures
apply_life_insurance_rw_mapping — src/rwa_calc/engine/sa/rw_adjustments.py:119
@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"),
)
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:693
@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¶
_compute_guarantor_rw_sa — src/rwa_calc/engine/irb/guarantee.py:201
@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 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).
_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)
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:132
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 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%
MDB Table 2B (Art. 117(1))
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) when True, CRR tables when False. PSE / RGLA / MDB /
IO / CCP values are framework-identical.
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 — Table 2B (Art. 117(1)).
.when(gec == "mdb")
.then(
_cqs_table_lookup_expr(
cqs_col,
_MDB_RW,
float(_MDB_UNRATED_RW),
)
)
# 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))
)
CRR Art. 237 — Maturity mismatch¶
apply_maturity_mismatch — src/rwa_calc/engine/crm/haircuts.py:661
@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¶
apply_maturity_mismatch — src/rwa_calc/engine/crm/haircuts.py:662
@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/hierarchy.py`` (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/hierarchy.py:2444-2447
# (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:496
@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:497
@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)
sa_rwa_filled = pl.col("sa_rwa").fill_null(0.0)
result = (
result
# Step 1: Portfolio-level totals (broadcast as scalar to every row)
.with_columns(
pl.when(is_eligible)
.then(pl.col("rwa_pre_floor"))
.otherwise(0.0)
.sum()
.alias("_u_trea"),
pl.when(is_eligible).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 & (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)
.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)
.then(pl.col("_portfolio_floor_binds"))
.otherwise(pl.lit(False))
.alias("is_floor_binding"),
)
# Step 5: Final RWA = pre-floor + pro-rata add-on
.with_columns(
pl.when(is_eligible)
.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)
.then(pl.col("rwa_pre_floor"))
.otherwise(0.0)
.sum()
.fill_null(0.0)
.alias("_sa_rwa_total"),
pl.when(is_equity)
.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:75
@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:
equity_prepared = prepare_equity_results(equity_bundle.results)
combined_unmultiplied = pl.concat(
[combined_unmultiplied, equity_prepared], how="diagonal_relaxed"
)
equity_results = equity_bundle.results
# 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)
# Pre-CRM summary uses the original (pre-substitution) class and is
# unaffected by the output floor, so it is built from the current
# ``combined``. The post-CRM reporting views and 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_crm_summary = generate_pre_crm_summary(combined)
# 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).
pre_floor_views: dict[str, pl.LazyFrame] = {
"combined": combined,
"pre_crm_summary": pre_crm_summary,
}
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
pre_crm_summary = pre_floor_dfs["pre_crm_summary"].lazy()
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),
)
# 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,
)
# Generate post-CRM reporting views from the (possibly floored)
# ``combined`` frame. When the floor binds, ``combined`` now carries
# the per-row ``floor_impact_rwa`` add-on, which the by-class /
# by-approach summaries fold into ``total_rwa`` so the reported totals
# reconcile with ``output_floor_summary.total_rwa_post_floor`` (P1.130).
# When the floor does not run (or does not bind), ``combined`` is the
# pre-floor frame and these views are identical to the pre-fix output.
post_crm_detailed = generate_post_crm_detailed(combined)
post_crm_summary = generate_post_crm_summary(post_crm_detailed)
summary_by_class = generate_summary_by_class(post_crm_detailed)
summary_by_approach = generate_summary_by_approach(post_crm_detailed)
# 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,
"post_crm_detailed": post_crm_detailed,
"post_crm_summary": post_crm_summary,
"summary_by_class": summary_by_class,
"summary_by_approach": summary_by_approach,
}
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,
floor_impact=(
post_floor_dfs["floor_impact"].lazy() if floor_impact is not None else None
),
output_floor_summary=output_floor_summary,
supporting_factor_impact=(
post_floor_dfs["supporting_factor_impact"].lazy()
if supporting_factor_impact is not None
else None
),
summary_by_class=post_floor_dfs["summary_by_class"].lazy(),
summary_by_approach=post_floor_dfs["summary_by_approach"].lazy(),
pre_crm_summary=pre_crm_summary,
post_crm_detailed=post_floor_dfs["post_crm_detailed"].lazy(),
post_crm_summary=post_floor_dfs["post_crm_summary"].lazy(),
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=[],
)
PS1/26, paragraph 110A — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
apply_due_diligence_override — src/rwa_calc/engine/sa/rw_adjustments.py:422
@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: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% SA CCF under Basel 3.1, except where the otherwise-resolved
CCF is 10% (Row 6 UCC) or 100% (Row 2) — the Row 4(b) carve-out.
"""
# 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: under CRR, "other commitments" mapped
# to MR (50%, >1yr) or MLR (20%, <=1yr). The sa_ccf_expression gives OC 50%
# as the conservative default; override to 20% when remaining maturity <= 1yr.
if not is_b31:
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()
if "maturity_date" in schema_names:
is_short_maturity = pl.col("maturity_date").is_not_null() & (
(
pl.col("maturity_date").cast(pl.Date) - pl.lit(config.reporting_date)
).dt.total_days()
<= _OC_SHORT_MATURITY_THRESHOLD_DAYS
)
exposures = exposures.with_columns(
pl.when(is_oc & is_short_maturity)
.then(pl.lit(_OC_SHORT_MATURITY_CCF))
.otherwise(pl.col("_sa_ccf_from_risk_type"))
.alias("_sa_ccf_from_risk_type"),
)
# 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%". When
# the flag is set under Basel 3.1, override the otherwise-resolved SA CCF
# to the MR / Row 4(b) rate (50%), unless that CCF is already 10% (Row 6
# UCC) or 100% (Row 2), in which case the carve-out leaves it untouched.
# No effect under CRR (Table A1 is Basel 3.1 only) — see the gate below.
if is_b31:
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)
exposures = 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"),
)
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:
airb_ccf = ccf_modelled_expr.fill_null(pl.col("_sa_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"),
)
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:846
@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]:
"""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, and the domestic-currency flag
used for CGCB zero-weight treatment and sovereign-derived fallbacks.
"""
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) / 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 == "MDB"
# 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") & ~pl.col(
"cp_is_qccp"
).fill_null(True)
exposures = 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")
)
# 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
PS1/26, paragraph 123 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_b31_append_retail_branches — src/rwa_calc/engine/sa/risk_weights.py:563
@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:539
@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:293
@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:294
@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:597
@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:456
@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:173
@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¶
_apply_defaulted_risk_weight — src/rwa_calc/engine/sa/risk_weights.py:1449
@cites("CRR Art. 127")
@cites("PS1/26, paragraph 127")
def _apply_defaulted_risk_weight(
exposures: pl.LazyFrame,
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
"""Apply Art. 127 defaulted risk weight to the full post-CRM exposure.
PS1/26 Art. 127(1)-(2) assign 100% or 150% to the part of a defaulted
exposure that is not secured by recognised collateral or covered by
recognised unfunded credit protection, where the unsecured part is
determined by the CRM method the institution applies (Art. 191A(2)).
Under the Financial Collateral Comprehensive Method (the default for
SA), eligible financial collateral has already reduced ``ead_final``
in the CRM stage and eligible residential/commercial real estate has
been routed via class reclassification — so ``ead_final`` already
represents the unsecured value and Art. 127(1) applies to it flat.
FCSM (Simple Method) is handled downstream by
``apply_fcsm_rw_substitution``, which blends the defaulted RW with
the collateral RW per the substitution rule.
Basel 3.1 Art. 127(3) / CRE20.88 exception: a residential RE default
that is not materially dependent on cash-flows of the property is
assigned 100% flat, regardless of provisions.
References:
- PS1/26 Art. 127(1): unsecured part 100%/150% by provision coverage
- PS1/26 Art. 127(2): unsecured part determined by the CRM method
- PS1/26 Art. 127(3) / CRE20.88: RESI RE non-income flat 100%
- CRR Art. 127(1)-(2): CRR predecessor (pre-provision denominator)
"""
resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
_uc = pl.col("exposure_class").fill_null("").str.to_uppercase()
ead = pl.col("ead_final")
if resolved_pack.feature("sa_revised_defaulted_treatment"):
# B31 RESI RE non-income-dependent: 100% flat (Art. 127(3) / CRE20.88).
is_resi_re_non_income = (
_is_residential_re_class(_uc)
& ~_is_commercial_re_class(_uc)
& ~pl.col("has_income_cover").fill_null(False)
)
# PS1/26 Art. 127(1): denominator is "the outstanding amount of the
# item or facility" — gross outstanding (pre-CRM, pre-provision).
# Reconstruct from ead_gross (post-CCF, post-provision, pre-CRM) plus
# provision_deducted.
gross_outstanding = pl.col("ead_gross") + pl.col("provision_deducted")
provision_rw = (
pl.when(
pl.col("provision_allocated")
>= _SA_B31_RW["defaulted_threshold"] * gross_outstanding
)
.then(pl.lit(_SA_B31_RW["defaulted_high"]))
.otherwise(pl.lit(_SA_B31_RW["defaulted_low"]))
)
defaulted_rw = (
pl.when(is_resi_re_non_income)
.then(pl.lit(_SA_B31_RW["defaulted_resi_re_non_income"]))
.otherwise(provision_rw)
)
else:
# CRR Art. 127(1): denominator is the pre-provision exposure value
# (ead_final is post-provision, so add provision_deducted back).
unsecured_pre_prov = ead + pl.col("provision_deducted")
defaulted_rw = (
pl.when(
pl.col("provision_allocated")
>= _SA_CRR_RW["defaulted_threshold"] * unsecured_pre_prov
)
.then(pl.lit(_SA_CRR_RW["defaulted_high"]))
.otherwise(pl.lit(_SA_CRR_RW["defaulted_low"]))
)
# Art. 128 (HIGH_RISK) takes precedence per Table A2 priority 4 > 5
is_defaulted = pl.col("is_defaulted").fill_null(False) & (_uc != "HIGH_RISK")
return exposures.with_columns(
pl.when(is_defaulted)
.then(defaulted_rw)
.otherwise(pl.col("risk_weight"))
.alias("risk_weight")
)
PS1/26, paragraph 128 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_b31_append_high_risk_branch — src/rwa_calc/engine/sa/risk_weights.py:548
@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/risk_weights.py:479
@cites("CRR Art. 129")
@cites("PS1/26, paragraph 129")
def _b31_unrated_cb_rw_expr() -> pl.Expr:
"""Build Polars expression for B31 Art. 129(5) unrated covered bond RW derivation.
Derives covered bond RW from the issuing institution's senior unsecured RW,
which can come from either source:
1. ECRA (rated institution): cp_institution_cqs → institution RW → CB RW
2. SCRA (unrated institution): cp_scra_grade → CB RW
Art. 129(5) operates on the resulting institution RW regardless of source
(ECRA or SCRA). The ECRA path is checked first; if cp_institution_cqs is
null, falls back to the SCRA path.
References:
PRA PS1/26 Art. 120 Table 3 ECRA: Institution risk weights (CQS 2 = 30%)
PRA PS1/26 Art. 120A: SCRA institution risk weights
PRA PS1/26 Art. 129(5): Unrated covered bond derivation from institution RW
"""
inst_table = INSTITUTION_RISK_WEIGHTS_B31_ECRA
cqs_to_cb_rw: dict[int, float] = {}
for cqs_val in [CQS.CQS1, CQS.CQS2, CQS.CQS3, CQS.CQS4, CQS.CQS5, CQS.CQS6]:
inst_rw = inst_table[cqs_val]
cb_rw = COVERED_BOND_UNRATED_DERIVATION_B31[inst_rw]
cqs_to_cb_rw[int(cqs_val)] = float(cb_rw)
# Build when/then: ECRA first (cp_institution_cqs)
expr = pl.when(pl.col("cp_institution_cqs") == 1).then(pl.lit(cqs_to_cb_rw[1]))
for cqs_int in [2, 3, 4, 5, 6]:
expr = expr.when(pl.col("cp_institution_cqs") == cqs_int).then(
pl.lit(cqs_to_cb_rw[cqs_int])
)
# SCRA fallback (cp_scra_grade) for unrated issuers
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)))
# Conservative default: Grade C equivalent (B31_COVERED_BOND_UNRATED_FROM_SCRA["C"])
return expr.otherwise(pl.lit(_SA_B31_RW["unrated_cb_default"]))
PS1/26, paragraph 132 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_append_ciu_branches — src/rwa_calc/engine/equity/calculator.py:119
@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 (ciu_mandate_rw x1.2 if third-party),
look_through (ciu_look_through_rw), 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(
pl.col("ciu_mandate_rw").fill_null(CIU_FALLBACK_RW)
* pl.when(pl.col("ciu_third_party_calc").fill_null(False))
.then(pl.lit(_CIU_THIRD_PARTY_MULTIPLIER))
.otherwise(pl.lit(_CIU_INTERNAL_MULTIPLIER))
)
.when(_is_ciu & (pl.col("ciu_approach") == "look_through"))
.then(pl.col("ciu_look_through_rw").fill_null(CIU_FALLBACK_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:588
@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"),
]
)
PS1/26, paragraph 139 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_prepare_risk_weight_lookup — src/rwa_calc/engine/sa/risk_weights.py:845
@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]:
"""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, and the domestic-currency flag
used for CGCB zero-weight treatment and sovereign-derived fallbacks.
"""
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) / 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 == "MDB"
# 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") & ~pl.col(
"cp_is_qccp"
).fill_null(True)
exposures = 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")
)
# 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
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:279
@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 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:110
@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 (Art. 163): Uniform 0.03% floor for all exposure classes.
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``.
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"))
# Optimisation: if all floors are the same (CRR case), return a scalar
all_values = set(floors.values())
if len(all_values) == 1:
return pl.lit(all_values.pop())
# Basel 3.1: differentiated floors by exposure class
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:277
@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)
return lf.with_columns(pl.max_horizontal(pl.col("pd"), 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:192
@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:246
@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:318
@cites("PS1/26, paragraph 164")
def _lgd_floor_blended_expression(
config: CalculationConfig,
*,
pack: ResolvedRulepack | None = None,
) -> pl.Expr:
"""
Build Polars expression for the Art. 164(4)(c) blended LGD floor.
For retail "other secured" exposures with mixed collateral, the LGD floor
is a weighted average of per-type LGDS floors and the unsecured LGDU,
weighted by the proportion of EAD covered by each collateral type:
LGD_floor = (E_unsecured / EAD) × LGDU
+ Σ_i (E_i / EAD) × LGDS_i
Where E_i comes from the Art. 231 sequential waterfall (crm_alloc_* columns)
and E_unsecured = EAD - total_collateral_for_lgd.
Applies to:
- retail_other (LGDU = 30%)
- retail_qrre (LGDU = 50%)
Does NOT apply to:
- retail_mortgage (flat 5% floor per Art. 164(4)(a))
- CRR (no LGD floors)
Requires crm_alloc_* columns from CRM waterfall and ead_gross.
Falls back to _lgd_floor_expression_with_collateral() for non-retail or
when allocation columns are absent.
References:
PRA PS1/26 Art. 164(4)(c)
"""
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"))
ead = pl.col("ead_gross")
total_coll = pl.col("total_collateral_for_lgd").fill_null(0.0)
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 for retail (Art. 164(4)(c))
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% (non-RRE 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_other: 30% (Art. 164(4)(c))
# - retail_qrre: 50% (Art. 164(4)(b)(i))
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%
.otherwise(pl.lit(floors["retail_lgdu"])) # 30%
)
numerator_with_unsecured = numerator + unsecured_portion * lgdu_expr
blended = pl.when(ead > 0).then(numerator_with_unsecured / ead).otherwise(pl.lit(0.0))
# Apply blended floor only to retail_other and retail_qrre with collateral.
# retail_mortgage uses flat 5% (Art. 164(4)(a)).
# Corporate uses single-type floor from _lgd_floor_expression_with_collateral().
is_blended_eligible = exp_class.is_in(["retail_other", "retail_qrre"])
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:304
@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))
- Retail QRRE unsecured: 50% (Art. 164(4)(b)(i))
- Financial: 0%, Receivables: 10%
- RRE: 10%, CRE: 10%, Other physical: 15%
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. 164(4)(c) blended floor for retail with mixed collateral
# Use blended floor where applicable (retail_other/qrre with collateral),
# fall back to single-type 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)
floored_lgd = pl.max_horizontal(pl.col(lgd_col), 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.5 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
_apply_purchased_receivable_ccf — src/rwa_calc/engine/ccf.py:592
@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 Art. 230 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026¶
apply_collateral — src/rwa_calc/engine/crm/collateral.py:300
@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,
) -> 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)
# 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,
)
apply_collateral — src/rwa_calc/engine/crm/collateral.py:301
@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,
) -> 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)
# 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,
)
overcollateralisation_ratio_expr — src/rwa_calc/engine/crm/expressions.py:131
@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))
)