Skip to content

Servicing Compliance Tests

Servicing compliance tests validate that a loan servicer is processing payments, tracking delinquencies, and reporting correctly. They are structurally different from CLO compliance tests and should not be confused with them.


How Servicing Tests Differ from CLO Tests

CLO compliance tests ask: "Is the portfolio healthy enough to protect investors?" They measure credit quality, concentration, and coverage against thresholds defined in an indenture document. A CLO test failure triggers cash diversion or trading restrictions -- consequences that affect portfolio economics.

Servicing compliance tests ask: "Are we processing payments and reporting correctly?" They measure operational accuracy, timeliness, and regulatory adherence against standards defined in servicing agreements and federal/state regulations. A servicing test failure can trigger regulatory fines, consent orders, or servicer termination.

Dimension CLO Tests Servicing Tests
What they protect Investor capital Borrower rights and reporting accuracy
Failure source Market movements, credit deterioration Operational errors, system bugs, process gaps
Tolerance Percentage-based cushions (e.g., 7.5% CCC limit) Often zero tolerance (payment allocation must be exact)
Remediation Portfolio rebalancing, trading Process fixes, system corrections, borrower remediation
Regulatory body Rating agencies, trustee oversight CFPB, state regulators, HUD, Ginnie Mae
Audit frequency Monthly/quarterly reporting cycles Continuous monitoring, annual regulatory exams

Do Not Reuse CLO Test Infrastructure for Servicing

The existing CalcBridge compliance test engine (test suites, thresholds, cushion analysis) was designed for CLO covenant testing. Servicing tests require different data structures, tolerance models, and result interpretation. Engineers should build servicing test infrastructure as a parallel system, not an extension of the CLO framework.


Servicing Test Categories

1. Payment Application Accuracy

The most fundamental servicing test: are payments being applied to the correct buckets?

Every loan payment must be allocated across principal, interest, fees, escrow, and potentially late charges. The allocation rules vary by loan type, servicing agreement, and regulatory requirements. Getting this wrong -- even by pennies -- compounds across thousands of loans and triggers regulatory findings.

What to validate:

  • Principal vs interest vs fee allocation matches contractual waterfall
  • Day count convention applied correctly (see below)
  • Partial payments applied in the correct priority order
  • Prepayments handled according to loan terms
  • Overpayments credited or refunded per agreement

Day count conventions:

Convention Calculation Common Use
30/360 Assumes 30-day months, 360-day year US corporate bonds, some mortgages
Actual/360 Actual days elapsed, 360-day year Commercial loans, LIBOR/SOFR-based
Actual/365 Actual days elapsed, 365-day year UK gilts, some ABS
Actual/Actual Actual days elapsed, actual year length US Treasuries

Zero Tolerance for Payment Allocation

Unlike CLO tests where a 0.5% cushion is acceptable, payment application tests typically have zero tolerance. A $0.01 mismatch between expected and actual allocation is a finding. CalcBridge servicing tests should validate to the penny.

Implementation example:

from decimal import Decimal


def validate_payment_application(
    payment: dict, expected: dict, tolerance: Decimal = Decimal("0.01")
) -> list[str]:
    """Validate principal/interest/fee allocation against expected values.

    Args:
        payment: Actual payment allocation from servicer system.
        expected: Expected allocation per contractual waterfall.
        tolerance: Maximum acceptable variance (default: $0.01).

    Returns:
        List of error descriptions. Empty list means pass.
    """
    errors = []
    components = ["principal", "interest", "fees", "escrow", "late_charges"]

    for component in components:
        actual = Decimal(str(payment.get(component, 0)))
        exp = Decimal(str(expected.get(component, 0)))
        variance = abs(actual - exp)

        if variance > tolerance:
            errors.append(
                f"{component.title()} mismatch: "
                f"actual={actual}, expected={exp}, variance={variance}"
            )

    # Validate total payment equals sum of components
    actual_total = sum(Decimal(str(payment.get(c, 0))) for c in components)
    expected_total = Decimal(str(payment.get("total", 0)))
    if actual_total != expected_total:
        errors.append(
            f"Component sum ({actual_total}) != payment total ({expected_total})"
        )

    return errors

2. Delinquency Classification

Accurate delinquency tracking is both an operational requirement and a regulatory obligation. Misclassifying a loan's delinquency status affects investor reporting, loss reserves, and borrower communications.

What to validate:

  • Correct bucket assignment (current, 30, 60, 90, 120+ days)
  • Aging methodology matches servicing agreement (contractual vs recency-of-payment)
  • Roll rates calculated correctly (loans moving between buckets)
  • Delinquency triggers borrower notifications on schedule
  • Reinstatement properly resets delinquency status

Aging methodologies:

Method Description When Used
Contractual Days since contractual due date Most residential servicing
Recency-of-Payment Days since last payment received Some commercial servicing
MBA Method Mortgage Bankers Association standard Industry standard for RMBS

Implementation example:

def classify_delinquency(days_past_due: int) -> str:
    """Classify loan delinquency status based on days past due.

    Uses standard 30-day bucket methodology per MBA convention.

    Args:
        days_past_due: Number of days since payment was due.

    Returns:
        Delinquency classification string.
    """
    if days_past_due <= 0:
        return "current"
    elif days_past_due <= 30:
        return "30_day"
    elif days_past_due <= 60:
        return "60_day"
    elif days_past_due <= 90:
        return "90_day"
    elif days_past_due <= 120:
        return "120_day"
    else:
        return "120_plus"


def validate_delinquency_classification(
    loans: list[dict],
) -> list[dict]:
    """Validate delinquency classifications for a portfolio of loans.

    Returns:
        List of misclassified loans with expected vs actual status.
    """
    misclassifications = []

    for loan in loans:
        expected = classify_delinquency(loan["days_past_due"])
        actual = loan.get("delinquency_status", "unknown")

        if expected != actual:
            misclassifications.append({
                "loan_id": loan["loan_id"],
                "days_past_due": loan["days_past_due"],
                "expected_status": expected,
                "actual_status": actual,
            })

    return misclassifications

3. Interest Accrual Validation

Interest accrual errors are among the most common servicing findings. They compound over time and are difficult to unwind once detected.

What to validate:

  • Day count convention matches loan documents
  • Variable rate resets applied on correct dates with correct base rates
  • Accrual start/stop dates respect payment and payoff timing
  • Compounding methodology (simple vs compound) matches terms
  • Negative amortization limits respected where applicable
  • Accrual properly stops on charged-off or paid-off loans

Multiple conventions in a single portfolio:

A servicer may handle loans using different day count conventions simultaneously. The accrual engine must track which convention applies to each loan and apply it correctly.

from datetime import date
from decimal import Decimal


def calculate_daily_interest(
    principal_balance: Decimal,
    annual_rate: Decimal,
    day_count_convention: str,
    accrual_date: date,
) -> Decimal:
    """Calculate daily interest accrual for a single loan.

    Args:
        principal_balance: Current outstanding principal.
        annual_rate: Annual interest rate (e.g., Decimal("0.055") for 5.5%).
        day_count_convention: One of "30/360", "actual/360", "actual/365".
        accrual_date: The date for which interest is being calculated.

    Returns:
        Daily interest amount.
    """
    if day_count_convention == "30/360":
        daily_rate = annual_rate / Decimal("360")
    elif day_count_convention == "actual/360":
        daily_rate = annual_rate / Decimal("360")
    elif day_count_convention == "actual/365":
        daily_rate = annual_rate / Decimal("365")
    elif day_count_convention == "actual/actual":
        import calendar
        year = accrual_date.year
        days_in_year = 366 if calendar.isleap(year) else 365
        daily_rate = annual_rate / Decimal(str(days_in_year))
    else:
        raise ValueError(f"Unknown day count convention: {day_count_convention}")

    return principal_balance * daily_rate


def validate_accrual(
    loan: dict, expected_accrual: Decimal, tolerance: Decimal = Decimal("0.01")
) -> dict | None:
    """Validate a loan's interest accrual against expected value.

    Returns:
        Error dict if mismatch exceeds tolerance, None if valid.
    """
    actual = Decimal(str(loan["accrued_interest"]))
    variance = abs(actual - expected_accrual)

    if variance > tolerance:
        return {
            "loan_id": loan["loan_id"],
            "actual_accrual": actual,
            "expected_accrual": expected_accrual,
            "variance": variance,
            "convention": loan.get("day_count_convention", "unknown"),
        }
    return None

4. Investor Remittance Reconciliation

Servicers collect payments from borrowers and remit funds to investors (or the loan owner) on a defined schedule. Remittance reconciliation verifies that the amounts remitted match the underlying collections.

What to validate:

  • Monthly remittance total equals sum of collected payments minus servicing fee
  • Advance obligations tracked correctly (servicer advances payments for delinquent borrowers)
  • Shortfall allocation follows the contractual waterfall
  • Float income properly accounted for
  • Timing: remittance occurs within contractual deadlines
def reconcile_remittance(
    collections: list[dict],
    remittance: dict,
    servicing_fee_rate: Decimal,
) -> list[str]:
    """Reconcile investor remittance against borrower collections.

    Args:
        collections: List of payment records collected in the period.
        remittance: Remittance record sent to investor.
        servicing_fee_rate: Annual servicing fee as decimal.

    Returns:
        List of reconciliation errors.
    """
    errors = []

    total_principal = sum(Decimal(str(c["principal"])) for c in collections)
    total_interest = sum(Decimal(str(c["interest"])) for c in collections)

    # Servicing fee is deducted from interest
    monthly_fee_rate = servicing_fee_rate / Decimal("12")
    expected_fee = total_interest * monthly_fee_rate
    expected_interest_remit = total_interest - expected_fee

    remit_principal = Decimal(str(remittance["principal"]))
    remit_interest = Decimal(str(remittance["interest"]))

    if abs(total_principal - remit_principal) > Decimal("0.01"):
        errors.append(
            f"Principal: collected={total_principal}, "
            f"remitted={remit_principal}"
        )

    if abs(expected_interest_remit - remit_interest) > Decimal("0.01"):
        errors.append(
            f"Interest: expected remit={expected_interest_remit}, "
            f"actual remit={remit_interest}"
        )

    return errors

5. Escrow Balance Validation

For residential mortgage servicing, escrow accounts hold funds for property taxes and insurance. RESPA (Real Estate Settlement Procedures Act) strictly regulates escrow management.

What to validate:

  • Escrow balance matches expected level based on tax and insurance disbursements
  • Annual escrow analysis completed on schedule
  • Cushion does not exceed RESPA limit (typically two months of disbursements)
  • Surplus refunds or shortage notices issued per regulatory timelines
  • Escrow-related disclosures provided to borrower

FARF Context

FARF manages government agency receivables, not residential mortgages, so escrow validation does not apply to the current CalcBridge servicing client. However, any general-purpose servicing platform must support escrow calculations. This test category is documented for future client onboarding.

from decimal import Decimal


def validate_escrow_balance(
    current_balance: Decimal,
    monthly_tax_amount: Decimal,
    monthly_insurance_amount: Decimal,
    max_cushion_months: int = 2,
) -> list[str]:
    """Validate escrow balance against RESPA requirements.

    Args:
        current_balance: Current escrow account balance.
        monthly_tax_amount: Monthly tax escrow payment.
        monthly_insurance_amount: Monthly insurance escrow payment.
        max_cushion_months: Maximum cushion allowed (RESPA default: 2 months).

    Returns:
        List of compliance issues.
    """
    errors = []
    monthly_total = monthly_tax_amount + monthly_insurance_amount
    max_cushion = monthly_total * Decimal(str(max_cushion_months))

    # Escrow balance should not exceed anticipated disbursements + cushion
    annual_disbursements = monthly_total * Decimal("12")
    if current_balance > annual_disbursements + max_cushion:
        excess = current_balance - (annual_disbursements + max_cushion)
        errors.append(
            f"Escrow balance exceeds RESPA limit by ${excess:.2f}. "
            f"Balance: ${current_balance:.2f}, "
            f"Max allowed: ${annual_disbursements + max_cushion:.2f}"
        )

    # Negative escrow balance indicates a shortage
    if current_balance < Decimal("0"):
        errors.append(
            f"Escrow shortage: ${abs(current_balance):.2f}. "
            f"Shortage notice required."
        )

    return errors

6. Loss Mitigation Documentation

When borrowers experience hardship, servicers must follow specific timelines and documentation requirements for loss mitigation (loan modifications, forbearance, short sales).

What to validate:

  • Modification application acknowledged within regulatory timeline
  • All required documents collected and tracked
  • Dual-tracking prohibition enforced (cannot foreclose while modification is pending)
  • Trial period payments tracked correctly
  • Permanent modification terms match approved terms
  • Document retention meets regulatory minimums
from datetime import date, timedelta


def validate_modification_timeline(
    application_date: date,
    acknowledgment_date: date | None,
    decision_date: date | None,
    max_acknowledgment_days: int = 5,
    max_decision_days: int = 30,
) -> list[str]:
    """Validate loss mitigation timeline compliance.

    Args:
        application_date: Date complete application received.
        acknowledgment_date: Date acknowledgment sent to borrower.
        decision_date: Date decision communicated to borrower.
        max_acknowledgment_days: Regulatory max for acknowledgment.
        max_decision_days: Regulatory max for decision.

    Returns:
        List of timeline violations.
    """
    errors = []
    today = date.today()

    if acknowledgment_date:
        ack_days = (acknowledgment_date - application_date).days
        if ack_days > max_acknowledgment_days:
            errors.append(
                f"Acknowledgment sent {ack_days} days after application "
                f"(max: {max_acknowledgment_days} days)"
            )
    elif (today - application_date).days > max_acknowledgment_days:
        errors.append(
            f"Acknowledgment overdue: application received {application_date}, "
            f"no acknowledgment sent after {(today - application_date).days} days"
        )

    if decision_date:
        decision_days = (decision_date - application_date).days
        if decision_days > max_decision_days:
            errors.append(
                f"Decision took {decision_days} days "
                f"(max: {max_decision_days} days)"
            )
    elif (today - application_date).days > max_decision_days:
        errors.append(
            f"Decision overdue: application received {application_date}, "
            f"no decision after {(today - application_date).days} days"
        )

    return errors

FARF-Specific Tests

The FARF implementation includes calculation validation tests that verify derived columns match Excel source values. These are not general servicing tests but rather data pipeline validation specific to the FARF workflow.

Calculated Column Validation

FARF tests validate that Python-calculated columns (CA-CI) match the values Excel would produce:

import numpy as np
import pandas as pd
from openpyxl import load_workbook


def validate_calculated_columns(
    fixture_path: str,
    sheet_name: str,
    calculated_df: pd.DataFrame,
    column_map: dict[str, str],
    tolerance: float = 0.01,
) -> dict:
    """Validate calculated columns against Excel source values.

    Args:
        fixture_path: Path to Excel fixture file.
        sheet_name: Sheet name to validate against.
        calculated_df: DataFrame with Python-calculated values.
        column_map: Mapping of {excel_column: dataframe_column}.
        tolerance: Acceptable float variance.

    Returns:
        Dict with validated_count, mismatch_count, and mismatches list.
    """
    wb = load_workbook(fixture_path, data_only=True)
    sheet = wb[sheet_name]

    mismatches = []
    validated = 0

    for excel_col, df_col in column_map.items():
        for row_idx, row in calculated_df.iterrows():
            excel_cell = f"{excel_col}{row_idx + 2}"  # +2 for header row
            excel_value = sheet[excel_cell].value
            python_value = row[df_col]

            if excel_value is None and pd.isna(python_value):
                validated += 1
                continue

            if isinstance(excel_value, (int, float)):
                if abs(float(excel_value) - float(python_value)) > tolerance:
                    mismatches.append({
                        "cell": excel_cell,
                        "column": df_col,
                        "excel": excel_value,
                        "python": python_value,
                    })
                else:
                    validated += 1
            else:
                if str(excel_value).strip() == str(python_value).strip():
                    validated += 1
                else:
                    mismatches.append({
                        "cell": excel_cell,
                        "column": df_col,
                        "excel": excel_value,
                        "python": python_value,
                    })

    return {
        "validated_count": validated,
        "mismatch_count": len(mismatches),
        "mismatches": mismatches,
    }

Tests Must Actually Validate Data

A test that reports "0 rows validated" and then prints "All tests passed" is worse than no test at all. Always assert that validated_count > 0 and mismatch_count == 0. See the project's CLAUDE.md for the full testing integrity policy.


Implementation Status

What Exists Today

Test Category Status Notes
FARF Calculated Column Validation Implemented Validates CA-CI against Excel for ABC, Atlys, PME
Payment Application Accuracy Not implemented Framework documented above
Delinquency Classification Not implemented Framework documented above
Interest Accrual Validation Partially implemented VRS (Variable Rate Schedules) exist in FARF
Investor Remittance Reconciliation Not implemented Framework documented above
Escrow Balance Validation Not applicable FARF handles government receivables, not mortgages
Loss Mitigation Documentation Not applicable FARF does not handle consumer loan modifications

Roadmap Considerations

Building a general-purpose servicing compliance test suite requires:

  1. Abstracting from FARF -- Current tests are tightly coupled to FARF's Excel format and column conventions
  2. Configurable tolerance models -- CLO cushion analysis does not apply; servicing tests need per-test tolerance configuration
  3. Regulatory calendar awareness -- Servicing compliance timelines vary by regulation and jurisdiction
  4. Borrower-level audit trails -- CLO tests operate at portfolio level; servicing tests must trace to individual borrower records

Relationship to CLO Compliance Tests

The existing CalcBridge compliance documentation covers CLO indenture testing:

The alert and reporting infrastructure could potentially be shared between CLO and servicing tests, but the test definitions, tolerance models, and result structures must remain separate.