Skip to content

Loan Pricing and Compliance Impact

Loan pricing is not just a trading concern -- it directly affects compliance test outcomes. Whether a loan was purchased at par, at a discount, or at a premium determines how it is valued in overcollateralization (OC) tests, how CCC haircuts are applied, and whether the portfolio's collateral balance is accurately represented.

This page explains the pricing mechanics that CalcBridge must handle correctly to produce accurate compliance results.

Par Value vs Market Value

Every loan has two prices that matter for compliance:

  • Par value (face value): The original principal amount of the loan. Always expressed as 100 (i.e., 100% of face value). This is what the borrower owes.
  • Market value: The current trading price expressed as a percentage of par. A loan "trading at 95" is worth 95% of its face value in the secondary market.

The gap between par and market value creates unrealized gains or losses that affect portfolio economics but are treated differently by different compliance tests.

Trading Price Scenarios

Scenario Par Value Purchase Price Currently Trading At Unrealized Gain/Loss
Par purchase $5M $5M (100) 100 $0 (flat)
Discount purchase $5M $4.75M (95) 100 +$250K
Premium purchase $5M $5.25M (105) 100 -$250K
Distressed $5M $3.5M (70) 70 $0 (bought at market)
Recovery trade $5M $2.0M (40) 55 +$750K

Why This Matters for Compliance

Most OC tests value loans at par, which means a loan purchased at 95 and counted at 100 in the OC test creates 5 points of "built-in" overcollateralization. This is legal and common, but the distinction between purchase price and par value is critical for accurate compliance calculations.

OC Test Impact

Overcollateralization (OC) tests compare the total collateral balance against tranche principal. The way loans are valued in the numerator depends on their pricing status.

Standard OC Valuation Rules

Most indentures follow these rules for the OC numerator:

Loan Status OC Valuation Rationale
Performing, purchased >= 80 Par value Standard treatment
Discount obligation (purchased < 80) Purchase price Prevents artificial OC inflation
Defaulted $0 or recovery estimate Conservative treatment
Current pay (interest only) Par value Still performing
PIK (payment in kind) Par + accrued PIK Includes capitalized interest

Discount Obligation Threshold

The 80% threshold is common but not universal. Some indentures use 85% or even 90%. The CalcBridge template must store the discount obligation threshold as a configurable parameter, not a hardcoded value.

Par Building

"Par building" is a common CLO strategy where managers buy discounted loans that are counted at par for OC test purposes:

Example:
- Manager buys $10M par value loan at 92 (pays $9.2M)
- OC test counts this at $10M (par)
- Net effect: $800K of "free" OC cushion

But if the loan was bought at 75:
- Manager buys $10M par value loan at 75 (pays $7.5M)
- OC test counts this at $7.5M (purchase price, below 80% threshold)
- No OC benefit from discount

This is why the purchase price must be stored alongside par value in CalcBridge. The OC calculation service needs both.

OC Ratio Calculation with Pricing Adjustments

import numpy as np
import pandas as pd


def calculate_oc_numerator(
    df: pd.DataFrame,
    discount_threshold: float = 80.0,
) -> pd.Series:
    """
    Calculate the OC-adjusted value for each loan.

    Loans purchased below the discount threshold are valued at purchase price.
    Defaulted loans are valued at zero.
    All other loans are valued at par.

    Args:
        df: DataFrame with 'par_value', 'purchase_price_pct', and 'status' columns.
        discount_threshold: Percentage below which purchase price is used (default 80).

    Returns:
        Series of OC-adjusted values per loan.
    """
    return np.where(
        df["status"] == "defaulted",
        0.0,
        np.where(
            df["purchase_price_pct"] < discount_threshold,
            df["par_value"] * df["purchase_price_pct"] / 100,
            df["par_value"],
        ),
    )


def calculate_oc_ratio(
    df: pd.DataFrame,
    tranche_balance: float,
    discount_threshold: float = 80.0,
) -> float:
    """
    Calculate the OC ratio for a given tranche.

    Args:
        df: Portfolio DataFrame.
        tranche_balance: Outstanding principal of the tranche being tested.
        discount_threshold: Discount obligation cutoff percentage.

    Returns:
        OC ratio as a percentage (e.g., 125.0 means 125%).
    """
    df["_oc_value"] = calculate_oc_numerator(df, discount_threshold)
    collateral_balance = df["_oc_value"].sum()
    df.drop(columns=["_oc_value"], inplace=True)

    if tranche_balance == 0:
        return float("inf")

    return (collateral_balance / tranche_balance) * 100

