Skip to content

Standardised Approach

The Standardised Approach (SA) uses regulatory-prescribed risk weights based on external credit ratings and exposure characteristics. It is the default approach for institutions without IRB approval.

Overview

RWA = EAD × Risk Weight × Supporting Factors

The SA calculation involves: 1. Determining the Exposure Class 2. Mapping to a Credit Quality Step (CQS) if rated 3. Looking up the Risk Weight 4. Applying Supporting Factors (CRR only)

Risk Weight Determination

flowchart TD
    A[Exposure] --> B{Has External Rating?}
    B -->|Yes| C[Map to CQS]
    B -->|No| D[Unrated Treatment]
    C --> E[Lookup RW by Class + CQS]
    D --> F[Default RW for Class]
    E --> G[Apply Supporting Factors]
    F --> G
    G --> H[Calculate RWA]

Credit Quality Steps (CQS)

External ratings are mapped to Credit Quality Steps:

CQS S&P/Fitch Moody's Description
CQS 1 AAA to AA- Aaa to Aa3 Prime/High Grade
CQS 2 A+ to A- A1 to A3 Upper Medium Grade
CQS 3 BBB+ to BBB- Baa1 to Baa3 Lower Medium Grade
CQS 4 BB+ to BB- Ba1 to Ba3 Non-Investment Grade
CQS 5 B+ to B- B1 to B3 Highly Speculative
CQS 6 CCC+ and below Caa1 and below Substantial Risk

Risk Weights by Exposure Class

Sovereign Exposures

Exposures to governments and central banks:

CQS Risk Weight
CQS 1 0%
CQS 2 20%
CQS 3 50%
CQS 4 100%
CQS 5 100%
CQS 6 150%
Unrated 100%

UK Government

UK Government (HM Treasury) exposures receive 0% risk weight as a CQS 1 sovereign.

Institution Exposures

Exposures to banks and investment firms:

CQS CRR Risk Weight Basel 3.1 (ECRA)
CQS 1 20% 20%
CQS 2 30%* 30%
CQS 3 50% 50%
CQS 4 100% 100%
CQS 5 100% 100%
CQS 6 150% 150%

*UK deviation from standard 50% Basel weight

Unrated Institutions: - CRR: 40% risk weight (with due diligence assessment) - Basel 3.1: Use Standardised Credit Risk Assessment (SCRA)

Corporate Exposures

Exposures to non-financial corporates:

CQS CRR Basel 3.1
CQS 1 20% 20%
CQS 2 50% 50%
CQS 3 100% 75%
CQS 4 100% 100%
CQS 5 150% 100%
CQS 6 150% 150%
Unrated 100% 100%

Corporate SME: - Same risk weights as Corporate - SME Supporting Factor may apply (CRR only)

Retail Exposures

Residential Mortgages (CRR):

Criterion Risk Weight
LTV ≤ 80%, performing 35%
LTV > 80% Split treatment: 35% on portion up to 80% LTV, 75% on excess

Residential Mortgages (Basel 3.1):

LTV General (Whole Loan) Income-Producing
≤ 50% 20% 30%
50-60% 25% 35%
60-70% 25% 45%
70-80% 30% 50%
80-90% 40% 60%
90-100% 50% 75%
> 100% 70% 105%

QRRE (Qualifying Revolving Retail Exposures):

Framework Risk Weight
CRR 75%
Basel 3.1 75%

Other Retail:

Framework Risk Weight
CRR 75%
Basel 3.1 75%

Defaulted Exposures

Exposures where the counterparty is in default receive 100% risk weight under SA. Defaulted treatment with provision-coverage differentiation is handled through the IRB approach (see IRB Approach).

Equity Exposures

Type SA Risk Weight
Central bank 0%
Listed / Exchange-traded / Government-supported 100%
Unlisted / Private equity / CIU / Other 250%
Speculative 400%

Commercial Real Estate

Scenario CRR Basel 3.1
Standard 100% 100%
CRR preferential (LTV ≤ 50%, income cover) 50% N/A
Income-Producing (LTV ≤ 60%) N/A 70%
Income-Producing (60-80%) N/A 90%
Income-Producing (LTV > 80%) N/A 110%
General (LTV ≤ 60%) N/A min(60%, Cpty RW)

EAD Calculation

On-Balance Sheet

EAD = Gross_Carrying_Amount - Specific_Provisions

Off-Balance Sheet

EAD = Committed_Amount × CCF

Credit Conversion Factors by Risk Type (CRR Art. 111):

The risk_type column determines the CCF for off-balance sheet exposures:

Risk Type Code SA CCF Description
Full Risk FR 100% Guarantees, acceptances, credit derivatives
Medium Risk MR 50% NIFs, RUFs, standby LCs, committed undrawn
Medium-Low Risk MLR 20% Documentary credits, short-term trade finance
Low Risk LR 0% Unconditionally cancellable commitments

Basel 3.1 Changes:

Item Type CRR CCF Basel 3.1 CCF
Unconditionally Cancellable 0% 10%
Trade Finance (ST) 20% 20%
Undrawn Commitments (<1yr) 20% 40%
Undrawn Commitments (≥1yr) 50% 40%
NIFs/RUFs 50% 50%
Direct Credit Substitutes 100% 100%

Credit Risk Mitigation

SA allows several CRM techniques:

Financial Collateral Simple Method

# Reduce RW based on collateral
Collateral_RW = Risk_Weight_of_Collateral_Issuer

# Apply lower of exposure RW or collateral RW
Effective_RW = min(Exposure_RW, Collateral_RW)

Financial Collateral Comprehensive Method

# Calculate adjusted values
E_adjusted = Exposure × (1 + H_e)  # Exposure haircut
C_adjusted = Collateral × (1 - H_c - H_fx)  # Collateral haircut

# Net exposure
E_star = max(0, E_adjusted - C_adjusted)

# RWA on net exposure
RWA = E_star × Risk_Weight

Supervisory Haircuts:

Collateral Type Haircut
Cash (same currency) 0%
Government bonds ≤1yr 0.5%
Government bonds 1-5yr 2%
Government bonds >5yr 4%
Corporate bonds AAA-AA ≤1yr 1%
Corporate bonds AAA-AA 1-5yr 4%
Corporate bonds AAA-AA >5yr 8%
Main index equity 15%
Other equity 25%
Currency mismatch add-on +8%

Guarantees (Substitution Approach)

# Guaranteed portion treated as exposure to guarantor
Guaranteed_RWA = Guaranteed_Amount × Guarantor_RW

# Unguaranteed portion at counterparty RW
Unguaranteed_RWA = (EAD - Guaranteed_Amount) × Counterparty_RW

# Total RWA
RWA = Guaranteed_RWA + Unguaranteed_RWA

Supporting Factors (CRR Only)

Supporting factors are implemented in sa/supporting_factors.py.

SME Supporting Factor

Reduces RWA for SME exposures (CRR Art. 501):

Conceptual Logic

The following illustrates the SME factor calculation. See the actual implementation for the full Polars-based processing.

# Check eligibility
if turnover <= EUR_50m and is_sme:
    threshold = EUR_2.5m  # ~GBP 2.18m (converted via eur_gbp_rate)

    if exposure <= threshold:
        factor = 0.7619
    else:
        factor = (threshold * 0.7619 + (exposure - threshold) * 0.85) / exposure

    RWA = RWA * factor
Actual Implementation (supporting_factors.py)
"""
CRR Supporting Factors for SA calculator (CRR2 Art. 501).

Applies SME and infrastructure supporting factors to RWA calculations.
These factors are CRR-specific and NOT available under Basel 3.1.

SME Supporting Factor - Tiered Approach (CRR2 Art. 501):
- Applies only to non-defaulted exposures (Art. 501 exclusion)
- Exposures up to EUR 2.5m (GBP 2.2m): factor of 0.7619
- Exposures above EUR 2.5m (GBP 2.2m): factor of 0.85

Formula:
    factor = [min(D, threshold) × 0.7619 + max(D - threshold, 0) × 0.85] / D

    Where D = drawn_amount + interest (on-balance-sheet amount owed)

The tier threshold is applied to drawn (on-balance-sheet) amounts only,
not the full post-CRM EAD which includes CCF-adjusted undrawn commitments.
The resulting blended factor is then applied to the full RWA.

Infrastructure Supporting Factor (CRR Art. 501a):
- Qualifying infrastructure: factor of 0.75

References:
- CRR2 Art. 501 (EU 2019/876 amending EU 575/2013)
- CRR Art. 501a: Infrastructure supporting factor
"""

from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal
from typing import TYPE_CHECKING

import polars as pl

if TYPE_CHECKING:
    from rwa_calc.contracts.config import CalculationConfig


@dataclass
class SupportingFactorResult:
    """Result of supporting factor calculation."""

    factor: Decimal
    was_applied: bool
    description: str


class SupportingFactorCalculator:
    """
    Calculate SME and infrastructure supporting factors for CRR.

    The supporting factors reduce RWA for qualifying exposures:
    - SME: Tiered factor (0.7619 up to threshold, 0.85 above)
    - Infrastructure: Flat 0.75 factor

    Under Basel 3.1, these factors are not available (returns 1.0).
    """

    def calculate_sme_factor(
        self,
        total_exposure: Decimal,
        config: CalculationConfig,
    ) -> 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)
        """
        if not config.supporting_factors.enabled:
            return Decimal("1.0")

        if total_exposure <= 0:
            return Decimal("1.0")

        # Get thresholds and factors from config
        threshold_eur = config.supporting_factors.sme_exposure_threshold_eur
        threshold_gbp = threshold_eur * config.eur_gbp_rate

        factor_tier1 = config.supporting_factors.sme_factor_under_threshold
        factor_tier2 = config.supporting_factors.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

    def calculate_infrastructure_factor(
        self,
        config: CalculationConfig,
    ) -> Decimal:
        """
        Get infrastructure supporting factor.

        Args:
            config: Calculation configuration

        Returns:
            Infrastructure factor (0.75 for CRR, 1.0 for Basel 3.1)
        """
        if not config.supporting_factors.enabled:
            return Decimal("1.0")

        return config.supporting_factors.infrastructure_factor

    def get_effective_factor(
        self,
        is_sme: bool,
        is_infrastructure: bool,
        total_exposure: Decimal,
        config: CalculationConfig,
    ) -> Decimal:
        """
        Get the most beneficial supporting factor.

        If both SME and infrastructure apply, returns the lower factor
        (more beneficial to the bank).

        Args:
            is_sme: Whether exposure qualifies for SME factor
            is_infrastructure: Whether exposure qualifies for infrastructure
            total_exposure: Total drawn (on-balance-sheet) amount for tier calc
            config: Calculation configuration

        Returns:
            Most beneficial factor (lowest value)
        """
        if not config.supporting_factors.enabled:
            return Decimal("1.0")

        factors = [Decimal("1.0")]

        if is_sme:
            factors.append(self.calculate_sme_factor(total_exposure, config))

        if is_infrastructure:
            factors.append(self.calculate_infrastructure_factor(config))

        # Return lowest factor (most beneficial)
        return min(factors)

    def apply_factors(
        self,
        exposures: pl.LazyFrame,
        config: CalculationConfig,
    ) -> pl.LazyFrame:
        """
        Apply supporting factors to exposures LazyFrame.

        The SME supporting factor threshold (EUR 2.5m) is applied at the
        counterparty level using drawn (on-balance-sheet) amounts only.
        All drawn amounts to the same counterparty are aggregated before
        determining the tiered factor. The resulting blended factor is
        then applied to each exposure'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 aggregation)

        Adds columns:
        - supporting_factor: float
        - rwa_post_factor: float (RWA after supporting factor)
        - supporting_factor_applied: bool
        - total_cp_drawn: float (total counterparty drawn amount, for SME)

        Args:
            exposures: Exposures with RWA calculated
            config: Calculation configuration

        Returns:
            Exposures with supporting factors applied
        """
        if not config.supporting_factors.enabled:
            # 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"),
                ]
            )

        # Get threshold in GBP
        threshold_gbp = float(
            config.supporting_factors.sme_exposure_threshold_eur * config.eur_gbp_rate
        )
        factor_tier1 = float(config.supporting_factors.sme_factor_under_threshold)
        factor_tier2 = float(config.supporting_factors.sme_factor_above_threshold)
        infra_factor = float(config.supporting_factors.infrastructure_factor)

        # Check for required columns
        schema = exposures.collect_schema()
        has_sme = "is_sme" in schema.names()
        has_infra = "is_infrastructure" in schema.names()
        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()

        # Build the drawn (on-balance-sheet) expression for tier calculation.
        # Use drawn_amount + interest when available; fall back to ead_final.
        if has_drawn:
            drawn_expr = pl.col("drawn_amount").clip(lower_bound=0.0) + pl.col(
                "interest"
            ).fill_null(0.0)
        else:
            drawn_expr = pl.col("ead_final")

        # Build SME factor expression with counterparty-level aggregation
        if has_sme:
            if has_counterparty:
                # Aggregate drawn amounts at counterparty level using window function
                # Only aggregate SME exposures with valid counterparty references
                total_cp_drawn_expr = (
                    pl.when(pl.col("is_sme") & pl.col("counterparty_reference").is_not_null())
                    .then(drawn_expr.sum().over("counterparty_reference"))
                    .otherwise(
                        # Fall back to individual drawn if no counterparty ref or not SME
                        drawn_expr
                    )
                )

                exposures = exposures.with_columns([total_cp_drawn_expr.alias("total_cp_drawn")])

                # Use counterparty total drawn for tier calculation
                ead_for_tier = pl.col("total_cp_drawn")
            else:
                # No counterparty reference column - use individual drawn amount
                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 but still
            # contribute to total_cp_drawn for tier calculation (CRR Art. 501)
            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)

            sme_factor_expr = (
                pl.when(pl.col("is_sme") & (ead_for_tier > 0) & ~is_btl & ~is_defaulted)
                .then((tier1_expr * factor_tier1 + tier2_expr * factor_tier2) / ead_for_tier)
                .when(pl.col("is_sme") & (ead_for_tier <= 0) & ~is_btl & ~is_defaulted)
                .then(
                    # Zero drawn = all within tier 1 → pure 0.7619
                    pl.lit(factor_tier1)
                )
                .otherwise(pl.lit(1.0))
            )
        else:
            sme_factor_expr = pl.lit(1.0)

        # Build infrastructure factor expression inline
        if has_infra:
            infra_factor_expr = (
                pl.when(pl.col("is_infrastructure"))
                .then(pl.lit(infra_factor))
                .otherwise(pl.lit(1.0))
            )
        else:
            infra_factor_expr = 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"),
            ]
        )


def create_supporting_factor_calculator() -> SupportingFactorCalculator:
    """Create a SupportingFactorCalculator instance."""
    return SupportingFactorCalculator()

Infrastructure Factor

Reduces RWA for qualifying infrastructure (CRR Art. 501a):

if is_qualifying_infrastructure:
    RWA = RWA * 0.75

Calculation Example

Exposure: - Corporate loan, £10m drawn - Rated A+ (CQS 2) - SME counterparty (turnover £30m) - Unsecured

Calculation:

# Step 1: EAD
EAD = £10,000,000

# Step 2: Risk Weight (CQS 2 Corporate)
RW = 50%

# Step 3: Base RWA
Base_RWA = £10,000,000 × 50% = £5,000,000

# Step 4: SME Factor (CRR only)
# Exposure > threshold, so tiered
threshold = EUR 2,500,000 × 0.8732 = £2,183,000
factor = (2,183,000 × 0.7619 + 7,817,000 × 0.85) / 10,000,000
factor = (1,663,427 + 6,644,450) / 10,000,000
factor = 0.831

# Step 5: Final RWA (CRR)
Final_RWA = £5,000,000 × 0.831 = £4,153,939

# Basel 3.1 (no SME factor)
Final_RWA_B31 = £5,000,000

Implementation Notes

Calculator Usage

The SA calculator is implemented in sa/calculator.py.

import polars as pl
from rwa_calc.engine.sa.calculator import SACalculator
from rwa_calc.contracts.config import CalculationConfig
from datetime import date

# Create SA calculator
calculator = SACalculator()

# Calculate RWA for a single exposure via calculate_branch()
df = pl.DataFrame({
    "exposure_reference": ["EX1"],
    "ead": [10_000_000.0],
    "exposure_class": ["CORPORATE"],
    "cqs": [2],
    "is_sme": [True],
}).lazy()

config = CalculationConfig.crr(reporting_date=date(2026, 12, 31))
result = calculator.calculate_branch(df, config).collect().to_dicts()[0]

# Access results
print(f"Risk Weight: {result['risk_weight']}")
print(f"RWA: {result['rwa']}")

Risk Weight Lookup

Risk weights are defined in:

  • CRR: data/tables/crr_risk_weights.pyget_combined_cqs_risk_weights(use_uk_deviation=True)
  • Basel 3.1: data/tables/b31_risk_weights.pyget_b31_combined_cqs_risk_weights()
from rwa_calc.data.tables.crr_risk_weights import get_combined_cqs_risk_weights

# Get CRR risk weight lookup table
rw_table = get_combined_cqs_risk_weights(use_uk_deviation=True)

# Table includes: exposure_class, cqs, risk_weight
# Example: CORPORATE, CQS 2 -> 50%
Actual Risk Weight Application (calculator.py)

See the _apply_risk_weights method in src/rwa_calc/engine/sa/calculator.py for the full implementation of risk weight lookups by exposure class and CQS.

Regulatory References

Topic CRR Article BCBS CRE
Risk weight assignment Art. 113-134 CRE20-22
CCFs Art. 111 CRE20.10
CRM Art. 192-241 CRE22
SME factor Art. 501 N/A
Real estate Art. 124-125 CRE20.70-90

Next Steps