Skip to content

ECL API

creditriskengine.ecl.ifrs9.ecl_calc

IFRS 9 ECL computation — 12-month and lifetime.

12-month ECL (Stage 1): ECL_12m = PD_12m * LGD * EAD * DF

Lifetime ECL (Stage 2 and 3): ECL_lifetime = Sum(t=1..T) [Marginal_PD(t) * LGD(t) * EAD(t) * DF(t)]

Reference: IFRS 9.5.5.1-5.5.20, IFRS 9.B5.5.28-B5.5.29.

discount_factors(eir, periods)

Calculate discount factors at the effective interest rate.

DF(t) = 1 / (1 + EIR)^t

Parameters:

Name Type Description Default
eir float

Effective interest rate (annualized).

required
periods int

Number of periods.

required

Returns:

Type Description
ndarray

Array of discount factors for periods 1..T.

Source code in creditriskengine\ecl\ifrs9\ecl_calc.py
def discount_factors(
    eir: float,
    periods: int,
) -> np.ndarray:
    """Calculate discount factors at the effective interest rate.

    DF(t) = 1 / (1 + EIR)^t

    Args:
        eir: Effective interest rate (annualized).
        periods: Number of periods.

    Returns:
        Array of discount factors for periods 1..T.
    """
    if eir <= -1.0:
        raise ValueError(f"EIR must be greater than -1, got {eir}")
    t = np.arange(1, periods + 1, dtype=np.float64)
    return 1.0 / (1.0 + eir) ** t

ecl_12_month(pd_12m, lgd, ead, eir=0.0)

Calculate 12-month ECL for Stage 1 exposures.

Formula

ECL_12m = PD_12m * LGD * EAD * DF(1)

Parameters:

Name Type Description Default
pd_12m float

12-month probability of default.

required
lgd float

Loss given default.

required
ead float

Exposure at default.

required
eir float

Effective interest rate for discounting.

0.0

Returns:

Type Description
float

12-month ECL amount.

Source code in creditriskengine\ecl\ifrs9\ecl_calc.py
def ecl_12_month(
    pd_12m: float,
    lgd: float,
    ead: float,
    eir: float = 0.0,
) -> float:
    """Calculate 12-month ECL for Stage 1 exposures.

    Formula:
        ECL_12m = PD_12m * LGD * EAD * DF(1)

    Args:
        pd_12m: 12-month probability of default.
        lgd: Loss given default.
        ead: Exposure at default.
        eir: Effective interest rate for discounting.

    Returns:
        12-month ECL amount.
    """
    df = 1.0 / (1.0 + eir) if eir > 0 else 1.0
    ecl = pd_12m * lgd * ead * df
    logger.debug(
        "12m ECL: PD=%.4f LGD=%.2f EAD=%.2f DF=%.4f ECL=%.2f",
        pd_12m, lgd, ead, df, ecl,
    )
    return ecl

ecl_lifetime(marginal_pds, lgds, eads, eir=0.0)

Calculate lifetime ECL for Stage 2/3 exposures.

Formula

ECL = Sum(t=1..T) [Marginal_PD(t) * LGD(t) * EAD(t) * DF(t)]

Parameters:

Name Type Description Default
marginal_pds ndarray

Array of marginal PDs for each period.

required
lgds ndarray | float

LGD values (scalar or array per period).

required
eads ndarray | float

EAD values (scalar or array per period).

required
eir float

Effective interest rate for discounting.

0.0

Returns:

Type Description
float

Lifetime ECL amount.

Source code in creditriskengine\ecl\ifrs9\ecl_calc.py
def ecl_lifetime(
    marginal_pds: np.ndarray,
    lgds: np.ndarray | float,
    eads: np.ndarray | float,
    eir: float = 0.0,
) -> float:
    """Calculate lifetime ECL for Stage 2/3 exposures.

    Formula:
        ECL = Sum(t=1..T) [Marginal_PD(t) * LGD(t) * EAD(t) * DF(t)]

    Args:
        marginal_pds: Array of marginal PDs for each period.
        lgds: LGD values (scalar or array per period).
        eads: EAD values (scalar or array per period).
        eir: Effective interest rate for discounting.

    Returns:
        Lifetime ECL amount.
    """
    periods = len(marginal_pds)
    dfs = discount_factors(eir, periods)

    if isinstance(lgds, (int, float)):
        lgds = np.full(periods, lgds)
    if isinstance(eads, (int, float)):
        eads = np.full(periods, eads)

    ecl = float(np.sum(marginal_pds * lgds * eads * dfs))
    logger.debug("Lifetime ECL: periods=%d ECL=%.2f", periods, ecl)
    return ecl

calculate_ecl(stage, pd_12m, lgd, ead, eir=0.0, marginal_pds=None, lgd_curve=None, ead_curve=None)

Unified ECL calculation dispatcher based on IFRS 9 stage.

Stage 1: 12-month ECL Stage 2/3/POCI: Lifetime ECL

Parameters:

Name Type Description Default
stage IFRS9Stage

IFRS 9 impairment stage.

required
pd_12m float

12-month PD.

required
lgd float

Loss given default (scalar).

required
ead float

Exposure at default (scalar).

required
eir float

Effective interest rate.

0.0
marginal_pds ndarray | None

Marginal PD curve (required for lifetime ECL).

None
lgd_curve ndarray | None

Optional LGD term structure.

None
ead_curve ndarray | None

Optional EAD term structure.

None

Returns:

Type Description
float

ECL amount.

Source code in creditriskengine\ecl\ifrs9\ecl_calc.py
def calculate_ecl(
    stage: IFRS9Stage,
    pd_12m: float,
    lgd: float,
    ead: float,
    eir: float = 0.0,
    marginal_pds: np.ndarray | None = None,
    lgd_curve: np.ndarray | None = None,
    ead_curve: np.ndarray | None = None,
) -> float:
    """Unified ECL calculation dispatcher based on IFRS 9 stage.

    Stage 1: 12-month ECL
    Stage 2/3/POCI: Lifetime ECL

    Args:
        stage: IFRS 9 impairment stage.
        pd_12m: 12-month PD.
        lgd: Loss given default (scalar).
        ead: Exposure at default (scalar).
        eir: Effective interest rate.
        marginal_pds: Marginal PD curve (required for lifetime ECL).
        lgd_curve: Optional LGD term structure.
        ead_curve: Optional EAD term structure.

    Returns:
        ECL amount.
    """
    if stage == IFRS9Stage.STAGE_1:
        return ecl_12_month(pd_12m, lgd, ead, eir)

    # Stage 2, 3, POCI: lifetime ECL
    if marginal_pds is None:
        raise ValueError("marginal_pds required for lifetime ECL (Stage 2/3/POCI)")

    lgd_input = lgd_curve if lgd_curve is not None else lgd
    ead_input = ead_curve if ead_curve is not None else ead
    return ecl_lifetime(marginal_pds, lgd_input, ead_input, eir)

creditriskengine.ecl.ifrs9.staging

IFRS 9 three-stage impairment model.

Reference: IFRS 9.5.5.1-5.5.20.

Stage 1: 12-month ECL (performing, no SICR) Stage 2: Lifetime ECL (performing, SICR identified) Stage 3: Lifetime ECL (credit-impaired / defaulted) POCI: Purchased or originated credit-impaired

assign_stage(days_past_due, is_credit_impaired=False, is_defaulted=False, is_poci=False, sicr_triggered=False, dpd_backstop=30)

Assign IFRS 9 impairment stage.

Logic per IFRS 9.5.5.1-5.5.20: - Stage 3: Credit-impaired or defaulted - Stage 2: SICR triggered or DPD > backstop (rebuttable, IFRS 9.B5.5.19) - Stage 1: All other performing exposures - POCI: Purchased/originated credit-impaired (separate treatment)

Parameters:

Name Type Description Default
days_past_due int

Days past due count.

required
is_credit_impaired bool

Whether exposure is credit-impaired.

False
is_defaulted bool

Whether exposure is in default.

False
is_poci bool

Whether this is a POCI asset.

False
sicr_triggered bool

Whether SICR assessment triggered Stage 2.

False
dpd_backstop int

DPD backstop for Stage 2 (default 30, per IFRS 9.5.5.11).

30

Returns:

Type Description
IFRS9Stage

IFRS9Stage enum value.

Source code in creditriskengine\ecl\ifrs9\staging.py
def assign_stage(
    days_past_due: int,
    is_credit_impaired: bool = False,
    is_defaulted: bool = False,
    is_poci: bool = False,
    sicr_triggered: bool = False,
    dpd_backstop: int = 30,
) -> IFRS9Stage:
    """Assign IFRS 9 impairment stage.

    Logic per IFRS 9.5.5.1-5.5.20:
    - Stage 3: Credit-impaired or defaulted
    - Stage 2: SICR triggered or DPD > backstop (rebuttable, IFRS 9.B5.5.19)
    - Stage 1: All other performing exposures
    - POCI: Purchased/originated credit-impaired (separate treatment)

    Args:
        days_past_due: Days past due count.
        is_credit_impaired: Whether exposure is credit-impaired.
        is_defaulted: Whether exposure is in default.
        is_poci: Whether this is a POCI asset.
        sicr_triggered: Whether SICR assessment triggered Stage 2.
        dpd_backstop: DPD backstop for Stage 2 (default 30, per IFRS 9.5.5.11).

    Returns:
        IFRS9Stage enum value.
    """
    if is_poci:
        return IFRS9Stage.POCI

    if is_credit_impaired or is_defaulted:
        return IFRS9Stage.STAGE_3

    if sicr_triggered or days_past_due > dpd_backstop:
        return IFRS9Stage.STAGE_2

    return IFRS9Stage.STAGE_1

stage_allocation_summary(stages, eads)

Summarize stage allocation by count and EAD.

Parameters:

Name Type Description Default
stages list[IFRS9Stage]

List of stage assignments.

required
eads list[float]

Corresponding EAD values.

required

Returns:

Type Description
dict[str, dict[str, float]]

Dict with count and ead totals per stage.

Source code in creditriskengine\ecl\ifrs9\staging.py
def stage_allocation_summary(
    stages: list[IFRS9Stage],
    eads: list[float],
) -> dict[str, dict[str, float]]:
    """Summarize stage allocation by count and EAD.

    Args:
        stages: List of stage assignments.
        eads: Corresponding EAD values.

    Returns:
        Dict with count and ead totals per stage.
    """
    summary: dict[str, dict[str, float]] = {}
    for stage, ead in zip(stages, eads, strict=False):
        key = stage.name
        if key not in summary:
            summary[key] = {"count": 0.0, "ead": 0.0}
        summary[key]["count"] += 1
        summary[key]["ead"] += ead
    return summary

creditriskengine.ecl.ifrs9.sicr

Significant Increase in Credit Risk (SICR) assessment.

Reference: IFRS 9.5.5.9-5.5.12, IFRS 9.B5.5.15-B5.5.22.

SICR is assessed by comparing lifetime PD at reporting date vs lifetime PD at origination. Uses a relative change threshold. 30 DPD backstop is rebuttable per IFRS 9.B5.5.19.

assess_sicr(current_lifetime_pd, origination_lifetime_pd, days_past_due=0, relative_threshold=2.0, absolute_threshold=0.005, dpd_backstop=30, use_dpd_backstop=True)

Assess whether Significant Increase in Credit Risk has occurred.

Quantitative assessment (IFRS 9.5.5.9): SICR if lifetime PD has increased significantly since origination. Typically: relative change > threshold OR absolute change > threshold.

Qualitative backstop (IFRS 9.B5.5.19): 30 DPD rebuttable presumption of SICR.

Parameters:

Name Type Description Default
current_lifetime_pd float

Current lifetime PD estimate.

required
origination_lifetime_pd float

Lifetime PD at origination date.

required
days_past_due int

Current days past due.

0
relative_threshold float

Relative PD increase threshold (default 2.0 = 200%).

2.0
absolute_threshold float

Absolute PD increase threshold (default 50 bps).

0.005
dpd_backstop int

DPD backstop (default 30 days).

30
use_dpd_backstop bool

Whether to apply DPD backstop.

True

Returns:

Type Description
bool

True if SICR is triggered.

Source code in creditriskengine\ecl\ifrs9\sicr.py
def assess_sicr(
    current_lifetime_pd: float,
    origination_lifetime_pd: float,
    days_past_due: int = 0,
    relative_threshold: float = 2.0,
    absolute_threshold: float = 0.005,
    dpd_backstop: int = 30,
    use_dpd_backstop: bool = True,
) -> bool:
    """Assess whether Significant Increase in Credit Risk has occurred.

    Quantitative assessment (IFRS 9.5.5.9):
        SICR if lifetime PD has increased significantly since origination.
        Typically: relative change > threshold OR absolute change > threshold.

    Qualitative backstop (IFRS 9.B5.5.19):
        30 DPD rebuttable presumption of SICR.

    Args:
        current_lifetime_pd: Current lifetime PD estimate.
        origination_lifetime_pd: Lifetime PD at origination date.
        days_past_due: Current days past due.
        relative_threshold: Relative PD increase threshold (default 2.0 = 200%).
        absolute_threshold: Absolute PD increase threshold (default 50 bps).
        dpd_backstop: DPD backstop (default 30 days).
        use_dpd_backstop: Whether to apply DPD backstop.

    Returns:
        True if SICR is triggered.
    """
    # DPD backstop
    if use_dpd_backstop and days_past_due > dpd_backstop:
        logger.debug("SICR triggered by DPD backstop: %d > %d", days_past_due, dpd_backstop)
        return True

    # Guard against zero origination PD
    if origination_lifetime_pd <= 0:
        return current_lifetime_pd > absolute_threshold

    # Relative change test
    relative_change = current_lifetime_pd / origination_lifetime_pd
    if relative_change > relative_threshold:
        logger.debug(
            "SICR triggered by relative PD change: %.4f / %.4f = %.2fx > %.2fx",
            current_lifetime_pd, origination_lifetime_pd, relative_change, relative_threshold,
        )
        return True

    # Absolute change test
    absolute_change = current_lifetime_pd - origination_lifetime_pd
    if absolute_change > absolute_threshold:
        logger.debug(
            "SICR triggered by absolute PD change: %.4f - %.4f = %.4f > %.4f",
            current_lifetime_pd, origination_lifetime_pd, absolute_change, absolute_threshold,
        )
        return True

    return False