CCC Haircut Mechanics

When CCC-rated exposure exceeds a threshold (typically 7.5% of total par), the excess CCC assets are subject to a "haircut" -- they are valued at the lower of par or market value instead of par. This reduces the collateral balance used in OC tests.

How the Haircut Works

  1. Calculate CCC exposure: Sum the par value of all loans rated Caa1/CCC+ or worse
  2. Check against threshold: Compare CCC par to total portfolio par
  3. If below threshold: All CCC assets are counted at par (no haircut)
  4. If above threshold: The excess CCC amount is valued at the lower of par or market value

Worked Example

Portfolio: $400M total par
CCC-rated loans: $35M par (8.75% of portfolio)
CCC threshold: 7.5%
Permitted CCC: $400M * 7.5% = $30M
Excess CCC: $35M - $30M = $5M

The $5M excess consists of two loans:
- Loan X: $3M par, trading at 85 -> haircut = $3M * (1 - 0.85) = $450K
- Loan Y: $2M par, trading at 72 -> haircut = $2M * (1 - 0.72) = $560K

Total haircut: $450K + $560K = $1,010K
Adjusted collateral balance: $400M - $1.01M = $398.99M

Haircut Ordering Matters

When multiple CCC loans exceed the threshold, the indenture typically specifies that the lowest-priced loans are haircut first. This maximizes the haircut impact (conservative approach). CalcBridge must sort CCC assets by market price ascending before applying haircuts.

CalcBridge Implementation

import numpy as np
import pandas as pd


def adjusted_collateral_balance(
    df: pd.DataFrame,
    ccc_threshold: float = 0.075,
    discount_threshold: float = 80.0,
) -> float:
    """
    Calculate the adjusted collateral balance after CCC haircuts.

    This is the primary OC numerator calculation that accounts for:
    1. Defaulted loan exclusion
    2. Discount obligation adjustments
    3. CCC excess haircuts (lowest-priced first)

    Args:
        df: Portfolio DataFrame with columns:
            - par_value: Face value of each loan
            - purchase_price_pct: Original purchase price as % of par
            - market_price_pct: Current market price as % of par
            - status: Loan status ('performing', 'defaulted', etc.)
            - is_ccc: Boolean flag for CCC/Caa1 or worse rated
        ccc_threshold: Maximum CCC exposure as fraction of total par (default 7.5%)
        discount_threshold: Cutoff for discount obligation treatment (default 80%)

    Returns:
        Adjusted collateral balance as a float.
    """
    # Step 1: Exclude defaulted loans
    active = df[df["status"] != "defaulted"].copy()

    # Step 2: Calculate base OC value (handle discount obligations)
    active["oc_value"] = np.where(
        active["purchase_price_pct"] < discount_threshold,
        active["par_value"] * active["purchase_price_pct"] / 100,
        active["par_value"],
    )

    # Step 3: CCC haircut calculation
    total_par = active["par_value"].sum()
    ccc_mask = active["is_ccc"]
    ccc_par = active.loc[ccc_mask, "par_value"].sum()

    if ccc_par / total_par > ccc_threshold:
        excess = ccc_par - (total_par * ccc_threshold)

        # Sort CCC assets by market price ascending (worst first)
        ccc_assets = active[ccc_mask].sort_values("market_price_pct")

        haircut = 0.0
        remaining_excess = excess
        for _, loan in ccc_assets.iterrows():
            if remaining_excess <= 0:
                break
            affected_par = min(loan["par_value"], remaining_excess)
            # Haircut = difference between par and market value
            haircut += affected_par * max(0, 1 - loan["market_price_pct"] / 100)
            remaining_excess -= affected_par

        return active["oc_value"].sum() - haircut

    return active["oc_value"].sum()

Performance Note on the Loop

The CCC haircut calculation uses iterrows() because it requires sequential processing (each loan consumes part of the remaining excess). In practice, the number of CCC loans subject to haircut is small (typically < 20), so vectorizing this loop provides negligible benefit. Do not refactor this into a vectorized operation unless profiling shows it is a bottleneck.

Pricing Impact on Compliance Summary

This diagram shows how pricing flows through the compliance calculation pipeline:

flowchart TD
    A[Loan Data] --> B{Purchase Price<br/>vs Par}
    B -->|">= 80%"| C[Count at Par<br/>in OC Test]
    B -->|"< 80%"| D[Count at<br/>Purchase Price]
    B -->|Defaulted| E[Count at $0]

    C --> F[Collateral<br/>Balance]
    D --> F
    E --> F

    F --> G{CCC Exposure<br/>vs Threshold}
    G -->|Below| H[No Haircut]
    G -->|Above| I[Haircut Excess<br/>Lowest Price First]

    H --> J[Adjusted<br/>Collateral Balance]
    I --> J

    J --> K[OC Ratio =<br/>Balance / Tranche]
    K --> L{Pass / Fail?}

    style L fill:#FEF3C7,stroke:#F59E0B
    style J fill:#EFF6FF,stroke:#3B82F6

Portfolio Valuation Methods

Different compliance tests may use different valuation approaches:

Valuation Method Used For Description
Par OC tests (standard) Face value of performing loans
Market CCC haircuts, stress tests Current trading price
Purchase price Discount obligations Original acquisition cost
Recovery Defaulted assets Estimated recovery value
Amortized cost IC tests (some structures) Accreted purchase discount

Implementation Considerations

def get_loan_value(
    par_value: float,
    purchase_price_pct: float,
    market_price_pct: float,
    status: str,
    valuation_method: str = "par",
    discount_threshold: float = 80.0,
    recovery_rate: float = 0.0,
) -> float:
    """
    Return the appropriate loan value based on valuation method and status.

    This function centralizes the valuation logic so that different compliance
    tests can request the correct value without reimplementing the rules.
    """
    if status == "defaulted":
        if valuation_method == "recovery":
            return par_value * recovery_rate / 100
        return 0.0

    if valuation_method == "par":
        if purchase_price_pct < discount_threshold:
            return par_value * purchase_price_pct / 100
        return par_value

    if valuation_method == "market":
        return par_value * market_price_pct / 100

    if valuation_method == "purchase":
        return par_value * purchase_price_pct / 100

    if valuation_method == "amortized_cost":
        # Simplified: linear accretion from purchase price to par
        return par_value * purchase_price_pct / 100

    raise ValueError(f"Unknown valuation method: {valuation_method}")

Engineering Implications

Required Data Fields

For accurate pricing-aware compliance calculations, CalcBridge must store and track these fields per loan:

Field Type Description Source
par_value float Face value / principal amount Trustee report
purchase_price_pct float Original purchase price as % of par Trade records
market_price_pct float Current market price as % of par Pricing feed
status string performing, defaulted, current_pay, etc. Trustee report
is_ccc bool Whether rated Caa1/CCC+ or worse Rating service
acquisition_date date When the loan was purchased Trade records

Template Configuration

The compliance test template must expose these pricing-related parameters:

{
    "coverage": {
        "discount_obligation_threshold_pct": 80.0,
        "ccc_excess_threshold_pct": 7.5,
        "ccc_haircut_method": "lowest_price_first",
        "defaulted_valuation": "zero",
        "senior_tranche_balance": 350000000,
        "mezzanine_tranche_balance": 400000000
    }
}

Data Quality Checks

CalcBridge should validate pricing data before running compliance tests:

def validate_pricing_data(df: pd.DataFrame) -> list[str]:
    """
    Return a list of data quality warnings for pricing fields.
    Empty list means all checks passed.
    """
    warnings = []

    # Purchase price should be between 0 and 200
    invalid_purchase = df[
        (df["purchase_price_pct"] < 0) | (df["purchase_price_pct"] > 200)
    ]
    if len(invalid_purchase) > 0:
        warnings.append(
            f"{len(invalid_purchase)} loans have purchase_price_pct "
            f"outside valid range [0, 200]"
        )

    # Market price should exist for CCC loans (needed for haircut)
    ccc_no_market = df[df["is_ccc"] & df["market_price_pct"].isna()]
    if len(ccc_no_market) > 0:
        warnings.append(
            f"{len(ccc_no_market)} CCC-rated loans are missing market_price_pct. "
            f"CCC haircut calculation will be inaccurate."
        )

    # Defaulted loans should have status set
    likely_defaulted = df[
        (df["market_price_pct"] < 30) & (df["status"] != "defaulted")
    ]
    if len(likely_defaulted) > 0:
        warnings.append(
            f"{len(likely_defaulted)} loans trade below 30 but are not marked "
            f"as defaulted. Verify status field."
        )

    return warnings

See also: