Skip to content

Component Overview

This document provides detailed documentation of each component in the RWA calculator.

Component Summary

Component Module Purpose
Loader engine/loader.py Load data from files
Hierarchy Resolver engine/stages/hierarchy/ (package) Resolve hierarchies
Classifier engine/stages/classify/ (package) Classify exposures
CRM Processor engine/crm/processor.py Apply CRM
SA Calculator engine/sa/calculator.py Standardised RWA
IRB Calculator engine/irb/calculator.py IRB RWA
Slotting Calculator engine/slotting/calculator.py Slotting RWA
Equity Calculator engine/equity/calculator.py Equity RWA
Aggregator engine/aggregator/aggregator.py Combine results

engine/hierarchy.py and engine/classifier.py survive only as ~24-28 line back-compat import shims re-exporting from the stage packages above.

Calculator / Domain Transforms

Calculator and domain logic is written as plain module-level typed functions (fn(lf: LazyFrame, config, ...) -> LazyFrame) composed via lf.pipe(fn, ...) — for example engine/sa/risk_weights.py, engine/sa/rw_adjustments.py, engine/irb/transforms.py, and engine/slotting/transforms.py. Each calculator class is a thin orchestrator that pipes these transforms in regulatory order.

Polars namespace registrations (@pl.api.register_*_namespace) are extinct and banned by scripts/arch_check.py check 14 — there is no lf.sa / lf.irb / lf.slotting accessor and no IRBLazyFrame / SlottingLazyFrame export.

Loader

Purpose

Load raw data from Parquet or CSV files into LazyFrames.

Interface

class LoaderProtocol(Protocol):
    def load(self, path: Path) -> RawDataBundle:
        """Load raw data from the specified path."""
        ...

Implementation

class ParquetLoader:
    """Load data from Parquet files."""

    def load(self, path: Path) -> RawDataBundle:
        return RawDataBundle(
            counterparties=pl.scan_parquet(path / "counterparties.parquet"),
            facilities=pl.scan_parquet(path / "facilities.parquet"),
            loans=pl.scan_parquet(path / "loans.parquet"),
            contingents=self._load_optional(path / "contingents.parquet"),
            collateral=self._load_optional(path / "collateral.parquet"),
            guarantees=self._load_optional(path / "guarantees.parquet"),
            provisions=self._load_optional(path / "provisions.parquet"),
            ratings=self._load_optional(path / "ratings.parquet"),
            org_mappings=self._load_optional(path / "org_mapping.parquet"),
            lending_mappings=self._load_optional(path / "lending_mapping.parquet"),
            fx_rates=self._load_optional(path / "fx_rates.parquet"),
            facility_mappings=self._load_optional(path / "facility_mapping.parquet"),
            model_permissions=self._load_optional(path / "model_permissions.parquet"),
        )

    def _load_optional(self, path: Path) -> pl.LazyFrame | None:
        return pl.scan_parquet(path) if path.exists() else None

Key Features

  • Lazy loading for performance
  • Optional file handling
  • Schema validation
  • Error accumulation

Hierarchy Resolver

Purpose

Resolve counterparty and facility hierarchies, inherit ratings, unify exposures, and calculate facility undrawn amounts.

Interface

class HierarchyResolverProtocol(Protocol):
    def resolve(
        self,
        raw_data: RawDataBundle,
        config: CalculationConfig
    ) -> ResolvedHierarchyBundle:
        """Resolve hierarchies and inherit attributes."""
        ...

Implementation

The resolve() method orchestrates the full hierarchy resolution:

class HierarchyResolver:
    """Resolve counterparty, facility, and lending group hierarchies."""

    def resolve(self, data: RawDataBundle, config: CalculationConfig) -> ResolvedHierarchyBundle:
        # Step 1: Build counterparty hierarchy lookup
        #   → _build_ultimate_parent_lazy() - traverse org_mappings (up to 10 levels)
        #   → _build_rating_inheritance_lazy() - inherit ratings from parent if missing
        #   → Returns CounterpartyLookup (counterparties, parent_mappings,
        #                                  ultimate_parent_mappings, rating_inheritance)

        # Step 2: Unify exposures (loans + contingents + facility undrawn)
        #   → _build_facility_root_lookup() - traverse facility hierarchies
        #   → _calculate_facility_undrawn() - limit minus aggregated drawn amounts
        #   → Combines all exposure types into single LazyFrame

        # Step 2a: Apply FX conversion (exposures + CRM data)

        # Step 2b: Add collateral LTV to exposures

        # Step 3: Calculate residential property coverage

        # Step 4: Calculate lending group totals (retail threshold)

        # Step 5: Add lending group totals to exposures

        return ResolvedHierarchyBundle(...)

Key Internal Methods

Method Purpose
_build_counterparty_lookup() Build complete counterparty hierarchy with ratings
_build_ultimate_parent_lazy() Traverse org_mappings to find ultimate parent (up to 10 levels)
_build_rating_inheritance_lazy() Inherit ratings: own → parent → unrated
_build_facility_root_lookup() Traverse facility-to-facility hierarchies to find root facility
_calculate_facility_undrawn() Calculate undrawn = limit - sum(descendant drawn), excluding sub-facilities. Suppresses the synthetic facility_undrawn exposure row when committed=False — uncommitted (unconditionally cancellable) facilities carry no commitment EAD because the bank can refuse to lend; loans and contingents mapped to the facility are unaffected and continue to flow as their own exposure rows
_expand_mof_facility_undrawn() For Multiple Option Facility (MOF) parents — replaces the parent's single undrawn row with one row per committed descendant sub-facility (with positive headroom), allocated by waterfall in descending SA CCF order, capped per-sub at sub_limit − sub_drawn and globally at the parent's headroom. Emits a residual row at the parent's own risk_type when sub-limits don't cover the full parent limit. Uncommitted subs are skipped entirely. Each split row carries the sub's risk_type and counterparty_reference; provenance lives in mof_risk_type_source
_derive_facility_share_counterparty() For non-MOF Facility Shares (one facility, many counterparties on its loans/contingents) — allocate the undrawn to the riskiest member by SA-equivalent risk-weight preview. Skipped on MOF parents because each sub waterfall row already carries the right counterparty natively
_unify_exposures() Combine loan, contingent, and facility_undrawn into single LazyFrame
_calculate_lending_group_totals() Aggregate exposure by lending group for retail threshold
_add_collateral_ltv() Add LTV from collateral (direct → facility → counterparty priority)
_calculate_residential_property_coverage() Separate residential vs all-property collateral coverage
_add_lending_group_totals_to_exposures() Join lending group totals to each exposure

Key Features

  • Iterative join-based hierarchy resolution (counterparty and facility)
  • Support for deep hierarchies (up to 10 levels)
  • Multi-level facility hierarchy: drawn amounts aggregated to root facility
  • Sub-facility exclusion from undrawn exposure output (avoids double-counting)
  • Multiple Option Facility (MOF) parents emit per-sub waterfall undrawn rows (any facility with at least one child_type='facility' mapping is a MOF). Sub-facilities are sorted by descending SA CCF under the active framework and each takes the lesser of its own headroom (sub_limit − sub_drawn) and the parent's remaining headroom. Sub-limits beyond the parent's cap spill out, leftover parent headroom emits a residual row at the parent's own risk_type, uncommitted subs are skipped, and each row records its source in mof_risk_type_source for audit.
  • Non-MOF Facility Share undrawn is allocated to the riskiest counterparty among the descendant loan/contingent counterparties by SA-equivalent risk-weight preview; the original facility counterparty is preserved as original_counterparty_reference for audit. MOF parents skip this override because each waterfall row already carries its sub-facility's own counterparty.
  • Rating inheritance from parent (own → parent → unrated)
  • Lending group aggregation with residential property exclusion (CRR Art. 123(c))
  • Multi-level collateral linking (direct, facility, counterparty) with pro-rata allocation
  • FX conversion of exposures and CRM data
  • Facility-mapping schema normalised at the resolver boundary: legacy node_type is accepted as an input alias and renamed to child_type; missing column is synthesised as null. New producers MUST emit child_type.
  • Non-blocking error accumulation

Classifier

Purpose

Assign regulatory exposure classes and calculation approaches based on counterparty entity type.

Interface

class ClassifierProtocol(Protocol):
    def classify(
        self,
        resolved: ResolvedHierarchyBundle,
        config: CalculationConfig
    ) -> ClassifiedExposuresBundle:
        """Classify exposures into regulatory classes."""
        ...

Entity Type Mappings

The classifier uses entity_type as the single source of truth for exposure class determination. Two separate mappings exist for SA and IRB approaches:

ENTITY_TYPE_TO_SA_CLASS - Maps to SA exposure class for risk weight lookup:

Entity Type SA Class
sovereign, central_bank CENTRAL_GOVT_CENTRAL_BANK
rgla_sovereign, rgla_institution RGLA
pse_sovereign, pse_institution PSE
mdb, international_org MDB
institution, bank, ccp, financial_institution INSTITUTION
corporate, company CORPORATE
individual, retail RETAIL_OTHER
specialised_lending SPECIALISED_LENDING

ENTITY_TYPE_TO_IRB_CLASS - Maps to IRB exposure class for formula selection:

Entity Type IRB Class Notes
sovereign, central_bank CENTRAL_GOVT_CENTRAL_BANK
rgla_sovereign, pse_sovereign CENTRAL_GOVT_CENTRAL_BANK Govt-backed = central govt IRB treatment
rgla_institution, pse_institution INSTITUTION Commercial = institution IRB treatment
mdb, international_org CENTRAL_GOVT_CENTRAL_BANK CRR Art. 147(3)
institution, bank, ccp, financial_institution INSTITUTION
corporate, company CORPORATE
individual, retail RETAIL_OTHER
specialised_lending SPECIALISED_LENDING

Classification Pipeline

The classify() method executes these steps in sequence:

Step 1: _add_counterparty_attributes()
        Join exposures with counterparty data (entity_type, revenue, assets, etc.)

Step 2: _classify_exposure_class()
        Map entity_type to exposure_class_sa and exposure_class_irb

Step 3: _apply_sme_classification()
        Check annual_revenue < EUR 50m for CORPORATE -> CORPORATE_SME

Step 4: _apply_retail_classification()
        Aggregate by lending group, check retail threshold (EUR 1m CRR / GBP 880k B31)
        Apply mortgage classification for RETAIL_MORTGAGE

Step 5: _identify_defaults()
        Check default_status, set exposure_class_for_sa = DEFAULTED

Step 5a: _apply_infrastructure_classification()
        Check product_type for infrastructure lending

Step 5b: _apply_fi_scalar_classification()
        Derive requires_fi_scalar from user-supplied apply_fi_scalar flag.
        (User sets apply_fi_scalar=True for LFSE — total assets ≥ EUR 70bn
        under CRR Art. 142(1)(4), or ≥ GBP 79bn under PS1/26 Glossary p. 78 —
        or for any unregulated FSE. No automatic threshold check in code.)

Step 6: _determine_approach()
        Assign SA/FIRB/AIRB/SLOTTING based on IRB permissions

Step 7: _add_classification_audit()
        Build audit trail string for traceability

Step 7a: _enrich_slotting_exposures()
        Add slotting_category, sl_type, is_hvcre for specialised lending

Step 8: Assemble bundle
        All exposures stay on the single unified frame; downstream
        consumers filter on the `approach` column

FI Scalar (CRR Art. 153(2))

The 1.25x IRB correlation multiplier is controlled by the user-supplied apply_fi_scalar flag on counterparties. The classifier derives requires_fi_scalar directly from this flag.

Key Features

  • Dual exposure class mapping: SA and IRB classes tracked separately
  • Entity type as single source: No conflicting boolean flags
  • SME identification: Corporate exposures with revenue < EUR 50m
  • Retail threshold checking: Lending group aggregation against retail threshold (EUR 1m CRR / GBP 880k Basel 3.1)
  • Mortgage detection: Product type pattern matching
  • FI scalar: User-controlled apply_fi_scalar flag
  • Infrastructure classification: For supporting factor eligibility
  • Slotting enrichment: Category, type, HVCRE flags from patterns
  • Full audit trail: Classification reasoning captured per exposure

Output Columns

The classifier adds these columns to exposures:

Column Description
exposure_class SA exposure class (backwards compatible)
exposure_class_sa SA exposure class (explicit)
exposure_class_irb IRB exposure class
is_sme SME classification flag
is_mortgage Mortgage product flag
is_defaulted Default status flag
is_infrastructure Infrastructure lending flag
requires_fi_scalar FI scalar required (1.25x correlation)
qualifies_as_retail Meets retail threshold
approach Assigned calculation approach (SA/FIRB/AIRB/SLOTTING)
classification_reason Audit trail string

See Classification for detailed documentation of the classification algorithm.

CRM Processor

Purpose

Apply credit risk mitigation (collateral, guarantees, provisions).

Interface

class CRMProcessorProtocol(Protocol):
    def get_crm_unified_bundle(
        self,
        data: ClassifiedExposuresBundle,
        config: CalculationConfig,
    ) -> CRMAdjustedBundle:
        """Apply CRM and return the unified bundle (no approach split)."""
        ...

Implementation

get_crm_unified_bundle() is the CRM stage's single entry point (the legacy apply_crm()/get_crm_adjusted_bundle() dual path was deleted in migration Phase 2). All exposures travel on one unified frame; the pipeline splits by approach once, just before the calculators.

class CRMProcessor:
    """Process credit risk mitigation (Art. 111(1)(a)-(b) compliant)."""

    def get_crm_unified_bundle(
        self,
        data: ClassifiedExposuresBundle,
        config: CalculationConfig,
    ) -> CRMAdjustedBundle:
        # Step 0: Funded-only two-layer protection look-through (Art. 191A)

        # Step 1: Resolve provisions (before CCF)
        #   SA: drawn-first deduction, remainder reduces nominal
        #   IRB/Slotting: tracked but not deducted
        # Step 2: Apply CCFs (uses nominal_after_provision)
        # Step 3: Initialize EAD waterfall + crm_post_ead checkpoint
        exposures = self._run_ead_pipeline(data, config)

        # Step 4: Apply collateral (3 lookup collects: direct/facility/counterparty)
        #   + misdirected-AIRB diagnostics (CRM006)
        exposures, applied = self._apply_collateral_unified_step(
            exposures, collateral, config, errors
        )

        # Step 5: Apply guarantees (cross-approach CCF substitution),
        #   behind the crm_pre_guarantee_unified checkpoint
        exposures = self._apply_guarantees_step(
            exposures, guarantees, data, config, errors
        )

        # Step 6: Finalize EAD (no provision subtraction — already in ead_pre_crm)
        # Step 7: Audit columns, then the crm_exit stage edge
        return CRMAdjustedBundle(exposures=exposures, crm_errors=errors, ...)

Key Features

  • Supervisory haircut application
  • Currency mismatch handling
  • Maturity mismatch adjustment
  • Guarantee substitution
  • Provision allocation

SA Calculator

Purpose

Calculate RWA using the Standardised Approach.

Interface

class SACalculatorProtocol(Protocol):
    def calculate_branch(
        self,
        exposures: pl.LazyFrame,
        config: CalculationConfig,
        *,
        errors: list[CalculationError] | None = None,
    ) -> pl.LazyFrame:
        """Calculate SA RWA on pre-filtered SA-only rows."""
        ...

    def calculate_unified(
        self,
        exposures: pl.LazyFrame,
        config: CalculationConfig,
        *,
        errors: list[CalculationError] | None = None,
    ) -> pl.LazyFrame:
        """SA risk weights on the unified frame (B3.1 output floor path)."""
        ...

Implementation

class SACalculator:
    """Calculate Standardised Approach RWA (thin orchestrator over the plain typed SA transforms)."""

    def calculate_branch(self, exposures, config, *, errors=None):
        if errors is not None:
            self._warn_equity_in_main_table(exposures, errors)  # SA005

        return (
            exposures.pipe(apply_risk_weights, config, pack=pack)
            .pipe(apply_fcsm_rw_substitution, config)
            .pipe(apply_life_insurance_rw_mapping)
            .pipe(apply_guarantee_substitution, config, pack=pack)
            .pipe(apply_currency_mismatch_multiplier, config, pack=pack)
            .pipe(apply_due_diligence_override, config, errors=errors, pack=pack)  # SA004
            .pipe(calculate_rwa)
            .pipe(apply_supporting_factors, config, errors=errors, pack=pack)      # SF001
        )  # + approach_applied / rwa_final standardisation for the aggregator

The transforms (apply_risk_weights, apply_currency_mismatch_multiplier, …) are plain typed functions imported from engine/sa/risk_weights.py, engine/sa/rw_adjustments.py, and engine/sa/factors_output.py.

The optional errors accumulator is the branch-path error channel: the pipeline passes one list into every calculate_branch call and merges the accumulated CalculationErrors into the result bundle with their original codes.

Key Features

  • Risk weight lookup by class and CQS
  • LTV-based real estate weights (Basel 3.1)
  • SME supporting factor application
  • Infrastructure factor application

IRB Calculator

Purpose

Calculate RWA using IRB approaches (F-IRB and A-IRB).

Interface

class IRBCalculatorProtocol(Protocol):
    def calculate_branch(
        self,
        exposures: pl.LazyFrame,
        config: CalculationConfig,
        *,
        errors: list[CalculationError] | None = None,
    ) -> pl.LazyFrame:
        """Calculate IRB RWA on pre-filtered IRB-only rows."""
        ...

Implementation

The IRB Calculator is a thin orchestrator over plain typed transform functions (engine/irb/transforms.py) composed via LazyFrame.pipe:

class IRBCalculator:
    """Calculate IRB RWA using K formula."""

    def calculate_branch(self, exposures, config, *, errors=None):
        exposures = (
            exposures.pipe(classify_approach, config)             # F-IRB vs A-IRB
            .pipe(apply_firb_lgd, config, pack=resolved_pack)     # Supervisory LGD for F-IRB
            .pipe(prepare_columns, config, pack=resolved_pack)    # Ensure required columns
            .pipe(apply_all_formulas, config, pack=resolved_pack) # Full IRB calculation
            .pipe(apply_post_model_adjustments, config, pack=resolved_pack)
            .pipe(compute_el_shortfall_excess, errors=errors)
            .pipe(apply_guarantee_substitution, config, pack=resolved_pack)
        )
        # Supporting factors (CRR only — Art. 501), then aggregator columns
        exposures = self._apply_supporting_factors(exposures, config, errors=errors)
        return exposures  # + approach_applied / rwa_final / irb_maturity_m

IRB Transforms

The plain transform functions in engine/irb/transforms.py cover each calculation step (composed via lf.pipe(fn, config)):

Function Description
classify_approach(config) Classify as F-IRB or A-IRB
apply_firb_lgd(config) Apply supervisory LGD for F-IRB
prepare_columns(config) Ensure required columns exist
(PD floor) Apply PD floor (0.03% CRR, 0.05% Basel 3.1)
(LGD floor) Apply LGD floor (Basel 3.1 A-IRB only)
(correlation) Calculate asset correlation with SME adjustment
(capital requirement K) Calculate K
(maturity adjustment) Calculate maturity adjustment
apply_all_formulas(config) Run the complete calculation (floors, correlation, K, maturity adjustment, RWA, expected loss)

Polars namespace registrations are banned by scripts/arch_check.py check 14 — there is no .irb accessor or IRBLazyFrame class.

Key Features

  • Composable transforms: plain typed functions piped in regulatory order
  • Pure Polars expressions: Full lazy evaluation with polars-normal-stats for statistical functions
  • Streaming-capable: No data materialization required, enabling large dataset processing
  • PD and LGD floor application
  • Correlation calculation with SME adjustment
  • K formula implementation using normal_cdf and normal_ppf
  • Maturity adjustment
  • Expected loss calculation
  • CRR 1.06 scaling factor

Slotting Calculator

Purpose

Calculate RWA using the slotting approach for specialised lending.

Interface

class SlottingCalculatorProtocol(Protocol):
    def calculate_branch(
        self,
        exposures: pl.LazyFrame,
        config: CalculationConfig,
        *,
        errors: list[CalculationError] | None = None,
    ) -> pl.LazyFrame:
        """Calculate Slotting RWA on pre-filtered slotting-only rows."""
        ...

Implementation

class SlottingCalculator:
    """Calculate Slotting RWA for specialised lending."""

    def calculate_branch(self, exposures, config, *, errors=None):
        exposures = (
            exposures.pipe(prepare_columns, config)
            .pipe(apply_slotting_weights, config, pack=pack)   # Art. 153(5) tables
            .pipe(calculate_rwa)
        )
        # Supporting factors (CRR Art. 501/501a), EL rates + shortfall/excess
        exposures = self._apply_supporting_factors(exposures, config, errors=errors)
        exposures = exposures.pipe(apply_el_rates, config, pack=pack).pipe(
            compute_el_shortfall_excess, errors=errors
        )
        return exposures  # + approach_applied / rwa_final

The slotting transforms (prepare_columns, apply_slotting_weights, calculate_rwa, apply_el_rates, compute_el_shortfall_excess) are plain typed functions in engine/slotting/transforms.py.

Key Features

  • Slotting category to risk weight mapping
  • Pre-operational project finance handling
  • HVCRE treatment
  • Infrastructure factor application

Equity Calculator

Purpose

Calculate RWA for equity exposures using SA (Article 133) or IRB Simple (Article 155) risk weights.

Interface

class EquityCalculatorProtocol(Protocol):
    def get_equity_result_bundle(
        self,
        data: CRMAdjustedBundle,
        config: CalculationConfig,
    ) -> EquityResultBundle:
        """Calculate equity RWA and return as bundle."""
        ...

Implementation

The approach is determined by the firm's IRB permissions:

  • SA (Article 133): Default approach. Risk weights based on equity type (central bank 0%, listed 100%, unlisted 250%, speculative 400%).
  • IRB Simple (Article 155): When IRB is permitted. Risk weights differ (private equity diversified 190%, exchange-traded 290%, other 370%).
class EquityCalculator:
    """Calculate equity exposure RWA."""

    def get_equity_result_bundle(
        self,
        data: CRMAdjustedBundle,
        config: CalculationConfig
    ) -> EquityResultBundle:
        approach = self._determine_approach(config)
        exposures = self._prepare_columns(data.equity_exposures, config)

        if approach == "sa":
            exposures = self._apply_equity_weights_sa(exposures, config)
        else:
            exposures = self._apply_equity_weights_irb_simple(exposures, config)

        exposures = self._calculate_rwa(exposures)
        audit = self._build_audit(exposures, approach)

        return EquityResultBundle(
            results=exposures,
            calculation_audit=audit,
            approach=approach,
            errors=[],
        )

Key Features

  • Approach determination from IRB permissions
  • SA Article 133 risk weight assignment
  • IRB Simple Article 155 risk weight assignment
  • Diversified portfolio treatment for private equity
  • Equity exposures bypass CRM (no collateral applied)
  • Full audit trail

Aggregator

Purpose

Combine results from all calculators, apply output floor.

Interface

class OutputAggregatorProtocol(Protocol):
    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,
    ) -> AggregatedResultBundle:
        """Aggregate results and apply final adjustments."""
        ...

Implementation

class OutputAggregator:
    """Aggregate calculation results."""

    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,
    ) -> AggregatedResultBundle:
        # Combine the collected branch frames (+ equity results)
        combined = pl.concat([
            sa_results,
            irb_results,
            slotting_results,
            *( [equity_bundle.results] if equity_bundle else [] ),
        ], how="diagonal_relaxed")

        # Apply output floor — applicability resolves from the rulepack
        # (a cited pack Feature read from the resolved pack), not an inline
        # config.framework branch. The floor logic itself lives in
        # engine/aggregator/_floor.py (apply_floor_with_impact).
        if resolved_pack.feature("output_floor"):
            combined = apply_floor_with_impact(combined, resolved_pack)

        # Calculate totals
        totals = self._calculate_totals(combined)

        return AggregatedResultBundle(
            data=combined,
            total_rwa=totals.rwa,
            sa_rwa=totals.sa_rwa,
            irb_rwa=totals.irb_rwa,
            slotting_rwa=totals.slotting_rwa,
            total_expected_loss=totals.expected_loss,
        )

Key Features

  • Result combination
  • Output floor application
  • Floor impact calculation
  • Total aggregation
  • Breakdown by approach/class

Next Steps