Skip to content

Rating Agency Methodologies

Rating agencies assign credit ratings that drive nearly every compliance test in a CLO indenture. CalcBridge must correctly interpret ratings from Moody's, S&P, and Fitch, map between their scales, and apply the right factor tables when computing WARF, diversity scores, and CCC bucket thresholds.

This page documents the rating scales, factor tables, and engineering implications for developers building or debugging CalcBridge's compliance calculation pipeline.

The Three Rating Scales

Each agency uses a different notation system. The underlying credit quality tiers are roughly equivalent, but the symbols are not interchangeable.

Moody's S&P Fitch Category
Aaa AAA AAA Prime
Aa1 AA+ AA+ High Grade
Aa2 AA AA High Grade
Aa3 AA- AA- High Grade
A1 A+ A+ Upper Medium
A2 A A Upper Medium
A3 A- A- Upper Medium
Baa1 BBB+ BBB+ Lower Medium
Baa2 BBB BBB Lower Medium
Baa3 BBB- BBB- Lower Medium
Ba1 BB+ BB+ Speculative
Ba2 BB BB Speculative
Ba3 BB- BB- Speculative
B1 B+ B+ Highly Speculative
B2 B B Highly Speculative
B3 B- B- Highly Speculative
Caa1 CCC+ CCC+ Substantial Risk
Caa2 CCC CCC Substantial Risk
Caa3 CCC- CCC- Substantial Risk
Ca CC CC Extremely Speculative
C C/D C/D/RD Default

Investment Grade Boundary

The boundary between investment grade and speculative grade sits between Baa3/BBB- and Ba1/BB+. This distinction matters for certain indenture tests that cap sub-investment-grade exposure.

Rating Factor Tables

Rating factors convert letter grades into numeric values for WARF (Weighted Average Rating Factor) calculations. Critically, Moody's and S&P use different factor scales. The same portfolio will produce different WARF numbers depending on which agency's factors are applied. This is not a bug -- it reflects genuinely different loss probability models.

Credit Quality Moody's Factor S&P CDO Monitor Factor
Aaa / AAA 1 0.52
Aa2 / AA 10 8
A2 / A 120 65
Baa2 / BBB 260 195
Ba2 / BB 1,350 1,106
B2 / B 2,720 2,556
Caa1 / CCC+ 4,770 4,108

Factor Table Granularity

The table above shows representative notch-level factors. Each sub-notch (e.g., B1 vs B2 vs B3) has its own distinct factor value. CalcBridge must store the complete factor table for every notch, not just the mid-tier values. See the full factor dictionaries in the code example below.

Intermediate Notch Factors (Moody's)

For engineers implementing the full lookup, here are the Moody's factors at each notch:

Rating Factor Rating Factor Rating Factor
Aaa 1 A1 70 Ba1 940
Aa1 10 A2 120 Ba2 1,350
Aa2 10 A3 180 Ba3 1,766
Aa3 20 Baa1 260 B1 2,220
Baa2 260 B2 2,720
Baa3 610 B3 3,490
Caa1 4,770
Caa2 6,500
Caa3 8,070

WARF Calculation

WARF (Weighted Average Rating Factor) is the single most important rating-derived metric in CLO compliance. It measures the portfolio's overall credit quality as a par-weighted average of rating factors.

WARF = Sum(Par_i * Factor_i) / Sum(Par_i)

Worked Example

Consider a 3-loan portfolio:

Loan Par Value Moody's Rating Moody's Factor S&P Rating S&P Factor
Loan A $50M B1 2,220 B+ 2,040
Loan B $30M Baa3 610 BBB- 437
Loan C $20M Ba1 940 BB+ 776

Moody's WARF:

= ($50M * 2,220 + $30M * 610 + $20M * 940) / ($50M + $30M + $20M)
= (111,000,000 + 18,300,000 + 18,800,000) / 100,000,000
= 148,100,000 / 100,000,000
= 1,481 -- wait, let's recalculate with the actual numbers from the spec

Using the exact values specified:

Moody's WARF = (50 * 2,220 + 30 * 610 + 20 * 940) / 100
            = (111,000 + 18,300 + 18,800) / 100
            = 148,100 / 100
            = 1,481

Different Factor Tables Produce Different Results

The same portfolio yields materially different WARF values depending on the agency:

  • Moody's WARF: (50 * 2,220 + 30 * 610 + 20 * 940) / 100 = 1,481
  • S&P WARF: (50 * 2,040 + 30 * 437 + 20 * 776) / 100 = 1,288.6

If the indenture WARF limit is 1,400, this portfolio fails Moody's but passes S&P. The rating_source parameter in CalcBridge determines which table is used, and it must match what the indenture specifies.

CalcBridge Implementation

import numpy as np
import pandas as pd

# Complete factor tables -- every notch must be present
MOODYS_FACTORS = {
    "Aaa": 1, "Aa1": 10, "Aa2": 10, "Aa3": 20,
    "A1": 70, "A2": 120, "A3": 180,
    "Baa1": 260, "Baa2": 260, "Baa3": 610,
    "Ba1": 940, "Ba2": 1350, "Ba3": 1766,
    "B1": 2220, "B2": 2720, "B3": 3490,
    "Caa1": 4770, "Caa2": 6500, "Caa3": 8070,
    "Ca": 10000, "C": 10000,
}

SP_FACTORS = {
    "AAA": 0.52, "AA+": 8, "AA": 8, "AA-": 15,
    "A+": 35, "A": 65, "A-": 120,
    "BBB+": 195, "BBB": 195, "BBB-": 437,
    "BB+": 776, "BB": 1106, "BB-": 1543,
    "B+": 2040, "B": 2556, "B-": 3214,
    "CCC+": 4108, "CCC": 5520, "CCC-": 7362,
    "CC": 10000, "C": 10000, "D": 10000,
}

FACTOR_TABLES = {
    "moodys": MOODYS_FACTORS,
    "sp": SP_FACTORS,
}

def calculate_warf(df: pd.DataFrame, rating_source: str = "moodys") -> float:
    """
    Calculate Weighted Average Rating Factor using vectorized operations.

    Args:
        df: DataFrame with 'par_value' and rating columns.
        rating_source: 'moodys' or 'sp' -- determines factor table and column.

    Returns:
        WARF as a float. Returns NaN if no valid ratings exist.
    """
    factors = FACTOR_TABLES[rating_source]
    rating_col = "rating_moodys" if rating_source == "moodys" else "rating_sp"

    # Map ratings to factors -- unmapped ratings become NaN
    df["_factor"] = df[rating_col].map(factors)

    # Warn on unmapped ratings (data quality issue)
    unmapped = df[df["_factor"].isna() & df[rating_col].notna()]
    if len(unmapped) > 0:
        unmapped_ratings = unmapped[rating_col].unique().tolist()
        raise ValueError(
            f"Unmapped {rating_source} ratings: {unmapped_ratings}. "
            f"Update the factor table or fix the input data."
        )

    # Vectorized WARF: weighted average of factors by par value
    valid = df.dropna(subset=["_factor", "par_value"])
    if valid["par_value"].sum() == 0:
        return float("nan")

    warf = (valid["_factor"] * valid["par_value"]).sum() / valid["par_value"].sum()

    # Clean up temporary column
    df.drop(columns=["_factor"], inplace=True)

    return float(warf)

Moody's Diversity Score

The diversity score is unique to Moody's and has no direct S&P or Fitch equivalent. It estimates the number of uncorrelated assets that would produce a similar loss distribution as the actual portfolio.

  • A score of 80 means the portfolio behaves like 80 independent, equally-sized loans.
  • Higher is better -- more diversification reduces tail risk.
  • Many indentures specify a minimum diversity score alongside WARF limits.

How It Works

The calculation groups obligors by industry (using Moody's 33-industry classification), then applies pair-wise correlation adjustments. Two loans in the same industry contribute less to diversity than two loans in different industries.

flowchart LR
    A[Portfolio<br/>Positions] --> B[Group by<br/>Obligor]
    B --> C[Map to<br/>33 Industries]
    C --> D[Calculate<br/>HHI per Industry]
    D --> E[Apply<br/>Correlation Matrix]
    E --> F[Diversity<br/>Score]

    style F fill:#DCFCE7,stroke:#22C55E

S&P and Fitch Alternatives

S&P uses the CDO Monitor model which internally accounts for concentration but does not output a single "diversity score" number. Fitch uses its own portfolio credit model. When an indenture references "diversity score" without qualification, it almost always means the Moody's methodology.

Industry Classification Differences

The same company can land in different industries depending on which agency's classification system the indenture specifies.

Classification System Number of Industries Used By
Moody's 33-Industry 33 categories Moody's rated CLOs
S&P GICS 11 sectors, 25 industry groups S&P rated CLOs

Example: A diversified financial services company might be classified as:

  • Moody's: "Banking & Finance" (industry #1)
  • S&P GICS: "Diversified Financial Services" (sector: Financials, industry group: 4020)

This matters because industry concentration tests produce different results depending on the classification system.

# Industry concentration depends on classification system
def calculate_industry_concentration(
    df: pd.DataFrame,
    classification: str = "moodys_33"
) -> pd.DataFrame:
    """
    Calculate industry concentration using the specified classification.

    Args:
        df: DataFrame with 'par_value' and industry columns.
        classification: 'moodys_33' or 'gics' -- determines grouping column.

    Returns:
        DataFrame with industry name, par exposure, and concentration percentage.
    """
    industry_col = (
        "industry_moodys" if classification == "moodys_33"
        else "industry_gics_sector"
    )

    total_par = df["par_value"].sum()

    concentration = (
        df.groupby(industry_col)["par_value"]
        .sum()
        .reset_index()
        .rename(columns={"par_value": "par_exposure"})
    )
    concentration["concentration_pct"] = (
        concentration["par_exposure"] / total_par * 100
    )

    return concentration.sort_values("concentration_pct", ascending=False)

Template Configuration Required

When setting up a compliance test suite, the indenture specifies which industry classification to use. The CalcBridge template must store this as a parameter -- defaulting to Moody's 33-industry is incorrect for S&P-rated deals.

Cross-Mapping Between Scales

CalcBridge needs a cross-mapping service to convert ratings between agencies. This is required when:

  1. A loan has only one agency rating but the indenture requires another
  2. Split rating logic needs to compare across scales (see Split Ratings)
  3. Reporting requires a unified numeric scale
# Bidirectional cross-mapping
MOODYS_TO_SP = {
    "Aaa": "AAA", "Aa1": "AA+", "Aa2": "AA", "Aa3": "AA-",
    "A1": "A+", "A2": "A", "A3": "A-",
    "Baa1": "BBB+", "Baa2": "BBB", "Baa3": "BBB-",
    "Ba1": "BB+", "Ba2": "BB", "Ba3": "BB-",
    "B1": "B+", "B2": "B", "B3": "B-",
    "Caa1": "CCC+", "Caa2": "CCC", "Caa3": "CCC-",
    "Ca": "CC", "C": "C",
}

SP_TO_MOODYS = {v: k for k, v in MOODYS_TO_SP.items()}

# Numeric scale for ordinal comparison (lower = better quality)
NUMERIC_SCALE = {
    "Aaa": 1, "AAA": 1,
    "Aa1": 2, "AA+": 2,
    "Aa2": 3, "AA": 3,
    "Aa3": 4, "AA-": 4,
    "A1": 5, "A+": 5,
    "A2": 6, "A": 6,
    "A3": 7, "A-": 7,
    "Baa1": 8, "BBB+": 8,
    "Baa2": 9, "BBB": 9,
    "Baa3": 10, "BBB-": 10,
    "Ba1": 11, "BB+": 11,
    "Ba2": 12, "BB": 12,
    "Ba3": 13, "BB-": 13,
    "B1": 14, "B+": 14,
    "B2": 15, "B": 15,
    "B3": 16, "B-": 16,
    "Caa1": 17, "CCC+": 17,
    "Caa2": 18, "CCC": 18,
    "Caa3": 19, "CCC-": 19,
    "Ca": 20, "CC": 20,
    "C": 21, "D": 21,
}

def cross_map_rating(
    rating: str,
    from_agency: str,
    to_agency: str
) -> str | None:
    """
    Convert a rating from one agency's scale to another.
    Returns None if the rating is not recognized.
    """
    if from_agency == "moodys" and to_agency == "sp":
        return MOODYS_TO_SP.get(rating)
    elif from_agency == "sp" and to_agency == "moodys":
        return SP_TO_MOODYS.get(rating)
    return None

Engineering Implications

Implementing rating agency support correctly requires changes across several CalcBridge components:

1. Configurable Factor Tables

The WARF calculation service cannot hardcode a single factor table. It must accept a rating_source parameter that selects the appropriate table at runtime.

# In the compliance test suite configuration
{
    "rating": {
        "warf_rating_source": "moodys",  # or "sp"
        "warf_maximum": 2850,
        "factor_table_version": "2024-01",  # Factor tables can be updated
    }
}

2. Template rating_source Parameter

Every compliance test template that touches ratings must carry a rating_source field. This affects:

  • WARF calculation (factor table selection)
  • CCC bucket definition (which rating to check)
  • Diversity score (Moody's only)
  • Industry concentration (classification system selection)

3. Cross-Mapping Service

A standalone service for converting between rating scales. Required for split rating resolution, reporting normalization, and cases where a loan has only one agency rating.

4. Industry Classification Support

The concentration test engine needs to support both Moody's 33-industry and GICS classification systems. The template must specify which system the indenture requires.

flowchart TD
    A[Indenture<br/>Requirements] --> B{Which Agency?}
    B -->|Moody's| C[Moody's Factors<br/>33-Industry<br/>Diversity Score]
    B -->|S&P| D[S&P CDO Monitor<br/>GICS Sectors<br/>No Diversity Score]
    B -->|Both| E[Split Rating<br/>Resolution Required]
    C --> F[Configure Template]
    D --> F
    E --> F
    F --> G[Run Compliance<br/>Tests]

    style A fill:#EFF6FF,stroke:#3B82F6
    style G fill:#DCFCE7,stroke:#22C55E

See also: