Skip to content

RWA API

creditriskengine.rwa.irb.formulas

IRB Risk Weight Formulas — BCBS d424 (December 2017).

This module implements the regulatory risk weight functions for all IRB asset classes. Each function documents its exact source paragraph from the Basel III framework.

CRITICAL: These formulas directly affect bank capital requirements. Every parameter, threshold, and formula must be verified against the referenced Basel Committee text.

asset_correlation_corporate(pd)

Asset correlation for corporate, sovereign, and bank exposures.

Formula (BCBS CRE31.5): R = 0.12 * (1 - exp(-50 * PD)) / (1 - exp(-50)) + 0.24 * (1 - (1 - exp(-50 * PD)) / (1 - exp(-50)))

Produces R in [0.12, 0.24]: - R -> 0.24 as PD -> 0 - R -> 0.12 as PD -> 1

Parameters:

Name Type Description Default
pd float

Probability of Default (annualized, in [0.0003, 1.0])

required

Returns:

Type Description
float

Asset correlation R in [0.12, 0.24]

Source code in creditriskengine\rwa\irb\formulas.py
def asset_correlation_corporate(pd: float) -> float:
    """Asset correlation for corporate, sovereign, and bank exposures.

    Formula (BCBS CRE31.5):
        R = 0.12 * (1 - exp(-50 * PD)) / (1 - exp(-50))
          + 0.24 * (1 - (1 - exp(-50 * PD)) / (1 - exp(-50)))

    Produces R in [0.12, 0.24]:
    - R -> 0.24 as PD -> 0
    - R -> 0.12 as PD -> 1

    Args:
        pd: Probability of Default (annualized, in [0.0003, 1.0])

    Returns:
        Asset correlation R in [0.12, 0.24]
    """
    exp_factor = (1.0 - math.exp(-50.0 * pd)) / (1.0 - math.exp(-50.0))
    return 0.12 * exp_factor + 0.24 * (1.0 - exp_factor)

sme_firm_size_adjustment(turnover_eur_millions)

SME firm-size adjustment to correlation for corporate exposures.

Formula (BCBS CRE31.6): Adjustment = -0.04 * (1 - (min(max(S, 5), 50) - 5) / 45)

Where S = annual turnover in EUR millions. - S is floored at EUR 5M and capped at EUR 50M - Maximum reduction is 0.04 (at S = EUR 5M) - No adjustment when S >= EUR 50M

Parameters:

Name Type Description Default
turnover_eur_millions float

Annual sales/turnover in EUR millions

required

Returns:

Type Description
float

Correlation adjustment (negative value to subtract from R)

Source code in creditriskengine\rwa\irb\formulas.py
def sme_firm_size_adjustment(turnover_eur_millions: float) -> float:
    """SME firm-size adjustment to correlation for corporate exposures.

    Formula (BCBS CRE31.6):
        Adjustment = -0.04 * (1 - (min(max(S, 5), 50) - 5) / 45)

    Where S = annual turnover in EUR millions.
    - S is floored at EUR 5M and capped at EUR 50M
    - Maximum reduction is 0.04 (at S = EUR 5M)
    - No adjustment when S >= EUR 50M

    Args:
        turnover_eur_millions: Annual sales/turnover in EUR millions

    Returns:
        Correlation adjustment (negative value to subtract from R)
    """
    s = min(max(turnover_eur_millions, 5.0), 50.0)
    return -0.04 * (1.0 - (s - 5.0) / 45.0)

asset_correlation_residential_mortgage(pd)

Asset correlation for residential mortgage exposures.

Formula (BCBS CRE31.8): R = 0.15

Fixed correlation of 0.15 per the 2017 Basel III reform.

Parameters:

Name Type Description Default
pd float

Probability of Default (unused, kept for interface consistency)

required

Returns:

Type Description
float

Fixed correlation of 0.15

Source code in creditriskengine\rwa\irb\formulas.py
def asset_correlation_residential_mortgage(pd: float) -> float:
    """Asset correlation for residential mortgage exposures.

    Formula (BCBS CRE31.8):
        R = 0.15

    Fixed correlation of 0.15 per the 2017 Basel III reform.

    Args:
        pd: Probability of Default (unused, kept for interface consistency)

    Returns:
        Fixed correlation of 0.15
    """
    return 0.15

asset_correlation_qrre(pd)

Asset correlation for Qualifying Revolving Retail Exposures.

Formula (BCBS CRE31.9): R = 0.04

Fixed correlation for all QRRE exposures.

Parameters:

Name Type Description Default
pd float

Probability of Default

required

Returns:

Type Description
float

Fixed correlation of 0.04

Source code in creditriskengine\rwa\irb\formulas.py
def asset_correlation_qrre(pd: float) -> float:
    """Asset correlation for Qualifying Revolving Retail Exposures.

    Formula (BCBS CRE31.9):
        R = 0.04

    Fixed correlation for all QRRE exposures.

    Args:
        pd: Probability of Default

    Returns:
        Fixed correlation of 0.04
    """
    return 0.04

asset_correlation_other_retail(pd)

Asset correlation for Other Retail exposures.

Formula (BCBS CRE31.10): R = 0.03 * (1 - exp(-35 * PD)) / (1 - exp(-35)) + 0.16 * (1 - (1 - exp(-35 * PD)) / (1 - exp(-35)))

Range: [0.03, 0.16]

Parameters:

Name Type Description Default
pd float

Probability of Default

required

Returns:

Type Description
float

Asset correlation in [0.03, 0.16]

Source code in creditriskengine\rwa\irb\formulas.py
def asset_correlation_other_retail(pd: float) -> float:
    """Asset correlation for Other Retail exposures.

    Formula (BCBS CRE31.10):
        R = 0.03 * (1 - exp(-35 * PD)) / (1 - exp(-35))
          + 0.16 * (1 - (1 - exp(-35 * PD)) / (1 - exp(-35)))

    Range: [0.03, 0.16]

    Args:
        pd: Probability of Default

    Returns:
        Asset correlation in [0.03, 0.16]
    """
    exp_factor = (1.0 - math.exp(-35.0 * pd)) / (1.0 - math.exp(-35.0))
    return 0.03 * exp_factor + 0.16 * (1.0 - exp_factor)

maturity_adjustment(pd, maturity)

Maturity adjustment factor for corporate, sovereign, bank exposures.

Formula (BCBS CRE31.7): b = (0.11852 - 0.05478 * ln(PD))^2 MA = (1 + (M - 2.5) * b) / (1 - 1.5 * b)

Where M = effective maturity in years.

For F-IRB: M = 2.5 years fixed (BCBS CRE32.47). For A-IRB: M = max(1, effective maturity), capped at 5 years. Retail exposures: NO maturity adjustment.

Parameters:

Name Type Description Default
pd float

Probability of Default (>= 0.0003)

required
maturity float

Effective maturity M in years

required

Returns:

Type Description
float

Maturity adjustment factor (multiplier to capital requirement K)

Source code in creditriskengine\rwa\irb\formulas.py
def maturity_adjustment(pd: float, maturity: float) -> float:
    """Maturity adjustment factor for corporate, sovereign, bank exposures.

    Formula (BCBS CRE31.7):
        b = (0.11852 - 0.05478 * ln(PD))^2
        MA = (1 + (M - 2.5) * b) / (1 - 1.5 * b)

    Where M = effective maturity in years.

    For F-IRB: M = 2.5 years fixed (BCBS CRE32.47).
    For A-IRB: M = max(1, effective maturity), capped at 5 years.
    Retail exposures: NO maturity adjustment.

    Args:
        pd: Probability of Default (>= 0.0003)
        maturity: Effective maturity M in years

    Returns:
        Maturity adjustment factor (multiplier to capital requirement K)
    """
    pd_calc = max(pd, PD_FLOOR)
    b = (0.11852 - 0.05478 * math.log(pd_calc)) ** 2
    return (1.0 + (maturity - 2.5) * b) / (1.0 - 1.5 * b)

irb_capital_requirement_k(pd, lgd, correlation)

IRB capital requirement K (before maturity adjustment).

Formula (BCBS CRE31.4): K = LGD * [N((1-R)^(-0.5) * G(PD) + (R/(1-R))^0.5 * G(0.999)) - PD]

Where: - N() = standard normal CDF - G() = standard normal inverse CDF - R = asset correlation - PD = probability of default (floored at 0.03%) - LGD = loss given default

The 0.999 confidence level = 99.9th percentile.

Parameters:

Name Type Description Default
pd float

Probability of Default (>= 0.0003 floor applied)

required
lgd float

Loss Given Default (in [0, 1])

required
correlation float

Asset correlation R

required

Returns:

Type Description
float

Capital requirement K as a fraction of EAD

Source code in creditriskengine\rwa\irb\formulas.py
def irb_capital_requirement_k(
    pd: float,
    lgd: float,
    correlation: float,
) -> float:
    """IRB capital requirement K (before maturity adjustment).

    Formula (BCBS CRE31.4):
        K = LGD * [N((1-R)^(-0.5) * G(PD) + (R/(1-R))^0.5 * G(0.999)) - PD]

    Where:
    - N() = standard normal CDF
    - G() = standard normal inverse CDF
    - R = asset correlation
    - PD = probability of default (floored at 0.03%)
    - LGD = loss given default

    The 0.999 confidence level = 99.9th percentile.

    Args:
        pd: Probability of Default (>= 0.0003 floor applied)
        lgd: Loss Given Default (in [0, 1])
        correlation: Asset correlation R

    Returns:
        Capital requirement K as a fraction of EAD
    """
    if not 0.0 < correlation < 1.0:
        raise ValueError(f"Correlation must be in (0, 1), got {correlation}")

    pd_floored = max(pd, PD_FLOOR)

    g_pd = norm.ppf(pd_floored)
    g_999 = norm.ppf(0.999)

    conditional_pd = norm.cdf(
        (1.0 / math.sqrt(1.0 - correlation)) * g_pd
        + math.sqrt(correlation / (1.0 - correlation)) * g_999
    )

    k = lgd * (float(conditional_pd) - pd_floored)
    return max(k, 0.0)

irb_risk_weight(pd, lgd, asset_class, maturity=2.5, turnover_eur_millions=None, is_qrre_transactor=False, ead=1.0)

Full IRB risk weight computation.

Formula (BCBS CRE31.4-31.10): RW = K * 12.5 * MA (corporate/sovereign/bank) RW = K * 12.5 (retail)

The 12.5 multiplier converts K to risk weight because Capital = 8% * RWA = K * EAD, so RW = K * 12.5.

PD Floor (CRE32.13): 0.03% for all non-defaulted exposures.

Parameters:

Name Type Description Default
pd float

Probability of Default

required
lgd float

Loss Given Default

required
asset_class str

One of 'corporate', 'sovereign', 'bank', 'residential_mortgage', 'qrre', 'other_retail'

required
maturity float

Effective maturity in years (non-retail only)

2.5
turnover_eur_millions float | None

For SME firm-size correlation adjustment

None
is_qrre_transactor bool

If True, apply 0.75× RW scalar per CRE31.9 fn 15

False
ead float

Exposure at Default (default 1.0)

1.0

Returns:

Type Description
float

Risk weight as a percentage (e.g., 75.0 means 75%)

Source code in creditriskengine\rwa\irb\formulas.py
def irb_risk_weight(
    pd: float,
    lgd: float,
    asset_class: str,
    maturity: float = 2.5,
    turnover_eur_millions: float | None = None,
    is_qrre_transactor: bool = False,
    ead: float = 1.0,
) -> float:
    """Full IRB risk weight computation.

    Formula (BCBS CRE31.4-31.10):
        RW = K * 12.5 * MA    (corporate/sovereign/bank)
        RW = K * 12.5          (retail)

    The 12.5 multiplier converts K to risk weight because
    Capital = 8% * RWA = K * EAD, so RW = K * 12.5.

    PD Floor (CRE32.13): 0.03% for all non-defaulted exposures.

    Args:
        pd: Probability of Default
        lgd: Loss Given Default
        asset_class: One of 'corporate', 'sovereign', 'bank',
                     'residential_mortgage', 'qrre', 'other_retail'
        maturity: Effective maturity in years (non-retail only)
        turnover_eur_millions: For SME firm-size correlation adjustment
        is_qrre_transactor: If True, apply 0.75× RW scalar per CRE31.9 fn 15
        ead: Exposure at Default (default 1.0)

    Returns:
        Risk weight as a percentage (e.g., 75.0 means 75%)
    """
    pd_floored = max(pd, PD_FLOOR)

    # Defaulted exposures: K = max(0, LGD - EL_BE)
    if pd >= 1.0:
        return 0.0

    # Step 1: Determine asset correlation
    if asset_class in ("corporate", "sovereign", "bank"):
        r = asset_correlation_corporate(pd_floored)
        if asset_class == "corporate" and turnover_eur_millions is not None:
            r += sme_firm_size_adjustment(turnover_eur_millions)
            r = max(r, 0.0)
    elif asset_class == "residential_mortgage":
        r = asset_correlation_residential_mortgage(pd_floored)
    elif asset_class == "qrre":
        r = asset_correlation_qrre(pd_floored)
    elif asset_class == "other_retail":
        r = asset_correlation_other_retail(pd_floored)
    else:
        raise ValueError(f"Unknown asset class: {asset_class}")

    # Step 2: Capital requirement K
    k = irb_capital_requirement_k(pd_floored, lgd, r)

    # Step 3: Maturity adjustment (non-retail only)
    if asset_class in ("corporate", "sovereign", "bank"):
        m = max(1.0, min(maturity, 5.0))
        ma = maturity_adjustment(pd_floored, m)
        k *= ma

    # Step 4: Convert to risk weight
    rw = k * 12.5

    # Step 5: QRRE transactor scalar (BCBS CRE31.9, footnote 15)
    # Transactors are obligors who repay balances in full each month.
    # They receive a 0.75× multiplier on the risk weight.
    if asset_class == "qrre" and is_qrre_transactor:
        rw *= 0.75

    logger.debug(
        "IRB RW: asset_class=%s pd=%.4f lgd=%.2f R=%.4f K=%.6f RW=%.2f%%",
        asset_class, pd_floored, lgd, r, k, rw * 100.0,
    )

    return rw * 100.0

double_default_rw(pd_obligor, pd_guarantor, lgd, maturity=2.5, asset_class='corporate')

Double default risk weight using the substitution approach.

When a guarantee or credit derivative exists, the bank may substitute the guarantor's PD for the obligor's PD while retaining the asset correlation derived from the obligor's asset class.

Formula (BCBS CRE32.38-41): 1. Effective PD = max(PD_guarantor, PD_FLOOR) 2. Correlation R = derived from obligor's asset class using PD_obligor 3. K = IRB capital requirement using effective PD, obligor's R, and LGD 4. Apply maturity adjustment for non-retail asset classes 5. RW = K * 12.5

The guarantor PD is floored at 0.03% per CRE32.13.

Parameters:

Name Type Description Default
pd_obligor float

Probability of Default of the original obligor.

required
pd_guarantor float

Probability of Default of the guarantor.

required
lgd float

Loss Given Default (in [0, 1]).

required
maturity float

Effective maturity in years (non-retail only).

2.5
asset_class str

One of 'corporate', 'sovereign', 'bank', 'residential_mortgage', 'qrre', 'other_retail'.

'corporate'

Returns:

Type Description
float

Risk weight as a percentage (e.g., 75.0 means 75%).

Source code in creditriskengine\rwa\irb\formulas.py
def double_default_rw(
    pd_obligor: float,
    pd_guarantor: float,
    lgd: float,
    maturity: float = 2.5,
    asset_class: str = "corporate",
) -> float:
    """Double default risk weight using the substitution approach.

    When a guarantee or credit derivative exists, the bank may substitute
    the guarantor's PD for the obligor's PD while retaining the asset
    correlation derived from the obligor's asset class.

    Formula (BCBS CRE32.38-41):
        1. Effective PD = max(PD_guarantor, PD_FLOOR)
        2. Correlation R = derived from obligor's asset class using PD_obligor
        3. K = IRB capital requirement using effective PD, obligor's R, and LGD
        4. Apply maturity adjustment for non-retail asset classes
        5. RW = K * 12.5

    The guarantor PD is floored at 0.03% per CRE32.13.

    Args:
        pd_obligor: Probability of Default of the original obligor.
        pd_guarantor: Probability of Default of the guarantor.
        lgd: Loss Given Default (in [0, 1]).
        maturity: Effective maturity in years (non-retail only).
        asset_class: One of 'corporate', 'sovereign', 'bank',
                     'residential_mortgage', 'qrre', 'other_retail'.

    Returns:
        Risk weight as a percentage (e.g., 75.0 means 75%).
    """
    pd_obligor_floored = max(pd_obligor, PD_FLOOR)
    pd_eff = max(pd_guarantor, PD_FLOOR)

    # Defaulted guarantor: no benefit
    if pd_guarantor >= 1.0:
        return 0.0

    # Step 1: Asset correlation from obligor's asset class and PD
    if asset_class in ("corporate", "sovereign", "bank"):
        r = asset_correlation_corporate(pd_obligor_floored)
    elif asset_class == "residential_mortgage":
        r = asset_correlation_residential_mortgage(pd_obligor_floored)
    elif asset_class == "qrre":
        r = asset_correlation_qrre(pd_obligor_floored)
    elif asset_class == "other_retail":
        r = asset_correlation_other_retail(pd_obligor_floored)
    else:
        raise ValueError(f"Unknown asset class: {asset_class}")

    # Step 2: Capital requirement using guarantor PD with obligor correlation
    k = irb_capital_requirement_k(pd_eff, lgd, r)

    # Step 3: Maturity adjustment (non-retail only)
    if asset_class in ("corporate", "sovereign", "bank"):
        m = max(1.0, min(maturity, 5.0))
        ma = maturity_adjustment(pd_eff, m)
        k *= ma

    # Step 4: Convert to risk weight
    rw = k * 12.5

    logger.debug(
        "Double default RW: asset_class=%s pd_obligor=%.4f pd_guarantor=%.4f "
        "pd_eff=%.4f lgd=%.2f R=%.4f K=%.6f RW=%.2f%%",
        asset_class, pd_obligor_floored, pd_guarantor, pd_eff, lgd, r, k,
        rw * 100.0,
    )

    return rw * 100.0

equity_irb_rw(pd, equity_type='listed')

IRB simple risk weight method for equity exposures.

Formula (BCBS CRE33): RW = max(floor, 2.5 * corporate_IRB_RW(PD, LGD=90%, M=5))

Where: - Listed equity: floor = 200% - Private/unlisted equity: floor = 300% - LGD is fixed at 90% per CRE33 - Maturity M is fixed at 5 years per CRE33 - The corporate IRB RW uses the standard corporate correlation

Parameters:

Name Type Description Default
pd float

Probability of Default.

required
equity_type str

One of 'listed' or 'private'.

'listed'

Returns:

Type Description
float

Risk weight as a percentage (e.g., 200.0 means 200%).

Raises:

Type Description
ValueError

If equity_type is not 'listed' or 'private'.

Source code in creditriskengine\rwa\irb\formulas.py
def equity_irb_rw(
    pd: float,
    equity_type: str = "listed",
) -> float:
    """IRB simple risk weight method for equity exposures.

    Formula (BCBS CRE33):
        RW = max(floor, 2.5 * corporate_IRB_RW(PD, LGD=90%, M=5))

    Where:
    - Listed equity: floor = 200%
    - Private/unlisted equity: floor = 300%
    - LGD is fixed at 90% per CRE33
    - Maturity M is fixed at 5 years per CRE33
    - The corporate IRB RW uses the standard corporate correlation

    Args:
        pd: Probability of Default.
        equity_type: One of 'listed' or 'private'.

    Returns:
        Risk weight as a percentage (e.g., 200.0 means 200%).

    Raises:
        ValueError: If equity_type is not 'listed' or 'private'.
    """
    if equity_type not in ("listed", "private"):
        raise ValueError(
            f"equity_type must be 'listed' or 'private', got '{equity_type}'"
        )

    # Fixed parameters per CRE33
    lgd = 0.90
    maturity = 5.0

    # Corporate IRB RW with fixed LGD=90% and M=5
    corporate_rw = irb_risk_weight(
        pd=pd, lgd=lgd, asset_class="corporate", maturity=maturity
    )

    # Apply 2.5× multiplier
    scaled_rw = 2.5 * corporate_rw

    # Apply floor based on equity type
    floor = 200.0 if equity_type == "listed" else 300.0

    rw = max(floor, scaled_rw)

    logger.debug(
        "Equity IRB RW: type=%s pd=%.4f corporate_rw=%.2f%% "
        "scaled=%.2f%% floor=%.2f%% final=%.2f%%",
        equity_type, max(pd, PD_FLOOR), corporate_rw, scaled_rw, floor, rw,
    )

    return rw

creditriskengine.rwa.standardized.credit_risk_sa

Standardized Approach (SA) for Credit Risk — BCBS d424 CRE20.

Risk weight assignment logic for all SA exposure classes. Supports jurisdiction-specific overrides via YAML config.

get_sovereign_risk_weight(cqs, jurisdiction=Jurisdiction.BCBS, is_domestic_own_currency=False)

Risk weight for sovereign exposures.

Reference: BCBS CRE20.7, Table 1.

Parameters:

Name Type Description Default
cqs CreditQualityStep

Credit quality step.

required
jurisdiction Jurisdiction

Regulatory jurisdiction.

BCBS
is_domestic_own_currency bool

If True, domestic sovereign in own currency.

False

Returns:

Type Description
float

Risk weight as percentage.

Source code in creditriskengine\rwa\standardized\credit_risk_sa.py
def get_sovereign_risk_weight(
    cqs: CreditQualityStep,
    jurisdiction: Jurisdiction = Jurisdiction.BCBS,
    is_domestic_own_currency: bool = False,
) -> float:
    """Risk weight for sovereign exposures.

    Reference: BCBS CRE20.7, Table 1.

    Args:
        cqs: Credit quality step.
        jurisdiction: Regulatory jurisdiction.
        is_domestic_own_currency: If True, domestic sovereign in own currency.

    Returns:
        Risk weight as percentage.
    """
    if is_domestic_own_currency:
        # Most jurisdictions assign 0% to own sovereign in domestic currency
        return 0.0
    return SOVEREIGN_RW.get(cqs.value, 100.0)

get_bank_risk_weight(cqs=None, jurisdiction=Jurisdiction.BCBS, scra_grade=None, is_short_term=False)

Risk weight for bank exposures.

Uses ECRA (External Credit Risk Assessment) if CQS provided, otherwise SCRA (Standardized Credit Risk Assessment).

Reference: BCBS CRE20.15-20.21.

Parameters:

Name Type Description Default
cqs CreditQualityStep | None

Credit quality step (for ECRA).

None
jurisdiction Jurisdiction

Regulatory jurisdiction.

BCBS
scra_grade str | None

SCRA grade A/B/C (when ECRA not used).

None
is_short_term bool

If True, use short-term claim risk weights.

False

Returns:

Type Description
float

Risk weight as percentage.

Source code in creditriskengine\rwa\standardized\credit_risk_sa.py
def get_bank_risk_weight(
    cqs: CreditQualityStep | None = None,
    jurisdiction: Jurisdiction = Jurisdiction.BCBS,
    scra_grade: str | None = None,
    is_short_term: bool = False,
) -> float:
    """Risk weight for bank exposures.

    Uses ECRA (External Credit Risk Assessment) if CQS provided,
    otherwise SCRA (Standardized Credit Risk Assessment).

    Reference: BCBS CRE20.15-20.21.

    Args:
        cqs: Credit quality step (for ECRA).
        jurisdiction: Regulatory jurisdiction.
        scra_grade: SCRA grade A/B/C (when ECRA not used).
        is_short_term: If True, use short-term claim risk weights.

    Returns:
        Risk weight as percentage.
    """
    if cqs is not None:
        if is_short_term:
            return BANK_ECRA_SHORT_TERM_RW.get(cqs.value, 20.0)
        return BANK_ECRA_RW.get(cqs.value, 50.0)
    if scra_grade is not None:
        rw = BANK_SCRA_RW.get(scra_grade.upper())
        if rw is None:
            raise ValueError(f"Invalid SCRA grade: {scra_grade}. Must be A, B, or C.")
        return rw
    # Default unrated
    return 50.0

get_corporate_risk_weight(cqs, jurisdiction=Jurisdiction.BCBS, is_investment_grade=None, is_sme=False)

Risk weight for corporate exposures.

Reference: BCBS CRE20.28-20.32, Table 7.

UK PRA divergence (PS9/24, para 3.17): Unrated investment-grade corporates = 65%.

EU CRR3 Art. 501 — SME supporting factor: Exposures <= EUR 2.5M: multiply RW by 0.7619 Exposures > EUR 2.5M: 0.7619 for first EUR 2.5M, 0.85 for remainder

Parameters:

Name Type Description Default
cqs CreditQualityStep

Credit quality step.

required
jurisdiction Jurisdiction

Regulatory jurisdiction.

BCBS
is_investment_grade bool | None

For UK PRA unrated corporate treatment.

None
is_sme bool

If True and jurisdiction supports it, apply SME factor.

False

Returns:

Type Description
float

Risk weight as percentage.

Source code in creditriskengine\rwa\standardized\credit_risk_sa.py
def get_corporate_risk_weight(
    cqs: CreditQualityStep,
    jurisdiction: Jurisdiction = Jurisdiction.BCBS,
    is_investment_grade: bool | None = None,
    is_sme: bool = False,
) -> float:
    """Risk weight for corporate exposures.

    Reference: BCBS CRE20.28-20.32, Table 7.

    UK PRA divergence (PS9/24, para 3.17):
        Unrated investment-grade corporates = 65%.

    EU CRR3 Art. 501 — SME supporting factor:
        Exposures <= EUR 2.5M: multiply RW by 0.7619
        Exposures > EUR 2.5M: 0.7619 for first EUR 2.5M, 0.85 for remainder

    Args:
        cqs: Credit quality step.
        jurisdiction: Regulatory jurisdiction.
        is_investment_grade: For UK PRA unrated corporate treatment.
        is_sme: If True and jurisdiction supports it, apply SME factor.

    Returns:
        Risk weight as percentage.
    """
    if cqs == CreditQualityStep.UNRATED:
        if jurisdiction == Jurisdiction.UK and is_investment_grade:
            return 65.0
        rw = 100.0
    else:
        rw = CORPORATE_RW.get(cqs.value, 100.0)

    # EU SME supporting factor (CRR3 Art. 501)
    if is_sme and jurisdiction == Jurisdiction.EU:
        rw *= 0.7619

    return rw

get_residential_re_risk_weight(ltv, jurisdiction=Jurisdiction.BCBS, is_cashflow_dependent=False, is_income_producing=False)

Risk weight for residential real estate exposures.

Reference: BCBS CRE20.71-20.86, Tables 12-13. Whole-loan approach (BCBS/EU CRR3).

Parameters:

Name Type Description Default
ltv float

Loan-to-value ratio (e.g., 0.75 for 75%).

required
jurisdiction Jurisdiction

Regulatory jurisdiction.

BCBS
is_cashflow_dependent bool

If True, use cashflow-dependent table.

False
is_income_producing bool

If True, treated as income-producing.

False

Returns:

Type Description
float

Risk weight as percentage.

Source code in creditriskengine\rwa\standardized\credit_risk_sa.py
def get_residential_re_risk_weight(
    ltv: float,
    jurisdiction: Jurisdiction = Jurisdiction.BCBS,
    is_cashflow_dependent: bool = False,
    is_income_producing: bool = False,
) -> float:
    """Risk weight for residential real estate exposures.

    Reference: BCBS CRE20.71-20.86, Tables 12-13.
    Whole-loan approach (BCBS/EU CRR3).

    Args:
        ltv: Loan-to-value ratio (e.g., 0.75 for 75%).
        jurisdiction: Regulatory jurisdiction.
        is_cashflow_dependent: If True, use cashflow-dependent table.
        is_income_producing: If True, treated as income-producing.

    Returns:
        Risk weight as percentage.
    """
    if is_cashflow_dependent or is_income_producing:
        table = RRE_CASHFLOW_DEPENDENT_RW
    else:
        table = RRE_WHOLE_LOAN_RW

    # India (RBI) specific treatment
    if jurisdiction == Jurisdiction.INDIA:
        if ltv <= 0.80:
            return 20.0
        return 35.0

    for ltv_lower, ltv_upper, rw in table:
        if ltv_lower < ltv <= ltv_upper:
            return rw
    # LTV exactly 0 case
    if ltv <= 0:
        return table[0][2]
    return table[-1][2]

uk_pra_loan_splitting_rre(loan_amount, property_value, counterparty_rw=100.0, is_cashflow_dependent=False)

UK PRA loan-splitting for residential real estate (PS9/24).

The UK PRA diverges from the EU/BCBS whole-loan approach by splitting each residential mortgage into two tranches:

  • Secured tranche: The portion of the loan up to the LTV threshold (55% of property value). This tranche receives the lower LTV-based risk weight from the RRE table.
  • Unsecured tranche: The remainder of the loan above the threshold. This tranche receives the counterparty risk weight (typically 100% for unrated corporates/retail).

The blended risk weight is the EAD-weighted average of both tranches.

Reference: PRA PS9/24, Chapter 4 (Real Estate).

Parameters:

Name Type Description Default
loan_amount float

Outstanding loan amount.

required
property_value float

Current property value.

required
counterparty_rw float

Risk weight for the unsecured tranche (default 100%).

100.0
is_cashflow_dependent bool

If True, use cashflow-dependent RRE table.

False

Returns:

Type Description
dict[str, float]

Dict with: - 'secured_amount': Amount in the secured tranche. - 'unsecured_amount': Amount in the unsecured tranche. - 'secured_rw': Risk weight for the secured tranche (%). - 'unsecured_rw': Risk weight for the unsecured tranche (%). - 'blended_rw': EAD-weighted blended risk weight (%). - 'ltv': Loan-to-value ratio.

Source code in creditriskengine\rwa\standardized\credit_risk_sa.py
def uk_pra_loan_splitting_rre(
    loan_amount: float,
    property_value: float,
    counterparty_rw: float = 100.0,
    is_cashflow_dependent: bool = False,
) -> dict[str, float]:
    """UK PRA loan-splitting for residential real estate (PS9/24).

    The UK PRA diverges from the EU/BCBS whole-loan approach by splitting
    each residential mortgage into two tranches:

    - **Secured tranche**: The portion of the loan up to the LTV threshold
      (55% of property value). This tranche receives the lower LTV-based
      risk weight from the RRE table.
    - **Unsecured tranche**: The remainder of the loan above the threshold.
      This tranche receives the counterparty risk weight (typically 100%
      for unrated corporates/retail).

    The blended risk weight is the EAD-weighted average of both tranches.

    Reference: PRA PS9/24, Chapter 4 (Real Estate).

    Args:
        loan_amount: Outstanding loan amount.
        property_value: Current property value.
        counterparty_rw: Risk weight for the unsecured tranche (default 100%).
        is_cashflow_dependent: If True, use cashflow-dependent RRE table.

    Returns:
        Dict with:
            - 'secured_amount': Amount in the secured tranche.
            - 'unsecured_amount': Amount in the unsecured tranche.
            - 'secured_rw': Risk weight for the secured tranche (%).
            - 'unsecured_rw': Risk weight for the unsecured tranche (%).
            - 'blended_rw': EAD-weighted blended risk weight (%).
            - 'ltv': Loan-to-value ratio.
    """
    if property_value <= 0 or loan_amount <= 0:
        return {
            "secured_amount": 0.0,
            "unsecured_amount": loan_amount,
            "secured_rw": 0.0,
            "unsecured_rw": counterparty_rw,
            "blended_rw": counterparty_rw,
            "ltv": 0.0,
        }

    ltv = loan_amount / property_value

    # PRA splitting threshold: 55% of property value
    split_threshold = 0.55 * property_value
    secured_amount = min(loan_amount, split_threshold)
    unsecured_amount = max(loan_amount - split_threshold, 0.0)

    # Secured tranche risk weight based on the LTV of the secured portion
    secured_ltv = secured_amount / property_value
    table = RRE_CASHFLOW_DEPENDENT_RW if is_cashflow_dependent else RRE_WHOLE_LOAN_RW
    secured_rw = table[0][2]  # default to first bucket
    for ltv_lower, ltv_upper, rw in table:
        if ltv_lower < secured_ltv <= ltv_upper:
            secured_rw = rw
            break

    # Unsecured tranche gets counterparty risk weight
    unsecured_rw = counterparty_rw

    # Blended risk weight (loan_amount > 0 guaranteed by early return above)
    blended_rw = (
        secured_amount * secured_rw + unsecured_amount * unsecured_rw
    ) / loan_amount

    return {
        "secured_amount": secured_amount,
        "unsecured_amount": unsecured_amount,
        "secured_rw": secured_rw,
        "unsecured_rw": unsecured_rw,
        "blended_rw": blended_rw,
        "ltv": ltv,
    }

get_commercial_re_risk_weight(ltv, counterparty_rw=100.0, is_cashflow_dependent=False, is_adc=False, is_presold_residential=False)

Risk weight for commercial real estate exposures.

Reference: BCBS CRE20.87-20.98, Tables 14-15.

Parameters:

Name Type Description Default
ltv float

Loan-to-value ratio.

required
counterparty_rw float

Risk weight of the counterparty.

100.0
is_cashflow_dependent bool

If True, use IPRE table (Table 15).

False
is_adc bool

If True, Land ADC treatment (150%).

False
is_presold_residential bool

If True and ADC, use 100%.

False

Returns:

Type Description
float

Risk weight as percentage.

Source code in creditriskengine\rwa\standardized\credit_risk_sa.py
def get_commercial_re_risk_weight(
    ltv: float,
    counterparty_rw: float = 100.0,
    is_cashflow_dependent: bool = False,
    is_adc: bool = False,
    is_presold_residential: bool = False,
) -> float:
    """Risk weight for commercial real estate exposures.

    Reference: BCBS CRE20.87-20.98, Tables 14-15.

    Args:
        ltv: Loan-to-value ratio.
        counterparty_rw: Risk weight of the counterparty.
        is_cashflow_dependent: If True, use IPRE table (Table 15).
        is_adc: If True, Land ADC treatment (150%).
        is_presold_residential: If True and ADC, use 100%.

    Returns:
        Risk weight as percentage.
    """
    if is_adc:
        if is_presold_residential:
            return LAND_ADC_PRESOLD_RW
        return LAND_ADC_RW

    if is_cashflow_dependent:
        for ltv_lower, ltv_upper, rw in CRE_IPRE_RW:
            if ltv_lower < ltv <= ltv_upper:
                return rw
        if ltv <= 0:
            return CRE_IPRE_RW[0][2]
        return CRE_IPRE_RW[-1][2]

    # Not cashflow dependent
    if ltv <= 0.60:
        return min(60.0, counterparty_rw)
    elif ltv <= 0.80:
        return 75.0
    else:
        return counterparty_rw

get_defaulted_risk_weight(specific_provisions_pct, is_rre_secured=False)

Risk weight for defaulted exposures.

Reference: BCBS CRE20.99-20.101.

Parameters:

Name Type Description Default
specific_provisions_pct float

Specific provisions as % of outstanding.

required
is_rre_secured bool

If secured by residential real estate.

False

Returns:

Type Description
float

Risk weight as percentage.

Source code in creditriskengine\rwa\standardized\credit_risk_sa.py
def get_defaulted_risk_weight(
    specific_provisions_pct: float,
    is_rre_secured: bool = False,
) -> float:
    """Risk weight for defaulted exposures.

    Reference: BCBS CRE20.99-20.101.

    Args:
        specific_provisions_pct: Specific provisions as % of outstanding.
        is_rre_secured: If secured by residential real estate.

    Returns:
        Risk weight as percentage.
    """
    # CRE20.101: RRE-secured defaulted exposures always get 100%
    if is_rre_secured:
        return 100.0
    if specific_provisions_pct >= 0.20:
        return 100.0
    return 150.0

get_retail_risk_weight(is_regulatory_retail=True)

Risk weight for retail exposures.

Reference: BCBS CRE20.65.

Parameters:

Name Type Description Default
is_regulatory_retail bool

If True, meets regulatory retail criteria.

True

Returns:

Type Description
float

Risk weight as percentage (75% or 100%).

Source code in creditriskengine\rwa\standardized\credit_risk_sa.py
def get_retail_risk_weight(is_regulatory_retail: bool = True) -> float:
    """Risk weight for retail exposures.

    Reference: BCBS CRE20.65.

    Args:
        is_regulatory_retail: If True, meets regulatory retail criteria.

    Returns:
        Risk weight as percentage (75% or 100%).
    """
    return 75.0 if is_regulatory_retail else 100.0

get_equity_risk_weight(is_listed=True, is_speculative=False)

Risk weight for equity exposures.

Reference: BCBS CRE20.49-20.58.

Parameters:

Name Type Description Default
is_listed bool

If True, listed equity.

True
is_speculative bool

If True, speculative unlisted equity.

False

Returns:

Type Description
float

Risk weight as percentage.

Source code in creditriskengine\rwa\standardized\credit_risk_sa.py
def get_equity_risk_weight(
    is_listed: bool = True,
    is_speculative: bool = False,
) -> float:
    """Risk weight for equity exposures.

    Reference: BCBS CRE20.49-20.58.

    Args:
        is_listed: If True, listed equity.
        is_speculative: If True, speculative unlisted equity.

    Returns:
        Risk weight as percentage.
    """
    if is_speculative:
        return 400.0
    if is_listed:
        return 250.0
    return 400.0

get_subordinated_debt_risk_weight()

Risk weight for subordinated debt.

Reference: BCBS CRE20.49.

Returns:

Type Description
float

150% risk weight.

Source code in creditriskengine\rwa\standardized\credit_risk_sa.py
def get_subordinated_debt_risk_weight() -> float:
    """Risk weight for subordinated debt.

    Reference: BCBS CRE20.49.

    Returns:
        150% risk weight.
    """
    return 150.0

get_covered_bond_risk_weight(cqs, is_qualifying=True, issuer_cqs=None)

Risk weight for covered bond exposures.

Reference: BCBS CRE20.60-67, Table 10.

Qualifying covered bonds receive preferential risk weights based on the bond's own CQS. Non-qualifying covered bonds, or unrated qualifying covered bonds, fall back to the issuing bank's risk weight.

Parameters:

Name Type Description Default
cqs CreditQualityStep

Credit quality step of the covered bond.

required
is_qualifying bool

If True, bond meets CRE20.61-66 qualifying criteria.

True
issuer_cqs CreditQualityStep | None

CQS of the issuing bank (used for unrated or non-qualifying bonds).

None

Returns:

Type Description
float

Risk weight as percentage.

Source code in creditriskengine\rwa\standardized\credit_risk_sa.py
def get_covered_bond_risk_weight(
    cqs: CreditQualityStep,
    is_qualifying: bool = True,
    issuer_cqs: CreditQualityStep | None = None,
) -> float:
    """Risk weight for covered bond exposures.

    Reference: BCBS CRE20.60-67, Table 10.

    Qualifying covered bonds receive preferential risk weights based on the
    bond's own CQS. Non-qualifying covered bonds, or unrated qualifying
    covered bonds, fall back to the issuing bank's risk weight.

    Args:
        cqs: Credit quality step of the covered bond.
        is_qualifying: If True, bond meets CRE20.61-66 qualifying criteria.
        issuer_cqs: CQS of the issuing bank (used for unrated or
                    non-qualifying bonds).

    Returns:
        Risk weight as percentage.
    """
    if not is_qualifying:
        # Non-qualifying: use issuer bank RW via ECRA table
        if issuer_cqs is not None:
            return get_bank_risk_weight(cqs=issuer_cqs)
        return get_bank_risk_weight()

    # Qualifying: look up covered bond table
    if cqs == CreditQualityStep.UNRATED:
        # Unrated qualifying covered bonds: use issuer bank RW
        if issuer_cqs is not None:
            return get_bank_risk_weight(cqs=issuer_cqs)
        return get_bank_risk_weight()

    return COVERED_BOND_RW.get(cqs.value, 100.0)

get_mdb_risk_weight(mdb_category=1, cqs=CreditQualityStep.UNRATED)

Risk weight for multilateral development bank exposures.

Reference: BCBS CRE20.8-9.

Qualifying MDBs (Category 1) receive 0% risk weight. Category 2 MDBs are treated using the bank ECRA table. Non-qualifying MDBs use the corporate risk weight table.

Categories: - Category 1 (qualifying): IBRD, IFC, ADB, AfDB, EBRD, IADB, EIB, etc. These receive 0% RW per CRE20.8. - Category 2: Other MDBs that meet some but not all qualifying criteria. Use bank ECRA table per CRE20.9. - Non-qualifying (category 3+): Use corporate risk weight table.

Parameters:

Name Type Description Default
mdb_category int

MDB category (1 = qualifying, 2 = bank table, 3+ = corporate table).

1
cqs CreditQualityStep

Credit quality step from external rating.

UNRATED

Returns:

Type Description
float

Risk weight as percentage.

Source code in creditriskengine\rwa\standardized\credit_risk_sa.py
def get_mdb_risk_weight(
    mdb_category: int = 1,
    cqs: CreditQualityStep = CreditQualityStep.UNRATED,
) -> float:
    """Risk weight for multilateral development bank exposures.

    Reference: BCBS CRE20.8-9.

    Qualifying MDBs (Category 1) receive 0% risk weight. Category 2 MDBs
    are treated using the bank ECRA table. Non-qualifying MDBs use the
    corporate risk weight table.

    Categories:
    - Category 1 (qualifying): IBRD, IFC, ADB, AfDB, EBRD, IADB, EIB, etc.
      These receive 0% RW per CRE20.8.
    - Category 2: Other MDBs that meet some but not all qualifying criteria.
      Use bank ECRA table per CRE20.9.
    - Non-qualifying (category 3+): Use corporate risk weight table.

    Args:
        mdb_category: MDB category (1 = qualifying, 2 = bank table,
                      3+ = corporate table).
        cqs: Credit quality step from external rating.

    Returns:
        Risk weight as percentage.
    """
    if mdb_category == 1:
        return 0.0

    if mdb_category == 2:
        return BANK_ECRA_RW.get(cqs.value, 50.0)

    # Non-qualifying MDBs: use corporate table
    return CORPORATE_RW.get(cqs.value, 100.0)

assign_sa_risk_weight(exposure_class, cqs=CreditQualityStep.UNRATED, jurisdiction=Jurisdiction.BCBS, ltv=None, counterparty_rw=100.0, is_investment_grade=None, is_sme=False, is_cashflow_dependent=False, is_income_producing=False, is_adc=False, is_presold_residential=False, is_domestic_own_currency=False, specific_provisions_pct=0.0, is_rre_secured=False, is_listed=True, is_speculative=False, is_regulatory_retail=True, scra_grade=None, is_short_term=False, is_qualifying=True, issuer_cqs=None, mdb_category=1, config=None)

Assign SA risk weight based on exposure class and parameters.

Master dispatcher that routes to the appropriate risk weight function.

Parameters:

Name Type Description Default
exposure_class SAExposureClass

SA exposure class per BCBS CRE20.

required
cqs CreditQualityStep

Credit quality step from external rating.

UNRATED
jurisdiction Jurisdiction

Regulatory jurisdiction.

BCBS
ltv float | None

Loan-to-value ratio for real estate.

None
counterparty_rw float

Counterparty risk weight for CRE.

100.0
is_investment_grade bool | None

For UK unrated corporate treatment.

None
is_sme bool

For EU SME supporting factor.

False
is_cashflow_dependent bool

For real estate cashflow dependency.

False
is_income_producing bool

For income-producing property.

False
is_adc bool

For land ADC exposures.

False
is_presold_residential bool

For pre-sold ADC.

False
is_domestic_own_currency bool

For domestic sovereign treatment.

False
specific_provisions_pct float

For defaulted exposures.

0.0
is_rre_secured bool

For defaulted RRE-secured exposures.

False
is_listed bool

For equity classification.

True
is_speculative bool

For speculative equity.

False
is_regulatory_retail bool

For retail qualification.

True
scra_grade str | None

SCRA grade for banks (A/B/C).

None
is_short_term bool

For short-term bank claims (CRE20.17).

False
is_qualifying bool

For covered bonds qualifying criteria (CRE20.61-66).

True
issuer_cqs CreditQualityStep | None

Issuing bank CQS for covered bonds.

None
mdb_category int

MDB category (1=qualifying, 2=bank, 3+=corporate).

1
config dict[str, Any] | None

Optional jurisdiction config dict.

None

Returns:

Type Description
float

Risk weight as percentage.

Source code in creditriskengine\rwa\standardized\credit_risk_sa.py
def assign_sa_risk_weight(
    exposure_class: SAExposureClass,
    cqs: CreditQualityStep = CreditQualityStep.UNRATED,
    jurisdiction: Jurisdiction = Jurisdiction.BCBS,
    ltv: float | None = None,
    counterparty_rw: float = 100.0,
    is_investment_grade: bool | None = None,
    is_sme: bool = False,
    is_cashflow_dependent: bool = False,
    is_income_producing: bool = False,
    is_adc: bool = False,
    is_presold_residential: bool = False,
    is_domestic_own_currency: bool = False,
    specific_provisions_pct: float = 0.0,
    is_rre_secured: bool = False,
    is_listed: bool = True,
    is_speculative: bool = False,
    is_regulatory_retail: bool = True,
    scra_grade: str | None = None,
    is_short_term: bool = False,
    is_qualifying: bool = True,
    issuer_cqs: CreditQualityStep | None = None,
    mdb_category: int = 1,
    config: dict[str, Any] | None = None,
) -> float:
    """Assign SA risk weight based on exposure class and parameters.

    Master dispatcher that routes to the appropriate risk weight function.

    Args:
        exposure_class: SA exposure class per BCBS CRE20.
        cqs: Credit quality step from external rating.
        jurisdiction: Regulatory jurisdiction.
        ltv: Loan-to-value ratio for real estate.
        counterparty_rw: Counterparty risk weight for CRE.
        is_investment_grade: For UK unrated corporate treatment.
        is_sme: For EU SME supporting factor.
        is_cashflow_dependent: For real estate cashflow dependency.
        is_income_producing: For income-producing property.
        is_adc: For land ADC exposures.
        is_presold_residential: For pre-sold ADC.
        is_domestic_own_currency: For domestic sovereign treatment.
        specific_provisions_pct: For defaulted exposures.
        is_rre_secured: For defaulted RRE-secured exposures.
        is_listed: For equity classification.
        is_speculative: For speculative equity.
        is_regulatory_retail: For retail qualification.
        scra_grade: SCRA grade for banks (A/B/C).
        is_short_term: For short-term bank claims (CRE20.17).
        is_qualifying: For covered bonds qualifying criteria (CRE20.61-66).
        issuer_cqs: Issuing bank CQS for covered bonds.
        mdb_category: MDB category (1=qualifying, 2=bank, 3+=corporate).
        config: Optional jurisdiction config dict.

    Returns:
        Risk weight as percentage.
    """
    if exposure_class == SAExposureClass.SOVEREIGN:
        return get_sovereign_risk_weight(cqs, jurisdiction, is_domestic_own_currency)

    if exposure_class in (SAExposureClass.BANK, SAExposureClass.SECURITIES_FIRM):
        # When SCRA grade is provided, use SCRA path (cqs=None)
        bank_cqs = None if scra_grade is not None else cqs
        return get_bank_risk_weight(bank_cqs, jurisdiction, scra_grade, is_short_term)

    if exposure_class in (SAExposureClass.CORPORATE, SAExposureClass.CORPORATE_SME):
        is_sme_flag = is_sme or exposure_class == SAExposureClass.CORPORATE_SME
        return get_corporate_risk_weight(cqs, jurisdiction, is_investment_grade, is_sme_flag)

    if exposure_class == SAExposureClass.RESIDENTIAL_MORTGAGE:
        if ltv is None:
            raise ValueError("LTV required for residential mortgage risk weight")
        return get_residential_re_risk_weight(
            ltv, jurisdiction, is_cashflow_dependent, is_income_producing
        )

    if exposure_class == SAExposureClass.COMMERCIAL_REAL_ESTATE:
        if ltv is None:
            raise ValueError("LTV required for commercial real estate risk weight")
        return get_commercial_re_risk_weight(
            ltv, counterparty_rw, is_cashflow_dependent, is_adc, is_presold_residential
        )

    if exposure_class == SAExposureClass.LAND_ADC:
        return get_commercial_re_risk_weight(
            ltv=1.0, is_adc=True, is_presold_residential=is_presold_residential
        )

    if exposure_class == SAExposureClass.RETAIL:
        return get_retail_risk_weight(is_regulatory_retail)

    if exposure_class == SAExposureClass.RETAIL_REGULATORY:
        return 75.0

    if exposure_class == SAExposureClass.DEFAULTED:
        return get_defaulted_risk_weight(specific_provisions_pct, is_rre_secured)

    if exposure_class == SAExposureClass.EQUITY:
        return get_equity_risk_weight(is_listed, is_speculative)

    if exposure_class == SAExposureClass.SUBORDINATED_DEBT:
        return get_subordinated_debt_risk_weight()

    if exposure_class == SAExposureClass.PSE:
        # PSEs Option A: use bank risk weight table per CRE20.10
        return get_bank_risk_weight(cqs, jurisdiction, scra_grade)

    if exposure_class == SAExposureClass.COVERED_BOND:
        return get_covered_bond_risk_weight(cqs, is_qualifying, issuer_cqs)

    if exposure_class == SAExposureClass.MDB:
        return get_mdb_risk_weight(mdb_category, cqs)

    if exposure_class == SAExposureClass.OTHER:
        return 100.0

    return 100.0

creditriskengine.rwa.output_floor

Output floor mechanism per BCBS d424, RBC25.2-25.4.

The output floor ensures that total RWA under internal models cannot fall below a specified percentage of total SA RWA.

Total_RWA_floored = max(Total_RWA_internal, floor_pct * Total_RWA_standardized)

Phase-in schedules differ by jurisdiction.

OutputFloorCalculator

Calculate output-floored RWA.

The output floor ensures IRB RWA does not fall below a percentage of SA RWA.

Reference: BCBS d424, RBC25.2-25.4.

Example

calc = OutputFloorCalculator(Jurisdiction.EU, date(2026, 6, 30)) result = calc.calculate(irb_rwa=800.0, sa_rwa=1200.0) result["floored_rwa"] 800.0 # if 55% * 1200 = 660 < 800

Source code in creditriskengine\rwa\output_floor.py
class OutputFloorCalculator:
    """Calculate output-floored RWA.

    The output floor ensures IRB RWA does not fall below
    a percentage of SA RWA.

    Reference: BCBS d424, RBC25.2-25.4.

    Example:
        >>> calc = OutputFloorCalculator(Jurisdiction.EU, date(2026, 6, 30))
        >>> result = calc.calculate(irb_rwa=800.0, sa_rwa=1200.0)
        >>> result["floored_rwa"]
        800.0  # if 55% * 1200 = 660 < 800
    """

    def __init__(
        self,
        jurisdiction: Jurisdiction,
        reporting_date: date,
    ) -> None:
        self.jurisdiction = jurisdiction
        self.reporting_date = reporting_date
        self.floor_pct = get_output_floor_pct(jurisdiction, reporting_date)

    def calculate(
        self,
        irb_rwa: float,
        sa_rwa: float,
        pre_floor_irb_rwa: float | None = None,
    ) -> dict[str, float]:
        """Apply the output floor.

        Formula:
            floored_rwa = max(irb_rwa, floor_pct * sa_rwa)

        For EU: applies transitional cap limiting RWA increase to 25%.

        Args:
            irb_rwa: Total RWA from internal models.
            sa_rwa: Total RWA from standardized approach.
            pre_floor_irb_rwa: Pre-floor IRB RWA for EU transitional cap.

        Returns:
            Dict with floored_rwa, floor_pct, floor_rwa, is_binding, add_on.
        """
        floor_rwa = self.floor_pct * sa_rwa
        is_binding = floor_rwa > irb_rwa
        floored_rwa = max(irb_rwa, floor_rwa)
        add_on = max(0.0, floor_rwa - irb_rwa)

        # EU transitional cap: CRR3 Art. 92a(3)
        if (
            self.jurisdiction == Jurisdiction.EU
            and EU_TRANSITIONAL_CAP_ENABLED
            and is_binding
        ):
            base_rwa = pre_floor_irb_rwa if pre_floor_irb_rwa is not None else irb_rwa
            max_increase = base_rwa * EU_MAX_RWA_INCREASE_PCT
            if add_on > max_increase:
                add_on = max_increase
                floored_rwa = irb_rwa + add_on
                logger.info(
                    "EU transitional cap applied: add-on capped at %.2f (25%% of %.2f)",
                    max_increase, base_rwa,
                )

        logger.debug(
            "Output floor: jurisdiction=%s date=%s floor_pct=%.1f%% "
            "irb_rwa=%.2f sa_rwa=%.2f floor_rwa=%.2f floored_rwa=%.2f binding=%s",
            self.jurisdiction.value, self.reporting_date, self.floor_pct * 100,
            irb_rwa, sa_rwa, floor_rwa, floored_rwa, is_binding,
        )

        return {
            "floored_rwa": floored_rwa,
            "floor_pct": self.floor_pct,
            "floor_rwa": floor_rwa,
            "is_binding": is_binding,
            "add_on": add_on,
            "irb_rwa": irb_rwa,
            "sa_rwa": sa_rwa,
        }

calculate(irb_rwa, sa_rwa, pre_floor_irb_rwa=None)

Apply the output floor.

Formula

floored_rwa = max(irb_rwa, floor_pct * sa_rwa)

For EU: applies transitional cap limiting RWA increase to 25%.

Parameters:

Name Type Description Default
irb_rwa float

Total RWA from internal models.

required
sa_rwa float

Total RWA from standardized approach.

required
pre_floor_irb_rwa float | None

Pre-floor IRB RWA for EU transitional cap.

None

Returns:

Type Description
dict[str, float]

Dict with floored_rwa, floor_pct, floor_rwa, is_binding, add_on.

Source code in creditriskengine\rwa\output_floor.py
def calculate(
    self,
    irb_rwa: float,
    sa_rwa: float,
    pre_floor_irb_rwa: float | None = None,
) -> dict[str, float]:
    """Apply the output floor.

    Formula:
        floored_rwa = max(irb_rwa, floor_pct * sa_rwa)

    For EU: applies transitional cap limiting RWA increase to 25%.

    Args:
        irb_rwa: Total RWA from internal models.
        sa_rwa: Total RWA from standardized approach.
        pre_floor_irb_rwa: Pre-floor IRB RWA for EU transitional cap.

    Returns:
        Dict with floored_rwa, floor_pct, floor_rwa, is_binding, add_on.
    """
    floor_rwa = self.floor_pct * sa_rwa
    is_binding = floor_rwa > irb_rwa
    floored_rwa = max(irb_rwa, floor_rwa)
    add_on = max(0.0, floor_rwa - irb_rwa)

    # EU transitional cap: CRR3 Art. 92a(3)
    if (
        self.jurisdiction == Jurisdiction.EU
        and EU_TRANSITIONAL_CAP_ENABLED
        and is_binding
    ):
        base_rwa = pre_floor_irb_rwa if pre_floor_irb_rwa is not None else irb_rwa
        max_increase = base_rwa * EU_MAX_RWA_INCREASE_PCT
        if add_on > max_increase:
            add_on = max_increase
            floored_rwa = irb_rwa + add_on
            logger.info(
                "EU transitional cap applied: add-on capped at %.2f (25%% of %.2f)",
                max_increase, base_rwa,
            )

    logger.debug(
        "Output floor: jurisdiction=%s date=%s floor_pct=%.1f%% "
        "irb_rwa=%.2f sa_rwa=%.2f floor_rwa=%.2f floored_rwa=%.2f binding=%s",
        self.jurisdiction.value, self.reporting_date, self.floor_pct * 100,
        irb_rwa, sa_rwa, floor_rwa, floored_rwa, is_binding,
    )

    return {
        "floored_rwa": floored_rwa,
        "floor_pct": self.floor_pct,
        "floor_rwa": floor_rwa,
        "is_binding": is_binding,
        "add_on": add_on,
        "irb_rwa": irb_rwa,
        "sa_rwa": sa_rwa,
    }

get_output_floor_pct(jurisdiction, reporting_date)

Get the applicable output floor percentage for a jurisdiction and date.

Parameters:

Name Type Description Default
jurisdiction Jurisdiction

Regulatory jurisdiction.

required
reporting_date date

Reporting/calculation date.

required

Returns:

Type Description
float

Floor percentage (e.g., 0.725 for 72.5%).

float

Returns 0.0 if floor is not yet effective.

Source code in creditriskengine\rwa\output_floor.py
def get_output_floor_pct(
    jurisdiction: Jurisdiction,
    reporting_date: date,
) -> float:
    """Get the applicable output floor percentage for a jurisdiction and date.

    Args:
        jurisdiction: Regulatory jurisdiction.
        reporting_date: Reporting/calculation date.

    Returns:
        Floor percentage (e.g., 0.725 for 72.5%).
        Returns 0.0 if floor is not yet effective.
    """
    schedule = OUTPUT_FLOOR_SCHEDULES.get(jurisdiction.value)
    if schedule is None:
        # Default to BCBS schedule
        schedule = OUTPUT_FLOOR_SCHEDULES["bcbs"]

    floor_pct = 0.0
    for effective_str, pct in schedule:
        effective = date.fromisoformat(effective_str)
        if reporting_date >= effective:
            floor_pct = pct
        else:
            break

    return floor_pct