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:
- Abstracting from FARF -- Current tests are tightly coupled to FARF's Excel format and column conventions
- Configurable tolerance models -- CLO cushion analysis does not apply; servicing tests need per-test tolerance configuration
- Regulatory calendar awareness -- Servicing compliance timelines vary by regulation and jurisdiction
- 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:
- Test Suites & Thresholds -- CLO covenant test configuration
- Running Tests -- CLO test execution
- Interpreting Results -- CLO cushion analysis
- Compliance Alerts -- Alert configuration (applicable to both segments)
- Generating Reports -- Report generation (applicable to both segments)
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.
Related Documentation¶
- Servicer Domain -- Servicer domain overview and FARF architecture
- Regulatory Landscape -- Regulations driving servicing compliance requirements
- Compliance Testing Overview -- CLO compliance testing (for comparison)