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/hierarchy/ (package) Resolve hierarchies
Classifier engine/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

Each stage package is the single home of its component — there is no top-level alias module. Import HierarchyResolver from engine/hierarchy/, ExposureClassifier from engine/classify/, RealEstateSplitter from engine/re_split/ and FXConverter from engine/fx/.

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_members() For non-MOF Facility Shares (one facility any of several counterparties may draw) — derive the member set: the facility's own counterparty_reference (the owner is always a member) unioned with the distinct counterparties on its descendant loans and contingents, each resolved to its root facility. A union of more than one member is what makes the facility a share. MOF parents are anti-joined out, because each sub waterfall row already carries the right counterparty natively
_apply_facility_share_fanout() Replicate a share's single undrawn row into one candidate row per member, each carrying the full headroom (each is "as if this member drew the whole line" — nothing is pro-rated and nothing is drawn-weighted). Each candidate takes exposure_reference = <facility>_UNDRAWN@<member>, the member as counterparty_reference, the owner in original_counterparty_reference, facility_share_group = <facility> and is_facility_share_candidate = True; source_exposure_reference stays <facility>, so reconciliation keys are unchanged. Nothing is ranked here
_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.
  • A non-MOF Facility Share fans out into one undrawn candidate row per member, rather than being allocated here. The member set is the facility's owner unioned with the distinct counterparties on its descendant loans and contingents; more than one member makes it a share. Every candidate carries the full headroom, its own member as counterparty_reference, the owner in original_counterparty_reference for audit, and the facility_share_group / is_facility_share_candidate carriers. Each then flows through the classifier, CRM and the calculators as an ordinary row of its own member — its own exposure class, model permission, PD/LGD and CRM — and the aggregator keeps one and drops the rest, so the allocation is decided on real priced RWA. MOF parents are excluded because each waterfall row already carries its sub-facility's own counterparty. The SA-equivalent risk-weight preview that used to choose the winner here was removed on 2026-09-05; see Facility Share Allocation.
  • 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 Internal Steps

Step Purpose
resolve_facility_shares() (engine/aggregator/_facility_share.py) Runs at the head of aggregate(), on the three branch frames (sa_results, irb_results, slotting_results) plus the concatenation — before the securitisation views, the residual multiplier, the expected-loss summary and the output floor. Keeps exactly one undrawn candidate per facility_share_group and drops the rest from every frame, then collapses the winner's exposure_reference back to <facility>_UNDRAWN, so the aggregator exit keeps its one undrawn row per facility invariant. The metric is P0 (argmax own-approach RWA) under CRR, and under Basel 3.1 when the firm has elected own_approach; otherwise P2 — evaluate assignment A (argmax own RWA) and assignment B (argmax floored-branch marginal) end to end and keep the larger, ties to A. Emits the per-candidate facility_share_resolution audit frame, sets facility_share_metric_used / facility_share_trea_alternative on OutputFloorSummary, and raises an AGG003 warning where every candidate of a group carries a non-finite own-approach RWA and a deterministic fallback ordering picked the member instead. See Facility Share Allocation
apply_floor_with_impact() (engine/aggregator/_floor.py) Basel 3.1 output floor at portfolio level, gated on the output_floor pack Feature and the entity-scope check; distributes the shortfall pro-rata by sa_rwa across floor-eligible rows

The drop must precede the floor and the expected-loss summary: a loser left on a branch frame inflates S-TREA, and its expected loss feeds the CET1 deduction and therefore OF-ADJ. Filtering the combined frame alone is green on rwa_final and wrong on OF-ADJ.

Key Features

  • Facility-share candidate resolution before the floor
  • Result combination
  • Output floor application
  • Floor impact calculation
  • Total aggregation
  • Breakdown by approach/class

Next Steps